Policies
Roles are arrays of allow and deny grants over permission references; definePolicy binds them to a subject, context and validation mode.
A policy answers "who may do what, under which conditions". In PermDock it is data: roles are arrays of grants, grants are allow or deny of a permission reference with optional conditions and options, and definePolicy binds the roles to a definition and to the function that turns your user into a subject. 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, role, allow, deny, subject } from 'permdock'
import { permissions } from './permissions'
const member = role('member', [
allow(permissions.post.read),
allow(permissions.post.list),
allow(permissions.post.create),
allow(permissions.post.update, { where: { authorId: subject.id } }),
allow(permissions.post.publish, (post, ctx) => post.authorId === ctx.subject.id),
allow(permissions.post.delete, { where: { authorId: subject.id }, approval: 'human' }),
allow(permissions.billing.invoice.pay, { limit: { count: 10, per: '1h' } }),
allow(permissions.billing.plan.view),
])
const admin = role('admin', [
...member.grants,
allow(permissions.post.delete),
allow(permissions.billing.plan.change),
deny(permissions.post.publish, { where: { published: true } }),
])
export const policy = definePolicy(permissions, {
roles: [member, admin],
subject: (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 { name, grants, on?, assignable }. 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).
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' } },
subject: subjectFromClerk,
})scopes.tenant.key names the field every tenant-owned resource carries; it replaces where: { orgId: subject.orgId } on each grant and is checked against every scoped resource's schema at compile time. 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 |
|---|---|
| omitted | Unconditional |
{ 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' } | Grant is valid but the decision is approval-required until a human approves |
{ ..., limit: { count, per } } | Quota grant (Later): non-portable, needs a LimitStore |
Conditions are covered on their own page: conditions. Collection actions accept no where or check because there is no row; they accept closures over ctx only.
Evaluation semantics
The rules are short and they are the whole story (ADR 0010):
- Collect the grants of every global role in
subject.roles, of every scoped role held by one of the subject's memberships, and of the declared roles that tenant-defined custom roles resolve to, for the requested permission key. - 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). - 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: subject.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: 'tenant' })with a grant on a resource whose schema lacksscopes.tenant.keyfails to compile.
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 raises an elicitation, HTTP returns 403 Problem Details with an approval-required type. See decisions and approvals.
Feature flags and entitlements are context, not grants
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: subject.id } })])
const newCheckout = role('new-checkout', [allow(permissions.billing.invoice.pay, { where: { orgId: subject.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 (ADR 0023); 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 scheduled for Phase 4. It is non-portable (it cannot appear in a snapshot or an RLS policy), it needs a pluggable LimitStore to count usage, and when the quota is exhausted the decision is denied with reason limit. The OWASP Agentic Top 10 asks for a maximum rate per tool, which is the use case.
definePolicy options
| Option | Type | Notes |
|---|---|---|
roles | Role[] | Required. Fragments with the same name merge. Scoped roles (on) and global roles mix freely. |
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). |
subject | (user) => Principal | null | Required. Returns the values referenced as subject.<field> in conditions, plus roles, 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 subject.context.<key>. Declaring it makes createPermDock async. 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. |
The subject 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 | Promise<boolean>, where ctx contains subject, actor, delegation and the loaded context. 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
@permdock/testing renders a matrix of roles by permissions with the outcome for representative fixtures, and snapshots it:
import { policyMatrix } from '@permdock/testing'
test('policy matrix', () => {
expect(policyMatrix(policy, { fixtures: { post: [ownPost, otherPost] } })).toMatchSnapshot()
})A change in the matrix is a change in who can do what, and shows up in review as a snapshot diff.
Open questions
- Ergonomics of
subject.*references versus closures: whether to offer typed helpers such assubject.context.teamIds.contains(row.teamId)or keep the object form only. Scoped roles remove the most common case (tenant and team membership) from this question. - The
LimitStoreinterface, and whether quotas belong in core or in a separate entry. - Whether same-named role fragments should be allowed to both
allowanddenythe same permission or whether that should be a lint error. - Anonymous grants: whether
role('anonymous', ...)is applied automatically whensubjectreturnsnull, or whether anonymous is always fully denied. - The name of the policy-level default unauthorized handler (
onDeniedabove is a placeholder; the plan only says "policy default"). - Whether a fluent policy builder should exist outside core for teams that prefer it; core stays data (ADR 0024 alternatives).