PermDock
Adapters

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.

Status: planned Phase: 3

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] } (CASL research). PermDock adopts the same fail-closed behaviour and adds the Prisma 8 native RLS target introduced in July 2026 (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))
  • toWhere(condition, options?) maps fields to model fields by name (or options.fields), and operators to Prisma filter operators: eq to equals, ne to not, in / notIn, gt / gte / lt / lte, isNull to equals: null, contains to has for scalar lists and contains for strings, and / or / not to AND / OR / NOT.
  • No grant compiles to { OR: [] }.
  • permdockExtension() is a Prisma client extension that detects OR: [] anywhere in a where and rewrites the query so it returns no records for findMany, findFirst, count, aggregate, updateMany and deleteMany, 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.

RLS generation:

// emitted by: permdock rls generate --target prisma --dialect supabase
model Post {
  id        String  @id
  authorId  String
  published Boolean
  @@rls
}

policy_select post_read_member {
  target = Post
  roles  = [authenticated]
  using  = "(select auth.uid()) = \"authorId\""
}

policy_update post_update_member {
  target    = Post
  roles     = [authenticated]
  using     = "(select auth.uid()) = \"authorId\""
  withCheck = "(select auth.uid()) = \"authorId\""
}

@@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

  1. The request-scoped PermDock is created.
  2. permdock.where(permission) flattens grants (each allow ANDed with higher-priority deny negations, ORed together) into a portable tree; unconditional allow yields true, no grant yields false.
  3. toWhere compiles the tree into a WhereInput; true becomes {} and false becomes { OR: [] }. Subject values are inlined as literal filter values (Prisma parameterises them).
  4. The app composes the input with its own filters. With permdockExtension installed, an OR: [] anywhere guarantees zero rows for every operation type.
  5. 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 toWhere cannot help, which is why generated schemas are recommended.
  • Closure grants: permdock.where marks them { portable: false } and toWhere throws PermDockValidationError naming 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 answer 403 instead of an empty list; { OR: [] } alone cannot distinguish forbidden from not found (CASL #794, #404).
  • Writes: updateMany / deleteMany with { OR: [] } affect zero rows; single-record update / delete should be preceded by assert(permissions.post.update, post) because Prisma's unique-where does not accept the filter.
  • Non-portable grant: PermDockValidationError at compile time.
  • In RLS mode: policies filter (USING) or raise 42501 (WITH CHECK), which Prisma surfaces as a known request error; permdock rls verify maps both to filtered / rejected.

Example app

apps/examples/prisma: a Next.js route handler API with toWhere on list routes, permdockExtension installed, a Prisma 8 schema with generated policy_* blocks, and a parity test running filter in-process against findMany with toWhere and against RLS via set local role.

Open questions

  • Whether toWhere should generate relation filters (some / every / none) for membership conditions or require a subquery-free formulation.
  • How to type toWhere without importing @prisma/client in the adapter, given Prisma 7 and 8 custom output paths.
  • Whether the extension should also guard findUnique by throwing when { OR: [] } would be the only filter (unique lookups ignore it).
  • Prisma 8 policy syntax is new; the exact quoting and roles shape will be pinned once the target ships in Phase 3.

On this page