django-anvil v0.1
🔍Search docs...⌘K
Core concepts

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:

ManagerBehavior
Model.objectsDefault. Filtered to the current organization; empty if none in context.
Model.all_objectsSame 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."