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
| Operator | Meaning | SQL |
|---|---|---|
eq (default for a bare value) | equals | = |
ne | not equals | <> |
in, notIn | membership in a literal array or a context array | IN, NOT IN |
gt, gte, lt, lte | comparison on numbers, strings, dates | >, >=, <, <= |
isNull | true or false | IS NULL, IS NOT NULL |
contains | array column contains value, or string contains substring (typed by the field) | @> or LIKE; RLS: value = any(column) or LIKE |
and, or, not | compound | AND, OR, NOT |
memberOf | the 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> ...) |
sqlFunction | a named Postgres function plus a portable twin the evaluator, filter, where and snapshots run | name(args) (--inline-functions emits the twin instead) |
related | the 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'sprincipalfunction (principal.id,principal.orgId).context.<key>refers to a value loaded by the policy'scontextfunction (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:
| Where | Holds when |
|---|---|
| In memory | The 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
| Intent | Condition | Supabase 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:
| Action | where | check | Generated policy |
|---|---|---|---|
read (and other read-like instance actions) | required for a conditional grant | not allowed | FOR SELECT USING (where) |
create (collection) | not allowed | on the new row | FOR INSERT WITH CHECK (check) |
update | current row | next row; defaults to where when omitted | FOR UPDATE USING (where) WITH CHECK (check) |
delete | current row | not allowed | FOR 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": [...] }, withresourceandparentswhen the scope is a resource (tenancy). - A graph test is
{ "op": "related", "resource": "<resource>", "relation": "<name>", "field": "<row field>", "depth": <0-32> }, with"parent": truewhen the field holds the row's parent id,hops([{ "link", "resource" }]) when the grant follows links,idsin place ofrelationfor a resource role held on a self-parented resource,restrictednaming 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": truewhen 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/orare 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:
| Target | Entry | What you get |
|---|---|---|
| In memory | permdock.can, decide, filter | Boolean per row, fail-closed on missing fields |
| Arrays | permdock.filter(permissions.post.read, posts) | Post[] |
| Query fragment | permdock.where(permissions.post.read) | The portable condition for the subject's matching grants: allows OR'd, each AND'd with NOT of matching denies |
| Drizzle | toWhere(permdock.where(...), posts) from permdock/drizzle | An SQL expression for .where(); empty allow is constant false; memberOf is equality, inArray, parent or, or exists |
| Prisma | toWhere(...) from permdock/prisma | A WhereInput object; fail-closed as { OR: [] } when nothing is allowed; memberOf uses frozen membership ids |
| Kysely | toWhere(...) from permdock/kysely | An expression builder callback; empty allow is eb.lit(false); memberOf matches Drizzle |
| Prisma 8 | toPredicate(...) from permdock/prisma | A function of the model's field proxy |
| Postgres RLS | permdock rls generate | CREATE 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.whereskips it and setspartial.permdock.snapshot()emits the grant as{ "portable": false }; a client asking about that permission getsstatus: 'server-only'and the provider asks the decision endpoint, batched by permission key and resource id.permdock rls generatelists it under "not generated";permdock usageflags 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. inrequires an array on the right-hand side and a scalar field on the left.checkon areadordeletegrant is a type error, as iswhereon a collection action. Collection grants may carrycheck.- Closures require the exact
(data: Post, ctx) => booleansignature; thedatatype is the schema output. A thenable from a closure is aclosure-errordenial.
Semantics to keep in mind
- Three-valued logic. In memory a comparison with
undefinedornullisfalse, nevertrue, mirroring SQL whereNULL = xis unknown and unknown filters out.isNullis 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 aDateor a{ date }tag, and the other side must then be aDate, epoch milliseconds or an ISO 8601 string (the Postgres text form included). Otherwise both sides must have the same primitive type:'2'never equals2, and ids such as'user-2','org-1'or'acme-1'are never read as dates. - Case and collation are the database's business.
containsis one operator for both arrays and strings; on strings it is case-sensitive in memory and compiles toLIKE, notILIKE. - Nested paths (
address.city) and array-of-object fields are not supported. Conditions address top-level fields of the resource schema; relation data goes throughmemberships(roles held somewhere) orcontext(everything else). relatedis evaluated through the instance'sRelationSourcecache. Any read it cannot answer fails the whole grant, including undernotandor(relationships).- An opaque node anywhere in a condition, including under
notandor, fails the whole grant withopaque-condition, sonot(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. memberOfis evaluated against the frozen subject's memberships, so an expired membership or an active tenant with no membership makes itfalse, the same way a missing field does. It is generated from role declarations only; there is no hand-writtenmemberOfin awhere.
Last updated on