@octabits-io/rls
Row-level security for Postgres as a capability you ask for by name, not a
feature bolted to an ORM. The root is the policy as SQL and the one
set_config statement every runtime runs first — pure strings, no
dependency at all. Each subpath is a runtime for something you already run:
./prisma for a Prisma client, ./drizzle for Drizzle on node-postgres,
./postgres for plain SQL — pg, PGlite, postgres.js or Kysely, no ORM.
./testing is the tenant isolation matrix for your own test suite: every
persona against every scoped table, as the app role, with the failures
returned as values.
npm i @octabits-io/rls # no dependencies; your ORM is yours already
# ./drizzle expects drizzle-orm and pg, which a Drizzle host already has
import { rlsPolicySql } from '@octabits-io/rls';
import { createScopedPrismaClient, withSystemMode } from '@octabits-io/rls/prisma';
// once, at setup — the policy is idempotent
await rawPrisma.$executeRawUnsafe(rlsPolicySql({ table: 'invoice', scopeColumn: 'org_id', scopeSetting: 'app.org_id' }));
// every operation on `prisma` — model and raw — runs as $transaction([set_config…, operation])
export const prisma = createScopedPrismaClient(rawPrisma, { gucs: { 'app.org_id': orgId } });
// background work that must see every row
await withSystemMode(rawPrisma, (tx) => tx.invoice.count());
| Subpath | Deps | Contents |
|---|---|---|
. |
none | rlsPolicySql, setConfigStatement, SessionVars — the SQL, for any stack that can send a string |
./prisma |
none (the client is structural) | withGucs, withSystemMode, createScopedPrismaClient; re-exports rlsPolicySql and setConfigStatement so a Prisma host needs one import |
./drizzle |
drizzle-orm, pg (optional peers) |
createScopedDb, runWithGucs, withSystemMode, augmentDrizzle, the pinned-connection helpers |
./postgres |
none (executor and runner are structural) | withGucs, withSystemMode, createScopedExecutor, poolTransactions, postgresJsTransactions; re-exports rlsPolicySql and setConfigStatement |
./testing |
none (no test framework, no driver) | defineIsolationMatrix, runIsolationMatrix, formatIsolationFailures, policiedTables — the isolation matrix, failures as values |
A boundary lint keeps the root importing nothing at all, ./prisma and
./postgres importing only the root and ./testing only ./postgres, and
everything but ./drizzle type-checks with no Node typings.
How it works
Every unit of work runs inside a short transaction whose first statement is
set_config(name, value, true) — transaction-local, so nothing leaks to the
next checkout of a pooled connection, and safe behind PgBouncer — and the
policy compares the row’s scope column against current_setting(name, true).
A request that never set the setting matches zero rows, which is the safe
failure.
rlsPolicySql is that policy, idempotent:
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS scope_isolation ON invoice;
CREATE POLICY scope_isolation ON invoice
USING (org_id = NULLIF(current_setting('app.org_id', true), '') OR NULLIF(current_setting('app.system_mode', true), '') = 'true')
WITH CHECK (org_id = NULLIF(current_setting('app.org_id', true), '') OR NULLIF(current_setting('app.system_mode', true), '') = 'true');
FORCE because the table’s owner is not exempt otherwise; NULLIF because
an unset setting reads as NULL or '' depending on the connection’s history,
and either must match nothing; the system-mode branch is how background work
sees every row on purpose. Every identifier — table, column, setting, policy
— is validated against a pattern before it is interpolated; no SQL is built
from an unchecked name.
setConfigStatement(gucs) returns the one parameterised
SELECT set_config($1, $2, true), … statement and its parameters, for
whatever runs first in your transaction.
./prisma
withGucs(client, gucs, fn)— one interactive transaction with the settings applied first;fnreceives the transaction client. Use that inside it, never the scoped client, whose per-call transaction would nest.withSystemMode(client, fn)—withGucswithapp.system_mode = 'true', for background work that must see every row.createScopedPrismaClient(client, { gucs })— a client extension over$allOperations: every operation, model and raw alike, runs as$transaction([set_config…, operation]). One transaction per operation — the same trade the Drizzle per-call proxy makes and won: concurrent operations inside a request still run in parallel on separate connections. It returns the client’s own type, because it changes what the operations do, not what they are. An emptygucsthrows: a scoped client with no scope would run everything unscoped.
The client is typed by the three methods used ($transaction,
$executeRawUnsafe, $extends), so @prisma/client is never imported and
never a peer; a generated Prisma 7 client satisfies it as-is.
./drizzle
createScopedDb(db, gucs)— a proxy shaped exactly like your Drizzle db: every top-level operation (builder chains,query.*.findFirst/findMany,execute,transaction) runs in a short transaction whose first statement sets the settings. Single statements take a pinned-client fast path:BEGIN; SELECT set_config(…)as one packet, then the statement, thenCOMMIT— three round-trips. It is fail-closed: a member of the db the proxy has not classified throws instead of running without the settings, and a unit test enumerates the installed drizzle-orm’s surface, so an upgrade that adds a builder fails your suite, not your tenant isolation. Synchronous builder APIs (toSQL,prepare) throw on the proxy; userunWithGucsfor those.runWithGucs(db, gucs, fn)/withSystemMode(db, fn)— one Drizzle-managed transaction with the settings applied first; nestedtx.transaction()keeps savepoint semantics.acquireScopedClient/releaseScopedClient— the pinned-connection model: one transaction per request, committed or rolled back at the end.augmentDrizzle(db, schema)— adds.tables, which the fast path reads to rebuild a client-bound instance. A db without it still works, through the Drizzle-managed transaction, one round-trip dearer.assertSafeGucListValue/joinGucList— for a list joined into one setting: an element with,or'would widen the policy’s match set, so it throws.
import { drizzle } from 'drizzle-orm/node-postgres';
import { augmentDrizzle, createScopedDb, withSystemMode } from '@octabits-io/rls/drizzle';
const db = augmentDrizzle(drizzle({ client: pool, schema }), schema);
const scoped = createScopedDb(db, { 'app.org_id': orgId }); // per request
await scoped.select().from(invoices); // only this org's rows
await withSystemMode(db, (tx) => tx.delete(sessions)); // every scope, one unit of work
One transaction per operation, deliberately: concurrent queries inside a
request run in parallel on separate connections. A benchmark against one
pinned connection per request lost on every route by 5–47 % at p50.
src/drizzle.ts is the one file of the package outside its portability gate:
it types the pool with pg.
./postgres
For a host with no ORM. Every Postgres store in this repository takes an
executor — query(text, params) → { rows } — and an executor alone cannot
carry a setting: over a pool, the set_config and the query may land on two
connections. So the runtime takes a transaction runner,
transaction(fn), which hands fn an executor bound to one open transaction.
withGucs(runner, gucs, fn)— one transaction with the settings applied first; every queryfnsends throughtxis scoped. Usetxinside it.withSystemMode(runner, fn)— the same withapp.system_mode = 'true'.createScopedExecutor(runner, { gucs })— an executor whose everyqueryruns as its own short transaction, settings first. Hand it to a store that takes an executor — the ledger, the feed, the pending proposals — and the store is scoped without knowing it. Two queries that must commit together belong inwithGucs. An emptygucsthrows.
The runner, per client:
import { createScopedExecutor, poolTransactions, postgresJsTransactions, withSystemMode } from '@octabits-io/rls/postgres';
const runner = poolTransactions(pool); // pg: checkout, BEGIN, fn, COMMIT or ROLLBACK, release
const runner = pglite; // PGlite: its own transaction() is one, as-is
const runner = postgresJsTransactions(sql); // postgres.js: its own begin()
const db = createScopedExecutor(runner, { gucs: { 'app.org_id': orgId } }); // per request
const ledger = createPostgresAgentLedgerStore({ db }); // scoped
await withSystemMode(runner, (tx) => tx.query('delete from session where expires_at < now()'));
Kysely needs no adapter of its own — its transaction runs compiled raw SQL:
import { CompiledQuery } from 'kysely';
const runner: TransactionRunner = {
transaction: (fn) => kysely.transaction().execute((trx) =>
fn({ query: (text, params = []) => trx.executeQuery(CompiledQuery.raw(text, params)) })),
};
poolTransactions destroys a client whose BEGIN or ROLLBACK failed instead
of returning it to the pool. Nothing here imports a driver, not even as a type,
and the module type-checks with no Node typings.
./testing
@octabits-io/data-lifecycle audits that every
scoped table carries a policy. The matrix tests what the policies do: a
persona of scope A sees exactly the rows it should and none of scope B’s,
writes none of B’s, and cannot widen its own rights. It is data — the
personas, the tables with their id column, the ids each persona must see —
plus one seam, as(persona, fn): run fn with an executor bound to one
transaction as that persona, the app role with the settings applied. The
runner returns the failures as values, so any test runner asserts on them;
nothing here imports a test framework.
import { withGucs } from '@octabits-io/rls/postgres';
import { defineIsolationMatrix, formatIsolationFailures, runIsolationMatrix } from '@octabits-io/rls/testing';
const matrix = defineIsolationMatrix({
personas: ['acme-member', 'globex-member', 'stranger'],
tables: [
{ table: 'invoice' },
// A row of another scope the persona must not be able to insert, every NOT NULL column named.
{ table: 'note', insert: (persona) => ({ id: `probe-${persona}`, org_id: persona === 'acme-member' ? 'globex' : 'acme', body: '' }) },
{ table: 'staff_only' },
],
expect: {
'acme-member': { invoice: ['inv-a1', 'inv-a2'], note: ['n-a1'], staff_only: [] },
'globex-member': { invoice: ['inv-g1'], note: [], staff_only: [] },
stranger: { invoice: [], note: [], staff_only: [] },
},
// The host's scope setter: the app role, the settings applied, one transaction that rolls back when fn throws.
as: (persona, fn) => withGucs(runner, GUCS[persona], fn),
escalations: [
// Must be refused or touch nothing — write it with RETURNING.
{ name: 'promote oneself', sql: `UPDATE role_assignment SET role = 'owner' WHERE member = current_setting('app.member', true) RETURNING id` },
],
});
const failures = await runIsolationMatrix(matrix, { owner: adminPool }); // owner: sees every row, only reads
expect(failures, formatIsolationFailures(failures)).toEqual([]);
What it runs, per persona and table:
- Visible:
SELECT idas the persona againstexpect— ids outside the expectation areleakedIds, expected ids the persona did not see aremissingIds(a policy that hides a scope’s own rows is a bug too). - Write probes:
UPDATEandDELETEagainst rows of another scope (the owner’s rows minus the expectation,probeLimitof them, default 3), eachRETURNING— a returned row is awritefailure; then anINSERTof the rowinsert(persona)names, which the policy’sWITH CHECKmust refuse. Every probe runs in its ownastransaction and ends by throwing a private marker, so the runner rolls it back: the fixture survives a leaking policy, and an owner read after each probe proves the rollback happened.asmust therefore roll back whenfnthrows — theTransactionRunnercontract, whichwithGucs, a Drizzle transaction and a Prisma$transactionall keep. - Escalations: each statement as each persona (or the
personasit names); one that resolves with rows is anescalationfailure. ASECURITY DEFINERhelper is probed the same way:SELECT … FROM helper() WHERE scope <> current scope. - Coverage: every table under row-level security in the database
(
pg_class.relrowsecurityor a row inpg_policy;policiedTables(owner)lists them) must be intables, every listed table must carry a policy, and every persona needs an expectation for every table — so a new scoped table fails the suite until someone adds its expectations on purpose.skipCoverage: truefor a database with policied tables the matrix is not about.
where (with whereParams) narrows a table’s reads to the fixture’s rows —
"id = ANY($1)" — so rows other tests leave behind are not counted; ids are
compared as strings, so a serial or uuid column works as is, and a
non-unique idColumn compares as a set. Failures are
{ kind: 'visible' | 'write' | 'escalation' | 'coverage', persona, table, detail, leakedIds?, missingIds? }; formatIsolationFailures prints one
line each for the assertion message.
The reference hosts run one: apps/starter’s pnpm guard (check 20 in
src/guard.test.ts) writes a row of every organisation into every scoped
table, made up from the column types, and derives the expectations as the
owner (exactly the rows whose scope column is mine) on PGlite;
apps/starter-next/src/isolation.test.ts writes a fixture of its own into
the live Postgres and names the ids. The add-a-scoped-table skill ends
with adding the table to the matrix.
When not to use it
- Another database.
set_config,current_settingand forced row-level security are Postgres. There is no equivalent to bind on MySQL or SQLite. - A scope that changes inside one client.
createScopedPrismaClientdecides the scope once, where the client is made. Make one scoped client per request (or per process in a single-scope app); do not try to swap the settings on a shared client.
What no runtime can do for you
Connect as a role that is not a superuser (Postgres never applies policies to
superusers), give the scope column a default of
current_setting('app.org_id', true) if creates inside a scoped transaction
should land in that scope without every call site naming it, and audit that
every scoped table actually carries a policy —
@octabits-io/data-lifecycle does the last one
from a live database. apps/starter-next in the repository does all three and
proves the policies bite with a NOSUPERUSER role.
Stability
Pre-1.0: patches never break, minors say so in the changelog. The emitted SQL,
the extension name (octabits-rls) and the shape of every function are part
of the surface.
From @octabits-io/rls@0.5.0 in the platform repository.