Multi-tenant isolation
Multi-tenant isolation guarantees one account's data is never visible to another, enforced at both the database and the application layer so a single missed check can't leak across tenants. Caisson's tenancy-rls package makes withTenant the sole entry to tenant data: skip it and a query has no bound account and returns zero rows, never someone else's.
In code
/**
* Run `fn` inside a transaction scoped to `accountId`: `SET LOCAL ROLE app` +
* `set_config('app.current_account', accountId, true)`. The account id must come from a verified
* session/JWT (ADR-0015); never from request params. An empty id is refused outright (never run
* unscoped).
*/
export async function withTenant<T>(
db: Transactor,
accountId: string,
fn: (tx: TenantExecutor) => Promise<T>,
): Promise<T> {
if (accountId.length === 0) {
throw new TenancyError(
"Refusing to run a tenant query without an account id",
);
}
return db.transaction(async (tx) => {
// Bind the GUC first (as the privileged role), then drop to `app` for the actual work.
await tx.query(`SELECT set_config($1, $2, true)`, [TENANT_GUC, accountId]);
await ensureRoleGuard(db, tx, "app");
await tx.exec(`SET LOCAL ROLE app`);
return fn(tx);
});
}How it holds
One entry point, or no data
withTenant is the only function in tenancy-rls that opens tenant access: it refuses to run at all with an empty account id, and the whole call runs inside one transaction so the bound account can never drift mid-query.
Account and user access paths never widen each other
withTenant binds the account GUC and leaves the user GUC unset; withUser, the identity-to-account bootstrap read (ADR-0176), does the reverse. A table's policy checks one GUC or the other, so neither access path can accidentally see through the grant the other one holds.
Role legitimacy checked before every privilege drop
Before withTenant ever runs SET LOCAL ROLE app, ensureRoleGuard queries pg_roles and refuses to proceed if that role turns out to be SUPERUSER or BYPASSRLS. A misconfigured privileged role is rejected outright instead of silently reopening cross-tenant access.
Isolation survives a connection-pooler reset
buildTenantPolicySql wraps the GUC read in NULLIF(current_setting(...), ''): a pooler that resets a custom GUC to an empty string instead of unsetting it would otherwise coincidentally match a row whose column happens to be empty. NULLIF folds that reset value to NULL first, so the comparison denies regardless.