Isolating tenants with Postgres row-level security.
Multi-tenant isolation that lives in your application code is isolation you can forget to apply. One missing WHERE tenant_id = ? and a bug becomes a data breach. The more durable answer is to make the database itself refuse to return rows that are not yours.
The trap: a superuser app
Like a lot of systems, the platform started out connecting to Postgres as a privileged role. That is convenient and quietly dangerous: a privileged role bypasses row-level security entirely, so even if you write the policies, they do nothing. Isolation ends up resting on every query being written correctly, forever, by everyone.
The fix: least privilege plus forced RLS
The platform moved the application to a dedicated, least-privilege database role that owns the tables and runs with row-level security forced. With FORCE, even the table owner is subject to the policies. Each row is scoped to its tenant and user, and the database evaluates that scoping on every read and write — not the application.
- The app role can no longer see across tenants, even with a crafted or buggy query.
- A forgotten filter in application code is no longer a breach; the database still withholds the rows.
- Personal task data is partitioned per user on top of the tenant boundary.
What it cost
Moving off a superuser connection is not free. Ownership, grants, and policies have to be right across the whole schema, and a migration has to wall every table at once — a partial rollout is worse than none. The payoff is that isolation becomes a property of the system rather than a discipline the team has to sustain query by query.
Where it fits
This is one layer of the isolation story. Above it sits a ticketed data broker, so agents never touch personal data directly — they request scoped, attributable access. Identity is handled with OIDC/Keycloak in front. Row-level security is the floor the rest stands on.