PermDock
Decisions

0024: Scoped roles and memberships

Why roles gain a scope (tenant, team, resource) declared on the role, why the subject gains memberships and an active tenant, why tenant-defined custom roles are data over declared assignable roles, why RoleSource and MembershipSource are subject inputs rather than stores, and why the API stays data with chaining only on the instance.

Status

Proposed, September 2026. Extends 0010 (policy as data), 0012 (the subject) and 0018 (providers own identity). Becomes accepted when the Phase 1 evaluator ships with the policy-matrix cases below.

Context

PermDock's roles were global strings and tenancy was a convention: an orgId on the principal and where: { orgId: subject.orgId } repeated on every grant. Teams were context.teamIds. Roles held on one document were referred to OpenFGA through permdock/pdp, and roles a tenant admin defines at runtime had no answer at all. Four adapter pages carried open questions waiting on this (Clerk multi-organisation snapshots, Better Auth admin versus organisation role collisions, the RLS memberOf node, the Eve tenant dimension).

The survey found the same structure in every authorization engine and auth provider that serves B2B SaaS: a role is a relation between a subject and a scope (Permit resource roles, Oso has_role(user, role, resource), OpenFGA organization#admin, Cerbos scoped policies, Clerk and Auth0 organisation roles, Better Auth member.role and teams); tenant-defined roles are always composed from a vocabulary the code declared (Oso grants_permission, OpenFGA role type, Better Auth dynamicAccessControl); parent derivation is declared one hop at a time; and providers own the tenant object, the membership rows and invitations. On the token side, RFC 9068 registers roles, groups and entitlements as JWT claims with SCIM encoding, and AuthZEN 1.0 names group memberships as a subject property.

Three further asks shaped the decision: developers wanted less repetition (the tenant condition on every grant), a typed way to extend a provider's principal, and UI hooks that know about tenants.

Decision

  1. Memberships on the principal. Principal gains memberships?: Membership[] and tenant?: string (the active tenant). A Membership is { tenant?, team?, on?, roles, via?, expiresAt? } with exactly one scope shape. roles on the principal keeps meaning global roles. Memberships come only from verified material (a subjectFrom* provider, subject, context, a MembershipSource); never from a model argument, an unsigned header, a request body or a CLI flag. There is never a default tenant.
  2. Scope is declared on the role. role(name, grants, { on: 'tenant' | 'team' | resource | resource[], assignable? }). A scoped role's grants match only when the row's scope key equals the membership's scope (or the row is the membership's resource or a descendant through a declared parent), and collection actions require the active tenant to hold the role. definePolicy gains scopes: { tenant: { key }, team: { key } }, typed against every scoped resource's schema. Deny overrides allow across scopes.
  3. Custom roles are data over declared assignable roles. CustomRole = { tenant, name, includes: string[] }. A membership role name no role() declared resolves through the tenant's RoleSource to the declared roles it includes; unresolvable names drop. A custom role can never carry a condition or exceed the union of assignable roles. RoleSource.assignable(tenant) may narrow the set (plan gating, Clerk role sets).
  4. RoleSource and MembershipSource are subject inputs, not stores. They run once per createPermDock, their output is frozen into the subject, and they are the only extension interfaces that may influence an outcome. PermDock never writes memberships or custom roles. memoryRoleSource and the default membership source ship in the package; permdock/cloud implements neither (invariant 15). Every adapter accepts memberships and customRoles next to store, sink and snapshots.
  5. One new condition node, memberOf, with an in-memory evaluator, a JSON form and Drizzle, Prisma, Kysely and RLS compilation (invariant 6). RLS compiles the active tenant to a claim comparison and other scopes to exists over a mapped membership table. Custom roles compile after resolution, so SQL sees declared names only.
  6. Snapshot v2 carries subject.memberships, subject.tenant, per-grant scope and a simulated flag; snapshots are scoped to the active tenant by default with tenants: 'all' as the opt-in. Decision events gain tenant, membership and via. The AuthZEN mapping places memberships under subject.properties and the active tenant under context.
  7. Declarations stay data; chaining lives on the instance. allow and deny accept an array of references; role takes an options object. No fluent policy builder and no can as a definer (0005, 0010). The instance gains tenant(id) and team(id) (derived frozen instances) and the read-only memberships(), tenants(), roles({ tenant }) and assignable().
  8. Typed extension through generics filled from a Standard Schema, never global augmentation. Every provider satisfies SubjectResolver<TInput, TPrincipal> and exports a base principal type (SupabasePrincipal, ClerkPrincipal, BetterAuthPrincipal, JwtPrincipal, McpPrincipal); custom claims are validated and typed through a schema option. PrincipalOf<typeof policy> and SubjectOf<typeof policy> replace an inference accessor. No declare module, no $Infer (0006).
  9. Default claim mapping follows RFC 9068. permdock/jwt reads roles into global roles, groups into team memberships and entitlements into roles under the entitlements-are-roles rule, keyed on the SCIM value sub-attribute and accepting plain string arrays (JWT authorization claims).
  10. UI gets tenant-aware hooks, not components. useTenant, useMemberships, useRoles, useAssignableRoles, usePermissions, useFilter, useApproval, useSubject and the pure describe(decision) helper join usePermission and <Protected>; <Protected> hides, "disabled with a reason" is a hook plus describe. Vue, Svelte and Solid mirror the names (UI).

Consequences

  • Single-tenant applications change nothing: roles still selects global roles and every existing sample compiles.
  • Multi-tenant policies lose the repeated tenant condition; the failure mode "one grant forgot the where" becomes impossible for scoped roles. The threat model gains rows for an active tenant without a membership, custom-role escalation beyond the assignable set and cross-tenant resource memberships.
  • The policy matrix in @permdock/testing gains cases: tenant allow versus global deny; team role inside and outside the active tenant; resource role through one and two parent hops; expired membership; custom role including an unknown name; collection action with an active tenant the subject is not a member of; tenants: 'all' snapshot equality with per-tenant snapshots.
  • The RLS parity suite gains memberOf fixtures for each dialect; the RLS adapter needs a membership table mapping per scope.
  • Wire formats bump: snapshot v2, decision event and catalog fields; the PermDock-Cloud repository reads them and must follow.
  • permdock doctor gains checks: tenant roles with a resource lacking the scope key, memberships with no scope, a team role with no team source, a claims.tenant path on a multi-tenant issuer without an onboarded-tenant comparison.
  • Naming grows (Membership, memberOf, RoleSource, MembershipSource, SubjectResolver, the hooks); the reserved-words list grows (fluent builder, $Infer, <Can>).
  • Nested groups, arbitrary-depth trees and cross-tenant graphs remain the pdp bridge's job; declaring that boundary explicitly is part of this decision.

Alternatives considered

  • Conventions only (keep orgId plus where on every grant, document it well). Rejected: the repetition is the bug source, teams and resource roles have no convention at all, and custom roles are impossible without a resolution step.
  • Scope on the membership instead of the declaration (a membership says "admin, but only for tenant rows"). Rejected: the policy would no longer state where a role applies, the catalog could not show it, and RLS could not compile it without reading memberships at generate time.
  • A ReBAC graph in core (tuples, usersets, recursive parents). Rejected: it is the Zanzibar problem, the non-goal stands, and permdock/pdp already bridges to OpenFGA and SpiceDB; PermDock keeps finite, typed parent chains.
  • Custom roles as tenant-authored grants (a tenant admin picks permissions directly). Rejected: a permission-level editor exposes conditions and denies to people who cannot review them; composing reviewed roles keeps the code the only place a condition is written (Oso, OpenFGA and Better Auth reached the same conclusion).
  • A fluent policy builder (policy(permissions).role('admin').allow(...).on('tenant')). Rejected: breaks permdock collect and usage static analysis and snapshot diffs, degrades inference across long chains, and reintroduces the CASL builder style the naming convention reserves against. definePolicy accepts plain arrays, so a community builder remains possible outside core.
  • Global module augmentation or a $Infer accessor for provider principal types. Rejected: augmentation types values that may not exist at runtime (Hono's own documentation now discourages ContextVariableMap), and an inference accessor cannot see configuration TypeScript cannot link statically; generics filled from a Standard Schema validate and type in one step.
  • A permdock/cloud membership or role source. Rejected: it would put the Cloud in the decision path, which invariant 15 forbids; the Cloud may host a role editor that writes to the app's own source.
  • Storing memberships in PermDock. Rejected: providers already own tenants, memberships and invitations (0018); a second store would drift.

On this page