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
- Memberships on the principal.
Principalgainsmemberships?: Membership[]andtenant?: string(the active tenant). AMembershipis{ tenant?, team?, on?, roles, via?, expiresAt? }with exactly one scope shape.roleson the principal keeps meaning global roles. Memberships come only from verified material (asubjectFrom*provider,subject,context, aMembershipSource); never from a model argument, an unsigned header, a request body or a CLI flag. There is never a default tenant. - 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 declaredparent), and collection actions require the active tenant to hold the role.definePolicygainsscopes: { tenant: { key }, team: { key } }, typed against every scoped resource's schema. Deny overrides allow across scopes. - Custom roles are data over declared assignable roles.
CustomRole = { tenant, name, includes: string[] }. A membership role name norole()declared resolves through the tenant'sRoleSourceto 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). RoleSourceandMembershipSourceare subject inputs, not stores. They run once percreatePermDock, 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.memoryRoleSourceand the default membership source ship in the package;permdock/cloudimplements neither (invariant 15). Every adapter acceptsmembershipsandcustomRolesnext tostore,sinkandsnapshots.- 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 toexistsover a mapped membership table. Custom roles compile after resolution, so SQL sees declared names only. - Snapshot v2 carries
subject.memberships,subject.tenant, per-grantscopeand asimulatedflag; snapshots are scoped to the active tenant by default withtenants: 'all'as the opt-in. Decision events gaintenant,membershipandvia. The AuthZEN mapping places memberships undersubject.propertiesand the active tenant undercontext. - Declarations stay data; chaining lives on the instance.
allowanddenyaccept an array of references;roletakes an options object. No fluent policy builder and nocanas a definer (0005, 0010). The instance gainstenant(id)andteam(id)(derived frozen instances) and the read-onlymemberships(),tenants(),roles({ tenant })andassignable(). - 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 aschemaoption.PrincipalOf<typeof policy>andSubjectOf<typeof policy>replace an inference accessor. Nodeclare module, no$Infer(0006). - Default claim mapping follows RFC 9068.
permdock/jwtreadsrolesinto global roles,groupsinto team memberships andentitlementsinto roles under the entitlements-are-roles rule, keyed on the SCIMvaluesub-attribute and accepting plain string arrays (JWT authorization claims). - UI gets tenant-aware hooks, not components.
useTenant,useMemberships,useRoles,useAssignableRoles,usePermissions,useFilter,useApproval,useSubjectand the puredescribe(decision)helper joinusePermissionand<Protected>;<Protected>hides, "disabled with a reason" is a hook plusdescribe. Vue, Svelte and Solid mirror the names (UI).
Consequences
- Single-tenant applications change nothing:
rolesstill 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/testinggains 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
memberOffixtures 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 doctorgains checks: tenant roles with a resource lacking the scope key, memberships with no scope, a team role with no team source, aclaims.tenantpath 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
pdpbridge's job; declaring that boundary explicitly is part of this decision.
Alternatives considered
- Conventions only (keep
orgIdpluswhereon 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/pdpalready 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: breakspermdock collectandusagestatic analysis and snapshot diffs, degrades inference across long chains, and reintroduces the CASL builder style the naming convention reserves against.definePolicyaccepts plain arrays, so a community builder remains possible outside core. - Global module augmentation or a
$Inferaccessor for provider principal types. Rejected: augmentation types values that may not exist at runtime (Hono's own documentation now discouragesContextVariableMap), 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/cloudmembership 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.
Related
- Tenants, teams and scoped roles
- SaaS tenancy and roles
- JWT authorization claims, AuthZEN
- Extension interfaces, UI
- Policies, Subject, Conditions, Snapshots, Wire formats
- RLS adapter, Clerk, Better Auth, Supabase, JWT, pdp
- 0005: naming convention, 0006: explicit factory, not plugin, 0010: policy as data, 0012: two-principal subject, 0018: authentication is upstream, 0021: embedded PDP, hosted ADS
0023: Compose with the OpenAPI toolchain, do not wrap it
Why PermDock reaches spec producers (next-openapi-gen, hono-openapi, @orpc/openapi), SDK generators (Hey API, Orval) and docs UIs (Scalar) through standard security fields and an Overlay rather than per-tool packages, why the same rule applies to every adjacent ecosystem, and what stays out of scope.
0025: Draft protocols: build, name or track
Why every draft or beta specification PermDock follows gets one of three postures (build against a pinned revision, reserve names only, or track), why --target 3.3 emits the OpenAPI 3.3 Security Profile proposal today rather than waiting for 3.3.0, how the pinned draft revision is recorded in x-permdock-catalog, and why PermDock never emits another vendor's unregistered extension.