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: principal.id } });
allow(permissions.post.read, {
  where: { or: [{ published: true }, { authorId: principal.id }] },
});
allow(permissions.invoice.pay, {
  where: { orgId: principal.orgId, amount: { lte: 10_000 } },
});
allow(permissions.post.read, { where: { teamId: { in: context.teamIds } } });
allow(permissions.post.update, {
  where: { authorId: principal.id },
  check: { authorId: principal.id },
});

An object literal is an implicit and over its fields. A field value is either a literal (true, 'published', 42), a principal reference (principal.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 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; RLS: value = any(column) or LIKE
and, or, notcompoundAND, OR, NOT
memberOfthe row's scope field names an instance of a named scope (or a resource) where the subject holds one of the listed roles, through a membership of exactly that scope; emitted by scoped role declarations, not written by hand= on the active tenant claim, or EXISTS (select 1 from <membership table> ...)
sqlFunctiona named Postgres function plus a portable twin the evaluator, filter, where and snapshots runname(args) (--inline-functions emits the twin instead)
relatedthe subject holds a relation on the object the row's field names, or on one of its ancestors within depth parent hops; emitted by relation(..., { through: 'parent' }) and edge-table relations, not written by hand (relationships)field in (select descendant from permdock_closure where ... and ancestor = any (array(select permitted_<resource>_ids('<relation>'))))

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. sqlFunction is the node for SQL-authority policies whose database helper is the source of truth:

allow(permissions.job.read, {
  where: sqlFunction("job_permitted", {
    args: [{ field: "id" }],
    twin: {
      or: [
        { scope: "public" },
        {
          and: [
            { scope: "team" },
            { op: "memberOf", scope: "team", field: "teamId", roles: [] },
          ],
        },
      ],
    },
  }),
});

Subject references

principal and context (imported from permdock) are typed reference builders, not the current user:

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

There is no subject builder: a ref whose path starts with subject. is not a reference at all, so it fails closed.

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.

Token claims are principal.claims.<path>, and a path may be nested: principal.claims.attrs.region. permdock rls generate compiles a nested path to (select auth.jwt()) -> 'attrs' ->> 'region', casts it to the column's type, and compiles in / notIn against an array claim (subject attributes in RLS). A snapshot does not carry the claims; it carries each claim a grant reads as a literal in that grant's condition, bound when the snapshot is built. context.* refs are portable to snapshots and ORM where clauses but not to RLS, because the request context is not in the token (doctor PD027).

Live sessions

{ subject: { session: { live: true } } } holds only when the Auth server still holds the session behind the token. Use it on a grant that must stop at sign-out or revocation, not at token expiry:

allow(permissions.invoice.pay, {
  where: { subject: { session: { live: true } } },
});

A signed JWT stays valid until exp after its session is revoked, so the claims cannot answer this. Each side checks the session itself:

WhereHolds when
In memoryThe subject has liveSession: true. subjectFromSupabase(claims, { liveSession: true }) sets it for a token with a session_id; pass the option only after auth.getUser() or better-supabase's checkSession checked the session for this request (Supabase)
Snapshot, where()The condition is bound to a constant when the snapshot or the filter is built
Supabase RLS"permdock".permdock_session_live() finds the token's session_id in auth.sessions for the caller, with not_after unset or in the future

A failed check denies with reason condition. Only the supabase dialect compiles it: rls generate exits 2 on neon or guc, which have no auth.sessions. The key subject with any other value is a comparison on a row field named subject; a malformed { session: … } throws when the policy is defined.

Common patterns

IntentConditionSupabase RLS output
Ownership{ where: { authorId: principal.id } }(select auth.uid()) = author_id
Tenancy{ where: { orgId: principal.orgId } }org_id = ((select auth.jwt()) ->> 'orgId')::uuid
Public rows{ where: { published: true } }published = true
Owner or public{ where: { or: [{ published: true }, { authorId: principal.id }] } }(published = true OR (select auth.uid()) = author_id)
Membership (array in context){ where: { teamId: { in: 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: principal.id }, check: { authorId: principal.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: principal.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": "principal.id" } },
    { "op": "in", "field": "teamId", "value": { "ref": "context.teamIds" } },
    {
      "op": "lte",
      "field": "createdAt",
      "value": { "date": "2026-09-01T00:00:00Z" }
    }
  ]
}
  • A literal value is a JSON literal.
  • A reference is { "ref": "principal.<path>" } or { "ref": "context.<path>" }; no other prefix resolves.
  • 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 graph test is { "op": "related", "resource": "<resource>", "relation": "<name>", "field": "<row field>", "depth": <0-32> }, with "parent": true when the field holds the row's parent id, hops ([{ "link", "resource" }]) when the grant follows links, ids in place of relation for a resource role held on a self-parented resource, restricted naming the row's restricted column when it closes the walked path, restrictedAncestors ({ "resource", "id", "parent", "field", "depth" }) when a closed link also checks the rows above the row, and "passRestricted": true when the parent walk passes restricted rows. It never appears in a snapshot: grants that need it are server-only there.
  • A live-session test is { "op": "liveSession" } (live sessions). It never appears in a snapshot, which carries the bound constant.
  • 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(); empty allow is constant false; memberOf is equality, inArray, parent or, or exists
PrismatoWhere(...) from permdock/prismaA WhereInput object; fail-closed as { OR: [] } when nothing is allowed; memberOf uses frozen membership ids
KyselytoWhere(...) from permdock/kyselyAn expression builder callback; empty allow is eb.lit(false); memberOf matches Drizzle
Prisma 8toPredicate(...) from permdock/prismaA function of the model's field proxy
Postgres RLSpermdock rls generateCREATE POLICY statements, Drizzle pgPolicy entries or Prisma 8 policy_* blocks

related needs the graph tables, not only the row. permdock.where() keeps it; Drizzle and Kysely compile it to a subquery when options.relations says where the tables are, and Prisma needs resolveRelated to replace it with ids first. Without either, the compilers refuse it with non-portable-condition. A relation with a period stays out of where() (partial: true); re-check those rows with filter after loadRelations, or let RLS enforce them (relationships).

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.principal.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 not portable, 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; named functions listed in rls.functions become sqlFunction nodes; anything else (now() arithmetic, CASE, multi-join subqueries, unmapped 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 fails its grant, a deny with one denies, 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. Prefer sqlFunction when the SQL is a named helper whose body you can twin. Supabase's authorize('perm') RBAC helper is imported the same way: list it in rls.functions and it becomes a sqlFunction node, not a dedicated op.

Type safety

  • Field names, literal types and reference types come from the resource schema output. { where: { authorId: principal.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. Collection grants may carry check.
  • Closures require the exact (data: Post, ctx) => boolean signature; the data type is the schema output. A thenable from a closure is a closure-error denial.

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. A comparison is by instant only when one side is a Date or a { date } tag, and the other side must then be a Date, epoch milliseconds or an ISO 8601 string (the Postgres text form included). Otherwise both sides must have the same primitive type: '2' never equals 2, and ids such as 'user-2', 'org-1' or 'acme-1' are never read as dates.
  • Case and collation are the database's business. contains is one operator for both arrays and strings; on strings it is case-sensitive in memory and compiles to LIKE, not ILIKE.
  • Nested paths (address.city) and array-of-object fields are not supported. Conditions address top-level fields of the resource schema; relation data goes through memberships (roles held somewhere) or context (everything else).
  • related is evaluated through the instance's RelationSource cache. Any read it cannot answer fails the whole grant, including under not and or (relationships).
  • An opaque node anywhere in a condition, including under not and or, fails the whole grant with opaque-condition, so not(opaque) never matches.
  • A deny that cannot be evaluated denies the decision: a closure that throws or returns a thenable (closure-error), an opaque condition (opaque-condition) or a relation read that cannot be answered (relation-depth, relation-unavailable). An allow that cannot be evaluated only fails that grant. A snapshot applies the same rule to an opaque deny.
  • 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. It is generated from role declarations only; there is no hand-written memberOf in a where.

Last updated on

On this page