Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Multi-tenancy (djangors-contrib-tenancy)

djangors-contrib-tenancy provides row-level multi-tenancy: a shared database schema where tenant-scoped tables carry a foreign key to a Tenant, and every query against them is automatically and verifiably restricted to the current request’s tenant. See docs/design/12.1-multi-tenancy.md in the repository for the full design rationale, including why this model was chosen over schema-per-tenant or database-per-tenant.

Tenant and TenantMembership

#![allow(unused)]
fn main() {
use djangors_contrib_tenancy::{Tenant, TenantMembership};

fn describe(tenant: &Tenant, membership: &TenantMembership) -> String {
    format!("{} ({})", tenant.name, membership.role)
}
}

Tenant is deliberately generic (name/slug/is_active). It represents a school, a bank branch, or a seller account equally well; your application names its own domain concept on top.

TenantMembership links a user to a tenant with a role, and a user may hold more than one membership (belonging to several tenants). That’s more general than simply putting a tenant_id directly on your user model, and it doesn’t require modifying djangors-auth’s User struct at all.

Resolving the current tenant (TenantResolutionLayer)

A request’s tenant is never trusted from a client-supplied header alone. TenantResolutionLayer reads the X-Tenant-Id header, then verifies the authenticated user actually has a real TenantMembership row for that tenant before accepting it. A forged or stale header for a tenant the user doesn’t belong to is silently rejected (no CurrentTenant gets set at all, which downstream scoping then treats as unauthorized), never treated as a default/fallback tenant.

#![allow(unused)]
fn main() {
use djangors_contrib_tenancy::TenantResolutionLayer;
use djangors_orm::djangors_db::Database;

fn build_layer(db: Database) -> TenantResolutionLayer<impl Fn(&hyper::Request<hyper::body::Incoming>) -> Option<i64> + Clone> {
    // The extractor closure reads whatever your own upstream auth middleware already stored on
    // the request (e.g. an authenticated user id extension) - this crate doesn't assume a
    // specific auth setup.
    TenantResolutionLayer::new(db, |req| {
        req.extensions().get::<CurrentUserId>().map(|u| u.0)
    })
}

#[derive(Clone, Copy)]
struct CurrentUserId(i64);
}

Scoping a model to the current tenant

djangors-rest already ships the enforcement primitive this crate builds on: the Scoped trait, which ScopedViewSet<M> requires. A model without a scope() implementation simply cannot be used with ScopedViewSet at all, a compile-time guarantee rather than a convention someone has to remember. tenant_scope() is a one-line helper for writing that implementation:

#![allow(unused)]
fn main() {
use djangors_contrib_tenancy::tenant_scope;
use djangors_core::{Request, error::DjangorsError};
use djangors_macros::Model;
use djangors_orm::QuerySet;
use djangors_rest::Scoped;

#[derive(Model, Debug, Clone, sqlx::FromRow)]
#[djangors(app = "myapp", table_name = "myapp_schoolclass")]
struct SchoolClass {
    #[djangors(primary_key, auto)]
    id: i64,
    #[djangors(foreign_key(on_delete = "cascade"))]
    tenant: djangors_orm::ForeignKey<djangors_contrib_tenancy::Tenant>,
    #[djangors(max_length = 100)]
    name: String,
}

impl Scoped for SchoolClass {
    fn scope(req: &Request, qs: QuerySet<Self>) -> Result<QuerySet<Self>, DjangorsError> {
        tenant_scope(req, qs, "tenant")
    }
}
}

Every ScopedViewSet::<SchoolClass> request now only ever sees rows belonging to the current, membership-verified tenant. A user for tenant A genuinely cannot see tenant B’s classes, list them, or fetch one by id.

Scoping the admin to the current tenant

The generated admin UI enforces the same tenant boundary as ScopedViewSet. An AdminSite is configured with a TenantScope, which names the tenant FK field present on every registered model plus a function that extracts the current tenant id from a request. The extractor typically reads djangors_contrib_tenancy::CurrentTenant from request extensions — which TenantResolutionLayer populates — so the admin and the REST layer resolve the tenant the same way:

#![allow(unused)]
fn main() {
use djangors_contrib_tenancy::CurrentTenant;
use djangors_core::Request;

let site = djangors_admin::AdminSite::new()
    .with_tenant_scoping("tenant", |req: &Request| req.ext::<CurrentTenant>().map(|t| t.0));
}

The tenant is resolved per request, not stored

This is the important part. A ModelAdmin is built once at registration and shared across every concurrent request behind an Arc. The tenant restriction is therefore passed as a TenantFilter argument (Option<(&'static str, i64)>) into each admin data method — changelist, export_csv_rows, get_by_pk, update_from_form, update_fields_from_form, create_from_form, and delete_by_pk — and is never held on the admin itself.

It must not be stored on the admin: state kept on the shared object could be overwritten by a second request while the first was awaiting the database, so one tenant’s request could read another tenant’s rows. A custom ModelAdmin must honour the tenant argument it receives rather than caching a tenant on self.

Superusers are unrestricted

TenantFilter is None for a superuser, so they see every tenant’s rows in the admin.

It fails closed

For a non-superuser on a tenant-scoped site, if no tenant id can be resolved from the request, the handler returns Forbidden. It does not fall back to running the query unscoped. Failing open here would silently expose every tenant’s rows to any admin user whose request happened to lack a resolved tenant — the exact leak the scoping exists to prevent.

What scoping actually guarantees

The trait-level docs make the guarantees precise:

  • Reads and writes by primary key are filtered by tenant too. A cross-tenant primary key reads as “not found” rather than leaking a row (get_by_pk), and updates refuse to touch another tenant’s row.
  • Deletes refuse to remove a row that belongs to another tenant.
  • Objects created through a scoped add-form have their tenant field forced to the current tenant, so a submitted form cannot plant a row in someone else’s tenant.
  • Object history is gated on the object’s visibility, because the log table has no tenant column of its own.

TenantResolutionFailed

Previously a database failure during the membership lookup was indistinguishable from “this user is not a member”, so an outage surfaced to clients as Unauthorized. The middleware now records a TenantResolutionFailed marker on the request when the lookup itself fails, and tenant_scope maps that to Internal (500) instead of Unauthorized. For callers who need to name the type, TenantResolutionService is now re-exported from the crate root.

What this doesn’t cover yet

Schema-per-tenant/database-per-tenant support, subdomain-based tenant resolution (v1 is X-Tenant-Id-header only), and automatic Postgres Row-Level Security policy generation are deliberately out of scope for this first slice.