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 permdock() 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 (ormParity 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
drizzle-zodschema.rls import --from drizzlewrites a separate// @generatedmodule whose resources reference the tables'drizzle-zodschemas. 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
Remote PDP
The permdock/pdp provider is an AuthZEN policy enforcement point client that asks a remote decision point such as Cerbos, Topaz, Keycloak, Axiomatics, PlainID or OPA, maps requests and responses to PermDock decisions, fails closed on anything unknown, and bridges to OpenFGA or SpiceDB relation graphs.
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.