Multi-tenancy
Shared-schema: every tenant's rows live in the same tables, tagged with an
organization_id and auto-scoped â one database, no per-tenant schemas to
provision or migrate separately.
Fails closed. Model.objects (the default manager) returns
nothing if there's no organization in context for the current request â never "every
organization's rows." This applies even to a plain Model.objects.all()
anywhere in the codebase: a developer who forgets to scope a query by hand still can't leak
another tenant's data through it.
TenantScopedModel
Opt in per model by inheriting it, and set tenant_scoped = True on the Resource:
from django_anvil.tenancy.models import TenantScopedModel
class Product(TenantScopedModel): # adds the `organization` FK
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
class ProductResource(Resource):
model = Product
...
tenant_scoped = True
Every TenantScopedModel gets two managers:
| Manager | Behavior |
|---|---|
Model.objects | Default. Filtered to the current organization; empty if none in context. |
Model.all_objects | Same queryset, unfiltered â for the deliberate cases (Django admin, scripts) that need every organization's rows. |
Common footgun: a management command or one-off script runs with no
request/organization context, so Model.objects.get_or_create(...) there always
"sees" zero existing rows â silently duplicating data, or worse, raising an
IntegrityError against a unique field on the second run. Use
Model.all_objects in scripts, migrations, and shell sessions.
How the current organization is resolved
Resolved from an X-Org-Id header (only honored if the user is really a
member) or automatically if the user belongs to exactly one organization â resolved
inside the DRF ViewSet itself (TenantScopedViewSetMixin.initial()),
not via Django middleware.
Why not middleware? DRF's own authentication (tokens, force_authenticate in
tests) runs inside the view, after Django's middleware chain has already executed â
a plain middleware would still see AnonymousUser at that point. Resolving it in
the ViewSet's own initial() (after super().initial() has run DRF's
authentication) means this works no matter which auth scheme is in use.
CurrentOrganizationMiddleware still exists, for the non-DRF views it does work
correctly for (session auth).
Django admin sees every organization
Generated ModelAdmins override get_queryset to use
all_objects automatically â admin is a global, already-permissioned surface, not
something that should silently show only whichever org (if any) the logged-in staff user
happens to belong to.
class ProductAdmin(admin.ModelAdmin):
...
def get_queryset(self, request):
return Product.all_objects.all()
RBAC + tenancy composition
A role check happens first (in check_permissions), then the tenant-scoped
queryset filters what that role is allowed to see. A resource with
"view": "public" and tenant_scoped = True means: no login required
to try, but you'll only ever see rows for an organization you're actually a member
of â public doesn't mean "every tenant's data."