PermDock
Concepts

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 argumentMeaning
{ 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) => booleanClosure: 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:

  1. Collect every grant from top-level grants and from role bindings whose name the subject holds, then keep those whose to: selector matches (anyone(), authenticated(), a role, a plan, a relation, an actor, an assurance check, or an intersection). A relation selector also contributes a portable where.
  2. 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 its validFrom / validUntil window as of the decision clock (validity); an inactive allow records an inactive-grant denial.
  3. If any deny matches, the outcome is denied. Deny overrides allow, regardless of role, scope or declaration order.
  4. Otherwise, if any allow matches, the outcome is granted (or approval-required if the matched allow carries approval). Allows OR together, across scopes.
  5. Otherwise the outcome is denied with an empty match. Nothing granted means denied; there is no not-applicable. Denial reasons include tenant-mismatch, no-membership, scope and expired-membership so the UI can say why.
  6. 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 if archive was never defined.
  • allow(permissions.post.update, { where: { autorId: principal.id } }) fails to compile: autorId is not a field of Post.
  • roles: user.roles where roles contains a name no role() declared is a runtime deny for that role plus a permdock doctor warning, not a throw. On a membership, such a name is first resolved as a tenant-defined custom role through the RoleSource and dropped if that fails (tenancy).
  • role('viewer', [...], { on: 'customers' }) fails to compile when the policy declares customer, and definePolicy throws when a resource a scoped grant touches has no memberOf relation on the scope's key.
  • A permission held as plain Permission at run time (from listPermissions, a catalog or findPermission) does not say whether it is an instance or a collection action, so can, decide, assert and explain take it with an explicit data argument: permdock.can(permission, row), or permdock.can(permission, undefined) for a check without a row. A literal reference keeps its narrower overload, so permdock.can(permissions.post.update) without a row still fails to compile.
  • definePolicy returns Policy<TUser, TPrincipal>, typed from the principal mapper. createPermDock in core and in every adapter is generic over both, so a typed policy passes to any adapter without a cast, and the adapter's subject option is checked against TUser. Policy.subject and Policy.context are declared as methods so a typed policy stays assignable to helpers that only read roles and grants. TUser never defaults to any, 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 },
});
  • mode is 'hard' (the default, the behaviour above) or 'soft'. A soft limit grants past count and adds the obligation { kind: 'over-limit' }, so the app can bill the overage, warn, or queue the work. The store still counts only up to count.
  • alertAt is a fraction of count, above 0 and at most 1. Once this call brings usage to that fraction, the decision carries { kind: 'near-limit' }. An over-limit call carries only over-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, filter and simulate report what the call would leave. approval-required and denied never carry quota.
  • A limit denial carries detail: { count, window, resetsAt } (LimitDetail): the grant's count, its window in seconds and the Unix second it resets. HTTP adapters render it as 429 with Retry-After and the RateLimit / RateLimit-Policy fields (RateLimit header fields); limit-unavailable renders as 503.
  • 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 an alertAt outside 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) is true and filter keeps 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 with pick after filter.
  • 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

OptionTypeNotes
rolesRoleBinding[]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.
grantsGrant[]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 | nullRequired (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.
contextasync (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 | voidOptional 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 result partial.
  • permdock.snapshot() serialises the grant as { portable: false } so the client knows to ask the decision endpoint.
  • permdock rls generate reports 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 approval must carry an approval at least as strict (the same by, and no distinct: false unless the code grant sets it too), or it is dropped as weaker-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() and assurance() are code-only selectors; a hosted grant naming them, or an undeclared name, is dropped as unknown-grantee.
  • Only portable conditions are accepted: no closures, no opaque, no sqlFunction, no path through __proto__, constructor or prototype (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 a hosted-grant-dropped value on on('error') carrying the document fingerprint, the grant id and the reason; it never makes can() throw.
  • A decision a hosted grant matched carries matched.hosted with the document fingerprint and the grant id, and so does its decision event. The merged instance's fingerprint hashes the code fingerprint with the document's, so an approval token issued under one document does not resume under another.
  • permdock rls, permdock openapi and permdock collect keep reading the code policy. A permission compiled into generated RLS should not be hostable, because the database never sees the hosted grant; permdock doctor reports it as PD020.
  • A snapshot built from an instance reflects the hosted grants that instance merged, because it is the instance's computed answer. A SnapshotSource never 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

On this page