Policies
Grants name a grantee with to selectors; definePolicy binds typed vocabulary, a principal mapper, and portable conditions.
A policy answers "who may do what, under which conditions". In PermDock it is data: typed roles and plans sit next to permissions, grants are allow or deny of a permission reference with a to: selector, and definePolicy binds the vocabulary to the function that turns your user into a principal. Because the policy is data, it can be snapshotted to the client, compiled to SQL, printed as a matrix in tests, and diffed in review.
The policy is server-only. It is the one file in a PermDock app that must never reach a client bundle; permdock doctor checks for it.
Shape
import {
definePolicy,
defineRoles,
role,
allow,
deny,
principal,
relation,
anyone,
} from "permdock";
import { permissions } from "./permissions";
export const roles = defineRoles({ member: {}, admin: {} });
const member = role(roles.member, [
allow(permissions.post.read),
allow(permissions.post.list),
allow(permissions.post.create),
allow(permissions.post.update, { to: relation(permissions.post, "author") }),
allow(permissions.post.delete, {
where: { authorId: principal.id },
approval: "human",
}),
]);
const admin = role(roles.admin, [
...member.grants,
allow(permissions.post.delete),
deny(permissions.post.publish, { to: anyone(), where: { published: true } }),
]);
export const policy = definePolicy(
{ permissions, roles },
{
roles: [member, admin],
principal: (user: User | null) =>
user && { id: user.id, orgId: user.orgId, roles: user.roles },
context: async (user) => ({ teamIds: await loadTeamIds(user.id) }),
validate: "boundary",
},
);Roles
role(name, grants, options?) returns a RoleBinding: { name, grants, on?, assignable, exclusiveWith?, min?, max?, transferOnly?, assigns?, for?, meta? }. exclusiveWith is a static-separation list for PD018 and decideRoleChange; it does not change evaluation. min, max, transferOnly, assigns and for are the role's ownership rules: for lists the membership kinds (via) that may hold the role, and a role held through any other kind grants nothing. meta is a RoleMeta, whose audience feeds permdock.audiences(). A role is looked up by name from subject.roles or from a membership, so role names are the only strings in a policy, and they are data you already store (in your users table, JWT claims or Better Auth roles). Roles are not Postgres roles; when RLS is generated, app roles become claims and every policy targets TO authenticated (see RLS).
Typed vocabulary
Roles and plans are declared the same way permissions are. defineRoles and definePlans produce trees of frozen JSON leaves: a Role leaf is { key, on?, assignable, meta }, a Plan leaf is { key, meta }, and identity is by key. definePolicy takes the vocabulary object { permissions, roles?, plans? } as its first argument (a bare permission tree still works), and role() accepts a Role leaf or a name string; the leaf's on and assignable are the defaults for the binding.
The instance exposes the same trees as permdock.permissions, permdock.roles and permdock.plans, and heldRoles({ tenant?, scope?, id? }) and assignableRoles() return Role[], ranked by the assigns graph when the policy has one. audiences() lists the distinct meta.audience values of the roles held in the active tenant, and decideRoleChange(change) checks an assign, revoke or transfer (ownership). A name that only exists in a RoleSource or a token becomes a synthesised leaf with assignable: false. The reason is the same as for permissions: roles.admin autocompletes, a typo is a compile error, the catalog can list roles and plans, and a pla or entitlements claim has a typed place to land. The leaves carry no grants or closures, so they are client-safe and ship in the snapshot.
defineRoles(tree, { x }) and definePlans(tree, { x }) take a Standard Schema for every leaf's meta.x. It validates at definition time, throws on invalid data, and types roles.admin.meta.x. On a role tree the same schema checks the meta.x of custom roles a RoleSource returns; an invalid one is dropped, not the role.
Scoped roles
The third argument says where a role applies. Without it the role is global and is selected by principal.roles, as in the example above. With on, the role is selected by a membership and its grants match only rows in that scope:
const viewer = role(
"viewer",
[allow([permissions.post.read, permissions.post.list])],
{ on: "tenant" },
);
const lead = role("lead", [allow(permissions.post.publish)], { on: "team" });
const editor = role("editor", [allow(permissions.document.update)], {
on: permissions.document,
});
export const policy = definePolicy(permissions, {
roles: [viewer, lead, editor, admin],
scopes: {
tenant: { key: "orgId" },
team: { key: "teamId", within: "tenant" },
},
subject: subjectFromClerk,
});scopes declares the policy's named scopes in order; each key names the row field holding that scope's id, and every resource a scoped grant touches declares it as a memberOf relation. It replaces where: { orgId: principal.orgId } on each grant. assignable (default true for scoped roles) marks roles a tenant admin may hand out and compose into tenant-defined custom roles. The full model, memberships, evaluation rules, custom roles and the RoleSource and MembershipSource interfaces, is on tenants, teams and scoped roles.
Spreading grants
...member.grants copies a role's grants into another. It is plain array spread, so the result is visible, reviewable and needs no hierarchy resolver. The admin above has every member grant plus unconditional delete.
Role fragments
Roles may be declared in several files and passed together to definePolicy. Roles with the same name merge their grants in declaration order:
export const policy = definePolicy(permissions, {
roles: [...postRoles, ...billingRoles], // two 'member' fragments become one role
subject,
});Merging is a concatenation of grant arrays; the evaluation rules below make order irrelevant to the outcome. A fragment that references a leaf outside permissions is a type error, which is what keeps a feature from granting a permission the app never merged. See larger apps.
Grants
allow(permission, condition?) and deny(permission, condition?) are the only two grant constructors. The first argument is a reference or an array of references (allow([permissions.post.read, permissions.post.list]), allow(listPermissions(permissions.post))); an array is declaration sugar that becomes one grant per leaf in the normalised policy, the catalog and the snapshot.
| Second argument | Meaning |
|---|---|
{ to } | Grantee selector: anyone(), authenticated(), relation(resource, name), inherit(permission, { through }), a Role or Plan leaf, actor(kind), assurance({ acr }), or an array (intersection). Required on top-level grants. role() bindings fill a role selector. |
{ where } | Portable condition on the current row (RLS USING) |
{ check } | Portable condition on the next row (RLS WITH CHECK) for create and update |
{ where, check } | Both, for update |
(data, ctx) => boolean | Closure: runtime only, branded non-portable |
{ ..., approval: 'human' } or { ..., approval: { by, distinct } } | Grant is valid but the decision is approval-required. 'human' lets any authenticated person other than the actor and the principal approve. { by } names eligible approvers with the same grantee selectors as to. distinct defaults to true; distinct: false lets the principal approve their own request (doctor PD024 lists each one). staleOn: 'resource-change' binds the approval to the row's version field, so it no longer applies once the row changes (approval security) |
{ ..., limit: { count, per, mode?, alertAt? } } | Quota grant: non-portable, needs a LimitStore. mode: 'hard' (default) denies past count; mode: 'soft' grants with an over-limit obligation; alertAt adds a near-limit obligation |
{ ..., fields: ['title', 'body'] } | Schema-aware field list: keys of the resource type; omitted means every field; empty never matches |
{ ..., validFrom, validUntil } | When the grant applies, as RFC 3339 strings or Unix seconds; validFrom inclusive, validUntil exclusive. Either bound alone is allowed (validity) |
{ ..., requires: permissions.file.read } | An allow on an instance action counts only on rows whose scope instance (or the whole app) is one where the subject also holds that permission through a role grant without a row condition, minus a deny of it there; a list requires each of them (requires) |
{ ..., group: 'sent' } | A stable name for the grant's condition group in generated SQL: its grant key is <permission>#sent instead of a positional #n, so hand-written SQL that repeats the condition keeps the key when other grants change (grant keys). Lower case letters, digits, _ and -, starting with a letter; ignored in process |
{ ..., name: 'lock' } | A stable name for the grant. A breakGlass override lifts a named deny; on any grant it is reported on decision.matched.name, decision events, snapshots and the catalog |
{ ..., meta: { description, x } } | Display text and app data for the grant, reported on decision.matched.meta, decision events, snapshot grants and catalog grants. Plain JSON; x is checked by definePolicy(…, { x: { grant } }). Left out of the policy fingerprint, so editing it invalidates no token. Never read in evaluation |
{ ..., obligations: ['watermark'] } | Allows only. Each entry, a name or { name, detail }, becomes a { kind: 'app', name, detail? } obligation on the granted decision (obligations) |
Conditions are covered on their own page: conditions. Collection actions accept no where (there is no current row). They may carry check on the proposed body; without a body a check grant does not match. Closures over ctx only remain valid on collection grants.
Validity
role(roles.contractor, [
allow(permissions.doc.read, {
validFrom: "2026-03-01T00:00:00Z",
validUntil: "2026-04-01T00:00:00Z",
}),
deny(permissions.doc.delete, { validUntil: launch, name: "freeze" }),
]);validFrom and validUntil bound a grant in time without a membership: a contractor's read access for the length of an engagement, a change freeze that lifts itself at launch, a permission that opens on a release date. definePolicy normalises both to Unix seconds on Grant.validity as { from?, until? }, and throws on a bound that does not parse or a window that ends before it starts, so a typo never widens a grant.
Outside its window a grant contributes nothing. An inactive allow adds an inactive-grant denial carrying the window as detail: { from, until }, so a UI can say "from 1 March" instead of "no access"; an inactive deny does not apply, and explain lists it under skipped with why: 'validity'. The window is read against the decision clock: now on decide, can and simulate (simulate(checks, { now }) reads a whole batch as of one instant), so a test or a review screen asks "what will this role do after launch?" without changing the policy. Validity is portable: where() and filter drop inactive grants, the snapshot carries validity and the client evaluator checks it against its own clock, and permdock rls generate ANDs now() >= to_timestamp(from) and now() < to_timestamp(until) into the grant's access check, so the database agrees with the application to the second.
Why
Validity is a field of the grant, not a where operator on a clock ref, because the two mean different things in a policy diff and in the catalog: a condition tells you which rows a grant reaches, a window tells you when it exists at all. Keeping it separate lets explain report an inactive allow as inactive-grant rather than as a failed condition, lets where() drop the grant entirely instead of compiling a tautology, and lets a reviewer see "expires 1 April" next to the grant without reading its condition. Only a fixed range is supported: a recurring window (office hours, weekdays) is a business rule the application knows better than the policy, and encoding time zones and calendars in a portable condition would have cost every compiler a dependency for one case. A membership's expiresAt remains the tool for "this person's access ends"; validity is for "this rule's life".
Requires
allow(permissions.drive.read, {
to: relation(permissions.drive, "viewer"),
requires: permissions.file.read,
});requires puts a ceiling on a grant: a drive share counts only while the user also holds file.read in the drive's organization. The requirement is met when a role grant of the named permission without a row condition (no where, check, closure or requires, and inside its validity window) reaches the subject globally, or reaches it on the row's instance of a scope the row's resource is partitioned by, with no deny of that permission at the same instance. Declared roles and custom roles count alike. It is allowed on an allow of an instance action only; definePolicy rejects it on a deny, on a collection action and for an undeclared permission.
A list requires every permission in it: requires: [permissions.file.read, permissions.file.download] counts a share only on rows whose organization grants the subject both. Each permission is checked on its own, so the subject may hold one globally and the other in the row's organization. An empty list is rejected.
The check folds into the grant's row condition as { op: 'in', field: '<scope key>', value: [<instance ids>] }, so where(), filter and the snapshot carry it, and a client decides it like any portable condition. permdock rls generate ANDs (select permdock_has_permission('<key>')) or "<scope key>" in (select permitted_<scope>_ids_by_permission('<key>')) into the grant's access check, one such check per listed permission, and the grant's key counts as conditioned in grant_keys.
A delegated caller (an OAuth client, an API key) uses an allow with requires only when its scopes cover every required permission, as the database decides it: the requirement is checked through helpers that apply the same key ceiling. Its scopes must also cover the granted permission, with one exception: a read-only granted permission (meta.readOnly, or the read and list actions) on an allow that is not a role grant. A key stored with the feature scope file:read therefore keeps reading the drives its shares reach after drive.read moves to relation grants with requires: file.read, without reissuing keys, and it reaches no other grant of drive.read. It cannot use an editor share's node.update grant that requires file.read, nor a grant with requires: [file.read, file.update], unless it also names node.update and every required key. A key scoped to drive:read alone reaches the other grants of drive.read but not the shares that require file.read. can(), where(), snapshots and rls.apiKeys apply the same rule.
Why
An organization ceiling is a statement about the subject's roles, not about the row's columns, so it cannot be a where the author writes. Reading it from the same helpers by permission key that SQL callers use (permdock_has_permission, permitted_<scope>_ids_by_permission) keeps the in-process and database answers on one definition: unconditional role grants minus denies at that instance. Grants that carry their own row condition or requires never satisfy a requirement, so requirements cannot chain or loop.
Evaluation semantics
The rules are short and they are the whole story:
- Collect every grant from top-level
grantsand from role bindings whose name the subject holds, then keep those whoseto:selector matches (anyone(),authenticated(), a role, a plan, a relation, an actor, an assurance check, or an intersection). A relation selector also contributes a portablewhere. - Drop a scoped grant whose scope does not match the request: the row's tenant key must equal the membership's tenant and that tenant must be the active one; the row's team key must equal the membership's team; a resource role must be held on that row or an ancestor through a declared
parent. Expired memberships contribute nothing. Global grants skip this step (tenancy). Drop a grant outside itsvalidFrom/validUntilwindow as of the decision clock (validity); an inactive allow records aninactive-grantdenial. - If any
denymatches, the outcome isdenied. Deny overrides allow, regardless of role, scope or declaration order. - Otherwise, if any
allowmatches, the outcome isgranted(orapproval-requiredif the matched allow carriesapproval). Allows OR together, across scopes. - Otherwise the outcome is
deniedwith an empty match. Nothing granted means denied; there is nonot-applicable. Denial reasons includetenant-mismatch,no-membership,scopeandexpired-membershipso the UI can say why. - For an agent subject, the result is then intersected with the delegated authority (see subject).
A grant "matches" when its condition evaluates to true for the given data and subject. An unconditional grant always matches. A closure that throws counts as not matching and is reported in the decision's denials with a reason. Evaluation never throws; can and decide are safe to call from render paths.
Because deny is absolute, the compiled SQL form is simple: each allow becomes a PERMISSIVE policy, each deny becomes a RESTRICTIVE policy with NOT (condition), and Postgres computes the same result.
What a missing grant means
There is no default-allow anywhere. A permission with no grant in any of the subject's roles is denied, and permdock usage reports it as granted-by-no-role so unreachable permissions are visible in CI.
Type safety
allow(permissions.post.archive)fails to compile ifarchivewas never defined.allow(permissions.post.update, { where: { autorId: principal.id } })fails to compile:autorIdis not a field ofPost.roles: user.roleswhererolescontains a name norole()declared is a runtime deny for that role plus apermdock doctorwarning, not a throw. On a membership, such a name is first resolved as a tenant-defined custom role through theRoleSourceand dropped if that fails (tenancy).role('viewer', [...], { on: 'customers' })fails to compile when the policy declarescustomer, anddefinePolicythrows when a resource a scoped grant touches has nomemberOfrelation on the scope's key.- A permission held as plain
Permissionat run time (fromlistPermissions, a catalog orfindPermission) does not say whether it is an instance or a collection action, socan,decide,assertandexplaintake it with an explicitdataargument:permdock.can(permission, row), orpermdock.can(permission, undefined)for a check without a row. A literal reference keeps its narrower overload, sopermdock.can(permissions.post.update)without a row still fails to compile. definePolicyreturnsPolicy<TUser, TPrincipal>, typed from theprincipalmapper.createPermDockin core and in every adapter is generic over both, so a typed policy passes to any adapter without a cast, and the adapter'ssubjectoption is checked againstTUser.Policy.subjectandPolicy.contextare declared as methods so a typed policy stays assignable to helpers that only read roles and grants.TUsernever defaults toany, which would hide real mismatches.
Derived instances
permdock.tenant(id) and permdock.team(id) narrow the same subject to one tenant or team. permdock.derive({ customRoles?, approvalPolicies?, relations? }) keeps the subject, its active tenant and team, the actor, the delegation, the sink and the limit store, and swaps the sources it names: a server action that edits roles derives an instance whose customRoles reads every role of the tenant, and one that runs a write derives an instance with the tenant's approvalPolicies. Only the sources it names are read again, for the subject's tenants; it returns a promise when one of them answers asynchronously. The original instance is unchanged. A fromSnapshot instance returns itself, because a snapshot carries its grants already resolved.
Listing hints
mayUse(permdock, permission) from permdock answers whether a permission could be granted to the instance's subject for some row: a grant in its snapshot for the active tenant, no unconditional deny, and a delegation that could cover it. A tool list, a command palette or a skill index filters with it; the call itself still runs can or decide, because a row condition or an approval decides only there. mayAccess is the policy-level twin for a proxy that has no instance yet.
permittedIds(permdock, permission, scope, { within?, conditioned? }) lists the instances of scope in which the subject holds permission with no row condition: an unconditional allow on a live membership of exactly that scope, minus the instances a deny of the permission reaches, within its delegation. A where, a check, a validity window or a non-portable test is a row condition; a fields list is not, so an allow limited to some fields counts, as it does in SQL. With conditioned: true it also lists the instances where an allow with a row condition applies and subtracts only unconditional denies, the twin of permitted_<scope>_ids_by_permission(p_permission, true): the list says where some rows may be allowed, and each row is still checked. It is the in-process mirror of the SQL permitted_<scope>_ids_by_permission, for a portal that lists the customers a contact may open or a query that filters by them; within keeps the instances of one tenant. Like mayUse it lists and never decides. The permissions a subject holds in a tenant are listPermissions(policy.permissions).filter((permission) => permdock.tenant(id).can(permission, undefined)).
Evaluation is synchronous
can, decide, assert, filter, where and snapshot() (without a signer) never await. A client, a filter over a list and a router guard need an answer on the first frame, and a Promise is truthy, so an async check that a caller forgot to await would fail open. Asynchronous work has one home: context, MembershipSource and RoleSource run once inside createPermDock, which may itself be async, and their output is frozen into the instance. Each check is then a pure function of frozen data.
Closure grants are typed (data, ctx) => boolean. A closure that returns a thenable is a closure-error denial reported to on('error'); the value is never awaited. There are no decideAsync or canAsync twins, because two evaluation paths can disagree and the synchronous one is easy to call by mistake, and PermDock does not infer asynchrony from the policy, because one async closure would make every can in the UI async. explain is decide with a trace attached, computed in process; describe(decision) turns a decision into prose (decisions).
Approval
approval: 'human' marks a grant whose match is necessary but not sufficient. When it is the matched allow, decide returns { outcome: 'approval-required', grant, reason, token } instead of granted. The token binds the pending approval to the permission key, resource id, subject and actor so an approved reply cannot be replayed against different arguments.
Adapters translate this outcome: the Vercel AI SDK gets user-approval, WorkflowAgent suspends through needsApproval, MCP answers input_required or a refusal carrying the token, HTTP returns 403 Problem Details with an approval-required type. See decisions and approvals.
Feature flags and entitlements are context, not grants
An EntitlementSource passed as entitlements to createPermDock (fromStripeEntitlements for Stripe) adds plan names for the active tenant to principal.plans, so plan('<lookup_key>') grantees apply without a hand-written subject step; seats on a membership (Membership.entitlements) match the same grantee inside the active tenant only (Supabase token hook).
Flags (Vercel Flags SDK, PostHog, LaunchDarkly) and billing entitlements (Stripe Entitlements, a plan column) answer "is this capability on for this subject", and it is tempting to make them grants. They are inputs to the subject instead. Resolve them in the policy's subject function (or context when they need a call) and let them select roles; the grants stay attached to roles:
const pro = role("pro", [
allow(permissions.report.export, { where: { ownerId: principal.id } }),
]);
const newCheckout = role("new-checkout", [
allow(permissions.billing.invoice.pay, { where: { orgId: principal.orgId } }),
]);
export const policy = definePolicy(permissions, {
roles: [member, pro, newCheckout],
subject: (user) =>
user && {
id: user.id,
orgId: user.orgId,
roles: [
...user.roles,
...(user.plan === "pro" || user.plan === "team" ? ["pro"] : []),
...(user.flags.newCheckout ? ["new-checkout"] : []),
],
},
});The same rule decides which roles a tenant may assign: a plan that lacks "Billing Manager" is expressed as RoleSource.assignable(tenant) returning a smaller set, never as a grant (tenancy, custom roles).
Three reasons to keep the split. The policy stays the single statement of who may do what; a flag flipping in a dashboard can only select a role the policy already declares, never widen a grant. Roles reach the snapshot, the SQL where and generated RLS the same way as any subject field, so the UI and the database agree, and permdock usage can show which grants a flag unlocks. And a flag is never a security control: a denied decision cannot be turned into granted by a flag, only a matching allow can, and deny still overrides. A flag evaluated asynchronously belongs in context with the role selection done there; the result is the same frozen subject. Provider permission arrays (WorkOS permissions, Kinde permissions, Auth0 permissions) and billing claims (Clerk Billing fea, Frontegg entitlements) follow the same rule as delegation.scopes, context or roles (authentication, provider recipes; Clerk provider). No flag SDK gets an adapter; the vendor-neutral way to read a flag is OpenFeature, the CNCF incubating standard whose JavaScript SDK evaluates getBooleanValue(flag, default, evaluationContext) against whichever provider is installed (LaunchDarkly, PostHog, Statsig, Unleash, Flagsmith, GrowthBook, Vercel Flags through community providers). A subject or context function that calls OpenFeature with the subject's id and organisation as the evaluation context works unchanged when the flag vendor changes, and the vendors named here are examples, not a list PermDock maintains.
Limits
limit: { count, per } is a quota grant. It is non-portable (it cannot appear as an evaluable snapshot grant or an RLS policy). Pass limits: memoryLimitStore() (or your store) to createPermDock. can, filter, simulate and the alternatives of a denial never consume; they peek remaining. decide and assert consume, except when the matched grant is approval: 'human'. Exhausted remaining is denied with reason limit. A missing store, a throw, or a thenable from consume / remaining is limit-unavailable. A Promise is never treated as granted. The OWASP Agentic Top 10 asks for a maximum rate per tool, which is the use case. See extension interfaces.
allow(permissions.report.export, {
limit: { count: 100, per: "day", mode: "soft", alertAt: 0.8 },
});modeis'hard'(the default, the behaviour above) or'soft'. A soft limit grants pastcountand adds the obligation{ kind: 'over-limit' }, so the app can bill the overage, warn, or queue the work. The store still counts only up tocount.alertAtis a fraction ofcount, above0and at most1. Once this call brings usage to that fraction, the decision carries{ kind: 'near-limit' }. Anover-limitcall carries onlyover-limit.- A granted decision under a limit carries
quota: { remaining, resetsAt }: what is left once this call counts, and the Unix second the window ends.can,filterandsimulatereport what the call would leave.approval-requiredanddeniednever carryquota. - A
limitdenial carriesdetail: { count, window, resetsAt }(LimitDetail): the grant's count, its window in seconds and the Unix second it resets. HTTP adapters render it as429withRetry-Afterand theRateLimit/RateLimit-Policyfields (RateLimit header fields);limit-unavailablerenders as503. - A soft limit is still fail-closed: no store, a throw or a thenable denies with
limit-unavailable, exactly as in hard mode. - An unknown
mode, or analertAtoutside that range, throws when the grant is defined.
Why
Quotas in SaaS plans are often soft: the customer keeps working past the included amount and pays for the overage, or gets a warning first. Modelling that as a second allow without a limit would grant silently, and the app could not tell an included call from an overage. An obligation on a granted decision keeps the three outcomes and says what the caller owes. The remaining count is already known to the LimitStore, so returning it costs nothing and lets an API send rate-limit headers without a second store read. The snapshot leaves it out because it changes on every call. Soft mode is not fail-open: it only changes what happens past the count, never what happens when the count cannot be read.
A denial's alternatives peek rather than consume because they are advice, not a call: a client that is refused report.delete would otherwise spend its report.export quota just by asking.
Field-level grants
allow and deny accept fields, a list of keys of the resource's schema output, so allow(permissions.post.read, { fields: ['title', 'body'] }) is a type error when title is not on the schema. An omitted list means every field. An empty list never matches, and forbidden keys (__proto__, constructor, prototype) are dropped; if that empties the list, the grant never matches.
- A field-restricted allow still grants the row, so
can(permission, row)istrueandfilterkeeps the record. can(permission, row, { field: 'title' })checks one key: an allow or deny matches when it has no list or its list includes the field. Deny still overrides allow per field, and a field-only deny does not deny the row.permdock.pick(permission, row)returns the own keys the subject may read; a denied row yields an empty object. Redact withpickafterfilter.- Collection grants take no
fields, and there are no*or**patterns.
fields is plain JSON on the grant and in the snapshot, so a client can redact without asking the server. where stays row-level, and so does generated RLS by default. permdock rls generate --fields views adds a security_invoker view <table>_visible whose restricted columns are case when <permitted> then col end, the database form of pick; --revoke-columns also closes those columns on the table itself, so a client that skips the view cannot read them (field security). Postgres column privileges were rejected for this: they belong to a database role, not to a row or a tenant, so they cannot say that finance reads amount only in its own organization. A permission per field (post.read.title) was rejected because it multiplies catalog and RLS keys and post.read would stop meaning the row; CASL-style globs were rejected because they are string keys that can grant fields nobody listed, when the schema already names them.
definePolicy options
| Option | Type | Notes |
|---|---|---|
roles | RoleBinding[] | Optional when grants is set. Fragments with the same name merge; if one allows and another denies the same permission, the deny wins as it does everywhere. Scoped roles (on) and global roles mix freely. |
grants | Grant[] | Top-level grants. Each entry needs a real to: selector. |
scopes | { tenant?: { key }, team?: { key } } | Required when any role is scoped to 'tenant' or 'team'. Names the field on every scoped resource that holds the tenant or team id; checked against each resource schema (tenancy). |
principal | (user) => Principal | null | Required (subject is accepted as an alias). Returns the values referenced as principal.<field> in conditions, plus roles, plans, memberships and tenant. null means anonymous. Any subjectFrom* provider satisfies it. |
context | async (user) => Record<string, unknown> | Optional. Loads relations (org settings, flags, legacy team id arrays) once per createPermDock; referenced as context.<key>. Declaring it makes createPermDock async. A thrown or rejected loader is fail-closed: empty context and an on('auth') event with reason: 'source-threw'. Team and resource memberships belong in memberships, not here, so scoped roles and memberOf can use them. |
validate | 'boundary', 'always' or 'never' | Default 'boundary'. Controls when resource schemas run; see validation. |
onDenied | (decision) => never | void | Optional default unauthorized handler for assert; runs last in the layered chain. |
fresh | (Permission | PermissionTree)[] | Optional. Sensitive permissions that deny with stale-credentials when the subject's memberships come from a token behind the MembershipSource authorization version (Supabase token hook). Live memberships are never stale. |
delegations | { from, to, permissions, validFrom?, validUntil? }[] | Optional. Standing delegations: holders of from (a role, authenticated(), a plan, assurance()) let actors matching to (actor('eve'), a kind, or { kind, id }) use permissions for them without a token saying so. A ceiling, never a grant: the principal's grants still decide, and a token delegation on the call must also cover (delegation). Part of the fingerprint and the catalog. |
x | { grant?, membership? } | Optional Standard Schemas for app data. grant checks every grant's meta.x at definePolicy and throws on invalid data. membership checks Membership.x as each subject resolves; an invalid x is dropped, never the membership, with an on('auth') event of reason schema (extend PermDock). |
The principal function is the only place PermDock touches your user object. It is called once per createPermDock and its return value is frozen. Anything not returned from it is invisible to conditions, which is deliberate: conditions can only reference values that also exist in the snapshot and in the SQL session.
Closures
A closure grant is (data, ctx) => boolean, where ctx contains subject, actor, delegation and the loaded context. A thenable returned by a closure is a closure-error denial. Closures are branded as non-portable at the type level:
permdock.where(permission)returns a condition that excludes closure grants and marks the resultpartial.permdock.snapshot()serialises the grant as{ portable: false }so the client knows to ask the decision endpoint.permdock rls generatereports the grant as not generated.
Use a closure when the check needs something a portable condition cannot express (a call to another service, a computed value). Prefer loading the data in context and writing a portable where when you can, so the UI, the query layer and the database all agree.
Testing a policy
describePolicy from permdock/testing asserts a matrix of subjects by permissions, with the outcome for representative fixtures:
import { describePolicy } from "permdock/testing";
describePolicy(policy, {
subjects: {
member: { id: "u1", roles: ["member"] },
admin: { id: "u2", roles: ["admin"] },
},
fixtures: { ownPost, otherPost },
matrix: {
[permissions.post.update.key]: {
ownPost: { member: "granted", admin: "granted" },
otherPost: { member: "denied", admin: "granted" },
},
},
});Every permission must have a row, so a new permission cannot ship untested, and a change in who can do what shows up in review as a changed cell. Testing lists the options.
Hostable permissions
An application can opt specific permissions into grants authored in PermDock Cloud without a deploy. hostable lists the subtrees or leaves hosted grants may touch; the default is none, so a policy without hostable ignores every hosted grant.
export const policy = definePolicy(vocabulary, {
roles: [member, admin],
hostable: [permissions.invoice, permissions.auditLog.read],
});
const permdock = await createPermDock(policy, user, {
policies: permdockCloud.policies,
});Hosted grants arrive as a PolicyDocument through a PolicySource whose current() is read once by createPermDock, and merge under rules that keep code in charge:
- Allows OR together and any matching deny wins, so a code deny always beats a hosted allow. A hosted grant may be a deny.
- A hosted allow on a permission a code grant guards with
approvalmust carry an approval at least as strict (the sameby, and nodistinct: falseunless the code grant sets it too), or it is dropped asweaker-approval. Hosted grants can add friction, never remove it. - The grantee is a declared role, plan or relation on the permission's resource.
anyone(),authenticated(),actor()andassurance()are code-only selectors; a hosted grant naming them, or an undeclared name, is dropped asunknown-grantee. - Only portable conditions are accepted: no closures, no
opaque, nosqlFunction, no path through__proto__,constructororprototype(non-portable). - A grant on a permission that is not
hostable(not-hostable), on an unknown key (unknown-permission) or with a malformed shape (invalid) is dropped too. Each drop is ahosted-grant-droppedvalue onon('error')carrying the document fingerprint, the grant id and the reason; it never makescan()throw. - A decision a hosted grant matched carries
matched.hostedwith the documentfingerprintand the grantid, and so does its decision event. The merged instance'sfingerprinthashes the code fingerprint with the document's, so an approval token issued under one document does not resume under another. permdock rls,permdock openapiandpermdock collectkeep reading the code policy. A permission compiled into generated RLS should not behostable, because the database never sees the hosted grant;permdock doctorreports it asPD020.- A snapshot built from an instance reflects the hosted grants that instance merged, because it is the instance's computed answer. A
SnapshotSourcenever delivers hosted grants of its own.
The opt-in exists because a hosted grant is an allow no code reviewer saw. Listing hostable permissions in code bounds what a Cloud admin, or an attacker holding a Cloud admin session, can widen.
Protected queries
Authorizing a data fetch takes the three instance methods you already have, with no wrapper API: where narrows the query before it runs, filter drops rows a closure or a non-portable grant refuses, and pick redacts fields.
import { toWhere } from "permdock/drizzle";
export async function listPosts(permdock: PermDock) {
const read = permissions.post.read;
const rows = await db
.select()
.from(posts)
.where(toWhere(permdock.where(read), posts)); // before: the database only returns candidate rows
return permdock.filter(read, rows).map((row) => permdock.pick(read, row)); // after: closures and fields
}For a single record, load it and call assert (or protect in an HTTP adapter) before returning it.
Field-level responses
Returning a whole row after an allowed read leaks every column the grant's fields leave out (OWASP API3, broken object property level authorization). Pass the response through pick, and check each field a write body sets against the row it changes:
const loadInvoice = (c: Context) => invoices.find(c.req.param("id"));
app.get(
"/invoices/:id",
protect(permissions.invoice.read, loadInvoice),
(c) =>
c.json(
c.get("permdock").pick(permissions.invoice.read, c.get("permdockData")),
), // only the fields the grant lists
);
app.patch(
"/invoices/:id",
protect(permissions.invoice.update, loadInvoice),
async (c) => {
const permdock = c.get("permdock");
const row = c.get("permdockData");
const changes: Partial<Invoice> = await c.req.json();
const refused = Object.keys(changes).filter(
(field) =>
!permdock.can(permissions.invoice.update, row, {
field: field as keyof Invoice,
}),
);
if (refused.length > 0) return c.json({ refused }, 403);
return c.json(
permdock.pick(
permissions.invoice.read,
await invoices.update(row.id, changes),
),
);
},
);pick returns only own keys the grant lists, so a column added to the table later stays out of the response until a grant names it. The write check runs against the stored row, not the body, so the grant's where sees the real owner. permdock rls generate --fields views gives a direct database read the same answer as pick (field security).
Design rationale
No protected-query helper. A protect(queryFn, { before, after }) wrapper, as Kilpi's $query offers, was considered and rejected. where already runs before the fetch and reaches the database, which a post-fetch hook cannot; filter and pick already cover the after step. A wrapper would add a fourth way to say the same thing and hide which of the three steps ran, so it is the recipe under Protected queries instead.
Policy as data. One check has to give the same answer in the browser, in an array filter, in an ORM where, in a Postgres RLS policy and in an audit log. Closures can serve only the first two: they cannot be hydrated to a client, compiled to SQL or read by a reviewer or an agent. So roles are arrays of grants, grants are data, and conditions are a small JSON AST with one interpreter per target (the in-memory evaluator, toWhere for Drizzle, Prisma and Kysely, and the RLS generator). The same JSON appears in the snapshot, the catalog and the AuthZEN context. The operator set is deliberately small because every compiler must implement every operator. A closure is still available when a check needs something the portable subset cannot express, but it is branded non-portable and the type system marks it server-only. MongoDB-style query syntax carries operators that do not compile to RLS, and a policy language such as Cedar, Rego or Polar is a second language with no inference over resource fields.
Grantees, not only roles. A grant names who it applies to with to:, not only a role name, because plans, ownership, assurance and agent-versus-human are different dimensions. Folding them into roles produced fake roles for each. An array selector is an intersection (to: [roles.member, plans.pro] requires both), while separate allow grants still OR together. Relations are declared on the resource node and compile to portable where conditions, so relation grants reach filter, where and RLS without a graph store; relation(resource, name, { through: 'parent', depth }) walks a parent chain through a RelationSource in process and a closure table in RLS (relationships). anyone() is the only selector that matches a null principal; there is no magic anonymous role. A Cedar-style permit builder and a Zanzibar tuple store were both rejected: the first is a DSL, the second leaves the subset the database can enforce, which parent chains over a closure table stay inside. For the same reason there is no fluent policy builder, in core or in a companion package.
Last updated on