PermDock
Concepts

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

OptionMeaning
minFewest 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.
maxMost holders an instance may have.
transferOnlyThe 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.
assignsRoles a holder may assign and revoke. Names must be declared roles; a role may list itself (owner assigns owner).
forMembership kinds (Membership.via) that may hold the role. A role held through any other kind, or through a membership with no via, grants nothing.
exclusiveWithRoles nobody may hold together with this one in the same instance (separation of duties, PD018).
meta.audienceThe 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 nothing

The 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.

RuleChecked onDenial 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 changeunknown-role, scope
The subject changes someone elseevery changeself-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, revokenot-assignable-by
The subject holds the role at the instancetransfernot-assignable-by
The target's via is in the role's for (for a custom role, in the for of every included role)assign, transfernot-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 rolesassign, transferconflicting-role
The holder count stays within min and max, and a transferOnly count does not movewhenever the count changeslast-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 B

Use 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>_ids helpers, and the exists check on a resource role's membership table, count a role with for only on rows whose via column (or claim entry) is listed; a table without a via column holds it for nothing.
  • A deferred constraint trigger per scope membership table, or on each table of the scope's fromTable / fromJunction membership sources, checks min and max at commit. A transfer done as two statements in one transaction passes; a commit that leaves an organization without an owner fails with SQLSTATE 23514 and hint last-holder.
  • Statement triggers over transition tables refuse a statement that changes a transferOnly role's holder count.
  • permdock_can_assign(p_role, p_scope_id) answers the assigns graph for the application's own policies on its membership tables. A global role that lists a global role in assigns assigns it with a null p_scope_id; no scoped role can.
  • With rls.assignments, a trigger on each membership table, on the global-roles table rls.roles and on an invitations table you list refuses a client write that assigns, changes or removes a role the caller may not assign there, by the assigns graph 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 whose assigns lists it passes. ownRole: 'refuse' also refuses a client write to the caller's own row, as decideRoleChange does. Writes by the table owner, a security definer function or a backend role are trusted, and permdock doctor PD064 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 min check waits for the commit, so the organization is never seen without an owner. Promoting first refuses the statement with hint transfer-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). assigns states 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. transferOnly cannot 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 min or transferOnly in the application only would let a script or an admin console write what decideRoleChange refuses, 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 leaves transferOnly off the role until it is, and keeps min.
  • Own rows are opt-in in the database. decideRoleChange always 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. assigns already 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 decide would 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, a within naming 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 matches trusted on decide: 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.audience names the surface once per role, and audiences() stays a read of held roles, so it can never grant anything.

Last updated on

On this page