Runtime Custom Roles
Sometimes you want your end users to define their own roles — a settings page where each workspace builds a "Billing Manager" or "Guide" from a set of permissions, like this:
Permissions matrix — What each role can do across the workspace · click a cell to toggle
Admin Ops Finance Guide Support View bookings ✓ ✓ ✓ ✓ ✓ Edit bookings ✓ ✓ — — ✓ Issue refunds ✓ — ✓ — — Manage settings ✓ — — — —
polizy supports this with no schema change and no redeploy. Roles become pure data (the canonical Zanzibar "roles as data" pattern), resolved by the existing group + hierarchy traversal — and the permission vocabulary stays compile-time type-safe.
Adding a role (a bundle of existing permissions) is pure data. Adding a new permission verb with new semantics is a schema change — in polizy and in every ReBAC system. This guide covers runtime roles built from your known action vocabulary, which is the common case (and exactly what a permissions matrix needs: new columns, fixed rows).
1. Add the role scaffold
withRoleScaffold merges a generic role scaffold into your schema while keeping
its literal types. You pass grantable — the actions custom roles may grant
(your fixed permission rows):
import { defineSchema, withRoleScaffold } from "polizy";
const base = defineSchema({
subjectTypes: ["user"],
objectTypes: ["workspace", "booking"],
relations: {
owner: { type: "direct" },
member: { type: "group" },
parent: { type: "hierarchy" },
},
actionToRelations: {
view_bookings: ["owner"],
edit_bookings: ["owner"],
issue_refunds: ["owner"],
manage_settings: ["owner"],
},
// Workspace-scoped capabilities flow down to the workspace's bookings.
hierarchyPropagation: {
view_bookings: ["view_bookings"],
edit_bookings: ["edit_bookings"],
issue_refunds: ["issue_refunds"],
manage_settings: ["manage_settings"],
},
});
export const schema = withRoleScaffold(base, {
grantable: ["view_bookings", "edit_bookings", "issue_refunds", "manage_settings"],
});
This adds, once:
- a
roleobject type, - a reserved
assigneegroup relation (user --assignee--> role), and - one
cap_<action>direct relation per grantable action (role --cap_<action>--> resource), appended to that action'sactionToRelations.
A custom role then resolves on the unchanged engine:
user --assignee--> role --cap_<action>--> workspace, with hierarchy propagation
carrying a workspace-scoped capability down to its bookings.
2. Create the AuthSystem
import { AuthSystem, InMemoryStorageAdapter } from "polizy";
const authz = new AuthSystem({
storage: new InMemoryStorageAdapter(),
schema,
// The scaffold adds a second group relation (`assignee`). Set the app's own
// group relation as the default so existing addMember() calls keep working.
defaultGroupRelation: "member",
});
defaultGroupRelation?When a schema has more than one group relation, addMember/removeMember
normally require an explicit as. The scaffold's assignee relation is excluded
from inference automatically, so a single pre-existing group relation still
infers — but setting defaultGroupRelation makes it explicit and future-proof.
3. Define and assign roles with RoleRegistry
RoleRegistry is ergonomic, typed sugar over the existing write APIs. The set of
actions a role may grant stays the compile-time-checked GrantableAction union;
only the role name is a runtime string.
import { RoleRegistry, InMemoryRoleCatalog } from "polizy";
const roles = new RoleRegistry(authz, schema, {
catalog: new InMemoryRoleCatalog(),
});
// Create a role at runtime — pure tuples, no schema change.
const billing = await roles.defineRole({
tenant: { type: "workspace", id: "acme" },
name: "billing_manager",
label: "Billing Manager",
can: ["view_bookings", "issue_refunds"], // 'isue_refunds' → compile error
});
// Assign a user to it.
await roles.assignRole({ type: "user", id: "alice" }, billing);
4. Toggle permissions (the matrix)
Each cell in a permissions matrix is one idempotent grant/revoke:
await roles.grantToRole(billing, "edit_bookings"); // tick the cell
await roles.revokeFromRole(billing, "edit_bookings"); // untick it
permissionMatrix(tenant) builds the whole grid (rows × columns) in a single
read — the natural backing for the UI:
const matrix = await roles.permissionMatrix({ type: "workspace", id: "acme" });
// matrix.permissions → the fixed action rows
// matrix.roles → [{ name, label, can: Set<action> }, ...] columns
Note that permissionMatrix retrieves tenant-scoped capabilities only (i.e. permissions granted on that specific tenant object, such as the workspace). In contrast, the getRolePermissions(role) method is broader, reporting capabilities across all objects that the role has been granted access on throughout the system.
Other registry methods: unassignRole, deleteRole (cascades capabilities and
memberships), listRoles, listRoleMembers, and roleRef(tenant, name).
5. Check permissions
Checking is the ordinary check() — there is no new verb:
await authz.check({
who: { type: "user", id: "alice" },
canThey: "issue_refunds",
onWhat: { type: "booking", id: "bk-101" }, // booking parented to workspace:acme
}); // → true
For per-resource checks to resolve, the resource must belong to the tenant via the
hierarchy (setParent({ child: booking, parent: workspace })) and the action must
be in hierarchyPropagation. For coarse, workspace-wide permissions you can also
check directly against the workspace object.
Type safety
- The
grantableactions stay literal-typed —defineRole({ can }),grantToRole(role, action), andcheck({ canThey })all autocomplete and reject typos at compile time. Custom roles are dynamic compositions of statically-known actions. - The role identity is a branded
RoleRef, so a role can't be confused with an ordinary resource. - Only the runtime-born role name is a string; a typo'd name fails closed (the
role simply has no capabilities), and
assignRolethrows for an unknown role when a catalog is configured.
Multi-tenancy
Role ids are tenant-namespaced (role:acme/billing_manager vs
role:globex/billing_manager), so two workspaces can define identically-named
roles with completely different permissions, on one shared AuthSystem.
The tenant ID (id) must not contain the forward slash (/) character. To prevent cross-tenant permission matrix contamination via prefix parsing, defineRole and roleRef will throw a SchemaError if the tenant ID contains a /.
Wildcard roles
Grant a role to every user with a wildcard assignment:
import { everyone } from "polizy";
await roles.assignRole(everyone("user"), guideRole); // everyone is a Guide
Persisting the catalog
The catalog tracks role existence and labels so permission-less roles stay
listable (the engine never reads it — capabilities and assignments live in your
storage adapter as tuples). Use InMemoryRoleCatalog, or PrismaRoleCatalog from
polizy/prisma-storage:
import { PrismaRoleCatalog } from "polizy/prisma-storage";
const roles = new RoleRegistry(authz, schema, {
catalog: PrismaRoleCatalog(prisma), // add the PolizyRole model to schema.prisma
});
model PolizyRole {
id String @id @default(cuid())
tenant String
key String
label String?
actions Json
@@unique([tenant, key])
@@index([tenant])
}
Try it
A full runnable demo — a click-to-toggle permissions matrix with add/delete role,
member assignment, per-tenant divergence, and a live check + explain — lives in
examples/permissions-matrix,
running a real Postgres in the browser via PGlite.
pnpm --filter example-permissions-matrix dev