Two walls, not one: multi-tenant Django with Postgres row-level security
How a small maritime back-office app keeps one shipowner's data from ever reaching another, even when the ORM filter is bypassed.
MarOps is a multi-tenant Django application for small dry-bulk shipowners.
Each office is a tenant; every table that holds office data carries a
tenant_id column. The rule in the project charter is blunt: "tenant_id on
every table, no exceptions. Adding it later is migration pain."
The column is the easy part. The hard part is making sure no query, ever, returns a row that belongs to someone else. This post describes the two walls we built, why one was not enough, and the one production gotcha that made the second wall silently useless for a day.
Wall 1: the ORM filters at SQL compile time
The obvious approach is a custom manager that filters by the current tenant. The trap in that approach is when the filter is evaluated. Django builds many querysets at import time, in forms, admin classes and model choices, long before any request has identified a tenant. If the manager reads the tenant when the queryset is constructed, those imports either fail or, worse, capture a stale tenant.
The fix is to defer the lookup to SQL compilation:
class CurrentTenantId(models.Expression):
def __init__(self):
super().__init__(output_field=models.BigIntegerField())
def as_sql(self, compiler, connection):
return "%s", [require_current_tenant().pk]
class TenantManager(models.Manager.from_queryset(TenantQuerySet)):
def get_queryset(self):
return super().get_queryset().filter(
tenant_id=CurrentTenantId(), deleted_at__isnull=True
)
CurrentTenantId is a bare expression. It renders to a parameter placeholder
and only asks the request context for the tenant when the compiler is
producing SQL, which is the moment the query actually runs. A queryset built
at import time is harmless; a queryset executed without a tenant raises
TenantContextRequired and stops.
The current tenant lives in a contextvars.ContextVar set by a middleware
that resolves the user's membership, so it is safe across async boundaries and
never leaks between requests.
Every model that carries tenant_id ships with a test asserting that a record
created under tenant A is invisible under tenant B. The charter calls this the
"tenant isolation test" and it is mandatory for every new model. That is the
first wall, and it catches the everyday mistakes.
Why one wall is not enough
The ORM wall protects code that goes through the default manager. It does not protect:
- raw SQL and
cursor.executecalls, - the escape hatch manager (
all_objects) that admin and reconciliation jobs legitimately use, - a future contributor who adds a model and forgets the manager,
- anything that talks to the database without Django at all: a reporting script, a backup tool, a debug session.
Each of those is a place where a single mistake exposes every tenant's data. For a product whose customers are competitors of one another, that is not an acceptable failure mode. We wanted the database to refuse.
Wall 2: Postgres row-level security
Postgres RLS lets a table carry a policy that filters every statement, for every role that is not the table owner or a superuser. The policy we install on every tenant table is:
ALTER TABLE "fleet_vessel" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "fleet_vessel" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "fleet_vessel"
USING (
NULLIF(current_setting('app.tenant_id', true), '') IS NULL
OR tenant_id IS NULL
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint
)
WITH CHECK ( -- same expression -- );
Three things to note.
The tenant comes from a connection setting, not a role. One database
role, one connection pool, and the middleware sets app.tenant_id on the
connection for the duration of the request:
def bind_db_tenant(tenant):
with connection.cursor() as cursor:
cursor.execute("SELECT set_config(%s, %s, false)", ["app.tenant_id", str(tenant.pk)])
set_config(..., false) makes the setting session-scoped, so it survives
across the transactions of one request. The middleware clears it in a
finally block. A connection_created signal handler re-applies it if
Django opens a fresh connection mid-request, which it does after a dropped
connection.
An empty setting means "no restriction", on purpose. Migrations, management commands, the nightly reconciliation job and the test suite all run without a tenant bound. Those paths are trusted code that legitimately works across tenants. The policy's first clause makes them work unchanged. The protection is for the web request path, where the setting is always bound.
WITH CHECK blocks writes too. USING filters reads; WITH CHECK
rejects any INSERT or UPDATE whose resulting row would not pass the
filter. A request bound to tenant A that tries to write a row for tenant B
gets a DatabaseError, not a silently misfiled record.
Keeping the policies in step with the schema
Policies are not part of Django's migration state, so we install them from a
post_migrate handler. It walks every registered model, keeps the ones with a
tenant_id relation, and re-runs the four statements above for each table.
A new tenant model is covered automatically on its first migrate; there is
nothing for a contributor to remember.
The one table exempted is the membership table itself: it is read to find the tenant before any tenant is bound, and it is already filtered by user.
A test pins this: it queries pg_policy and asserts that every table the
handler considers a tenant table has a forced tenant_isolation policy. If
someone adds a model and the handler somehow misses it, CI fails.
Proving the second wall actually stands
The interesting test cannot run as the test database's owner, because the
owner bypasses RLS. So the test creates a throwaway NOLOGIN role, grants it
table access, and switches to it with SET ROLE for the duration of the
assertions:
with non_superuser_session():
bind_db_tenant(tenant_a)
assert set(Vessel.all_objects.values_list("pk", flat=True)) == {vessel_a.pk}
bind_db_tenant(tenant_b)
assert set(Vessel.all_objects.values_list("pk", flat=True)) == {vessel_b.pk}
with pytest.raises(DatabaseError), transaction.atomic():
Vessel.all_objects.create(tenant=tenant_a, name="leak", imo_number="9000003")
unbind_db_tenant()
assert set(Vessel.all_objects.values_list("pk", flat=True)) == {vessel_a.pk, vessel_b.pk}
Note the use of all_objects, the unfiltered manager. The ORM wall is
deliberately out of the picture; only the database is standing between the two
tenants, and the test shows it holds for reads, for writes, and that unbinding
restores the cross-tenant view for trusted code.
The gotcha: superusers walk through walls
We deployed, ran the migration, saw nineteen policies installed, and assumed
we were done. We were not. The application was connecting as the Postgres
superuser created by the container's POSTGRES_USER, and superusers bypass
RLS unconditionally. So do table owners unless FORCE ROW LEVEL SECURITY is
set, which is why that line is there.
Every policy was in place and every policy was ignored.
The fix is operational, not code: a dedicated non-superuser role that owns the
tables (ownership is enough for migrate to run DDL), and the application
connects as that role. Because the bootstrap superuser also owns system
objects, REASSIGN OWNED refuses to run; tables and sequences have to be
transferred one by one in a DO block.
To stop this from regressing silently, there is a management command (output translated from Turkish):
$ python manage.py check_rls
role: marops_app (superuser: no)
tables with policy: 19, missing: 0
RLS active
It reports the connected role, whether it is a superuser, and which tenant tables lack a forced policy. It is the first thing run after every deploy.
What this costs
Very little. The policy expression is a comparison against a session
setting; on tables with a tenant_id index the planner uses the index as it
would for an explicit WHERE. The middleware adds one set_config round trip
per request. The full test suite, including the RLS tests, runs in a few
seconds.
What it buys is a property the ORM alone cannot give: a raw query, a forgotten manager, a debugging session with the application's credentials, none of them can return another tenant's row. The second wall does not replace the first; the first gives clear errors and clean code, the second makes the failure mode of the first survivable.
MarOps is a Django 5 / PostgreSQL 16 back-office application for small dry-bulk shipowners. Case study: zdeck.tech/case-study-marops.html.