Drizzle
permdock/drizzle compiles portable conditions to Drizzle where clauses with toWhere, generates pgPolicy entries for RLS through drizzle-orm/supabase helpers, and reuses drizzle-zod schemas for generated definitions.
permdock/drizzle takes the portable condition returned by permdock.where(permission) and turns it into a Drizzle SQL expression bound to a table. The same conditions feed permdock rls generate --target drizzle, which emits pgPolicy(...) entries next to the table definition so drizzle-kit owns the migration.
Purpose
A condition such as where: { authorId: principal.id } is evaluated in memory by can, serialised into snapshots for the client, and must also filter a list query in the database, or the UI and the API disagree about which rows exist. CASL proved that one condition AST can drive an in-memory interpreter and a where builder, and that priority must be respected when flattening allows and denies (landscape). permdock/drizzle is the Drizzle interpreter for PermDock's conditions, and Drizzle is the primary RLS compile target because pgPolicy and drizzle-orm/supabase already model policies as code (Postgres RLS).
API
import { toWhere } from "permdock/drizzle";
import { posts } from "./schema";
const rows = await db
.select()
.from(posts)
.where(toWhere(permdock.where(permissions.post.read), posts));
// combine with app filters: toWhere returns a Drizzle SQL expression
await db
.select()
.from(posts)
.where(
and(
toWhere(permdock.where(permissions.post.read), posts),
eq(posts.published, true),
),
);
// column mapping when schema field names differ from Drizzle column names
toWhere(condition, posts, { columns: { authorId: posts.author_id } });
// graph grants: related nodes compile to a subquery over the relation tables
toWhere(permdock.where(permissions.doc.read), docs, {
relations: { closure: "permdock.permdock_closure" },
});
// one row: not found, or found with the decision
const check = await checkRow(
db,
posts,
permdock.where(permissions.post.update),
eq(posts.id, id),
);
// run a transaction as the subject, so RLS policies see it
await withSubject(db, permdock, (tx) => tx.select().from(posts), {
dialect: "guc",
});toWhere(condition, table, options?)returns a DrizzleSQLand maps each condition field to a column oftableby name (or viaoptions.columns), and each operator (eq,ne,in,notIn,gt,gte,lt,lte,isNull,contains,and,or,not) to the Drizzle operator of the same meaning.tableis any Drizzle table object; no cast is needed.options.columnsaccepts only columns of that table orSQL, so a column of another table is a type error.options.relations({ tables?, closure? }) lets arelatednode compile: one subquery over the edge tables, the closure table when named, otherwise a recursive walk of the resource's own table bounded by the grant's depth (relationships). Without it arelatednode throwsnon-portable-condition.checkRow(db, table, condition, key, options?)selects the rows matchingkeywith the filter as a boolean column and returns{ found: false }or{ found: true, granted }, so a handler can answer404and403apart with one query. More than one row matchingkeythrows.withSubject(db, permdock, fn, options?)opens a transaction, sets the local role and the claim settings for the frozen subject in oneselect set_config(…)statement, then callsfn(tx).optionsaredialect(supabase,guc,neon),role(defaultauthenticatedwith a principal,anonwithout,falseto leave the role),gucPrefix,tenantClaimand extraclaims. They must match whatpermdock rls generatewas configured with;withSubjectdoes not read the config.drizzle-ormis an optional peer; tests can injectoptions.operators. The call stays two-step (permdock.wherethentoWhere) so the adapter never needs the instance.containson an array column (dataType: 'array') isvalue = any(column); on a string it islikewith%,_and\escaped; any other value is@>.- NULL follows the in-memory evaluator: a comparison with a NULL row value or an unresolved reference never matches, and
notkeeps NULL rows (not (x = 1)compiles tox <> 1 or x is null). memberOfcompiles from the frozen subject whenoptions.membershipsis omitted: tenant scope with an activeprincipal.tenantis equality on that id, otherwiseinArrayover held tenants; team scope isinArrayof team ids (dropping memberships whose tenant is not the active one); resource scope isinArrayon the row field plusoroverparents, a keyed parent ({ field, resource }) using only memberships on that resource. Withoptions.memberships, it emitsexists (select 1 from <table> m ...): one bound parameter per role (the role predicate is skipped when the list is empty),expires_at > nowwithnowbound in whole Unix seconds (rounded up, so a membership expires up to a second early, never late), the active tenant on tenant and team scopes, andm.<resource> = '<name>'when the table has aresourcecolumn. A keyed parent hop usesmemberships.resource[<parent>]and fails closed when that resource has no table.principal.<field>andcontext.<key>references are resolved to bound parameters from the request-scopedPermDockbefore compilation; the result contains no user-controlled SQL.- When nothing is granted,
toWherereturns a constantfalseSQL expression so the query yields no rows (fail closed). - Closure grants cannot compile;
permdock.wherereports them as{ portable: false }andtoWherethrowsPermDockValidationErrorwith the offending grant.
RLS generation reuses the same compiler:
// emitted by: permdock rls generate --target drizzle --dialect supabase
import { sql } from "drizzle-orm";
import { pgPolicy } from "drizzle-orm/pg-core";
import { authenticatedRole, authUid } from "drizzle-orm/supabase";
import * as schema from "./schema";
export const postReadMember = pgPolicy("post_read_member", {
as: "permissive",
for: "select",
to: authenticatedRole,
using: sql`("author_id" = ${authUid})`,
}).link(schema.posts);Each policy is linked to its table with .link, so the table definition stays untouched and drizzle-kit picks the policies up from the schema. The guc and neon dialects declare pgRole('authenticated').existing() in the file instead of importing from drizzle-orm/supabase. Grants, column privileges, enable row level security and the helper functions have no Drizzle form, so they go to <out>.migration.sql next to the policies file; apply it with your migrations. rls.drizzle.schema sets the import path and rls.drizzle.exports maps a table to its export name when it is not the table name in camelCase (RLS).
Request lifecycle
- The request-scoped
PermDockis created from the policy and subject. permdock.where(permission)flattens the grants for that permission: each allow condition is ANDed with the negation of all higher-priority denies, and the results are ORed. Unconditional allow yieldstrue; no grant yieldsfalse.toWherewalks the resulting tree and produces a Drizzle SQL expression bound to the table's columns, with subject values as parameters.- The app composes the expression with its own filters and runs the query.
on('decision')fires once withoutcomederived from whether anything was granted, pluspermdock.filter-style metadata for observability.
For RLS, the same tree is compiled by the CLI at build time instead of per request; principal.id becomes (select auth.uid()) under the supabase dialect, a GUC read under guc, and auth.user_id() under neon (see RLS).
What it validates
- Every field in the condition exists on the table (or in
options.columns); a missing column throws at compile time in tests and at first use in production with the field name. - Operator and column type compatibility is left to Drizzle's own typing;
toWhereis typed soconditionmust come from the resource whose schema the table was declared for whendrizzle-zodschemas are used in the definition. - Subject values are always parameters, never interpolated.
- Nothing about the rows themselves:
whereruns before rows exist, and rows coming back from the database are trusted (validate: 'boundary'does not run on them).
How denials surface
- No grant: a constant
falseexpression and an empty result; the caller sees zero rows, not an error. Combine withassert(permissions.post.list)beforehand to distinguish "forbidden" from "not found" (CASL issue #794 describes the ambiguity). - Non-portable grant:
PermDockValidationErrorwithreason: 'non-portable-condition'and the grant's role, so it is caught in tests rather than at runtime. - In RLS mode: a
USINGpolicy filters silently; aWITH CHECKpolicy raises42501;permdock rls verifyclassifies both.
Example app
apps/examples/drizzle: a Hono API over an in-process PGlite database. GET /posts selects through the list filter, behind the Hono middleware(). PATCH /posts/:id runs protect with a loader, then updates through the update filter; a denial is a 403 and a missing row a 404, both Problem Details. tests/integration/src/orm-parity.test.ts compares filter in memory with toWhere results on node-postgres and PGlite for every operator, NULL and negation case, reference, memberOf scope, parent hop and expired membership (testOrmParity in permdock/testing), and checkRow with can() for each row. orm-graph.test.ts does the same for graph grants, with-subject.test.ts runs withSubject against generated RLS as a non-owner role, and rls-orm-targets.test.ts applies the generated pgPolicy file to Postgres.
Why
- A hand-written Standard Schema wins over a generated one.
rls importwrites a separate// @generatedmodule whose resource schemas come from the tables' columns. A resource the application defines by hand keeps its own schema, and its entry in the generated module is left out of the merge: the hand-written schema is the boundary contract (invariant 9) and usually narrower than the table, for example without server-set columns. One resource is never defined with two schemas. Both are Standard Schemas, sotoWhereand validation treat them the same, andpermdock usagereports a condition on a field neither declares (usage).
Related standards
- Postgres RLS: policy semantics that the generated
pgPolicyentries follow, DrizzlepgPolicy,drizzle-orm/supabasehelpers andcrudPolicyfor Neon. - Conditions: the portable operator set.
- RLS adapter:
generate,import,verify.
Last updated on
Chat SDK
permdock/chat-sdk implements permdock/chat and the chat channel of permdock/delivery on the Chat SDK 4. chatSdkAuthor maps a verified Chat SDK author, chatSdkTransport posts direct messages and approval cards, and approvalActions applies approve and reject clicks as the clicking user.
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.