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.
Scoped roles answer "what may a holder do". A B2B product also needs rules about the holders themselves: every organization keeps at least one owner, an admin hands out member and viewer but never owner, a portal contact can never become an admin, and nobody quietly demotes themselves. PermDock declares these rules on the role, checks a proposed change with decideRoleChange, and generates Postgres triggers that refuse a write breaking them.
const staff = { for: ["staff"], meta: { audience: "staff" } } as const;
export const policy = definePolicy(
{ permissions, roles },
{
scopes: {
organization: { key: "organization_id" },
customer: { key: "customer_id", within: "organization" },
},
roles: [
role(roles.owner, ownerGrants, {
on: "organization",
...staff,
min: 1,
assigns: ["owner", "admin", "member", "viewer", "contact"],
}),
role(roles.admin, adminGrants, {
on: "organization",
...staff,
assigns: ["member", "viewer", "contact"],
}),
role(roles.member, memberGrants, { on: "organization", ...staff }),
role(roles.viewer, viewerGrants, { on: "organization", ...staff }),
role(roles.contact, contactGrants, {
on: "customer",
min: 0,
for: ["contact"],
meta: { audience: "portal" },
}),
role(roles["platform-admin"], platformGrants, {
meta: { audience: "platform" },
}),
],
subject: subjectFromSupabase,
},
);Role options
| Option | Meaning |
|---|---|
min | Fewest holders each instance of the role's scope keeps, default 0. Removing the last owner is refused; deleting the whole instance (no memberships left) is not. |
max | Most holders an instance may have. |
transferOnly | The holder count of an instance never changes once it has holders: the role only moves from one holder to another. Creating the first holder and removing the last (which min guards) are not transfers. |
assigns | Roles a holder may assign and revoke. Names must be declared roles; a role may list itself (owner assigns owner). |
for | Membership kinds (Membership.via) that may hold the role. A role held through any other kind, or through a membership with no via, grants nothing. |
exclusiveWith | Roles nobody may hold together with this one in the same instance (separation of duties, PD018). |
meta.audience | The surface the role's holders use, such as staff, portal or platform. |
min, max and transferOnly count holders per scope instance, so they need a named-scope on; definePolicy throws on a global or resource role that sets them, on max below 1, on min above max, and on an assigns entry that names no declared role. activation stays reserved for time-boxed roles.
Membership kinds
The kind of a membership is a security boundary. A staff member, a guest, a portal contact, a partner and a support engineer can all hold a membership in the same organization; for decides which of them may hold a role:
{ scope: 'organization', id: 'T', roles: ['admin'], via: 'staff' } // admin applies
{ scope: 'organization', id: 'T', roles: ['admin'], via: 'contact' } // admin grants nothing
{ scope: 'organization', id: 'T', roles: ['admin'] } // no kind: admin grants nothingThe filter runs when the subject is resolved, so decide, where(), snapshots, heldRoles and audiences all see the same roles, and the generated RLS helpers apply the same check to the membership table's via column or the claim's via. A role without for ignores the kind. Global roles have no kind, so a role with for held globally grants nothing.
Checking a role change
permdock.decideRoleChange(
{
kind: "assign", // 'assign' | 'revoke' | 'transfer'
role: roles.admin, // a Role leaf or a declared name
scope: "organization", // the scope the role is held at
id: "o_acme", // the instance
within: undefined, // ancestor ids, for a role on a nested scope
target: { id: "u_bob", via: "staff", roles: ["member"] }, // their membership there now
holders: 1, // how many hold the role in the instance now
},
{ trusted: false },
); // true only when `within` came from your own store
// { outcome: 'granted', change, role: 'owner' } or { outcome: 'denied', change, denials }The actor is always the instance's own subject; nothing in the change can stand in for it. The application supplies what only its store knows: the target's current membership in the instance and the holder count. decideRoleChange never writes.
Global roles
A role declared without on is a global role, held by the principal's roles and not through a membership. Change one with scope: 'global' and no id; id is required for every named scope and a global change that carries one is denied with validation.
permdock.decideRoleChange({
kind: "assign",
role: roles["platform-support"],
scope: "global",
target: { id: "u_bob", roles: [] },
});There is no tenant, so within and { trusted } do not apply and the change never answers no-membership. The authority is the one assignableRoles({ scope: 'global' }) lists: the declared roles with assignable: true (a global role is not assignable by default) whose every allow the subject holds through its global roles, or, with an assigns graph, exactly the roles the subject's global roles list. exclusiveWith, managedBy: 'idp' and the self-change rule behave as for a scoped role. min, max, transferOnly and for are not defined on a global role, so none of them applies. A transfer needs the subject to hold the role globally. A role held at a named scope is denied with scope, an undeclared name with unknown-role (a platform custom role is not changed through decideRoleChange), and the generated global-roles trigger judges the same write at commit.
Authority comes from memberships the subject holds now. An expired membership gives no tenant and no assign authority. For a role on a nested scope, the tenant and ancestors are read from the subject's own live membership at that instance (or at one of its ancestors), and a within that disagrees is denied with no-membership. When the subject holds nothing at the instance, as an organization admin assigning a role on a customer does, within is taken from the change only with { trusted: true }, which the application passes after loading the customer's ancestors from its own store. Without either, the change is denied with no-membership.
| Rule | Checked on | Denial reason |
|---|---|---|
The role is declared, or is a custom role of the instance's tenant, and is held at scope (global for a role with no on) | every change | unknown-role, scope |
| The subject changes someone else | every change | self-demotion (revoke, transfer), not-assignable-by (assign) |
The subject may hand the role out: it is in assignableRoles() for the instance's tenant and, with an assigns graph, a role the subject holds at the instance or an ancestor in within lists it (a custom role needs only the first) | assign, revoke | not-assignable-by |
| The subject holds the role at the instance | transfer | not-assignable-by |
The target's via is in the role's for (for a custom role, in the for of every included role) | assign, transfer | not-allowed-for-membership |
The target holds no role in the role's exclusiveWith (or listing it); a custom role takes the conflicts of its included roles | assign, transfer | conflicting-role |
The holder count stays within min and max, and a transferOnly count does not move | whenever the count changes | last-holder, max-holders, transfer-only |
countHolders(memberships, { scope, id, role }) reads the number from the same MembershipSource (its list), counting each principal with a live membership of that instance and role once, so the count comes from the source the subject resolves from instead of a second query; it answers undefined when the source cannot list or the read fails. An unknown or malformed holders fails closed: a role with min, max or transferOnly is denied with holders: null in the detail. Assigning a role the target already holds leaves the count alone; transferring to someone who already holds it counts as a removal. granted names the role that authorised the change (the transferred role for a transfer, the assigning role with an assigns graph, null otherwise). Pair it with a permission check for the action itself:
"use server";
export async function changeRole(input: unknown) {
const permdock = await getPermDock();
permdock.assert(permissions.member.assignRole);
const change = RoleChangeSchema.parse(input); // boundary data: validate it
const within = await db.scopes.ancestors(change.scope, change.id); // never from the request
const target = await db.memberships.find(
change.scope,
change.id,
change.targetId,
);
const holders = await db.memberships.count(
change.scope,
change.id,
change.role,
);
const decision = permdock.decideRoleChange(
{ ...change, within, target, holders },
{ trusted: true },
);
if (decision.outcome === "denied") return { denials: decision.denials };
await db.memberships.apply(change); // the triggers re-check at commit
return { changed: true };
}On the snapshot-backed client decideRoleChange always denies with unsupported: the snapshot carries no holder counts, so role changes are server decisions. The UI offers assignableRoles() instead; a snapshot answers assignableRoles({ scope: 'global' }) with an empty list.
Who may hand out what
Without assigns, assignableRoles() keeps its ceiling rule: the declared assignable roles the subject holds, plus those whose every allow the subject holds. Once any role declares assigns, the graph is authoritative: the subject hands out exactly the roles its held roles list, whether or not it holds their permissions (an admin who lists contact links portal contacts without holding quote.accept), and a role nobody lists is assignable only by a meta.manageRoles holder. RoleSource.assignable(tenant) narrows either list.
Rank and audiences
heldRoles() and assignableRoles() are ordered by the assigns graph: a role comes before the roles it assigns, then declaration order. owner ranks above admin because owner assigns admin, so a role chip or a members table shows rank without a hard-coded list. heldRoles({ scope, id }) narrows to the memberships of one scope instance. Without an assigns graph the order is unchanged.
audiences() returns the distinct meta.audience values of the roles held in the active tenant, in rank order, and snapshot.audiences carries the same list to the client:
permdock.audiences(); // ['staff'] for a member of T
permdock.tenant("B").audiences(); // ['portal'] for the same user, a contact in BUse it to pick a layout or a landing page (the staff app, the customer portal, the platform console) and to show a "switch to portal" link to someone who has both. It decides nothing: every page still checks its permissions.
In the database
permdock rls generate turns the rules into database objects (CLI):
- The
permitted_<scope>_idshelpers, and theexistscheck on a resource role's membership table, count a role withforonly on rows whoseviacolumn (or claim entry) is listed; a table without aviacolumn holds it for nothing. - A deferred constraint trigger per scope membership table, or on each table of the scope's
fromTable/fromJunctionmembership sources, checksminandmaxat commit. A transfer done as two statements in one transaction passes; a commit that leaves an organization without an owner fails with SQLSTATE23514and hintlast-holder. - Statement triggers over transition tables refuse a statement that changes a
transferOnlyrole's holder count. permdock_can_assign(p_role, p_scope_id)answers theassignsgraph for the application's own policies on its membership tables. A global role that lists a global role inassignsassigns it with a nullp_scope_id; no scoped role can.- With
rls.assignments, a trigger on each membership table, on the global-roles tablerls.rolesand on an invitations table you list refuses a client write that assigns, changes or removes a role the caller may not assign there, by theassignsgraph or, for a custom role, by what the caller may hand out. A global role is checked at no instance, so only a global role whoseassignslists it passes.ownRole: 'refuse'also refuses a client write to the caller's own row, asdecideRoleChangedoes. Writes by the table owner, asecurity definerfunction or a backend role are trusted, andpermdock doctorPD064 warns on a guarded table no migration puts the trigger on (CLI).
decideRoleChange gives the user a reason before the write; the triggers catch the write that skipped it (a script, an admin console, a second code path).
Moving a transfer-only role
The transfer-only trigger looks at each statement, so write a transfer in one of two shapes, both inside one transaction:
-
One statement that swaps both rows, so the holder count of the role never moves:
update organization_users set role_id = case user_id when $1 then $3 else $4 end -- $3: the old owner's new role, $4: owner where organization_id = $5 and user_id in ($1, $2); -- $1: current owner, $2: new owner -
Demote first, then promote. Demoting the only holder takes the count to zero, which is not a transfer; promoting the new holder brings it back to one. The
mincheck waits for the commit, so the organization is never seen without an owner. Promoting first refuses the statement with hinttransfer-only, because the count goes from one to two.
supabase-js and PostgREST run each request in its own transaction, so a two-statement transfer from the client commits after the demotion and fails min with hint last-holder. Put the transfer in a database function and call it with rpc(); the function body runs in one transaction. tests/integration/src/rls-ownership.test.ts runs all three orders.
Catalog, Cloud and doctor
The catalog lists each role's min, max, transferOnly, assigns, for, exclusiveWith and audience, so PermDock Cloud applies the same rules in its assignment checks. permdock doctor PD026 warns on a scope whose roles set no min; set min: 0 on one of them to record that none has to stay, as the customer scope above does (doctor).
Why
Ownership rules are the part of role management every B2B product writes by hand, and gets wrong in the same places: the last owner leaves and the organization is orphaned, an admin promotes themselves, an invite flow gives a contractor an admin role. CentraKit, the field-service product this page's example comes from, enforces "at least one owner" with a commit-time check and "you cannot change your own role" in every assignment function. Declaring the rules on the role puts them in the one place decide, snapshots, the catalog, the Cloud and the database already read.
- The graph, not the ceiling, when you draw one. "You cannot hand out what you do not hold" is a good default and stays the default. It cannot express "admins link portal contacts" (admins do not accept quotes) or "only owners appoint owners" (admins may hold every permission an owner has).
assignsstates the intent directly, and ranking falls out of it for free. - Membership kind is a security boundary. Shopify collaborators, Slack guests, Figma and Linear external members, and Microsoft GDAP partners all stop external people from reaching admin by the kind of membership, not by the role name. Dropping the role when the kind does not match, at resolution and in the RLS helpers, means a mis-provisioned membership fails closed everywhere at once.
- Counts are checked at commit. A transfer is two changes (promote one, demote the other); checking after each statement would refuse every transfer or require a special API. A deferred constraint trigger sees the final state, and an instance with no memberships left is being torn down, so deleting an organization still works.
transferOnlycannot be deferred (transition tables are statement-level), which is why it refuses per statement and treats "through zero" as a transfer. - No switch to leave the triggers out. The triggers enforce what the role declares, as a backstop for every write path. An option that kept
minortransferOnlyin the application only would let a script or an admin console write whatdecideRoleChangerefuses, with nothing in the policy to say so. An application whose transfer promotes before it demotes rewrites that one function as above; one that is not ready leavestransferOnlyoff the role until it is, and keepsmin. - Own rows are opt-in in the database.
decideRoleChangealways refuses a change that targets the actor, but a trigger cannot tell a self-service flow the application meant to allow, such as leaving an organization, from a self-promotion.assignsalready stops a caller from giving themselves a role they may not hand out;ownRole: 'refuse'closes the rest, a platform admin granting themselves another global role, for applications whose own-role changes all go through trusted functions. - The application supplies the holder count. PermDock never reads another user's memberships at decide time: the store is the application's, and a count read inside
decidewould be a hidden network call. Failing closed on a missing count keeps a forgotten argument from becoming a silent pass; the trigger is the backstop. - Ancestors come from a held membership or the application's store. The tenant decides which assign authority applies, and a nested change names its tenant through
within. Read from the request, awithinnaming the caller's own organization would let a lead in one organization hand out roles on another organization's team. Reading it from the subject's live membership closes that, and{ trusted: true }covers the one case a membership cannot: an organization admin acting on a scope they do not belong to, where only the application's store knows the parent. It matchestrustedondecide: the caller marks the data it loaded itself. - Audiences are metadata, not permissions. A layout switch keyed on role names drifts every time a role is added.
meta.audiencenames the surface once per role, andaudiences()stays a read of held roles, so it can never grant anything.
Last updated on
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.
Relationships
Object hierarchies (nested folders, sub-teams, reporting lines, account delegates) as relation grants that walk a parent chain, decided in process through a RelationSource and in Postgres through a closure table.