Skip to content

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 the rls/ 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 ...: the CASE structure clearly separates admin bypass from tenant matching. For admin queries the tenant check is skipped. For normal tenant queries the expression reduces to a simple tenant_id = <value> equality. Note: since current_setting() is VOLATILE, both CASE WHEN and OR-based policies have equivalent per-row evaluation cost. The primary performance benefit comes from auto-scoping (WHERE tenant_id = X in get_queryset()), which enables composite index usage.
  • current_setting(..., true): the true parameter returns empty string instead of raising an error when the GUC is not set. This enables fail-closed behavior.
  • nullif(..., ''): converts empty string to NULL, making the tenant_id = NULL comparison false (no rows match -- fail-closed).
  • ::int cast: ensures type safety. The cast type is configurable via TENANT_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:

  1. Django ORM filter: qs.filter(tenant_id=user.rls_tenant_id) -- catches misconfigurations at the application level.
  2. 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.