Engineering note · MarOps

September 2026

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.execute calls,
  • 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.