Tenancy (RLS)
Fail-closed multi-tenant Postgres RLS. A query with no tenant context returns nothing.
@caisson/tenancy-rls is the fail-closed multi-tenant layer. Every tenant table runs Postgres
row-level security with FORCE, and withTenant is the sole entry point that sets the tenant
context. A query that reaches the database with no context set returns nothing, never another
tenant's rows.
The contract
The policy is FORCEd, so it applies even to the table owner. The isolation guarantee is a test in
the suite, not a hope:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant', true)::uuid);With no app.tenant set, the same query that works inside a tenant scope is denied:
$ psql -c "select * from invoices"
ERROR: permission denied for table invoices
DETAIL: RLS policy "tenant_isolation" forbids SELECT
with no app.tenant set — fail-closed by default.withTenant sets the context, runs your callback inside it, and tears it down, there is no API
that reads tenant data without it:
import { withTenant } from "@caisson/tenancy-rls";
await withTenant(tenantId, async (db) => {
// Inside this scope RLS is enforced. Outside it, queries fail closed.
return db.query.invoices.findMany();
});API reference
Tenant scoping
withTenant(db, accountId, fn): the sole entry point to tenant data. Opens a transaction, binds theapp.current_accountGUC toaccountId, drops the connection to the unprivilegedapprole, then runsfn. ThrowsTenancyErroroutright on an emptyaccountId: it never runs a query unscoped.withUser(db, userId, fn): the identity→account bootstrap path: bindsapp.current_userinstead of the tenant GUC, for the one case that's legitimately a cross-account read keyed by user (resolving which accounts a signed-in user belongs to). Same fail-closed empty-id check, same drop toapp. A table without a matchinguser_idpolicy clause returns nothing under this scope.
export const TENANT_GUC = "app.current_account";
export const USER_GUC = "app.current_user";
function withTenant<T>(
db: Transactor,
accountId: string,
fn: (tx: TenantExecutor) => Promise<T>,
): Promise<T>;
function withUser<T>(
db: Transactor,
userId: string,
fn: (tx: TenantExecutor) => Promise<T>,
): Promise<T>;Both functions run a one-time-per-(Transactor, role) pre-flight the first time they touch a
given database: they check pg_roles and refuse to SET LOCAL ROLE app if that role turns out
to be SUPERUSER or BYPASSRLS: either one would silently no-op FORCE ROW LEVEL SECURITY
and reopen the cross-tenant leak with zero runtime signal. The check throws TenancyError
rather than warning, and a failed check is never cached, so fixing the role config un-wedges the
very next call.
Policy generation
buildTenantPolicySql(table, options?): returns the DDL that makes a table fail-closed tenant-isolated:ENABLE+FORCE ROW LEVEL SECURITY, aGRANTto the app role, and a policy that admits a row only when its tenant column matches the bound GUC. Emit it into the table's migration so a tenant table can never ship without the policy attached.
interface TenantPolicyOptions {
/** The tenant-key column. Default "account_id". */
column?: string;
/** The role policies apply to — must not be a superuser or BYPASSRLS role. Default "app". */
role?: string;
}
function buildTenantPolicySql(
table: string,
options?: TenantPolicyOptions,
): string;The generated USING/WITH CHECK clause wraps the GUC read in NULLIF(..., ''): a connection
pooler that resets a custom GUC to an empty string instead of fully unsetting it would otherwise
compare column = '': a deny that coincidentally holds only until a row's tenant column is
literally empty. Folding '' to NULL first keeps the comparison an unconditional deny.
Ports
TenantExecutor: the minimal query surfacewithTenant/withUserhand to your callback:query<T>(sql, params?)returning{ rows: T[] }, andexec(sql). Satisfied by PGlite, a PGlite transaction, and apg/Drizzle client alike.Transactor: the minimal surface a driver exposes to open one:transaction<T>(fn: (tx: TenantExecutor) => Promise<T>): Promise<T>.
Drivers
createSupabaseTransactor(config): anode-postgres-backedTransactor. Fails closed at construction (ConfigError) (never at the first query) on an empty or unparseableconnectionString, or one shaped like Supabase's transaction-mode pooler (port6543): Supavisor hands a different physical backend connection to each statement in that mode, soSET LOCAL ROLE/set_config(..., true)silently evaporate between statements and RLS quietly stops being tenant-scoped. Point it at a session-mode pooler or a direct connection (port5432) instead. An idle pooled connection'serrorevent is logged and survived rather than crashing the process.
interface SupabaseTransactorConfig {
/** A Postgres connection string — session-mode pooler or direct connection only. */
connectionString: string;
/** Test-only override: inject a Transactor instead of opening a real socket. */
driver?: Transactor;
}
function createSupabaseTransactor(config: SupabaseTransactorConfig): Transactor;ORM bridges
Both bridges feed an ORM's generated SQL through the unmodified TenantExecutor port, RLS
still does the isolation, these only carry the query text and params across.
queryDrizzle(tx, query)/execDrizzle(tx, query): run any object with a.toSQL()method (a realdrizzle-ormquery builder, or anything shaped the same) through a tenant-scopedTenantExecutor.execDrizzleis the exec-style convenience for a statement whose rows don't matter; it still routes throughqueryso bound params are never dropped.
interface DrizzleToSql {
toSQL(): { sql: string; params: unknown[] };
}
function queryDrizzle<T = Record<string, unknown>>(
tx: TenantExecutor,
query: DrizzleToSql,
): Promise<{ rows: T[] }>;
function execDrizzle(tx: TenantExecutor, query: DrizzleToSql): Promise<void>;createPrismaBridge(tx): wraps a tenant-scopedTenantExecutorbehind Prisma's raw surface, so call sites written againstprisma.$queryRawUnsafe/$executeRawUnsafeport over with a rename. There is no nested$transaction:withTenant's own callback is already the transaction boundary, so multi-statement work nests directly inside it.
interface PrismaRawClient {
$queryRawUnsafe<T = unknown>(
query: string,
...values: unknown[]
): Promise<T[]>;
$executeRawUnsafe(query: string, ...values: unknown[]): Promise<number>;
}
function createPrismaBridge(tx: TenantExecutor): PrismaRawClient;$executeRawUnsafe's affected-row count is a best-effort rows.length: TenantExecutor
exposes only returned rows, never a driver row count, so a bare UPDATE/DELETE with no
RETURNING reports 0 even when it changed rows. Add a RETURNING clause when the caller
needs an accurate count through this bridge.