Kysely
permdock/kysely compiles portable conditions into Kysely expression-builder callbacks with toWhere, fails closed when nothing is granted, and documents the Kysera @kysera/rls dual-mode prior art.
permdock/kysely is the Kysely interpreter for PermDock's portable conditions. toWhere returns a callback for Kysely's expression builder, so the permission filter composes with the rest of a typed query. Kysely has no RLS support of its own; RLS for Kysely users comes from permdock rls generate --target sql and a transaction helper that sets the session role and claims.
Purpose
Kysely users write queries by hand, which makes them the group most likely to forget the permission filter on one endpoint. toWhere makes the filter a one-liner derived from the same policy that guards the mutation. The only prior art for a dual-mode schema in the Kysely world is Kysera's @kysera/rls: one defineRLSSchema drives app-side query injection (filter / allow / deny / validate) and @kysera/rls/native emits ENABLE RLS / CREATE POLICY for policies that carry raw using / withCheck SQL, with syncContextToPostgres() mirroring context into app.* GUCs and createPolicyTester() for DB-less tests (Kysera multi-tenancy). It generates in one direction only and only for raw-SQL policies; PermDock generates from portable conditions and imports back (Postgres RLS).
API
import { toWhere, withSubject } from "permdock/kysely";
const rows = await db
.selectFrom("posts")
.selectAll()
.where(toWhere(permdock.where(permissions.post.read), "posts"))
.where("published", "=", true)
.execute();
// column mapping when schema fields differ from column names
toWhere(condition, "posts", { columns: { authorId: "author_id" } });
// RLS instead of app-side filtering: run the query under the caller's role and claims
await withSubject(db, permdock, async (trx) => {
return trx.selectFrom("posts").selectAll().execute(); // policies from `permdock rls generate --target sql` apply
});toWhere(condition, table, options?)returns(eb) => Expression<SqlBool>; fields map totable.columnreferences, operators map to Kysely binary operators andeb.and/eb.or/eb.not,in/notIntoin/not in,isNulltois null,containsto@>with a one-element array for fields inoptions.listFieldsand tolike(with%,_and\escaped) for strings.kyselyis an optional peer; the callback is typed structurally so tests need no install.memberOfand NULL handling match the Drizzle rules: frozen memberships become equality /in, andoptions.membershipsbecomeseb.exists(eb.selectFrom('<table> as m')...)with the same expiry, active-tenant and resource-column predicates. Withouteb.exists/eb.selectFromthe join fails closed (eb.lit(false)).where(expression)accepts the callback directly:db.selectFrom('post').where(toWhere(permdock.where(permissions.post.read), 'post')).- No grant returns
(eb) => eb.lit(false), so the query yields no rows (fail closed). principal.<field>andcontext.<key>values become bound parameters viaeb.val.options.relations({ tables?, closure? }) lets a graph grant'srelatednode compile to one subquery, over the closure table when named or a bounded recursive walk otherwise (relationships). Without it the node throwsnon-portable-condition.checkRow(db, table, condition, key, options?)takeskeyas an expression-builder callback ((eb) => eb('id', '=', id)), selects the filter as a boolean for the matching row and returns{ found: false }or{ found: true, granted }. More than one matching row throws.withSubject(db, permdock, fn, options?)opens a transaction, runs the preamble below and thenfn(trx), so the generated policies seepermdock.subject. The same function ships inpermdock/drizzleandpermdock/prisma(RLS adapter).
The preamble is one select set_config(…, true), … statement, so it costs one round trip. Its settings, in order:
role:authenticated, oranonwhen the subject has no principal, the same asset local role.options.roleaccepts onlyauthenticated,anonorfalse.falsekeeps the connection's role, for a login role RLS already applies to.- The claims.
supabaseandneonsetrequest.jwt.claims(sub,role, the tenant claim).gucsets<prefix>.user_idand one<prefix>.<claim>setting per claim.
options.dialect (default supabase), options.gucPrefix (default app) and options.tenantClaim (default tenant_id) must match the rls block in permdock.config.ts; withSubject does not read the config. options.claims adds claims from the verified token, such as user_role or memberships for jwt-mode helpers. sub, role and the tenant claim always come from the subject. options.sql passes Kysely's sql tag on runtimes without require.
Operator mapping:
| Portable operator | Kysely |
|---|---|
eq, ne, gt, gte, lt, lte | eb(col, '=' / '!=' / '>' / '>=' / '<' / '<=', eb.val(v)) |
in, notIn | eb(col, 'in', values) / eb(col, 'not in', values) |
isNull | eb(col, 'is', null) |
contains | eb(col, '@>', eb.val([v])) for options.listFields, like with escaped wildcards for text |
and, or, not | eb.and([...]), eb.or([...]), eb.not(...) |
Request lifecycle
- The request-scoped
PermDockis created from the policy and subject. permdock.where(permission)flattens grants into a portable tree (allows ORed, each ANDed with higher-priority deny negations); unconditional allow yieldstrue, no grant yieldsfalse.toWherecompiles the tree into an expression-builder callback bound to the table name and column map.- Kysely composes the callback with the rest of the query; subject values travel as parameters.
- With
withSubject, steps 2 to 4 are skipped and the database enforces the generated policies; the in-processPermDockis still used forassertbefore writes and foron('decision').
What it validates
- Column existence is checked by Kysely's types when the database interface is typed;
toWhereis generic over theDBtype so an unknown column is a type error. - Closure grants:
permdock.wherereports{ portable: false };toWherethrowsPermDockValidationErrornaming the grant. withSubjectrefuses any role other thanauthenticatedoranon, and any setting prefix or claim name outside[a-z_][a-z0-9_]*. It throws before opening the transaction. A connection user that is not a member of the role fails at therolesetting, sofnnever runs.- Rows returned from the database are trusted and not schema-validated.
How denials surface
- App-side:
eb.lit(false)and an empty result. Pair withassert(permissions.post.list)to answer403rather than an empty list when the caller lacks the collection permission. - Database-side (
withSubject):USINGpolicies filter silently;WITH CHECKviolations raise42501, which Kysely surfaces as a driver error;permdock rls verifyclassifies both asfilteredandrejected. - Non-portable grant:
PermDockValidationErrorat compile time, so it fails in tests. - Missing GRANT on the table also raises
42501before any policy runs;permdock rls generateemits grants alongside policies to avoid this masquerade.
Example app
None. Kysely is covered by tests/integration/src/orm-parity.test.ts (ormParity: filter in memory against toWhere on a testcontainers Postgres, with and without a memberships table), orm-graph.test.ts (relationship grants) and with-subject.test.ts (withSubject under the generated policies, compared with can()).
Why
- No
createPolicyTestertwin. Kysera's database-less tester evaluates policies without a query. In PermDock,describePolicyfrompermdock/testingalready does that: it runs the policy matrix, rows included, against the in-memory evaluator, which is the evaluatortoWhereis proven against.ormParitythen runs the same conditions through Kysely against SQLite or Postgres. A third tester would be a second in-memory evaluator to keep in step (testing).
Related standards
- Postgres RLS:
USINGversusWITH CHECK,42501, grants, Kysera's dual-mode design and theset_configpatterns. - Conditions: operator set.
- RLS adapter:
generate --target sqland the transaction preamblewithSubjectuses.
Last updated on
Prisma
permdock/prisma compiles portable conditions to Prisma where inputs with toWhere, fails closed with an empty OR when nothing is granted, and targets Prisma 8 native policy blocks for RLS generation.
Postgres RLS
permdock rls generate, import and verify round-trip PermDock policies and Postgres row-level security across Drizzle, raw SQL and Prisma 8 targets for Supabase, Neon and generic Postgres.