# Custom roles

Source: https://permdock.com/docs/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`.

```ts
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.

```ts
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](/docs/concepts/tenancy) covers memberships and the `RoleSource` interface.

## The ceiling [#the-ceiling]

The ceiling of a scope is the set of code allows of every declared role marked `assignable` in that [named scope](/docs/concepts/scopes): 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 [#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.

```ts
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 [#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](#levels) 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 [#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.

```ts
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](/docs/cli/diff)).

### Dropped entries [#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](/docs/cli/doctor)).

### Renamed keys [#renamed-keys]

A stored grant may name a permission by a key it was renamed from with `definePermissions(..., { renamed })` ([permissions](/docs/concepts/permissions#renamed-keys)). 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 [#who-may-assign-what]

```ts
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](/docs/concepts/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](/docs/concepts/extension-interfaces#membershipsource-and-rolesource)). 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](/docs/concepts/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 [#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](/docs/concepts/snapshots), [wire formats](/docs/concepts/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 [#row-level-security]

`permdock rls generate --custom-roles` resolves custom roles inside the generated helpers, with the same rules and the same ceiling ([RLS](/docs/adapters/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](/docs/cli/rls#triggers-and-audit)). An application that already stores custom roles in its own tables copies them in once with `rls generate --backfill-out` ([backfill](/docs/cli/rls#backfill-from-existing-tables)).
* `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](/docs/cli/rls#saving-a-custom-role)). 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](/docs/cli/rls#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](/docs/cli/rls#role-checks-per-statement-helpers)).

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 [#recipe-a-permission-matrix-editor]

A matrix editor shows permissions as rows and custom roles as columns, and saves each column as a `CustomRole`.

```tsx
"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>
  ));
}
```

```ts
"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 }`.
