PermDock
Concepts

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 }:

  1. 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's deny grants in the custom role's scope come along.
  2. Add each own allow. Its permission must be a declared key in the ceiling. It inherits every ceiling grant of that permission, together with the deny grants 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.
  3. Remove every permission named by an own deny, whether it came from an include or an own allow.
  4. Re-target the result to the custom role: each grant keeps its permission, where, check, approval, limit and fields, 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 level inherits each ceiling grant of the permission, as above, with the level's condition ANDed into its where and check. The ceiling condition still applies: job.update at all reaches 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 deny with a level is dropped as condition-not-allowed and 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 catalog lists the levels of each permission (levels in catalog-v1.json), and permdock diff reports 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:

ReasonMeaning
unknown-permissionThe key is not a declared permission
outside-ceilingThe permission is declared but no assignable declared role of the scope allows it
condition-not-allowedThe 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-levelThe 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-roleAn 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:

  • assignablePermissions is 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.
  • assignableRoles lists 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 declares assigns, the graph replaces this rule: the list is exactly the roles the held roles' assigns name, in rank order (ownership).
  • assignableRoles also lists the tenant's custom roles from the RoleSource that the subject may hand out. The source has to return every custom role of the tenant, not only those the subject holds; customRoleSource builds 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 with meta.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 a Role with on set 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 and decideRoleChange refuses 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; validateCustomRole reports why its entries were dropped.
  • assignableRoles({ scope: 'global' }) lists the global roles (declared without on) with assignable: true that the subject may hand out through its global roles, by the same rules without a tenant, and no custom role. It is the list decideRoleChange checks a scope: '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 emits on('auth') with source-threw.
  • Holding a declared role with meta.manageRoles: true, or being granted a permission with meta.manageRoles: true (typically member.assignRole), lifts the intersection: the full ceiling, narrowed by RoleSource.assignable, is assignable.
  • assignableLevels(permission) lists the levels of that permission the subject may hand out: every level with meta.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 its where. It is empty for a collection permission or a permission outside assignablePermissions.
  • Only live memberships count. A role held through a membership whose expiresAt has 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):

  • database mode reads custom_role_permissions (tenant_id, scope, scope_id, role, permission, effect) (plus a nullable level column when the policy declares levels) and custom_role_includes (tenant_id, scope, scope_id, role, include_role), written by the application from the same data its RoleSource reads. Both tables carry an identity id primary 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 with rls generate --backfill-out (backfill).
  • jwt mode reads a compact grants map on each membership of the memberships claim, { "billing-manager": ["@billing", "post.read", "-invoice.refund"] }, built with customRoleClaim(roles). A leveled allow is written job.read@team. It is off unless --custom-roles is set, and it grows every token.
  • Platform custom roles live in the same tables with scope = 'global' and a null tenant_id and scope_id. In jwt mode their entries ride the top-level role_grants claim, keyed by role name and built with customRoleClaim(roles). permdock_has unions them into every tenant check.
  • Both intersect with the generated permdock_ceiling view, so a row or claim entry naming a permission outside the ceiling reaches nothing.
  • In database mode, permdock_replace_custom_role_grants(tenant, scope, scope_id, role, allow, deny, include) saves a custom role with the rules of validateCustomRole and assignablePermissions, checked against the signed-in caller, and permdock_rename_custom_role_grants and permdock_delete_custom_role_grants move or remove its rows (RLS). A platform custom role passes a null tenant and scope global, and takes a meta.manageRoles permission held through a global role. rls.customRoleWrites.requires adds the check the editor's server action makes with assert: the caller must hold one of the named permissions before any write. The permdock_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 define dispatcher (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 dropped next 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 RoleSource reads the same table on the next request. With generated RLS in database mode, call permdock_replace_custom_role_grants from the action instead of writing custom_role_permissions and custom_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

On this page