PermDock
Adapters

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 to table.column references, operators map to Kysely binary operators and eb.and / eb.or / eb.not, in / notIn to in / not in, isNull to is null, contains to @> with a one-element array for fields in options.listFields and to like (with %, _ and \ escaped) for strings. kysely is an optional peer; the callback is typed structurally so tests need no install.
  • memberOf and NULL handling match the Drizzle rules: frozen memberships become equality / in, and options.memberships becomes eb.exists(eb.selectFrom('<table> as m')...) with the same expiry, active-tenant and resource-column predicates. Without eb.exists / eb.selectFrom the 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> and context.<key> values become bound parameters via eb.val.
  • options.relations ({ tables?, closure? }) lets a graph grant's related node compile to one subquery, over the closure table when named or a bounded recursive walk otherwise (relationships). Without it the node throws non-portable-condition.
  • checkRow(db, table, condition, key, options?) takes key as 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 then fn(trx), so the generated policies see permdock.subject. The same function ships in permdock/drizzle and permdock/prisma (RLS adapter).

The preamble is one select set_config(…, true), … statement, so it costs one round trip. Its settings, in order:

  1. role: authenticated, or anon when the subject has no principal, the same as set local role. options.role accepts only authenticated, anon or false. false keeps the connection's role, for a login role RLS already applies to.
  2. The claims. supabase and neon set request.jwt.claims (sub, role, the tenant claim). guc sets <prefix>.user_id and 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 operatorKysely
eq, ne, gt, gte, lt, lteeb(col, '=' / '!=' / '>' / '>=' / '<' / '<=', eb.val(v))
in, notIneb(col, 'in', values) / eb(col, 'not in', values)
isNulleb(col, 'is', null)
containseb(col, '@>', eb.val([v])) for options.listFields, like with escaped wildcards for text
and, or, noteb.and([...]), eb.or([...]), eb.not(...)

Request lifecycle

  1. The request-scoped PermDock is created from the policy and subject.
  2. permdock.where(permission) flattens grants into a portable tree (allows ORed, each ANDed with higher-priority deny negations); unconditional allow yields true, no grant yields false.
  3. toWhere compiles the tree into an expression-builder callback bound to the table name and column map.
  4. Kysely composes the callback with the rest of the query; subject values travel as parameters.
  5. With withSubject, steps 2 to 4 are skipped and the database enforces the generated policies; the in-process PermDock is still used for assert before writes and for on('decision').

What it validates

  • Column existence is checked by Kysely's types when the database interface is typed; toWhere is generic over the DB type so an unknown column is a type error.
  • Closure grants: permdock.where reports { portable: false }; toWhere throws PermDockValidationError naming the grant.
  • withSubject refuses any role other than authenticated or anon, 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 the role setting, so fn never 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 with assert(permissions.post.list) to answer 403 rather than an empty list when the caller lacks the collection permission.
  • Database-side (withSubject): USING policies filter silently; WITH CHECK violations raise 42501, which Kysely surfaces as a driver error; permdock rls verify classifies both as filtered and rejected.
  • Non-portable grant: PermDockValidationError at compile time, so it fails in tests.
  • Missing GRANT on the table also raises 42501 before any policy runs; permdock rls generate emits 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 createPolicyTester twin. Kysera's database-less tester evaluates policies without a query. In PermDock, describePolicy from permdock/testing already does that: it runs the policy matrix, rows included, against the in-memory evaluator, which is the evaluator toWhere is proven against. ormParity then 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).
  • Postgres RLS: USING versus WITH CHECK, 42501, grants, Kysera's dual-mode design and the set_config patterns.
  • Conditions: operator set.
  • RLS adapter: generate --target sql and the transaction preamble withSubject uses.

Last updated on

On this page