Add RLS policies with migrations
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
- A model that is already tenant-scoped (it has a tenant foreign key, the column is
tenant_idby default). See the README for how models acquire the tenant FK. BOUNDARY_TENANT_MODELconfigured in settings.- PostgreSQL 14+ for the policies to actually exist. You do not need a separate migration path for other backends:
EnableRLS,CreateTenantPolicyandDropTenantPolicyeach log one line and skip on any other database, emitting no DDL and raising nothing, so the same migration file applies everywhere and a SQLite development database builds from it unchanged. What you lose there is the RLS layer itself, not the migration.AdoptTenantAppis the one exception and still refuses off PostgreSQL; see Common pitfalls for why the two differ. - A migrating role that owns the tables and can create in the schema, and a non-superuser application role. See Grants for the role that migrates.
- The migration that creates the table (or adds the tenant FK column) already exists, or is created in the same file before the RLS operations.
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 boundary_current_tenant_id() 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.
- Create an empty migration in the app that owns the model:
bash
python manage.py makemigrations bookings --empty --name enable_rls
- Add
EnableRLSfollowed byCreateTenantPolicyto theoperationslist.CreateTenantPolicymust run afterEnableRLS, 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"),
]
```
- 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"),
]
- If your tenant column is not
tenant_id, passtenant_column. The value is the database column name, so include the_idsuffix 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 helper function, so both UUID and integer tenant primary keys work without further configuration.
- Apply the migration:
bash
python manage.py migrate bookings
What CreateTenantPolicy creates
- A helper function,
boundary_current_tenant_id(), which reads the session variable, casts it to the tenant PK type, and returnsNULLon any error (so a missing or empty value yields zero rows rather than an error). It is declaredLEAKPROOFonly whenBOUNDARY_FUNCTION_LEAKPROOFis set (default off), sinceLEAKPROOFrequires a superuser that managed Postgres does not grant.LEAKPROOFis a planner optimisation, not an isolation requirement. SeeBOUNDARY_FUNCTION_LEAKPROOF. - An isolation policy,
boundary_tenant_isolation, with bothUSINGandWITH CHECKclauses.USINGfiltersSELECT,UPDATE, andDELETE.WITH CHECKblocksINSERT(andUPDATE) of rows belonging to a different tenant. - An admin-bypass policy,
boundary_admin_bypass, that returns all rows when the admin flag session variable is'true'.
How the session variables drive the policies
The policies read two PostgreSQL session variables at query time:
- The tenant variable, configured via
BOUNDARY_DB_SESSION_VAR(defaultapp.current_tenant_id). Boundary sets this for you insideTenantContext: entering a tenant context runsSELECT set_config(<var>, <tenant_pk>, true), and thetruescopes it to the current transaction. This is why request handling andTenantContext.using(...)must run inside a transaction (the middleware wraps requests intransaction.atomic()whenBOUNDARY_WRAP_ATOMICisTrue). - The admin-bypass variable, configured via
BOUNDARY_ADMIN_FLAG_VAR(defaultapp.boundary_admin). Setting it to'true'makesboundary_admin_bypassreturn every row, regardless of tenant. Use this only for trusted cross-tenant work such as management commands and analytics. Boundary does not set this flag automatically; set it explicitly withset_configwhen you need it, and prefer transaction-local scope:
```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_VARandBOUNDARY_ADMIN_FLAG_VARwhen 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 viaDropTenantPolicythenCreateTenantPolicy) 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.
Grants for the role that migrates
The note above rules out a superuser, which raises the question it does not answer: what does a non-superuser role need in order to apply these operations at all? Three things.
| Needed | Why |
|---|---|
CONNECT on the database |
To connect at all. |
USAGE and CREATE on the schema |
CREATE is the privilege PostgreSQL requires to create an object in a schema. CreateTenantPolicy emits CREATE OR REPLACE FUNCTION boundary_current_tenant_id() unqualified, so it lands in the first schema on the role's search_path, and the role must be able to create there. CREATE is also what lets migrate create the tables themselves when the same role runs the whole migration. |
| Ownership of each table the operations touch | ALTER TABLE ... ENABLE ROW LEVEL SECURITY, FORCE ROW LEVEL SECURITY and CREATE POLICY are owner-restricted: PostgreSQL permits them to the table's owner (or a superuser), and no grantable privilege substitutes. A role that created the table is its owner, so a role that runs the whole migration satisfies this automatically. |
Everything in that table is PostgreSQL's documented privilege model, checked against the statements these operations actually emit (boundary/migrations_ops.py). Granting CREATE on the schema does not confer ownership of tables someone else created: if another role owns the tables, EnableRLS fails on ownership no matter what schema privileges the migrating role holds. Transfer ownership (ALTER TABLE ... OWNER TO) or run the policy migration as the owner.
On PostgreSQL 15 and later the grant must be explicit, because 15 removed the default CREATE that the public schema previously granted to PUBLIC. On 14 the grant is redundant but harmless, so writing it unconditionally is correct across every version this package supports:
GRANT CONNECT ON DATABASE myproject TO myproject_app;
GRANT USAGE, CREATE ON SCHEMA public TO myproject_app;
One role that both migrates and serves traffic is the simplest arrangement and the one the tutorial uses, since it is enough to get both isolation layers working. Production deployments commonly split it: a migrating role that owns the schema and its tables, and a narrower runtime role that only reads and writes. Both must be non-superuser and non-BYPASSRLS; ownership matters for the migrating role, and RLS enforcement matters for both.
Verify it worked
- Confirm RLS is enabled and forced on the table:
sql
SELECT relrowsecurity, relforcerowsecurity
FROM pg_class WHERE relname = 'bookings_booking';
-- expect: t | t
- 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 proname, proleakproof FROM pg_proc WHERE proname = 'boundary_current_tenant_id'; -- expect: one row. proleakproof is f by default, or t if you set -- BOUNDARY_FUNCTION_LEAKPROOF = True on a superuser cluster. ```
- 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;
- The system check
boundary.E006flags tenant-scoped tables that are missing RLS. Runpython manage.py checkand confirm the table is no longer reported.
Reversibility
All three operations are fully reversible:
- Reversing
EnableRLSdrops the boundary policies, then disables and unforces RLS on the table. - Reversing
CreateTenantPolicydrops both policies (it leaves the helper function in place, since it is shared across tables). - Reversing
DropTenantPolicyre-creates both policies.
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
- Running on a non-PostgreSQL database. Not a pitfall:
EnableRLS,CreateTenantPolicyandDropTenantPolicyapply on PostgreSQL and are a logged no-op anywhere else, emitting onelogger.infoline each on theboundary.migrationslogger (naming the operation, the model, the alias and the vendor) and no DDL. Keep one set of migration files and run them unchanged on SQLite locally; do not wrap them in a backend conditional and do not route them away. What you lose on that backend is the RLS layer itself, not the migration: the model keeps its ORM-layer filtering, andboundary.E006/boundary.W003go quiet, so a cleancheckthere says nothing about the database layer.AdoptTenantAppis the exception and still refuses off PostgreSQL, because an adopted table has no ORM layer beneath the policy. - Connecting as a superuser. Superusers bypass RLS. Isolation will appear broken (all rows visible) until you switch to a non-superuser role.
only superuser can define a leakproof functionon managed Postgres.LEAKPROOFmay only be declared by a superuser, which DigitalOcean, RDS, Cloud SQL, Azure, Heroku, and Supabase do not grant. This is off by default (BOUNDARY_FUNCTION_LEAKPROOF = False), so managed Postgres works out of the box; the optimisation only affects planner qualifier ordering, not isolation. SetBOUNDARY_FUNCTION_LEAKPROOF = Trueonly on a self-managed cluster where the migrating role is a superuser.- Changing
BOUNDARY_DB_SESSION_VARorBOUNDARY_ADMIN_FLAG_VARafter the policies exist. The names are baked into the policy SQL at migration time. If you change either setting later, re-run the policy migration so the database picks up the new name, otherwise the runtime variable and the policy disagree and isolation breaks. - Querying outside a transaction.
set_config(..., true)is transaction-scoped. Outside a transaction the tenant variable is not applied, so a non-superuser sees zero rows.TenantMiddlewareandTenantContext.using()(which underliestenant_scoped,boundary_run,boundary_run_all, and the Celery worker restoration) both open a transaction for you automatically whenBOUNDARY_WRAP_ATOMICisTrue(the default) and none is already active, so this normally cannot happen through boundary's own APIs. It remains a risk if you call_set_db_session()directly, disableBOUNDARY_WRAP_ATOMIC, or run raw SQL outside any of boundary's context helpers, in which case wrap the work intransaction.atomic()yourself. - A migrating role that does not own the tables.
EnableRLSandCreateTenantPolicyemit owner-restricted statements, so they fail for a role holding every grantable privilege on a table it does not own. See Grants for the role that migrates. - Wrong
model_name. Pass the bare model name ("Booking"), not the app label or a dotted path. The wrong name raises a lookup error during migration. - Forgetting the
_idsuffix intenant_column. The argument is the database column name; for a foreign key that istenant_id,org_id, and so on, nottenantororg. - Wrong ordering.
CreateTenantPolicyafterEnableRLS, and both after the table and tenant FK column exist. Reorder or add adependenciesentry if the column is added in a later migration.
Related
- README: Row Level Security for the operation reference and type-awareness details.
- README: settings for the full
BOUNDARY_settings table, includingBOUNDARY_DB_SESSION_VAR,BOUNDARY_ADMIN_FLAG_VAR, andBOUNDARY_WRAP_ATOMIC. - README: system checks for
boundary.E006(tenant-scoped table missing RLS).