architecture

Postgres row-level security for multi-tenant AI agents

How PANTHEON keeps one business's data out of another's AI agent: forced row-level security, a role that can't bypass it, and the exceptions.

An AI agent that serves many businesses from one database has a problem a single-tenant app never meets: the model writes the request, and the model can be talked into things. If the only thing standing between business A's customers and business B's agent is a WHERE tenant_id = ? in application code, then every query is one forgotten clause from a leak, and some of those queries were shaped by text a stranger typed.

So in PANTHEON the boundary is in the database, not in the application.

The rule, in four parts

Every tenant table is under forced row-level security. ENABLE is not enough: without FORCE, the table's owner skips the policy. Each table carries one policy, and it reads the tenant from the transaction, not from the query:

tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid

The application connects as a role that cannot get round it. pantheon_app is NOSUPERUSER NOBYPASSRLS. Row-level security does not bind superusers or roles with BYPASSRLS, so the runtime must never connect as either. The superuser exists for migrations and nothing else.

The tenant is set per transaction. When a session begins a transaction, a listener runs set_config('app.current_tenant', <tenant>, true). The true makes it local to that transaction, so a pooled connection cannot carry one business's tenant into the next request.

No tenant means no rows. If the setting is empty, NULLIF turns it into NULL, tenant_id = NULL is never true, and a SELECT returns nothing. It fails closed. The opposite default, every row when nothing is set, is the one that produces a passing test and a breach.

Tests that would fail

  • test_no_tenant_context_sees_nothing: a query with no tenant set returns zero rows.
  • test_tenant_cannot_read_across_tenants and test_cannot_write_as_another_tenant: tenant A cannot read or write B.
  • test_every_tenant_table_is_under_forced_rls: reads pg_class and fails for any table with a tenant_id column that is not enabled, forced and covered by exactly one policy. Before this test existed, the RLS tests named specific tables, so a new unprotected table could have shipped unnoticed (found by my own audit, 25 September).
  • test_privileged_role_bypasses_rls: the superuser does see everything, so the test would notice if the app were ever configured with it.

The exceptions, named

A rule with silent exceptions is not a rule. Four tables have a tenant_id and no RLS, and each is on an explicit allow-list in that coverage test, with the reason: API keys, OAuth identities and OAuth refresh tokens are looked up before any tenant is known (the key or token is what names the tenant), and Stripe webhooks arrive with no tenant and find their payment by its id.

There is one more, and it is deliberate. Letting one business's AI call another's over MCP needs exactly one read across tenants: resolve the handle, check the grant. That read is a single SECURITY DEFINER function owned by a dedicated role that is NOLOGIN and BYPASSRLS. Nothing can log in as it, and the only thing it can do is run those functions, which answer "allowed or not" without saying whether a handle exists.

What this does not prove

It is my own design, checked by my own tests and audits, not by an independent review. It also does not cover everything. In July my own audit found a real cross-tenant leak, and it was not in Postgres: chat history in Redis was keyed by the guest's phone number alone, so a guest messaging two businesses had business A's conversation loaded into business B's prompt. The fix put the tenant into the key itself, and test_history_is_tenant_isolated fails if it comes out. Row-level security protects the tables. Every other store needs its own boundary.

You can attack the isolation yourself on /prove.html: a read in your own sandbox tenant, then the same read across the boundary, against the running system. The table rules are also extracted as an open-source package, pantheon-rls.

← all posts