Security Model¶
This page documents the security guarantees of django-rls-tenants, what it protects against, and what falls outside its scope.
Fail-Closed Design¶
The most important security property: if no GUC variable is set, the RLS policy returns zero rows. This is the fail-closed default.
-- When rls.current_tenant is empty or unset:
tenant_id = coalesce(
nullif(current_setting('rls.current_tenant', true), '')::int,
NULL
)
-- Evaluates to: tenant_id = NULL → always false → zero rows
This means:
- Unauthenticated requests see zero rows (middleware does not set GUCs).
- Misconfigured middleware results in zero rows, not data leaks.
- Background tasks without explicit context see zero rows.
- Raw SQL queries without GUC context see zero rows.
What RLS Guarantees¶
Database-Level Enforcement¶
RLS policies are enforced by PostgreSQL, not by Django. This means every query is filtered, including:
- ORM queries (
Model.objects.all()) - Raw SQL (
cursor.execute("SELECT * FROM ...")) dbshellsessions (python manage.py dbshell)- Migration data operations
- Third-party libraries that issue SQL directly
- Database functions and triggers
FORCE ROW LEVEL SECURITY¶
The FORCE keyword ensures RLS applies even to the table owner (the database user
that created the table). Without FORCE, the table owner bypasses all RLS policies.
INSERT/UPDATE Validation¶
The WITH CHECK clause validates writes:
INSERT: the tenant FK must match the GUC value.UPDATE: the updated row must still match the GUC value.
This prevents a tenant from inserting data for another tenant or reassigning rows to a different tenant.
No Silent Fallback¶
Unlike application-level filtering, there is no code path where a developer can accidentally skip the filter. The policy is always active.
What RLS Does Not Guarantee¶
Schema-Level Isolation¶
RLS operates at the row level, not the schema level. All tenants share the same tables, indexes, and sequences. This means:
- Table structure is shared: all tenants see the same columns.
- Sequences are shared: auto-increment IDs are not tenant-specific (tenant A might see order #1, #3, #5 while tenant B sees #2, #4, #6).
- Indexes are shared: a unique constraint applies across all tenants unless it includes the tenant FK.
Cross-Tenant Unique Constraints¶
If you need uniqueness within a tenant (e.g., unique invoice numbers per tenant), include the tenant FK in the constraint:
class Invoice(RLSProtectedModel):
number = models.CharField(max_length=50)
class Meta(RLSProtectedModel.Meta):
constraints = [
models.UniqueConstraint(
fields=["tenant", "number"],
name="unique_invoice_per_tenant",
),
]
Aggregate Leaks¶
RLS prevents row-level access, but metadata can leak through side channels:
- Timing: query time may reveal data volume.
- Sequence values: auto-increment gaps reveal other tenants' activity.
- Error messages: unique constraint violations may reveal cross-tenant data.
For high-security environments, consider UUIDs instead of auto-increment IDs.
Superuser Access¶
PostgreSQL superusers bypass all RLS policies. The FORCE keyword applies to
the table owner, not to superusers.
Warning
Never use a PostgreSQL superuser as your Django DATABASES user in production.
Use a regular user that owns the application tables.
Connection-Level State¶
GUC variables are connection-level state. If multiple requests share a connection
(via connection pooling or CONN_MAX_AGE), GUCs from one request could leak to
another if cleanup fails.
Mitigations:
- Default behavior: middleware clears GUCs in
process_response. - Safety net:
request_finishedsignal clears GUCs ifprocess_responseis skipped. - USE_LOCAL_SET:
SET LOCALscopes GUCs to the transaction, preventing leaks.
See Connection Pooling for details.
Threat Model¶
Protected Against¶
| Threat | Mitigation |
|---|---|
| Developer forgets ORM filter | RLS policy enforces isolation regardless |
| Raw SQL without tenant filter | RLS policy applies to all SQL |
| Third-party library bypasses ORM | RLS policy applies at database level |
| Missing middleware (misconfiguration) | Fail-closed: zero rows returned |
| Unauthenticated access | No GUCs set → zero rows |
| Tenant impersonation via SQL | GUC values are set server-side, not by the client |
| INSERT/UPDATE to wrong tenant | WITH CHECK clause validates writes |
Not Protected Against¶
| Threat | Explanation |
|---|---|
| PostgreSQL superuser access | Superusers bypass all RLS |
| Schema-level information leaks | Shared tables, sequences, indexes |
| Timing side channels | Query duration may reveal data volume |
| GUC leak via connection pooling | Mitigated but not eliminated (see above) |
| Application-level logic bugs | RLS filters rows, not application behavior |
| Denial of service | RLS does not rate-limit or throttle |
Recommendations¶
- Use a non-superuser database role in production.
- Enable
USE_LOCAL_SETif using connection pooling. - Use UUIDs for tenant PKs if sequence-based leaks are a concern.
- Include tenant FK in unique constraints that should be per-tenant.
- Run
check_rlsin CI to verify policies exist. - Audit bypass usage (
admin_context,bypass_flag) regularly. - Test fail-closed behavior with
assert_rls_blocks_without_context.