Isolation: choosing where the boundary lives
Synced from tenant-kit/docs/ISOLATION.md — the repo is canonical.
Three strategies, one decision. This doc exists so the decision is made once, on the record, instead of re-argued each time a new table ships.
The spectrum
| Shared schema + RLS | Schema-per-tenant | Database-per-tenant | |
|---|---|---|---|
| The boundary is | A policy the database enforces per row | A search_path |
A connection string |
| Enforced by | Postgres, on every query, including yours | Discipline at connection setup | Physics — the rows aren't there |
| Migrations | One schema, one migration | N schemas × every migration | N databases × every migration, plus provisioning |
| Cross-tenant reports | One query | N-way UNION or ETL | ETL |
| Noisy-neighbor blast radius | Shared everything | Shared instance | Isolated |
| Per-tenant restore / export / deletion | Row-filtered, careful | pg_dump -n, decent |
pg_dump, trivial |
| Tenants it comfortably serves | Thousands+ | Hundreds | Tens (the contract-requires-it tier) |
| tenant-kit support | First-class: protect() + scopedExecutor |
Via routedExecutor (a routed executor may set search_path) |
Via routedExecutor |
Default to shared schema + RLS. Move a tenant out when a contract, a
regulator, or a genuinely enormous tenant forces it — and note that the
strategies compose: routedExecutor can send ninety-nine tenants at a shared
RLS-protected database and the one whale at its own.
How the RLS strategy actually works
Two sides of one contract, one name in the middle: the transaction-local
setting tenancy.tenant_id.
SQL side (sql/002_rls.sql):
SELECT tenancy.protect('public.projects');
installs, per table:
ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenancy_isolation ON public.projects
USING (tenant_id = tenancy.current_tenant()::text)
WITH CHECK (tenant_id = tenancy.current_tenant()::text);
TypeScript side (src/isolation.ts): tenancy.db() wraps every
statement in a transaction that first runs
SELECT set_config('tenancy.tenant_id', $1, true).
The row is visible iff its tenant column equals the setting. No setting —
current_tenant() is NULL — and the policy is false for every row: the
unscoped query returns nothing, the unscoped insert is rejected.
The load-bearing details
Each of these was a production incident somewhere before it was a rule here.
FORCE. Plain RLS exempts the table's owner — and the owner role is what most app connection strings authenticate as. Unforced RLS passes review and does nothing.protect()forces, unconditionally.- Superusers bypass RLS regardless. So your app must not connect as one,
and the test suite makes a dedicated non-superuser role precisely so its
green checkmarks mean something (
test/pg-executor.tsexplains). SET LOCAL, neverSET. A plainSEToutlives its transaction; on a pooled connection the next borrower inherits the previous request's tenant. This is the classic RLS-on-a-pool bug, and it is whyscopedExecutorrefuses to run any statement outside a transaction. There is a test that scopes a query and then asserts the pool's next user sees nothing.- The cast goes on the function.
tenant_id = current_tenant()::uuidkeeps the column's index;tenant_id::text = current_tenant()forces a scan on every policied query, and slow isolation is isolation someone will eventually remove.protect()reads the column's type from the catalog and casts the constant side. WITH CHECK, not justUSING.USINGfilters what you read;WITH CHECKrefuses writes that place rows outside your scope. Without it, a scoped session can insert rows it can never see again into another tenant — a write leak with no read to notice it. The cross-tenant UPDATE (moving a row across the boundary) is refused by the same clause.- Empty, not error. An unscoped read returning zero rows rather than
raising is a deliberate trade: it makes the forgotten-scope bug loud in
QA ("where did the data go?") but harmless in production. If you would
rather fail closed with an exception, add a
current_tenant() IS NOT NULL-asserting trigger — the setting name is exported asTENANT_SETTINGfor exactly this kind of extension.
What not to protect
The tenancy.tenants and tenancy.memberships tables. They are the
directory that resolve() reads before any scope exists; a policy on them
would require knowing the answer to ask the question. They carry no host
data. Reach them through the library, or through unscopedDb() — which is
named that so the reach reads as deliberate.
The other two strategies, honestly
routedExecutor(route) memoizes tenantId → SqlExecutor. That is the whole
offering, and the restraint is the point: the hard parts of physical
isolation are provisioning (who creates the database when a tenant signs
up at 3am), migration fan-out (a bad migration now fails per-tenant,
partially), and connection budgets (every isolated tenant is a pool;
Postgres connections are not free). Those are operational decisions with
your name on the pager, not defaults a library should pick.
What the library does guarantee: the routing function is consulted once per
tenant, the executor is reused, and everything downstream — including
billing-kit — sees the same SqlExecutor interface regardless of which
strategy produced it. Moving one tenant from the shared database to its own
is a change to your route function and a data copy, not an API migration.