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.
Status: planned Phase: 3
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 (RLS research).
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@>for arrays andlikefor strings.- No grant returns
(eb) => eb.lit(false), so the query yields no rows (fail closed). subject.<field>andsubject.context.<key>values become bound parameters viaeb.val.withSubject(db, permdock, fn)opens a transaction and runsset local roleplusset_config('request.jwt.claims', ..., true)(Supabase dialect) orset_config('app.user_id', ..., true)(GUC dialect) beforefn, so database-side RLS sees the same subject as the in-process check. Dialect follows the policy'srlsconfiguration.
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 | @> for array columns, like for text (column type from options.columns) |
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. withSubjectvalidates that the policy declares an RLS dialect and that the connection user mayset roleto the target role; otherwise it throws before runningfn.- 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 (parity between filter, toWhere and generated SQL policies against a testcontainers Postgres) and by a snippet in the RLS adapter page.
Related standards
- Postgres RLS:
USINGversusWITH CHECK,42501, grants. - Conditions: operator set.
- RLS adapter:
generate --target sqland the transaction preamblewithSubjectuses. - Research: Postgres RLS: Kysera
@kysera/rlsdual-mode design,onReserveConnectionandset_configpatterns.
Open questions
- Whether
withSubjectbelongs inpermdock/kyselyor in a database-agnosticpermdock/rlsruntime shared with Drizzle and Prisma. containssemantics per column type (array, JSONB, text) need a column-type hint or acolumnsmap entry.- Membership conditions: emit
in (select ...)througheb.selectFromor require the app to supply the subquery. - Whether to offer a
createPolicyTester-style DB-less parity helper in@permdock/testing, mirroring Kysera.
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.