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 |
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 |
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/checks.py |
Django system checks (W001--W004) |
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 (
-- Row matches current tenant
tenant_id = coalesce(
nullif(current_setting('rls.current_tenant', true), '')::int, NULL
)
-- OR admin bypass
OR coalesce(current_setting('rls.is_admin', true) = 'true', false)
)
WITH CHECK (
-- Same conditions for INSERT/UPDATE
tenant_id = coalesce(
nullif(current_setting('rls.current_tenant', true), '')::int, NULL
)
OR coalesce(current_setting('rls.is_admin', true) = 'true', false)
);
Key design decisions in the policy:
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, socoalescereturnsNULL, making thetenant_id = NULLcomparison false (no rows match).::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.
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.