ICVOSS DJANGO PACKAGE REGISTRY

The package index django-boundary Add RLS policies with migrations

Add RLS policies with migrations

Documentation

Goal

Enforce tenant isolation at the database level by adding PostgreSQL Row Level Security (RLS) to a tenant-scoped table, using boundary's migration operations. RLS is a second layer of defence: even raw SQL, a leaked connection, or a bug in your ORM scoping cannot read or write another tenant's rows.

The ORM layer (the tenant manager and middleware) works on any database. RLS enforcement is PostgreSQL-only and requires PostgreSQL 14 or later.

Prerequisites

Steps

The three operations live in boundary.migrations_ops:

Operation Constructor signature Effect
EnableRLS EnableRLS(model_name) ENABLE + FORCE ROW LEVEL SECURITY on the table
CreateTenantPolicy CreateTenantPolicy(model_name, tenant_column=None) Creates the isolation and admin-bypass policies plus the LEAKPROOF helper function
DropTenantPolicy DropTenantPolicy(model_name, tenant_column=None) Drops both policies

model_name is the model's name within its app (for example "Booking"), exactly as you would pass to migrations.CreateModel. It is not the app label or app_label.Model dotted path; the operation resolves the table from migration state via app_label.

  1. Create an empty migration in the app that owns the model:

bash python manage.py makemigrations bookings --empty --name enable_rls

  1. Add EnableRLS followed by CreateTenantPolicy to the operations list. CreateTenantPolicy must run after EnableRLS, and both must run after the table and its tenant FK column exist.

```python from django.db import migrations

from boundary.migrations_ops import CreateTenantPolicy, EnableRLS

class Migration(migrations.Migration): dependencies = [ ("bookings", "0001_initial"), ]

   operations = [
       EnableRLS("Booking"),
       CreateTenantPolicy("Booking"),
   ]

```

  1. If you create the table in the same migration, order the operations so the table exists first:

python operations = [ migrations.CreateModel(name="Booking", fields=[...]), EnableRLS("Booking"), CreateTenantPolicy("Booking"), ]

  1. If your tenant column is not tenant_id, pass tenant_column. The value is the database column name, so include the _id suffix for a foreign key:

python CreateTenantPolicy("Booking", tenant_column="org_id")

CreateTenantPolicy inspects the FK target's primary key and generates the matching cast in the LEAKPROOF function, so both UUID and integer tenant primary keys work without further configuration.

  1. Apply the migration:

bash python manage.py migrate bookings

What CreateTenantPolicy creates

How the session variables drive the policies

The policies read two PostgreSQL session variables at query time:

```python from django.db import connection

with connection.cursor() as cursor: cursor.execute("SELECT set_config('app.boundary_admin', 'true', true)") ```

Important: the generated SQL reads BOUNDARY_DB_SESSION_VAR and BOUNDARY_ADMIN_FLAG_VAR when the migration runs, so the policies test the same session variables the runtime sets. Because the names are baked into the migration SQL at apply time, changing either setting after the policies exist requires re-running the policy migration (drop and recreate, for example via DropTenantPolicy then CreateTenantPolicy) so the database picks up the new names. If the setting and the policy ever disagree, isolation breaks (every query returns zero rows), so keep them in sync.

A note on superusers

PostgreSQL superusers bypass RLS even with FORCE ROW LEVEL SECURITY. Run your application on a non-superuser database role so the policies actually apply. Verify enforcement using that role, not a superuser connection.

Verify it worked

  1. Confirm RLS is enabled and forced on the table:

sql SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'bookings_booking'; -- expect: t | t

  1. Confirm both policies and the helper function exist:

```sql SELECT polname FROM pg_policy WHERE polrelid = 'bookings_booking'::regclass ORDER BY polname; -- expect: boundary_admin_bypass, boundary_tenant_isolation

SELECT proleakproof FROM pg_proc WHERE proname = 'boundary_current_tenant_id'; -- expect: t ```

  1. Confirm isolation as a non-superuser role. With the tenant variable set, only that tenant's rows are visible:

sql BEGIN; SELECT set_config('app.current_tenant_id', '<tenant-a-pk>', true); SELECT count(*) FROM bookings_booking; -- only tenant A's rows COMMIT;

With no tenant set, you should see zero rows:

sql BEGIN; SELECT set_config('app.current_tenant_id', '', true); SELECT count(*) FROM bookings_booking; -- 0 COMMIT;

  1. The system check boundary.E006 flags tenant-scoped tables that are missing RLS. Run python manage.py check and confirm the table is no longer reported.

Reversibility

All three operations are fully reversible:

Roll back with python manage.py migrate bookings <previous_migration> or migrate bookings zero.

Use DropTenantPolicy when you want to remove the policies but keep RLS enabled on the table (for example to replace them with custom policies), rather than reversing the whole migration.

Common pitfalls