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
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 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 (
    -- 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): 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, so coalesce returns NULL, making the tenant_id = NULL comparison false (no rows match).
  • ::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.

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.