Architecture¶
django-rls-tenants is organized into two internal layers with a strict import boundary.
Two-Layer Design¶
┌─────────────────────────────────────────────┐
│ tenants/ layer │
│ (Django multitenancy: models, middleware, │
│ context managers, managers, testing) │
│ │
│ Imports from: rls/, django, stdlib │
├─────────────────────────────────────────────┤
│ rls/ layer │
│ (Generic PostgreSQL RLS primitives: GUC │
│ helpers, RLSConstraint, context managers) │
│ │
│ Imports from: django, stdlib ONLY │
└─────────────────────────────────────────────┘
rls/ Layer¶
The lower layer provides generic PostgreSQL RLS primitives with zero knowledge of tenants, users, or Django multitenancy concepts:
| Module | Purpose |
|---|---|
rls/guc.py |
set_guc(), get_guc(), clear_guc() -- manage PostgreSQL GUC variables |
rls/constraints.py |
RLSConstraint -- Django BaseConstraint that generates CREATE POLICY SQL. RLSM2MConstraint -- subquery-based policies for M2M join tables |
rls/context.py |
rls_context() -- generic context manager for setting/restoring GUC variables |
This layer is reusable outside the multitenancy use case. You could use RLSConstraint
and rls_context() to implement any RLS-based access control pattern.
tenants/ Layer¶
The upper layer builds Django multitenancy on top of rls/:
| Module | Purpose |
|---|---|
tenants/conf.py |
RLSTenantsConfig -- reads RLS_TENANTS settings |
tenants/models.py |
RLSProtectedModel -- abstract base with auto-FK and RLSConstraint. register_m2m_rls() -- auto-detection of M2M fields |
operations.py |
AddM2MRLSPolicy -- reversible migration operation for M2M RLS policies |
tenants/managers.py |
TenantQuerySet, RLSManager -- lazy GUC setting at query eval time |
tenants/context.py |
tenant_context(), admin_context(), @with_rls_context |
tenants/middleware.py |
RLSTenantMiddleware -- per-request RLS context |
tenants/bypass.py |
set_bypass_flag(), clear_bypass_flag(), bypass_flag() |
tenants/testing.py |
Test helpers: rls_bypass, rls_as_tenant, assertion functions |
tenants/types.py |
TenantUser protocol |
tenants/state.py |
get_current_tenant_id(), set_current_tenant_id(), reset_current_tenant_id() -- ContextVar-based tenant state for auto-scoping. Also get_rls_context_active(), set_rls_context_active(), reset_rls_context_active() -- tracks whether any RLS context is active (used by strict mode) |
tenants/checks.py |
Django system checks (W001--W007) |
Import Boundary¶
The rls/ layer must never import from tenants/. This boundary is enforced by
tests/test_layering.py, which scans all rls/ source files for forbidden imports.
This constraint ensures:
- The
rls/primitives remain generic and reusable. - Changes to the
tenants/layer cannot break therls/layer. - The dependency graph is a clean DAG (no cycles).
GUC Variable Flow¶
PostgreSQL GUC (Grand Unified Configuration) variables are the mechanism for communicating tenant identity from Django to RLS policies:
Django Application PostgreSQL
───────────────── ──────────
Middleware reads set_config('rls.current_tenant', '42')
request.user ──────▶ set_config('rls.is_admin', 'false')
ORM query executes ──────▶ SELECT * FROM orders
WHERE ... (+ RLS policy filter)
RLS policy evaluates:
current_setting('rls.current_tenant') = '42'
tenant_id = 42 ✓ (row returned)
tenant_id = 99 ✗ (row hidden)
Middleware cleanup ──────▶ set_config('rls.current_tenant', '')
set_config('rls.is_admin', '')
Policy Generation¶
The RLSConstraint generates SQL during Django migrations:
-- Generated by RLSConstraint
ALTER TABLE "myapp_order" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "myapp_order" FORCE ROW LEVEL SECURITY;
CREATE POLICY "myapp_order_tenant_isolation_policy"
ON "myapp_order"
USING (
CASE WHEN current_setting('rls.is_admin', true) = 'true'
THEN true
ELSE tenant_id = nullif(
current_setting('rls.current_tenant', true), '')::int
END
)
WITH CHECK (
CASE WHEN current_setting('rls.is_admin', true) = 'true'
THEN true
ELSE tenant_id = nullif(
current_setting('rls.current_tenant', true), '')::int
END
);
Key design decisions in the policy:
CASE WHEN ... THEN true ELSE ...: theCASEstructure clearly separates admin bypass from tenant matching. For admin queries the tenant check is skipped. For normal tenant queries the expression reduces to a simpletenant_id = <value>equality. Note: sincecurrent_setting()isVOLATILE, bothCASE WHENandOR-based policies have equivalent per-row evaluation cost. The primary performance benefit comes from auto-scoping (WHERE tenant_id = Xinget_queryset()), which enables composite index usage.current_setting(..., true): thetrueparameter returns empty string instead of raising an error when the GUC is not set. This enables fail-closed behavior.nullif(..., ''): converts empty string toNULL, making thetenant_id = NULLcomparison false (no rows match -- fail-closed).::intcast: ensures type safety. The cast type is configurable viaTENANT_PK_TYPE.FORCE ROW LEVEL SECURITY: ensures RLS applies even to the table owner, preventing accidental bypass through superuser connections.
M2M Policy Generation¶
For M2M through tables, RLSM2MConstraint and AddM2MRLSPolicy generate
EXISTS-based subquery policies instead of direct tenant_id checks, since
through tables have no tenant FK column:
-- Generated by RLSM2MConstraint / AddM2MRLSPolicy
CREATE POLICY "myapp_project_members_m2m_rls_policy"
ON "myapp_project_members"
USING (
CASE WHEN current_setting('rls.is_admin', true) = 'true'
THEN true
ELSE EXISTS (SELECT 1 FROM "myapp_project"
WHERE id = project_id
AND tenant_id = nullif(
current_setting('rls.current_tenant', true), '')::int)
AND EXISTS (SELECT 1 FROM "myapp_user"
WHERE id = user_id
AND tenant_id = nullif(
current_setting('rls.current_tenant', true), '')::int)
END
)
WITH CHECK (...same...);
EXISTS is preferred over IN (SELECT ...) because it gives the PostgreSQL
planner more optimisation flexibility (semi-join vs nested loop).
The auto-detection in register_m2m_rls() runs during AppConfig.ready() and
discovers all auto-generated through tables on RLSProtectedModel subclasses.
Explicit through models are skipped -- users manage those via standard
RLSProtectedModel inheritance.
Lazy QuerySet Evaluation¶
TenantQuerySet.for_user() solves a timing problem: Django querysets are lazy, so
the GUC must be set when the query actually executes, not when for_user() is called.
# qs is created here, but no SQL runs yet
qs = Order.objects.for_user(user)
# Query might be evaluated much later (e.g., in a template)
for order in qs: # <-- GUC is set HERE, just before the SELECT
print(order.title)
The solution: _fetch_all() is overridden to set GUC variables immediately before
calling super()._fetch_all(), with a try/finally block to clean up afterward.
Defense-in-Depth¶
For tenant (non-admin) users, for_user() applies two layers of filtering:
- Django ORM filter:
qs.filter(tenant_id=user.rls_tenant_id)-- catches misconfigurations at the application level. - RLS policy:
current_setting('rls.current_tenant')comparison -- enforces isolation at the database level, even for raw SQL.
Neither layer alone is sufficient:
- The ORM filter can be bypassed with raw SQL.
- The RLS policy could be misconfigured.
Together, they provide defense-in-depth.