Protecting Models¶
RLSProtectedModel is the abstract base class that adds database-enforced tenant
isolation to your models.
Basic Usage¶
Inherit from RLSProtectedModel to protect a model:
from django.db import models
from django_rls_tenants import RLSProtectedModel
class Order(RLSProtectedModel):
title = models.CharField(max_length=255)
amount = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
This automatically provides:
- A
tenantForeignKey -- added dynamically via theclass_preparedsignal, pointing to yourTENANT_MODEL. RLSManageras the default manager -- withfor_user()for scoped queries.RLSConstraintinMeta.constraints-- generates the RLS policy during migrations.
What the Migration Creates¶
When you run makemigrations and migrate, the RLSConstraint generates:
-- Enable RLS on the table
ALTER TABLE "myapp_order" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "myapp_order" FORCE ROW LEVEL SECURITY;
-- Create the isolation policy
CREATE POLICY "myapp_order_tenant_isolation_policy"
ON "myapp_order"
USING (
CASE WHEN (SELECT current_setting('rls.is_admin', true)) = 'true'
THEN true
ELSE tenant_id = nullif(
(SELECT current_setting('rls.current_tenant', true)), '')::int
END
)
WITH CHECK (
CASE WHEN (SELECT current_setting('rls.is_admin', true)) = 'true'
THEN true
ELSE tenant_id = nullif(
(SELECT current_setting('rls.current_tenant', true)), '')::int
END
);
The USING clause filters SELECT, UPDATE, and DELETE. The WITH CHECK clause
validates INSERT and UPDATE operations.
Custom ForeignKey¶
If you need to customize the tenant FK (e.g., nullable for admin users, custom
on_delete, or a different related name), declare it directly on your model.
The class_prepared handler detects the existing field and skips the auto-generation.
This example uses an RLS-protected User, the opt-in case described in
Protecting the User Model:
from django.contrib.auth.models import AbstractUser
from django.db import models
from django_rls_tenants import RLSProtectedModel
class User(AbstractUser, RLSProtectedModel):
# Custom FK: nullable for admins, custom related_name
tenant = models.ForeignKey(
"myapp.Tenant",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="users",
)
class Meta(AbstractUser.Meta, RLSProtectedModel.Meta):
pass
@property
def is_tenant_admin(self) -> bool:
return self.is_superuser
@property
def rls_tenant_id(self) -> int | None:
return self.tenant_id if self.tenant_id else None
Important
The field must be named tenant (or whatever TENANT_FK_FIELD is set to)
for the auto-detection to work. If you name it differently, the handler will add
a duplicate FK.
Automatic Query Scoping¶
When a tenant context is active (via tenant_context(), admin_context(), or
RLSTenantMiddleware), RLSManager automatically adds WHERE tenant_id = X
to every query. No extra calls are needed:
from django_rls_tenants import tenant_context, admin_context
# Queries are automatically scoped -- no for_user() needed
with tenant_context(tenant_id=42):
orders = Order.objects.all() # WHERE tenant_id = 42
active = Order.objects.filter(active=True) # WHERE tenant_id = 42 AND active
# Admin context -- no filter (sees all rows)
with admin_context():
all_orders = Order.objects.all() # no tenant filter
In views with RLSTenantMiddleware, this happens transparently:
def list_orders(request):
# Middleware already set the context -- queries are auto-scoped
orders = Order.objects.filter(is_active=True)
return render(request, "orders/list.html", {"orders": orders})
Why this matters: the RLS policy is a CASE WHEN <admin> THEN true ELSE
tenant_id = ... END expression, which PostgreSQL can only apply as a post-scan
filter, never as an index condition. The automatic ORM-level WHERE tenant_id = X
filter is a plain indexable clause, so it is what lets the planner use the tenant
FK index and any composite index you add. See the
Performance & Indexing guide for the measured plans.
Strict Mode Guard¶
When STRICT_MODE=True in your RLS_TENANTS configuration, TenantQuerySet
evaluation methods raise NoTenantContextError if no RLS context is active.
This catches accidental unscoped queries at the point of execution:
from django_rls_tenants.exceptions import NoTenantContextError
# Without context -- raises in strict mode
Order.objects.count() # NoTenantContextError
Order.objects.all().first() # NoTenantContextError
Order.objects.filter(active=True).exists() # NoTenantContextError
# With context -- works normally
with tenant_context(tenant_id=42):
Order.objects.count() # OK
The following queryset methods are guarded: iteration (_fetch_all()), count(),
exists(), aggregate(), update(), delete(), iterator(), aiterator(),
bulk_create(), bulk_update(), get(), first(), last().
Queryset construction (e.g., Order.objects.filter(...)) does not trigger the
check -- only evaluation does. This matches Django's lazy queryset philosophy. Reading a
queryset whose results are already cached runs no query and is not checked either.
See Configuration for setup.
Using for_user()¶
for_user() is still available. It scopes queries to a specific user's tenant and sets
the GUC variables only while each query runs, restoring the previous values afterwards
(see Lazy QuerySet Evaluation):
# Explicit scoping (still works, useful outside context managers)
def list_orders(request):
orders = Order.objects.for_user(request.user)
return render(request, "orders/list.html", {"orders": orders})
If both auto-scoping and for_user() are active simultaneously, the query gets two
redundant WHERE tenant_id = X clauses. This is by design for defense-in-depth; the
cost of the double equality check per row is negligible.
Querying with Context Managers¶
Context managers set the RLS context for any code block:
from django_rls_tenants import tenant_context, admin_context
# As a specific tenant (queries auto-scoped)
with tenant_context(tenant_id=42):
orders = Order.objects.all() # only tenant 42's orders
# As admin (see all)
with admin_context():
all_orders = Order.objects.all() # all tenants
Meta Class Inheritance¶
If you define a custom Meta class, inherit from RLSProtectedModel.Meta to
preserve the constraint:
class Order(RLSProtectedModel):
title = models.CharField(max_length=255)
class Meta(RLSProtectedModel.Meta):
db_table = "orders"
ordering = ["-created_at"]
If you do not inherit from RLSProtectedModel.Meta, the RLSConstraint will not
be included and RLS will not be applied to the table.
Protecting the User Model¶
The Quick Start and the
User Integration guide keep User a plain AbstractUser. The
TenantUser protocol only needs the two properties, and Django reads user rows itself
during login and session loading before the request's tenant context is established.
Inheriting from RLSProtectedModel as well, class User(AbstractUser, RLSProtectedModel),
requires explicit Meta inheritance, as in the Custom ForeignKey
example above. Without class Meta(AbstractUser.Meta, RLSProtectedModel.Meta), Django
inherits AbstractUser.Meta alone, so the user table has no RLSConstraint and remains
unprotected even though the auth through tables are registered for RLS. The model keeps
Django's UserManager; its user queries rely on database RLS rather than the automatic
ORM scoping provided by RLSManager.
This is an opt-in with consequences beyond the User table:
An RLS-protected User also protects Django's auth through tables
AbstractUser declares the groups and user_permissions many-to-many fields.
Their auto-generated through tables, <app>_user_groups and
<app>_user_user_permissions, are M2M relations on an RLS-protected model, so
register_m2m_rls() adds an RLSM2MConstraint to both at startup (see
Many-to-Many Relationships) and check_rls expects a
policy on each of them. It lists them under an extra M2M through tables: section,
counts them in the total (the Quick Start project reports
All 4 RLS-protected tables verified. instead of All 1 ...), and exits non-zero
until you apply the policies with an AddM2MRLSPolicy migration or
setup_m2m_rls.
What this means in practice:
- Reads of
Userbefore the request context is set need a bypass. Evaluating Django's lazyrequest.usercalls the auth backend beforeRLSTenantMiddlewarecan establish the tenant context. Addextra_bypass_flagsto theRLSConstraint(next section) and set the flag in bothauthenticate()andget_user(), as shown in Use Case: Authentication. Auth writes such aslast_loginupdates also need a tenant or admin context; a read bypass does not allow them. - Group and permission lookups need a tenant or admin context.
RLSM2MConstrainthas noextra_bypass_flagsoption; its policy only passes for the admin GUC or when the referenced user belongs to the current tenant. Fresh database queries for group membership and direct permissions return no rows without that context. Inside an authenticated request the middleware provides one; in scripts, shells and Celery tasks evaluate the queries insidetenant_context()oradmin_context(). Users withtenant=Noneneed an admin context to read those relations. - Permission results can outlive the RLS context. Django's
ModelBackendcaches permissions on the user instance. A lookup before entering a context can cache an empty result, and a result loaded inside a context can remain available after it exits. Use a fresh user instance when changing contexts. Also, active Django superusers passhas_perm()without querying these tables, and custom backends may grant permissions independently of them.
The library's example/ user and tests/test_app.AdminUser keep a plain AbstractUser.
The test app's separate ProtectedUser exercises extra bypass flags on an ordinary
RLS-protected model; it does not inherit Django's auth fields.
Extra Bypass Flags¶
For edge cases (e.g., authentication middleware that needs to read user records before the tenant context is set), you can add custom bypass flags to the RLS policy:
from django_rls_tenants.rls import RLSConstraint
class User(AbstractUser, RLSProtectedModel):
# Keep the nullable tenant FK and protocol properties from Custom ForeignKey.
class Meta(AbstractUser.Meta, RLSProtectedModel.Meta):
constraints = [
RLSConstraint(
field="tenant",
name="%(app_label)s_%(class)s_rls_constraint",
extra_bypass_flags=["rls.auth_bypass"],
),
]
Extra bypass flags are added to the USING clause only (not WITH CHECK), so they
allow reading but not writing without proper tenant context.
See Bypass Mode for more details.
Many-to-Many Relationships¶
M2M relationships between RLSProtectedModel subclasses are automatically detected
and protected. When Django auto-generates a through table for an M2M field,
django-rls-tenants registers an RLSM2MConstraint on it during AppConfig.ready().
Automatic Protection¶
Simply define your M2M fields as usual:
from django_rls_tenants import RLSProtectedModel
class Tag(models.Model):
"""Non-RLS model (shared across tenants)."""
name = models.CharField(max_length=100)
class Project(RLSProtectedModel):
name = models.CharField(max_length=100)
members = models.ManyToManyField("User") # both sides RLS-protected
tags = models.ManyToManyField(Tag) # one side RLS-protected
class Meta(RLSProtectedModel.Meta):
db_table = "myapp_project"
The auto-generated through tables (myapp_project_members, myapp_project_tags)
will get EXISTS-based subquery RLS policies that check each FK reference belongs
to the current tenant.
Migration Operation¶
For explicit control in migrations, use AddM2MRLSPolicy:
from django.db import migrations
import django_rls_tenants.operations
class Migration(migrations.Migration):
operations = [
django_rls_tenants.operations.AddM2MRLSPolicy(
m2m_table="myapp_project_members",
from_model="myapp.Project",
to_model="myapp.User",
from_fk="project_id",
to_fk="user_id",
from_tenant_fk="tenant", # or None if not RLS-protected
to_tenant_fk="tenant",
),
]
This operation is reversible -- rolling back the migration drops the policy and disables RLS on the through table.
Supported Scenarios¶
| Scenario | Example | Policy checks |
|---|---|---|
| Both sides protected | Project.members (Project + User) |
Both FK subqueries |
| One side protected | Project.tags (Project + Tag) |
Only the protected FK |
| Self-referential | SelfRefModel.friends |
Both FKs against same table |
Explicit Through Models¶
If you define an explicit through model (e.g., with extra fields), make it a
RLSProtectedModel subclass and manage RLS yourself -- the auto-detection skips
explicit through models.
class ProjectMembership(RLSProtectedModel):
project = models.ForeignKey(Project, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
role = models.CharField(max_length=50)
class Meta(RLSProtectedModel.Meta):
db_table = "myapp_project_membership"
class Project(RLSProtectedModel):
members = models.ManyToManyField("User", through=ProjectMembership)
Multiple Protected Models¶
You can have as many RLSProtectedModel subclasses as needed. Each gets its own
RLS policy: