Scope Requests Per Tenant¶
Problem¶
Several tenants share one Django project, one page tree, and one static pipeline, and every request must see only the data, theme, and asset URLs of the tenant it belongs to.
Solution¶
Resolve the tenant once in middleware and stash it on the request. A dependency provider, a context processor, and a custom static backend each read it back from there.
The examples/multi-tenant/ project in the repository applies the same pattern end to end. See Repository Examples.
Walkthrough¶
Resolve The Tenant In Middleware¶
The middleware parses the X-Tenant header, looks up the matching row, and attaches it to request.tenant.
A missing header is a 400 and an unknown slug is a 404.
from django.http import HttpResponse, HttpResponseBadRequest
from notes.models import Tenant
HEADER_NAME = "HTTP_X_TENANT"
class TenantMiddleware:
def __init__(self, get_response):
self._get_response = get_response
def __call__(self, request):
slug = request.META.get(HEADER_NAME, "").strip()
if not slug:
return HttpResponseBadRequest("Missing X-Tenant header.")
try:
tenant = Tenant.objects.get(slug=slug)
except Tenant.DoesNotExist:
return HttpResponse(f"Unknown tenant slug {slug!r}.", status=404)
request.tenant = tenant
return self._get_response(request)
Register it last in MIDDLEWARE so it runs after sessions and authentication.
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"notes.middleware.TenantMiddleware",
]
Read The Tenant Through One Helper¶
Every consumer reads the tenant through a single accessor instead of touching request.tenant directly.
On error pages where the middleware short-circuited, the attribute is absent and the helper returns None.
def get_active_tenant(request):
"""Return the tenant attached to `request` by `TenantMiddleware`."""
return getattr(request, "tenant", None)
Inject The Tenant Into Pages¶
A DDependencyBase marker plus a RegisteredParameterProvider lets page and action callables ask for the tenant by type.
The provider matches the bare DTenant annotation when a request carries a tenant.
from next.deps import DDependencyBase, RegisteredParameterProvider
from notes.access import get_active_tenant
class DTenant(DDependencyBase["Tenant"]):
"""DI marker that resolves to the active `Tenant` for the request."""
__slots__ = ()
class TenantProvider(RegisteredParameterProvider):
def can_handle(self, param, context):
if param.annotation is not DTenant:
return False
request = getattr(context, "request", None)
if request is None:
return False
return get_active_tenant(request) is not None
def resolve(self, _param, context):
return get_active_tenant(context.request)
Unlike DFlag[Flag], DTenant is matched by class identity rather than get_origin, so it carries no type parameter and the provider compares param.annotation to the class directly.
Import the module at the top of apps.py so the auto-registry wires the provider at startup.
The ready hook does not need to re-run anything because RegisteredParameterProvider registers the provider as a side effect of class definition.
from django.apps import AppConfig
from notes import providers
_ = providers
class NotesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "notes"
The _ = providers line documents the intentional side-effect import so a linter does not flag it as unused.
A page context function now requests the tenant by name and type, and the resolver hands back the model instance. Keep real annotations in these modules, because the resolver compares parameter annotations by identity.
from notes.models import Note
from notes.providers import DTenant
from next.pages import context
@context("notes")
def notes(active_tenant: DTenant) -> list[Note]:
"""Return every note that belongs to the active tenant."""
return list(Note.objects.filter(tenant=active_tenant))
Lift The Tenant To Every Descendant Page¶
A @context(..., inherit_context=True) callable on the workspace root publishes the tenant once, and every nested page reads it without re-resolving.
from notes.models import Note
from notes.providers import DTenant
from next.pages import context
@context("tenant", inherit_context=True)
def tenant(active_tenant: DTenant) -> "Tenant":
"""Expose the active tenant under `tenant` to every workspace page."""
return active_tenant
Theme The Chrome With A Context Processor¶
A context processor turns the tenant’s color into a CSS variable for every template.
List it in the page backend OPTIONS so the file router runs it.
from notes.access import get_active_tenant
def tenant_theme(request):
"""Surface per-tenant CSS variables to every page template."""
tenant = get_active_tenant(request)
if tenant is None:
return {"tenant_theme": {}, "tenant_theme_css": ""}
css_vars = {"--tenant-accent": tenant.primary_color}
css = ";".join(f"{name}:{value}" for name, value in css_vars.items())
return {"tenant_theme": css_vars, "tenant_theme_css": css}
NEXT_FRAMEWORK = {
"DEFAULT_PAGE_BACKENDS": [
{
"BACKEND": "next.urls.FileRouterBackend",
"DIRS": [],
"APP_DIRS": True,
"PAGES_DIR": "workspaces",
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"notes.context_processors.tenant_theme",
],
},
},
],
}
Prefix Asset URLs Per Tenant¶
Scope the static backend the same way.
Subclass StaticFilesBackend and override the renderer methods.
Read the tenant from the request keyword argument that the static manager passes to every renderer, then prepend the tenant slug to each collected URL.
Register the subclass in DEFAULT_STATIC_BACKENDS.
See Customise Rendered Static Tags under Tenant URL Prefix for the full implementation.
Verification¶
Send the same path with two different headers and confirm the responses diverge.
curl -H 'X-Tenant: acme' http://127.0.0.1:8000/notes/
curl -H 'X-Tenant: globex' http://127.0.0.1:8000/notes/
The Acme response lists only Acme notes.
The Globex response lists only Globex notes.
A request with no header returns 400.
See Also¶
See also
Dependency Injection for the request-scoped provider pattern.
Static Backends for the request-aware backend contract.
Context for inherit_context and context processors.
Request Lifecycle for where middleware sits in the request path.