Why Row-Level Security?¶
The Multitenancy Problem¶
Every SaaS application must scope data to the current tenant. In Django, the
typical approach is application-level filtering — custom managers, middleware
that injects .filter(tenant=request.tenant), or ORM rewriting.
This works until it doesn't:
- A developer writes a raw SQL query and forgets the WHERE clause.
- A management command iterates all rows without setting tenant context.
- Someone opens
dbshellto debug and runsSELECT * FROM orders. - A data migration touches rows across all tenants.
In every case, the application-level filter is the only gate. If it is bypassed — intentionally or by accident — tenant data leaks silently.
How Application-Level Filtering Works¶
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Request │────▶│ Django ORM │────▶│ PostgreSQL │
│ │ │ .filter() │ │ │
└─────────────┘ └──────┬──────┘ └─────────────┘
│
This is the only gate.
Raw SQL, dbshell, and
migrations bypass it.
The ORM filter is a convenience, not a guarantee. Anything that talks to the database without going through the filtered manager has unrestricted access.
What PostgreSQL Row-Level Security Does¶
RLS moves the filter from the application into the database itself. A policy
attached to the table tells PostgreSQL: "for every query, only return rows
where tenant_id matches the current session variable."
┌──────────────┐ ┌─────────────────────────────────┐
│ Any Query │────▶│ PostgreSQL │
│ (ORM, raw │ │ │
│ SQL, dbshell)│ │ ┌───────────────────────────┐ │
└──────────────┘ │ │RLS Policy: │ │
│ │tenant_id = current_setting│ │
│ │('rls.current_tenant') │ │
│ └───────────────────────────┘ │
│ │ │
│ ▼ │
│ Only matching rows returned │
└─────────────────────────────────┘
The SQL behind this is straightforward:
CREATE POLICY rls_policy ON orders
USING (tenant_id = COALESCE(
NULLIF(current_setting('rls.current_tenant', true), ''),
''
)::integer);
Key properties:
- Every query is filtered — ORM, raw SQL,
cursor.execute(),dbshell. - INSERT and UPDATE are checked — you cannot write a row for a tenant you do not belong to.
- FORCE ROW LEVEL SECURITY — even the table owner is subject to the policy.
The "Missing Context = Zero Rows" Guarantee¶
The most important safety property: if the session variable is not set, the
COALESCE expression evaluates to an empty string. An empty string does not
match any tenant_id, so the query returns zero rows.
This is fail-closed by design:
- Forgot to set the GUC? Zero rows.
- Middleware didn't run? Zero rows.
- Background task without context? Zero rows.
No configuration, no fallback, no silent data leak. The database itself enforces the boundary.
How django-rls-tenants Implements This¶
- Middleware reads
request.userand extracts tenant identity via theTenantUserprotocol. - GUC variables (
rls.current_tenant,rls.is_admin) are set on the PostgreSQL connection usingSETorSET LOCAL. - RLS policies are generated automatically when you run Django migrations — no hand-written SQL required.
- Context managers (
tenant_context,admin_context) handle non-request contexts like Celery tasks and management commands. - Bypass mode allows admin and migration scenarios to access all rows when explicitly requested.
When RLS Is the Right Choice¶
- You are building a multi-tenant SaaS on a shared database.
- You want database-enforced isolation that cannot be bypassed by application code.
- Developers on your team use raw SQL,
dbshell, or management commands. - You have 10+ tenants and want to avoid schema-per-tenant overhead.
- You want standard Django migrations that run once, not once per tenant.
When RLS Is NOT the Right Choice¶
- You need per-tenant schema customization (different columns per tenant). RLS operates on a single shared schema.
- You are not using PostgreSQL. RLS is a PostgreSQL feature.
- You have fewer than 5 tenants and schema-per-tenant is simpler and manageable for your operational needs.
- You need database-level isolation (separate databases per tenant) for compliance reasons.