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.
permdock/prisma turns permdock.where(permission) into a Prisma WhereInput for the matching model. When no grant applies it returns { OR: [] }, the fail-closed shape CASL established, and it ships a client extension that keeps that shape effective across all query types. For RLS, permdock rls generate --target prisma emits Prisma 8 policy_* blocks so the same conditions are enforced in the database.
Purpose
Prisma has no server-side row filter unless the database enforces one, so list queries must carry the permission filter in where. @casl/prisma showed the working pattern and its pitfalls: accessibleBy returns { OR: [] } when nothing is allowed, but Prisma did not reliably treat an empty OR as always-false (prisma#17367), so createCaslExtension rewrites any where containing it into { ...where, OR: [], AND: [where] } (landscape). PermDock adopts the same fail-closed behaviour and adds the Prisma 8 native RLS target (changelog).
API
import { toWhere, permdockExtension } from "permdock/prisma";
const prisma = new PrismaClient().$extends(permdockExtension());
const posts = await prisma.post.findMany({
where: {
AND: [toWhere(permdock.where(permissions.post.read)), { published: true }],
},
});
// typed to the model's WhereInput when the resource schema was generated from Prisma types
toWhere<Prisma.PostWhereInput>(permdock.where(permissions.post.read));
// required and list fields read from schema.prisma (or Prisma.dmmf.datamodel)
const model = prismaModelFields(
readFileSync("prisma/schema.prisma", "utf8"),
"Post",
);
toWhere(permdock.where(permissions.post.read), { model });
// graph grants: read the related ids first, then compile
const where = await resolveRelated(permdock.where(permissions.doc.read), {
run: ({ sql, values }) => prisma.$queryRawUnsafe(sql, ...values),
});
await prisma.doc.findMany({ where: toWhere(where) });
// one row: not found, or found with the decision
const check = await checkRow(
prisma.post,
permdock.where(permissions.post.update),
{ id },
);
// run a transaction as the subject, so RLS policies see it
await withSubject(prisma, permdock, (tx) => tx.post.findMany(), {
dialect: "guc",
});toWhere(condition, options?)maps fields to model fields by name (oroptions.fields), and operators to Prisma filter operators:eqtoequals,netonot,in/notIn,gt/gte/lt/lte,isNulltoequals: null,containstohasfor scalar lists (options.listFields) andcontainsfor strings with%,_and\escaped (Prisma passes the value intoLIKEas is),and/or/nottoAND/OR/NOT.options.requiredFieldslists the model's non-nullable fields. A negated condition keeps SQL NULL rows the way the in-memory evaluator does, which adds anisNullbranch; Prisma rejects a null filter on a required field, and its runtime data model does not say which fields are required. On a listed field the branch folds away (isNullis never, not-null is always). On any other field it staysequals: null, so a forgotten entry is a Prisma validation error, never extra rows.options.modeltakes the result ofprismaModelFields(datamodel, model), which reads required and list fields fromschema.prismatext or a DMMF datamodel, so you do not maintainrequiredFieldsandlistFieldsby hand. It skips relation fields and throws when the model is missing. Field names are checked afteroptions.fieldsmapping.- A
relatednode (a graph grant) cannot be a PrismaWhereInput: Prisma has no raw fragment insidewhere.resolveRelated(where, { run, relations?, parse? })runs one raw query per node through yourrunand replaces the node withinover the ids. It reads the ids when called, so run the filtered query straight after.parseconverts ids for non-text key columns. Without it,toWhererefuses the node withnon-portable-condition. checkRow(delegate, condition, unique, options?)runsfindFirstwith the unique filter and the permission filter, then, if that finds nothing,findFirstwith the unique filter alone. It returns{ found: false }or{ found: true, granted }, so a handler can answer404and403apart without loading the row intocan().withSubject(prisma, permdock, fn, options?)runsfninside$transactionafter oneselect set_config(…)statement that sets the local role and the claim settings for the frozen subject, with the same options as Drizzle:dialect,role,gucPrefix,tenantClaim,claims. They must match the RLS configuration.memberOfalways compiles from the frozen subject's memberships (active-tenant equality,inover held tenants or teams, keyed parent hops). PrismaWhereInputhas noEXISTS, so there is no memberships-table option; pass the subject.where()already carries it. The adapter does not import@prisma/client.- No grant compiles to
{ OR: [] }. permdockExtension()is a Prisma client extension that detectsOR: []anywhere in awhereand rewrites the query so it returns no records forfindMany,findFirst,count,aggregate,updateManyanddeleteMany, closing the gap in prisma#17367.- Prisma 7 custom-output generators are supported through a runtime entry that does not import
@prisma/client; types are passed by the caller as in the example.
Prisma 8
Prisma 8's ORM filters with typed field predicates instead of WhereInput objects. toPredicate(condition, options?) returns a function of the model's field proxy that builds the same filter:
import { toPredicate, prismaModelFields } from "permdock/prisma";
const readable = toPredicate(permdock.where(permissions.post.read), { model });
// readable(post) builds the predicate from the Post field proxy; pass it wherever Prisma 8 takes a filterIt uses .eq, .neq, .in, .gt, .gte, .lt, .lte, .like, .isNull and .isNotNull on each field, and and, or and not from @prisma/orm-postgres/orm-client (or options.combinators). Constant true and false are key.isNotNull() and key.isNull() on options.key (default id). It refuses, with non-portable-condition, contains on a list field or with a non-string value, exists, sql, and a related node that resolveRelated has not replaced. memberOf compiles from the subject, as in toWhere. toPredicate is unit-tested only; the integration suite runs Prisma 7.
RLS generation:
// emitted by: permdock rls generate --target prisma --dialect supabase
// add @@rls to model Post; keep these blocks in their namespace
policy_select post_read_member {
target = Post
roles = [authenticated]
using = "(\"author_id\" = (select auth.uid()))"
}
policy_update post_update_member {
target = Post
roles = [authenticated]
using = "(\"author_id\" = (select auth.uid()))"
withCheck = "(\"author_id\" = (select auth.uid()))"
}The file holds policy blocks only; add @@rls to each model named in the header. Prisma 8 policies are permissive, so a policy with a deny grant cannot be expressed and generate stops with an error naming --target sql. Grants, column privileges and helper functions go to <out>.migration.sql. rls.prisma.models maps a table to its model name when it is not the table name in PascalCase (RLS).
@@rls enables RLS fail-closed on the model; roles reference Prisma 8 role declarations (@prisma/orm-extension-supabase supplies anon, authenticated, service_role); prisma migration plan emits the ENABLE ROW LEVEL SECURITY and CREATE POLICY statements and prisma db verify fails on drift.
Request lifecycle
- The request-scoped
PermDockis created. permdock.where(permission)flattens grants (each allow ANDed with higher-priority deny negations, ORed together) into a portable tree; unconditional allow yieldstrue, no grant yieldsfalse.toWherecompiles the tree into aWhereInput;truebecomes{}andfalsebecomes{ OR: [] }. Subject values are inlined as literal filter values (Prisma parameterises them).- The app composes the input with its own filters. With
permdockExtensioninstalled, anOR: []anywhere guarantees zero rows for every operation type. on('decision')fires with the granted-or-not outcome for observability.
What it validates
- Condition fields must exist on the model when the resource schema was generated from Prisma types; otherwise the mismatch surfaces as a Prisma validation error at query time and
toWherecannot help, which is why generated schemas are recommended. - Closure grants:
permdock.wheremarks them{ portable: false }andtoWherethrowsPermDockValidationErrornaming the grant. - The extension validates nothing about rows; it only rewrites
where. - Rows returned by Prisma are trusted server data and are not validated (
validate: 'boundary').
How denials surface
- Not granted: an empty result set. As with Drizzle, run
assert(permissions.post.list)first when the API should answer403instead of an empty list;{ OR: [] }alone cannot distinguish forbidden from not found (CASL #794, #404). - Writes:
updateMany/deleteManywith{ OR: [] }affect zero rows; single-recordupdate/deleteshould be preceded byassert(permissions.post.update, post)because Prisma's unique-where does not accept the filter. The extension wraps onlyfindMany,findFirst,count,aggregate,updateManyanddeleteMany;findUniqueis left alone, so a unique lookup is guarded byasserton the loaded row. - Non-portable grant:
PermDockValidationErrorat compile time. - In RLS mode: policies filter (
USING) or raise42501(WITH CHECK), which Prisma surfaces as a known request error;permdock rls verifymaps both tofiltered/rejected.
Example app
apps/examples/prisma: a Hono server on 127.0.0.1:3468 running Prisma 7 with @prisma/adapter-pg against an in-process PGlite database (served over @electric-sql/pglite-socket). GET /posts returns the rows findMany selects through the list filter, behind the Hono permdock() middleware. PATCH /posts/:id runs protect with a loader, then updateMany 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 runs the full operator and scenario matrix against Postgres with ormParity, once with hand-written requiredFields and once with prismaModelFields, and compares checkRow with can(). orm-graph.test.ts covers resolveRelated, with-subject.test.ts runs withSubject against generated RLS, and rls-orm-targets.test.ts applies the policy blocks' SQL to Postgres.
Related standards
- Postgres RLS: policy semantics behind the
policy_*blocks. - Conditions: the operator set and the flattening rule.
- RLS adapter:
generate --target prisma,import,verify. - Landscape: CASL's
accessibleBy,{ OR: [] }andcreateCaslExtension.
Last updated on
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.
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.