Permissions
Permissions are typed references built from Standard Schema resources; each leaf is plain JSON identified by its key.
A permission in PermDock is a value, not a string. permissions.post.update is an object you can go to the definition of, rename safely, pass as a prop, log, and hand to can, allow, usePermission or registerTool. This page describes how the tree is built, what a leaf contains, and the invariants every adapter relies on.
Building the tree
import { definePermissions, resource } from "permdock";
import { z } from "zod"; // or valibot / arktype / effect — any Standard Schema
const Post = z.object({
id: z.string(),
authorId: z.string(),
orgId: z.string(),
published: z.boolean(),
});
const Invoice = z.object({
id: z.string(),
orgId: z.string(),
amount: z.number(),
});
export const permissions = definePermissions({
post: resource(Post, {
id: "id",
actions: ["read", "update", "delete", "publish"],
collection: ["create", "list"],
}),
billing: {
invoice: resource(Invoice, { actions: ["read", "pay"] }),
plan: resource({ collection: ["view", "change"] }),
},
});definePermissions walks the object eagerly and materialises a frozen tree. There is no Proxy and no lazy path accumulation; every node exists at module load, which is what makes the tree serialisable and cheap to autocomplete.
resource()
resource(schema?, options) describes one kind of thing:
| Option | Type | Purpose |
|---|---|---|
schema (first argument) | any Standard Schema | Infers the instance type for actions and conditions; validates untrusted input at trust boundaries. Optional: resource({ collection: [...] }) is schema-less. |
id | key of the schema output | Identity field used for client cache keys, filter, decision tokens and RLS row identity. Defaults to 'id' when the schema has one. |
actions | array or record | Instance-level actions: can(permissions.post.update, post) requires a Post. |
collection | array or record | Type-level actions: can(permissions.post.create) takes no instance. |
name | string | Optional. The resource name leaves, RLS tables, relations, parent and AuthZEN types use, in place of the last path segment. A letter, then letters, digits, _ or -. Names are unique across the merged tree. The key keeps the path. |
parent | { field, resource } | Optional. resource is the parent name string (the last path segment). Exactly one parent. The name is resolved at definePolicy after mergePermissions. See tenancy. |
levels | record of where objects | Optional. Named conditions a custom role picks per permission, such as own, team and all. Names match ^[a-z][a-z0-9_]*$; the resource needs instance actions. {} adds no condition. |
orderedLevels | true | Optional. The levels run from narrowest to widest in declaration order, so a holder of a level may hand out every earlier one (levels). Needs at least two levels; decisions are unchanged. |
meta | { title?, description?, x? } | Optional. Display text and app data for the resource. Plain JSON, read with getResource(tree, name)?.meta and carried on the catalog's resources.<name>.meta; PermDock never reads x. |
disclosure | 'hide' or 'reveal' | Optional, default 'reveal'. With 'hide', an HTTP adapter answers a denied check on a loaded row with 404 /not-found, the same body a missing row gets, so an id never confirms that a row exists; the reason stays on the decision event. A step-up or rate-limit denial still answers as usual, because it arises only when the subject holds a matching grant. See the threat model. |
actions versus collection
The split is the answer to CASL's "can I read some Post versus this Post" ambiguity. Arity is part of the type:
permdock.can(permissions.post.update, post); // ok
permdock.can(permissions.post.update); // type error: instance required
permdock.can(permissions.post.create); // ok: type-level "may I create"
permdock.can(permissions.post.create, body); // ok: optional body; a check grant matches only with a bodyCollection check is permissions.
An action name may appear in both lists when both readings are meaningful (read on an instance and read on the collection would be two different leaves: post.read and, for example, post.list). Prefer distinct names so .key values stay unambiguous.
Presets
crud, readable and writable are option factories. They return the same actions / collection records you can write by hand, including default meta. Pass the result into resource(); they are not constructors and they do not grant anything.
import {
definePermissions,
resource,
crud,
readable,
writable,
} from "permdock";
export const permissions = definePermissions({
post: resource(
Post,
crud({ id: "id", actions: { publish: { title: "Publish post" } } }),
),
report: resource(Report, readable({ id: "id" })),
setting: resource(Setting, writable({ id: "id" })),
});| Helper | Instance | Collection | Default meta |
|---|---|---|---|
crud() | read, update, delete | create, list | read and list carry readOnly: true; delete carries destructive: true |
readable() | read | list | both readOnly: true |
writable() | read, update | none | read carries readOnly: true |
The name is readable, not readOnly: readOnly is already ActionMeta.readOnly. Extra names merge into the preset records; overlapping meta fields on the extra win. There is no omit: write the lists by hand when the preset is the wrong shape. See permissions.
Nested groups
Groups nest arbitrarily: feature, then resource, then action is the common shape (billing.invoice.pay). A group is any plain object whose values are resources or further groups. Nesting is capped at 10; a deeper tree throws at definePermissions. Groups are the namespace mechanism for larger apps: a shared package owns one top-level group and the app merges it.
Metadata records
actions and collection accept a record instead of an array when a leaf needs metadata for catalogs, docs, OpenAPI or MCP tool descriptions:
post: resource(Post, {
actions: {
read: { title: "Read post", readOnly: true },
update: {
title: "Edit post",
description: "Change title or body",
tags: ["editor"],
},
delete: { title: "Delete post", destructive: true },
},
collection: ["create", "list"],
});Metadata is plain JSON and lands on the leaf as meta. x holds data the application owns, such as a risk level or an undo window: a JSON object nested at most 8 deep, frozen on the leaf and carried on the catalog and snapshots. PermDock never reads it.
invoice: resource(Invoice, {
actions: { send: { title: "Send invoice", x: { risk: "high", undo: "30m" } } },
}),Typed app data
Pass a Standard Schema per kind as definePermissions(tree, { x: { permission, resource } }) to check and type x. permission checks every action's meta.x, resource checks every resource's meta.x, and each leaf's meta.x takes the schema's output type:
const permissions = definePermissions(
{
invoice: resource(Invoice, {
actions: { send: { x: { risk: "high" } }, read: {} },
meta: { title: "Invoice", x: { owner: "billing" } },
}),
},
{
x: {
permission: z.object({ risk: z.enum(["low", "high"]) }),
resource: z.object({ owner: z.string() }),
},
},
);
permissions.invoice.send.meta.x?.risk; // "low" | "high" | undefined
getResource(permissions, "invoice")?.meta?.x?.owner; // string | undefinedA schema must validate synchronously; one that returns a Promise throws. Invalid x throws at definePermissions, because a bad definition is an author error. defineRoles(tree, { x }), definePlans(tree, { x }) and definePolicy(…, { x: { grant, membership } }) take the same kind of schema for roles, plans, grants and memberships (extend PermDock).
readOnly, destructive and idempotent become the MCP tool hints readOnlyHint, destructiveHint and idempotentHint in permdock/mcp (WebMCP reads only readOnly); an action named read or list is read-only unless readOnly says otherwise, and destructive is ignored on a read-only action. The hints inform the client and never replace the server's decision. The catalog prints title and description.
What a leaf contains
permissions.post.update;
// Permission<'post.update', Post>
// {
// key: 'post.update',
// scope: 'post:update',
// resource: 'post',
// action: 'update',
// meta: { ... }
// }| Field | Example | Used by |
|---|---|---|
key | 'post.update' | Grants, snapshots, cache keys, audit events, catalog, findPermission |
scope | 'billing:invoice:pay' | OAuth scopes, MCP scopeChallenge, OpenAPI securitySchemes, RFC 9396 authorization_details |
resource | 'post' | Lookup of the resource node (schema, id field), alternatives in denials |
action | 'update' | RLS command mapping (update becomes USING plus WITH CHECK), tool descriptions |
meta | { title, description, tags, readOnly, inferredFrom } | Catalogs, docs, tool hints; inferredFrom is written by permdock openapi import with the operation an action came from |
The schema is not on the leaf. It lives on the resource node, reachable through the definition, so a leaf stays JSON-serialisable while can(permissions.post.update, post) still knows that post must be a Post.
Resource names
Two resources may share a last path segment when one sets name. A platform resource and an organisation resource named billing keep their keys and get distinct names:
definePermissions({
platform: {
billing: resource({ name: "platform_billing", actions: ["view"] }),
},
billing: resource({ actions: ["view"] }),
});
// platform.billing.view: { key: 'platform.billing.view', resource: 'platform_billing' }
// billing.view: { key: 'billing.view', resource: 'billing' }Type-level view
Three type parameters ride along with each leaf: the key literal, the row type and the kind. The row type sits on a type-only PERMISSION_ROW key that no leaf has at run time, and RowOf<P> reads it back (unknown for a resource without a schema).
type UpdatePost = typeof permissions.post.update; // Permission<'post.update', Post, 'instance'>
type CreatePost = typeof permissions.post.create; // Permission<'post.create', Post, 'collection'>: optional body
type Row = RowOf<UpdatePost>; // Post
permdock.can(permissions.post.update, { authorId: user.id }); // any part of a Post
permdock.can(permissions.post.update, { current: post, next: draft }); // an update's pair
permdock.can(permissions.post.update, { published: "yes" }); // type error: published is a boolean
permdock.can(permissions.post.update, await request.json()); // unknown: parsed at run timedata on can, decide, assert, explain and decideEach is a RowData<P>: a Partial of the row or a { current, next } pair of them. Data typed unknown or any is accepted as an UncheckedData and parsed against the resource schema at run time, as before. getPermission and requirePermission in permdock/next, usePermission, useGate and <Protected data> in React, and usePermission in Vue and Solid and permission in Svelte take the same data. filter and pick infer the row type from the rows and check the permission against it. The row type of protect's loader stays as the loader returns it.
No template-literal unions are generated from the tree, which keeps TypeScript 7 checking fast and keeps error messages readable. Conditions and field lists are typed from the schema output (StandardSchemaV1.InferOutput), not from hand-written flat types.
Exported trees under isolatedDeclarations
With isolatedDeclarations: true, an exported definePermissions(...) needs a type annotation, and typeof of a local spec does not help, because the declaration file would then need the spec's inferred type. PermissionsOf<Spec> names the tree from a spec type written with ResourceInit<Row, Actions, Collection>:
import type { PermissionsOf, ResourceInit } from "permdock";
import { definePermissions, resource } from "permdock";
type Spec = {
readonly post: ResourceInit<
Post,
readonly ["read", "update"],
readonly ["list"]
>;
};
export const permissions: PermissionsOf<Spec> = definePermissions({
post: resource(postSchema, {
actions: ["read", "update"],
collection: ["list"],
}),
});The annotation is checked against the definition: an action in Spec that the definition lacks is a type error. The second and third parameters are the x types of definePermissions' options. tests/types/src/isolated-declarations.ts compiles this under TypeScript 5.9, 6 and 7.
Invariants
These hold for every leaf and every adapter depends on them.
- A leaf is plain, frozen, JSON-serialisable data. It can be passed as a prop across the RSC boundary, posted to the decision endpoint, stored in a queue message, or printed in a log.
- Identity is by
key, never by object identity. Two bundle copies of the definition module, a leaf that came back fromJSON.parse, and the leaf in the merged registry all resolve to the same grant.mergePermissionspreserves object identity as a convenience, but nothing relies on it. - An unknown reference is a type error.
allow(permissions.post.archive)fails to compile;findPermission(permissions, 'post.archive')returnsundefinedat runtime. - Leaves are prototype-safe. Keys such as
constructoror__proto__are rejected bydefinePermissions, and lookups use own-property checks. - The definition has no rules and no secrets, so it can be imported by client bundles, React Native, MCP servers, the CLI and tests.
Renamed keys
definePermissions(tree, { renamed }) maps keys a permission used to have to its current key. Rename the leaf in code and add the old key:
export const permissions = definePermissions(
{ customer: resource(Customer, { actions: ["read", "update"] }) },
{
renamed: {
"organization.customers.view": "customer.read",
"organization.customers.edit": "customer.update",
},
},
);A former key resolves wherever a key arrives as a string: findPermission, stored custom roles, OAuth scopes in their : form, AuthZEN actions and hosted grants. Code, decisions, snapshots and audit events see only the current key. formerKeys(leaf) returns a leaf's former keys; they live on a non-enumerable property, so a leaf that crossed JSON.stringify has none. The catalog lists them as renamedFrom, and permdock diff reports a rename as renamed and a dropped alias as the breaking alias-removed. rls migrate maps former keys without a keys entry, and rls generate seeds role_permissions under them too.
definePermissions throws when an old key is still a current key, a target is not a declared key, or an old key is not a valid key. An approval token binds the current key and the policy fingerprint, so an approval pending across a deploy that renames its key no longer resumes and is asked again.
Existing apps walks through a rename and its deprecation window.
Registry helpers
Helpers are functions, not methods on the tree, so a resource named list or find never collides with them.
import { listPermissions, findPermission } from "permdock";
listPermissions(permissions);
// [{ key: 'post.read', scope: 'post:read', resource: 'post', action: 'read', meta }, ...]
findPermission(permissions, "post.update"); // Permission | undefined
findPermission(permissions, scopeFromJwt); // strings from a DB, JWT scope or OpenAPI doc
findPermission(permissions, "organization.customers.view"); // a former key: the current leafisPermission(value) narrows an unknown to Permission without a cast: it checks for string key, scope, resource and action and an object meta. It accepts a leaf that crossed JSON.stringify (which drops the non-enumerable kind), and it does not look the key up, so the check that follows still decides by key.
A leaf passed from a Server Component to a Client Component arrives as the same five-field object, without kind; tests/bundle/src/rsc.test.ts renders one through React Flight. React's development build logs "Only plain objects can be passed to Client Components" for it, because kind is a non-enumerable own property; the production build does not. To avoid the warning in development, pass permission.key and call findPermission in the Client Component.
listPermissions is the runtime catalog. permdock collect produces the same list at build time and adds usage information; the two agree by construction because both come from the definition. findPermission is the only place a string enters the system, and its result is typed as a union of all leaves so the next call is typed again.
mergePermissions combines definitions from several files; see larger apps.
Serialised form
{
"key": "post.update",
"scope": "post:update",
"resource": "post",
"action": "update",
"meta": { "title": "Edit post", "tags": ["editor"] }
}This is the exact object you get from JSON.stringify(permissions.post.update). The wire formats page lists it next to conditions, snapshots and AuthZEN messages. The RFC 9396 authorization_details object a consent screen shows is derived from resource, action and the resource id, so a leaf carries no example object in meta.
Workflow verbs
Sensitive money or filing work is usually a chain of leaves, not one update. Name each verb on the resource so permdock doctor PD017 can see it, and so approval.by and exclusiveWith attach to the right step.
const Filing = z.object({
id: z.string(),
orgId: z.string(),
amount: z.number(),
});
const Invoice = z.object({
id: z.string(),
orgId: z.string(),
amount: z.number(),
});
const Claim = z.object({ id: z.string(), orgId: z.string() });
export const permissions = definePermissions({
filing: resource(Filing, {
actions: {
prepare: { title: "Prepare filing" },
approve: { title: "Approve filing" },
pay: { title: "Pay filing" },
settle: { title: "Settle filing" },
},
}),
billing: {
invoice: resource(Invoice, { actions: ["read", "submit", "pay"] }),
},
guarantee: {
claim: resource(Claim, { actions: ["prepare", "approve", "settle"] }),
},
});Typical policy:
allow(permissions.filing.prepare)for the clerk role.allow(permissions.filing.approve, { approval: { by: roles.admin } })so an admin other than the requester approves; neither the principal nor the actor can approve their own request.allow(permissions.filing.pay, { to: [roles.admin, assurance({ amr: ['hwk'], maxAge: 300 })] })so pay requires a hardware key within five minutes.role(roles.preparer, [allow(permissions.filing.prepare)], { exclusiveWith: ['approver'] })so PD018 flags a custom role that includes both.
Default PD017 verbs are approve, pay, settle, submit, transfer, refund, disburse. Override with doctor.sensitiveActions in permdock.config.ts. A matching deny on the same leaf silences the warning.
Operations
defineOperations(permissions, definitions) binds each action an application exposes to one leaf, for agent tools, automations and chat commands: operations.
Why
References, not string keys. A string key has no go-to-definition and no safe rename, and a typo is at best a compile error and at worst a silent deny. Template-literal unions over every resource and action slow the type checker on large catalogs, a string has nowhere to carry a scope or a title, and the union does not exist at runtime, so a catalog would have to be extracted from source. A reference fixes all four: the runtime definition is the catalog, an unknown permission is a type error, and types flow from the reference argument, so hooks and server helpers are direct exports with no per-app generic factory. Runtime subject detection in the CASL style is unnecessary because the reference already names the resource, and a Proxy tree was rejected because it cannot be serialised or enumerated.
Plain leaves, identity by key. Leaves cross RSC props, JSON bodies, queue messages and duplicate bundle copies, and Standard Schema validators are neither serialisable nor small, so the schema stays on the resource node and a leaf is only data. Because identity is the key, keys are part of the public contract: renaming a resource is a wire-visible change, and permdock collect --check reports it. renamed keeps stored keys resolving through that change instead of hiding it.
Arity in the type. If every action accepted optional data, a handler could check update without loading the row and an ownership condition would be evaluated against nothing. That mistake is easiest to make in an agent tool handler, where nothing else flags it. Arity cannot be inferred from the policy instead, because the policy is server-only while the definition is shared with clients. The same split explains why where, filter and permdock.where exist only for instance actions.
An optional body on collection actions. create stays a collection action so "may I create at all" needs no placeholder row. A collection grant may carry check on the proposed body but never where, since there is no current row. can(permissions.post.create, body) passes the body, typed as the schema output and boundary-validated unless the call passes trusted: true. Without a body, a check grant does not match: a row condition cannot be true for a row nobody described. An unconditional collection grant still matches with no body.
One parent, named by a string. parent.resource is a name so a feature package can declare parent: { field: 'projectId', resource: 'project' } before the app merges the project definition, and so the option stays plain JSON. A resource has at most one parent and must not name itself, which keeps memberOf a finite chain rather than a graph walk in evaluation and in RLS. The name is resolved at definePolicy, after mergePermissions; an unknown parent or a duplicate resource name in the merged tree throws at policy construction instead of silently never matching at check time.
Presets are sugar, not defaults. resource(schema) never implies CRUD, because a resource that is only listed would grow update and delete leaves. There is no closed action union (domain verbs such as publish and pay are first-class), no manage alias (it would silently cover actions the author never listed), and no possession in action names such as createOwn (ownership is a where; the action stays the verb RLS maps).
What permissions are not
- Not rules. Who may do what lives in the policy.
- Not classes. There is no subject detection, no
constructor.name, no tagging of user objects. - Not strings in the public API.
can('post.update', post)does not exist;can(findPermission(permissions, key)!, post)is the explicit escape hatch.
Last updated on