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. |
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
Two phantom type parameters ride along with each leaf: the key literal, the instance type, and the kind.
type UpdatePost = typeof permissions.post.update; // Permission<'post.update', Post, 'instance'>
type CreatePost = typeof permissions.post.create; // Permission<'post.create', Post, 'collection'>: optional body
function guard<K extends string, T>(permission: Permission<K, T>, data: T) {
/* ... */
}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.
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.
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.
Design rationale
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