Custom roles
Tenant-defined roles composed from declared roles and single permissions, bounded by a ceiling of assignable declared roles; how grants inherit declared conditions, how levels narrow them, how denies subtract, what assignableRoles and assignablePermissions return, how snapshots carry the result, and a matrix-editor recipe.
A custom role is a role a tenant admin defines at runtime. It is data from a RoleSource, never code, and it can only reach what the code already declared: every permission it grants is bounded by a ceiling built from the declared roles marked assignable.
type CustomRole = {
tenant: string; // absent when scope is 'global'
scope?: string; // the named scope it is held at, or 'global'; default the first scope
id?: string; // pins it to one instance of that scope
team?: string; // input only: scope = the second scope, id = this team
name: string; // unique within the tenant; a declared role name always wins
includes?: string[]; // declared roles to start from
grants?: { permission: string; effect?: "allow" | "deny"; level?: string }[]; // declared permission keys
meta?: RoleMeta; // { title?, description?, audience?, x?, ... }
};meta lands on the role leaf assignableRoles() returns, so a role picker can show the tenant's meta.title. meta.x holds the application's own data and is checked by the role tree's defineRoles(…, { x }) schema; an invalid x is dropped and the role stays.
const billingManager: CustomRole = {
tenant: "o_acme",
name: "billing-manager",
includes: ["billing"],
grants: [
{ permission: "post.read" },
{ permission: "invoice.refund", effect: "deny" },
],
};A member holds it like any role: { tenant: 'o_acme', roles: ['billing-manager'] }. Tenancy covers memberships and the RoleSource interface.
The ceiling
The ceiling of a scope is the set of code allows of every declared role marked assignable in that named scope: the first scope's roles for a custom role without scope, the roles of scope otherwise (the input shape team means the second scope). A custom role applies only through a membership of its own scope inside its tenant, and only to the pinned instance when id is set; there is no cascade between scopes. Resource roles and non-assignable roles such as owner are outside every ceiling; global roles are outside every tenant ceiling and form their own, below. Grants merged from a hosted policy document never widen it, so the ceiling is exactly what was reviewed in code.
Platform custom roles
A custom role with scope: 'global' and no tenant, team or id is a platform role, such as a support tier an operator defines at runtime. Its ceiling is the allows of every declared global role (one without on) marked assignable. A principal holds it by name in principal.roles, like a declared global role, and it applies in every tenant. RoleSource.globalRoles() returns these roles; it is read once per signed-in subject, and a role it returns with a tenant is ignored. memoryRoleSource routes a role without tenant there.
const roles = defineRoles({
support: { assignable: true },
billingOps: { assignable: true },
superadmin: {}, // not assignable: outside the global ceiling
});
const tier2: CustomRole = {
scope: "global",
name: "tier2",
includes: ["support"],
grants: [{ permission: "tenant.suspend" }],
};permdock catalog lists the assignable roles with the permissions each contains; the permission keys of the ceiling are what a role editor offers.
Resolution
resolveCustomRole(policy, role) is the one resolver behind evaluation, snapshot(), snapshotFor() and validateCustomRole(), so they cannot disagree. It returns { grants, dropped, renamed }:
- Start from the grants of each role in
includes. An allow that is itself a ceiling grant is kept as is. An allow from a non-assignable or other-scope role whose permission is in the ceiling is replaced by the ceiling's grants for that permission; one whose permission is outside the ceiling is dropped. An included role'sdenygrants in the custom role's scope come along. - Add each own
allow. Its permission must be a declared key in the ceiling. It inherits every ceiling grant of that permission, together with thedenygrants of the roles those grants belong to. When an included assignable role already grants the permission, the included grant is kept and the own allow adds nothing. - Remove every permission named by an own
deny, whether it came from an include or an own allow. - Re-target the result to the custom role: each grant keeps its permission,
where,check,approval,limitandfields, and its grantee becomes the custom role in its scope. Other grantee items, such as a plan gate, stay.
A custom-role grant never carries a condition or an approval of its own; a level picks one of the conditions the code declares. Where the permission exists on an included role, it inherits that role's condition; otherwise it inherits the condition of each assignable declared grant of that permission, OR-ed like any allows. An own allow of post.update, where editor allows it on the author's own posts and admin allows it on any post, therefore reaches any post, exactly as far as the ceiling does. To keep "own posts only", include editor instead, or pick a level.
Deny overrides allow inside the role: an own deny removes the permission even when an include or an own allow grants it, and the declared denies of the source roles keep applying (a billing deny on refunds above 1000 still denies them through a custom role that allows invoice.refund). An own deny subtracts from this custom role only. It is not a policy deny, so it does not take away a grant the member holds through another role. Deny the permission in code when it must win across roles.
Levels
A resource may declare named levels: conditions in code that a custom role picks by name. A level never comes from data, so a stored role can only narrow to a condition that was reviewed.
const permissions = definePermissions({
job: resource({
id: "id",
actions: ["read", "update", "close"],
levels: {
own: { ownerId: principal.id },
team: { teamId: { in: principal.teamIds } },
all: {},
},
}),
});
const dispatcher: CustomRole = {
tenant: "o_acme",
name: "dispatcher",
grants: [
{ permission: "job.read", level: "team" },
{ permission: "job.close", level: "own" },
{ permission: "job.close", level: "team" },
],
};- A level name matches
^[a-z][a-z0-9_]*$, and only a resource with instance actions declares levels.all: {}adds no condition. - An own allow with a
levelinherits each ceiling grant of the permission, as above, with the level's condition ANDed into itswhereandcheck. The ceiling condition still applies:job.updateatallreaches only the rows the ceiling grant reaches. - Several allows of one permission at different levels are OR-ed. An allow without a level reaches every row the ceiling reaches.
- A level is an allow refinement. A
denywith alevelis dropped ascondition-not-allowedand removes the permission. - An unknown level, a level on a collection permission, or a level on a key outside the ceiling is dropped as
unknown-level. The permission is then removed from the role, even when an include grants it, so a typo denies instead of widening. permdock cataloglists the levels of each permission (levelsincatalog-v1.json), andpermdock diffreports a removed level as breaking (level-removed): a stored role that picks it starts denying (diff).
Dropped entries
Nothing that fails resolution widens a role. dropped names each entry that was left out:
| Reason | Meaning |
|---|---|
unknown-permission | The key is not a declared permission |
outside-ceiling | The permission is declared but no assignable declared role of the scope allows it |
condition-not-allowed | The entry carries a field other than permission, effect and level (a where, an approval, a limit), an unknown effect, or a level on a deny. The permission is still removed, as if denied |
unknown-level | The level is not declared on the permission's resource, or the permission is a collection action. Reported with level; the permission is removed, as if denied |
unknown-role | An includes entry names no declared role; reported as { role, reason } |
validateCustomRole(policy, role) returns { ok, permissions, dropped, renamed }, where permissions is the sorted list of keys the role allows after the ceiling. Call it in the server action that saves a custom role. permdock doctor PD023 runs it over the customRoles of the doctor.memberships fixture (doctor).
Renamed keys
A stored grant may name a permission by a key it was renamed from with definePermissions(..., { renamed }) (permissions). It resolves to the current leaf, so a rename never breaks a saved role. renamed lists each such grant as { from, to }; rewrite those rows in storage before removing the alias. customRoleClaim(roles, policy) writes current keys into the token claim; without policy it copies the stored keys. permdock doctor PD055 prints the update statements for the doctor.memberships fixture.
Who may assign what
permdock.assignableRoles({ tenant? }) // Role[]
permdock.assignableRoles({ scope: 'global' }) // Role[], global roles only
permdock.assignablePermissions({ tenant? }) // Permission[]
permdock.assignableLevels(permission, { tenant? }) // string[]Both answer for the active tenant unless tenant is passed, and both follow the rule that you cannot hand out what you do not hold:
assignablePermissionsis the tenant ceiling intersected with the permissions the subject holds in that tenant, through declared roles, custom roles or global grants. It is empty without a tenant.assignableRoleslists the declared assignable roles the subject holds there, plus those whose every allow the subject holds. A role without allows is assignable only by its holders, because hosted grants may give it permissions later. Once any role declaresassigns, the graph replaces this rule: the list is exactly the roles the held roles'assignsname, in rank order (ownership).assignableRolesalso lists the tenant's custom roles from theRoleSourcethat the subject may hand out. The source has to return every custom role of the tenant, not only those the subject holds;customRoleSourcebuilds one from a store read (extension interfaces). A custom role qualifies when the subject may assign a declared role at the custom role's scope (any scope withmeta.manageRoles) and may hand out every permission and level the custom role allows at that scope: the ceiling of that scope intersected with what the subject holds. A custom role comes after the declared roles, sorted by name, as aRolewithonset to its scope. A custom role that allows nothing after the ceiling, because it is empty, only denies, or every entry is dropped (outside-ceiling,unknown-permission,unknown-level), is never offered anddecideRoleChangerefuses to assign it (not-assignable-by): it would grant nothing. An actor who may assign at its scope can still revoke it, so stale memberships can be cleaned up;validateCustomRolereports why its entries were dropped.assignableRoles({ scope: 'global' })lists the global roles (declared withouton) withassignable: truethat the subject may hand out through its global roles, by the same rules without a tenant, and no custom role. It is the listdecideRoleChangechecks ascope: 'global'change against.RoleSource.assignable(tenant), when the source implements it, narrows both to those role names. A custom role does not need to be listed: it is bounded by the narrowed ceiling instead. A source that throws assigns nothing and emitson('auth')withsource-threw.- Holding a declared role with
meta.manageRoles: true, or being granted a permission withmeta.manageRoles: true(typicallymember.assignRole), lifts the intersection: the full ceiling, narrowed byRoleSource.assignable, is assignable. assignableLevels(permission)lists the levels of that permission the subject may hand out: every level withmeta.manageRoles, otherwise the levels it holds. A grant without a condition holds every level, a custom-role grant at a level holds that level, and a declared grant holds the levels whose condition equals itswhere. It is empty for a collection permission or a permission outsideassignablePermissions.- Only live memberships count. A role held through a membership whose
expiresAthas passed neither holds permissions nor lifts the intersection, so an expired elevation cannot hand out roles or mint credentials.
decideRoleChange assigns and revokes a custom role with the same rules as a declared one (ownership). The change names the custom role, its scope and the instance; the role is looked up among the RoleSource roles of the instance's tenant, and a role pinned to one instance matches only that instance. A custom role never declares min, max, transferOnly or assigns, so no holder count applies and no held role has to list it. It inherits for and exclusiveWith from the declared roles it includes: a custom role that includes a staff-only role is denied on a contact membership.
RoleSource.assignable shapes what the editor offers and what a server action accepts. Evaluation always uses the declared ceiling, so a custom role saved before a plan downgrade keeps working until the application edits it.
Snapshots
snapshot() and snapshotFor(policy, user, { customRoles, assignable? }) carry the resolved custom-role grants as ordinary grants entries: role is the custom role name and membership is the membership that holds it, so fromSnapshot scopes them to the right scope instance. include trims them like any grant. snapshotFor takes assignable as a record of role names per tenant, the values RoleSource.assignable would return.
Snapshots also carry assignable: one { tenant, roles, permissions, levels? } entry per tenant in tenants, where roles are role names, permissions are permission leaves and levels maps a permission key to its assignable levels, trimmed by include. The snapshot-backed instance reads them for assignableRoles(), assignablePermissions() and assignableLevels(), and useAssignablePermissions() reads them on the client. The format stays Snapshot v: 1 (snapshots, wire formats).
For every custom role, decide on the server and fromSnapshot(snapshot).decide agree; the parity suite checks includes, own allows, inherited denies, own denies, dropped keys and team scope.
Row level security
permdock rls generate --custom-roles resolves custom roles inside the generated helpers, with the same rules and the same ceiling (RLS):
databasemode readscustom_role_permissions (tenant_id, scope, scope_id, role, permission, effect)(plus a nullablelevelcolumn when the policy declares levels) andcustom_role_includes (tenant_id, scope, scope_id, role, include_role), written by the application from the same data itsRoleSourcereads. Both tables carry an identityidprimary key for the application's own triggers, such as an audit trigger (triggers and audit). An application that already stores custom roles in its own tables copies them in once withrls generate --backfill-out(backfill).jwtmode reads a compactgrantsmap on each membership of themembershipsclaim,{ "billing-manager": ["@billing", "post.read", "-invoice.refund"] }, built withcustomRoleClaim(roles). A leveled allow is writtenjob.read@team. It is off unless--custom-rolesis set, and it grows every token.- Platform custom roles live in the same tables with
scope = 'global'and a nulltenant_idandscope_id. Injwtmode their entries ride the top-levelrole_grantsclaim, keyed by role name and built withcustomRoleClaim(roles).permdock_hasunions them into every tenant check. - Both intersect with the generated
permdock_ceilingview, so a row or claim entry naming a permission outside the ceiling reaches nothing. - In
databasemode,permdock_replace_custom_role_grants(tenant, scope, scope_id, role, allow, deny, include)saves a custom role with the rules ofvalidateCustomRoleandassignablePermissions, checked against the signed-in caller, andpermdock_rename_custom_role_grantsandpermdock_delete_custom_role_grantsmove or remove its rows (RLS). A platform custom role passes a null tenant and scopeglobal, and takes ameta.manageRolespermission held through a global role.rls.customRoleWrites.requiresadds the check the editor's server action makes withassert: the caller must hold one of the named permissions before any write. Thepermdock_trusted_*variants keep the definition checks and drop the caller checks, for migrations, jobs and backends; only the owner executes them until the application grants a role (trusted callers). - With levels declared, the ceiling gains one grant key per level,
job.read@own, whose policy branch ANDs the level's condition into the ceiling grant. A stored level the code does not declare removes the permission, as in-process. - With
--rbac supabase,authorize(permission, tenant)answers from custom roles too. - A membership table that stores a role id reads the key through the app's roles table (
role: { through: 'roles', on: { role_id: 'id' }, column: 'key' }). Custom roles are then roles rows owned by a tenant, with a key unique within it; the tables above match that key and the membership's tenant, so two tenants may each definedispatcher(RLS).
The parity suite runs permdock rls verify --db over every custom role, table and command in both modes, including a custom role that adds one permission and denies one.
Recipe: a permission matrix editor
A matrix editor shows permissions as rows and custom roles as columns, and saves each column as a CustomRole.
"use client";
import { useAssignablePermissions } from "permdock/react";
export function RoleColumn({
role,
onToggle,
}: {
role: CustomRole;
onToggle: (key: string, on: boolean) => void;
}) {
const offered = useAssignablePermissions(); // the ceiling this admin may hand out
const granted = new Set(
(role.grants ?? [])
.filter((g) => g.effect !== "deny")
.map((g) => g.permission),
);
return offered.map((leaf) => (
<label key={leaf.key}>
<input
type="checkbox"
checked={granted.has(leaf.key)}
onChange={(e) => onToggle(leaf.key, e.target.checked)}
/>
{leaf.meta.title ?? leaf.key}
</label>
));
}"use server";
import { validateCustomRole } from "permdock";
export async function saveRole(input: unknown) {
const permdock = await getPermDock();
permdock.assert(permissions.member.assignRole);
const role = CustomRoleSchema.parse(input); // boundary data: validate it
const offered = new Set(
permdock
.assignablePermissions({ tenant: role.tenant })
.map((leaf) => leaf.key),
);
const result = validateCustomRole(policy, role);
const beyond = result.permissions.filter((key) => !offered.has(key));
if (!result.ok || beyond.length > 0)
return { dropped: result.dropped, beyond };
await db.customRoles.upsert(role); // the table your RoleSource reads
return { saved: true };
}- Render
droppednext to the column so the admin sees why a key did not stick, instead of silently losing it. - Save through the application's own table; PermDock never writes a custom role from TypeScript. The
RoleSourcereads the same table on the next request. With generated RLS indatabasemode, callpermdock_replace_custom_role_grantsfrom the action instead of writingcustom_role_permissionsandcustom_role_includes: the database repeats these checks for the signed-in caller. - Include a declared role (
includes) when the admin wants its conditions, such as "own posts only"; toggle single permissions (grants) for everything else. - Offer
assignableLevels(leaf)as a select per cell when the resource declares levels, and save the choice as{ permission, level }.
Last updated on
Tenants, teams and scoped roles
Memberships put roles in a scope (an instance of a declared named scope, or one resource); scoped role declarations apply grants only inside that scope; tenant-defined custom roles compose declared roles and never widen them; RoleSource and MembershipSource are the only new inputs.
Ownership and audiences
Role rules as policy. Every organization keeps an owner (min, max, transferOnly), a role lists the roles it may hand out (assigns), membership kinds decide who may hold a role (for), decideRoleChange checks an assign, revoke or transfer, generated RLS enforces the counts at commit, and audiences tell the UI which surfaces a subject uses.