PermDock
Concepts

Conditions

One portable condition AST evaluates in memory, filters arrays, compiles to Drizzle, Prisma and Kysely where clauses, and generates Postgres RLS.

A condition is the part of a grant that depends on data: "the author is the current user", "the invoice belongs to the subject's org", "the post is not yet published". PermDock has exactly one condition language. It is a small JSON tree, it is written in TypeScript as an object literal typed from the resource schema, and every consumer (the in-memory evaluator, filter, the query compilers, the RLS generator, the snapshot) reads the same tree. Closures exist as an escape hatch and are marked as such.

Writing conditions

allow(permissions.post.update, { where: { authorId: subject.id } })
allow(permissions.post.read,   { where: { or: [{ published: true }, { authorId: subject.id }] } })
allow(permissions.invoice.pay, { where: { orgId: subject.orgId, amount: { lte: 10_000 } } })
allow(permissions.post.read,   { where: { teamId: { in: subject.context.teamIds } } })
allow(permissions.post.update, { where: { authorId: subject.id }, check: { authorId: subject.id } })

An object literal is an implicit and over its fields. A field value is either a literal (true, 'published', 42), a subject reference (subject.id), or an operator object ({ lte: 10_000 }). and, or and not nest. Field names and literal types are checked against StandardSchemaV1.InferOutput of the resource schema, so a typo or a string compared to a boolean is a compile error.

Operators

OperatorMeaningSQL
eq (default for a bare value)equals=
nenot equals<>
in, notInmembership in a literal array or a subject.context arrayIN, NOT IN
gt, gte, lt, ltecomparison on numbers, strings, dates>, >=, <, <=
isNulltrue or falseIS NULL, IS NOT NULL
containsarray column contains value, or string contains substring (typed by the field)@> or LIKE
and, or, notcompoundAND, OR, NOT
memberOfthe row's scope field names a tenant, team or resource where the subject holds one of the listed roles; emitted by scoped role declarations, not written by hand= on the active tenant claim, or EXISTS (select 1 from <membership table> ...)

That is the whole list. It is deliberately the subset that round-trips to Postgres RLS (research); anything richer belongs in context or a closure. memberOf is the one node the evaluator adds for scoped roles: role('viewer', grants, { on: 'tenant' }) wraps each grant's condition in memberOf(tenant, roles: ['viewer']), so a tenant-scoped grant compiles, snapshots and filters like any other condition.

Subject references

subject (imported from permdock) is a typed reference builder, not the current user:

  • subject.<field> refers to a field returned by the policy's subject function (subject.id, subject.orgId).
  • subject.context.<key> refers to a value loaded by the policy's context function (subject.context.teamIds).

References are what make a condition portable. In memory they read from the frozen subject; in a snapshot they read from the client's copy of the subject; in SQL they become (select auth.uid()), (select auth.jwt()) ->> 'orgId' or a current_setting() GUC depending on the dialect. The type of a reference is checked against the field it is compared to.

Common patterns

IntentConditionSupabase RLS output
Ownership{ where: { authorId: subject.id } }(select auth.uid()) = author_id
Tenancy{ where: { orgId: subject.orgId } }org_id = ((select auth.jwt()) ->> 'orgId')::uuid
Public rows{ where: { published: true } }published = true
Owner or public{ where: { or: [{ published: true }, { authorId: subject.id }] } }(published = true OR (select auth.uid()) = author_id)
Membership (array in context){ where: { teamId: { in: subject.context.teamIds } } }team_id IN (select team_id from team_user where user_id = (select auth.uid()))
Tenancy (scoped role)role('viewer', [allow(permissions.post.read)], { on: 'tenant' })org_id = ((select auth.jwt()) ->> 'tenant_id')::uuid
Team role (scoped role)role('lead', [allow(permissions.post.publish)], { on: 'team' })exists (select 1 from team_member m where m.team_id = team_id and m.user_id = (select auth.uid()) and m.role = 'lead')
Not archived{ where: { archivedAt: { isNull: true } } }archived_at IS NULL
Cannot reassign owner{ where: { authorId: subject.id }, check: { authorId: subject.id } }USING (...) WITH CHECK (...)

The right-hand column assumes the Supabase dialect; Neon and generic Postgres substitute auth.user_id() or a GUC. Column naming follows the mapping the RLS adapter derives from your schema or Drizzle table.

where versus check

where describes the row as it is now; check describes the row as it will be after the write. The split mirrors Postgres exactly:

ActionwherecheckGenerated policy
read (and other read-like instance actions)required for a conditional grantnot allowedFOR SELECT USING (where)
create (collection)not allowedon the new rowFOR INSERT WITH CHECK (check)
updatecurrent rownext row; defaults to where when omittedFOR UPDATE USING (where) WITH CHECK (check)
deletecurrent rownot allowedFOR DELETE USING (where)

The classic hole this closes: a user who may update their own posts must not be able to reassign authorId to someone else. allow(permissions.post.update, { where: { authorId: subject.id } }) alone lets Postgres reuse the USING clause as WITH CHECK, and PermDock does the same in memory: when check is omitted for update, the where condition is evaluated against the proposed row too. Pass check explicitly when the two differ.

In memory, update and create checks receive the next row as data; decide(permissions.post.update, { current, next }) is the two-row form used by adapters that have both.

JSON format

Conditions are plain JSON. No superjson, no class instances, no functions. The literal form you write is normalised into a tagged tree so consumers do not need to re-parse object shorthand:

{
  "op": "and",
  "conditions": [
    { "op": "eq", "field": "authorId", "value": { "ref": "subject.id" } },
    { "op": "in", "field": "teamId", "value": { "ref": "subject.context.teamIds" } },
    { "op": "lte", "field": "createdAt", "value": { "date": "2026-09-01T00:00:00Z" } }
  ]
}
  • A literal value is a JSON literal.
  • A reference is { "ref": "subject.<path>" }.
  • A scope test is { "op": "memberOf", "scope": "tenant" | "team" | "resource", "field": "<row field>", "roles": [...] }, with resource and parents when the scope is a resource (tenancy).
  • A date is a tagged ISO string { "date": "..." }, so it survives JSON without superjson and compares correctly in memory and in SQL.
  • Nested and / or are flattened and single-child compounds collapsed at definition time, the same normalisation CASL's ucast applies.

This is the format inside snapshots, catalogs and the generated permissions.generated.ts; see wire formats.

Compile targets

One AST, several interpreters:

TargetEntryWhat you get
In memorypermdock.can, decide, filterBoolean per row, fail-closed on missing fields
Arrayspermdock.filter(permissions.post.read, posts)Post[]
Query fragmentpermdock.where(permissions.post.read)The portable condition for the subject's matching grants: allows OR'd, each AND'd with NOT of matching denies
DrizzletoWhere(permdock.where(...), posts) from permdock/drizzleAn SQL expression for .where()
PrismatoWhere(...) from permdock/prismaA WhereInput object; fail-closed as { OR: [] } when nothing is allowed
KyselytoWhere(...) from permdock/kyselyAn expression builder callback
Postgres RLSpermdock rls generateCREATE POLICY statements, Drizzle pgPolicy entries or Prisma 8 policy_* blocks
import { toWhere } from 'permdock/drizzle'
const rows = await db.select().from(posts).where(toWhere(permdock.where(permissions.post.read), posts))

permdock.where follows the flattening CASL v7 fixed in rulesToCondition: walk grants, OR the allows, AND each with the negation of every deny, and return an always-false condition when nothing is allowed so the query returns no rows rather than all rows. If any matching grant is a closure, the result carries partial: true and the adapter page tells you to re-check rows in memory after the query.

Closures

allow(permissions.post.publish, (post, ctx) => post.authorId === ctx.subject.id && !post.published)

A closure is any function passed as the condition. It runs on the server with (data, ctx) where ctx has subject, actor, delegation and context. It may be async. It is branded NonPortable at the type level, so:

  • permdock.where skips it and sets partial.
  • permdock.snapshot() emits the grant as { "portable": false }; a client asking about that permission gets status: 'server-only' and the provider asks the decision endpoint, batched by permission key and resource id.
  • permdock rls generate lists it under "not generated"; permdock usage flags it.

Closures are the right tool for calls to another system or logic that is not a comparison. They are the wrong tool for ownership and tenancy, which should be portable so the UI, query and database agree.

Opaque conditions

permdock rls import reads existing policies from pg_policies. Expressions in the portable subset become where / check data; anything else (now() arithmetic, CASE, multi-join subqueries, custom functions) becomes an opaque node:

allow(permissions.post.read, { where: opaque({ sql: '(created_at > now() - interval \'30 days\')', fingerprint: 'sha256:...' }) })

An opaque condition keeps the SQL verbatim so rls generate can emit it back unchanged and the fingerprint (from the deparsed AST, not raw text, since pg_get_expr rewrites formatting) detects drift on re-import. In memory an opaque condition evaluates to false and the decision explains why (reason: 'opaque-condition'), so the client falls back to the decision endpoint and the endpoint falls back to the database. permdock rls verify reports opaque grants as untestable app-side.

Type safety

  • Field names, literal types and reference types come from the resource schema output. { where: { authorId: subject.orgId } } compiles only if both are strings; a mismatch is an error.
  • in requires an array on the right-hand side and a scalar field on the left.
  • check on a read or delete grant is a type error, as is where on a collection action.
  • Closures require the exact (data: Post, ctx) => boolean | Promise<boolean> signature; the data type is the schema output.

Semantics to keep in mind

  • Three-valued logic. In memory a comparison with undefined or null is false, never true, mirroring SQL where NULL = x is unknown and unknown filters out. isNull is the explicit way to test for absence.
  • Dates compare by instant. { date } tags carry ISO strings with an offset; comparisons parse them once.
  • Case and collation are the database's business. contains on strings is case-sensitive in memory and compiles to LIKE, not ILIKE.
  • Nested paths are not in v1. Conditions address top-level fields of the resource schema; relation data goes through memberships (roles held somewhere) or context (everything else).
  • memberOf is evaluated against the frozen subject's memberships, so an expired membership or an active tenant with no membership makes it false, the same way a missing field does.

Open questions

  • Exact field names of the JSON tree (op, field, value, ref, date above are provisional).
  • Whether contains should be split into arrayContains and stringContains for clearer SQL mapping.
  • Nested field paths (address.city) and array-of-object fields: v1 excludes them; a later version may add a typed path helper.
  • How much of the Supabase authorize('perm') RBAC pattern should be recognised on import as a named-permission node rather than opaque SQL.
  • Whether memberOf should be writable by hand in a where (for a grant that checks membership in a different scope than the role's) or stay generated from role declarations only.

On this page