Tenants, teams and scoped roles
Memberships put roles in a scope (a tenant, a team, one resource); scoped role declarations apply grants only inside that scope; tenant-defined custom roles compose declared roles and never widen them; RoleSource and MembershipSource are the only new inputs.
Status: proposed (ADR 0024)
Phase: 1 (evaluator, snapshot v2, interfaces), 3 (memberOf compilers, RLS), 4 (provider RoleSource implementations)
A role in a real SaaS application is rarely global. Alice is an admin of Acme and a viewer of Globex; the design team edits everything in the design folder; Bob shared one document with Carol; Acme's admin invented a "Billing Manager" role nobody coded. PermDock models all four with one addition to the subject: a list of memberships, each naming a scope and the roles held there. Roles declared with role() say where they apply; grants stay exactly as they are; the tenant condition you used to repeat on every grant becomes part of the role. Authentication, tenant objects, invitations and membership storage stay with your auth provider (ADR 0018); PermDock reads the result. The survey behind this page is SaaS tenancy and roles.
Memberships
type Membership = {
tenant?: string // tenant or organisation id
team?: string // team or group id, inside `tenant` when both are set
on?: { resource: string; id: string } // a resource-scoped role: "editor on document d_123"
roles: string[] // declared role names, or tenant-defined custom role names
via?: string // 'team:t_1' or 'group:<scim id>' when inherited; audit only
expiresAt?: number // Unix seconds; time-bound access
}
type Principal = {
id: string
kind?: 'user' | 'service' | 'workload'
roles?: string[] // global roles, unchanged
memberships?: Membership[]
tenant?: string // the active tenant for this request
assurance?: string
binding?: Binding
[k: string]: unknown
}roleson the principal keeps meaning global roles, so every existing sample and every single-tenant app is unchanged.- A membership with only
tenantis a tenant role; withtenantandteama team role; withona resource role. Exactly one of the three shapes applies per entry; a membership with none is ignored (fail-closed, reported bypermdock doctor). tenanton the principal is the active tenant: the one this request is about, taken from the URL, a header the server resolved, or the provider's active organisation. A value with no matching membership makes tenant-scoped roles contribute nothing. There is never a default tenant; an absent value means "no tenant", and tenant-scoped grants do not match (threat model).- Memberships come only from verified material: a
subjectFrom*provider, the policy'ssubjectorcontextfunction, or aMembershipSource. Never from a model argument, an unsigned header, a request body or a CLI flag. This is invariant 13 extended to memberships. - Identifiers, never display names. A team id is the provider's or SCIM group
id; a display name is editable by any group owner and not unique across tenants (JWT authorization claims).
// what a provider produces for Alice
{
id: 'u_alice',
roles: [], // no global roles
tenant: 'o_acme', // active for this request
memberships: [
{ tenant: 'o_acme', roles: ['admin'] },
{ tenant: 'o_globex', roles: ['viewer'] },
{ tenant: 'o_acme', team: 't_design', roles: ['lead'], via: 'group:9f2c' },
{ on: { resource: 'document', id: 'd_123' }, roles: ['editor'], expiresAt: 1789000000 },
],
}Scoped role declarations
role() gains a third argument that says where the role applies:
import { definePolicy, role, allow, deny, subject } from 'permdock'
const viewer = role('viewer', [allow([permissions.post.read, permissions.post.list])], { on: 'tenant' })
const admin = role('admin', [...viewer.grants, allow(permissions.post.delete), allow(permissions.member.invite)], { on: 'tenant' })
const lead = role('lead', [allow(permissions.post.publish)], { on: 'team' })
const editor = role('editor', [allow(permissions.document.update)], { on: permissions.document })
const owner = role('owner', [allow(permissions.org.delete, { approval: 'human' })], { on: 'tenant', assignable: false })
const support = role('support', [allow(permissions.post.read)]) // global, unchanged
export const policy = definePolicy(permissions, {
roles: [viewer, admin, lead, editor, owner, support],
scopes: {
tenant: { key: 'orgId' }, // the field on every tenant-scoped resource
team: { key: 'teamId' },
},
subject: subjectFromClerk, // or your own mapper; see "Where memberships come from"
})on | The role applies when | The grant's row must |
|---|---|---|
| omitted | The name is in principal.roles | Nothing extra: global |
'tenant' | A membership with that tenant holds the role and the tenant is the active one | Have scopes.tenant.key equal to the membership's tenant |
'team' | A membership with that team holds the role, inside the active tenant | Have scopes.team.key equal to the membership's team |
| a resource reference | A membership on that resource holds the role | Be that row (id equal), or a descendant through a declared parent |
Two more options:
assignable(defaulttruefor scoped roles,falsefor global roles) marks a role a tenant admin may hand out and compose into custom roles.ownerabove is held but never handed out.exclusiveWith: ['approver']is recorded as an open question (INCITS 359 separation of duty); it would be apermdock doctorlint over memberships, not an evaluation rule.
Scope keys and typing
scopes.tenant.key must be a field of StandardSchemaV1.InferOutput for every resource a tenant-scoped grant references; a missing field is a compile error on the grant, the same way id is checked today. A resource without the key is global by construction (a plan catalogue everyone reads), and permdock doctor warns when tenant roles exist and a resource that looks tenant-owned lacks the key. Collection actions (post.create, post.list) have no row, so they require the active tenant to be one of the subject's memberships holding the role; that is what stops "create in a tenant I can see but do not belong to".
The where: { orgId: subject.orgId } convention still works and still compiles; scoped roles are the shorter, un-forgettable form of the same condition. A grant may carry both (a tenant admin may only publish unpublished posts: allow(permissions.post.publish, { where: { published: false } }) on a tenant-scoped role).
Resource roles and parents
A resource role follows a declared parent chain:
export const permissions = definePermissions({
project: resource(Project, { id: 'id', actions: ['read', 'update'] }),
folder: resource(Folder, { id: 'id', actions: ['read', 'update'], parent: { field: 'projectId', resource: 'project' } }),
document: resource(Document, { id: 'id', actions: ['read', 'update', 'share'], parent: { field: 'folderId', resource: 'folder' } }),
})
const editor = role('editor', [allow([permissions.document.update, permissions.folder.update])], { on: [permissions.folder, permissions.document] })An editor membership on folder f_1 matches document.update for a document whose folderId is f_1. The chain is finite because it is typed: document to folder to project, three hops, declared once. Self-referential parents (folders in folders) are deliberately not expressible; that is the Zanzibar case and the pdp adapter bridges it (roadmap non-goals). The row must carry the parent field; a missing parent field is a non-match, never a walk-up query. Exact typing of parent (string name versus reference) is a Phase 1 detail recorded under open questions.
Grants over several references
allow and deny accept an array of references so a role does not repeat a condition per action:
allow([permissions.post.read, permissions.post.list, permissions.comment.read])
allow(listPermissions(permissions.post)) // everything on one resource
allow([permissions.post.update, permissions.post.delete], { where: { authorId: subject.id } })Each reference becomes its own grant in the normalised policy, the catalog and the snapshot; the array is declaration sugar only, and permdock collect sees the individual leaves. Strings never appear; listPermissions returns references.
Evaluation
The policy rules gain one step between collecting grants and applying deny-overrides:
- Collect candidate grants: every grant of every global role in
principal.roles, plus every grant of every scoped role held by a membership, plus the grants that custom roles resolve to (below). - Scope match. Drop a grant whose role is scoped unless the membership's scope matches the request: the row's tenant key equals the membership
tenantand the tenant is active; the row's team key equals the membershipteam; the row is the membership's resource or a descendant through declared parents. Expired memberships (expiresAtin the past) contribute nothing. For collection actions, the active tenant must be one of the membership tenants holding the role. - Any matching
denywins. Deny overrides allow across scopes: a globaldenybeats a tenantallow; a tenantdenybeats a teamallow. - Any matching
allowgrants (orapproval-required). Allows OR together across scopes. - Nothing matched:
denied. Reasons gaintenant-mismatch(the row belongs to another tenant),no-membership(the active tenant is not one of the subject's),scope(the role is held but not for this row) andexpired-membership. - Intersect with delegation, unchanged.
can() still never throws and a subject with memberships and no active tenant is simply denied everything tenant-scoped. simulate accepts { roles, memberships, tenant } so a test or a "view as" screen can ask "what would a Globex viewer see" without a real membership (UI).
Tenant-defined custom roles
A tenant admin wants a "Billing Manager" role. The policy did not declare it and must not have to. A custom role is data:
type CustomRole = {
tenant: string
name: string // 'billing-manager'; unique within the tenant
includes: string[] // declared roles marked assignable: ['billing-viewer', 'invoice-payer']
meta?: Record<string, unknown>
}- At evaluation time a membership role name that no
role()declared is looked up through theRoleSourcefor that tenant and replaced by the declared roles itincludes. Names that resolve to nothing are dropped (fewer grants, never more). A custom role therefore can never exceed the union of the tenant's assignable roles, and it can never carry a condition of its own; conditions live on declared grants that were reviewed in code. - Declared roles are the vocabulary. Small, composable,
assignableroles (billing-viewer,invoice-payer,member-inviter) make good building blocks; a monolithicadminmakes a poor one.permdock cataloglists assignable roles with theirmeta.titleand the permissions each contains, which is what a role editor renders. RoleSource.assignable(tenant)may return a subset of the declared assignable roles: a Starter plan withoutbilling-manager, a regulated tenant withoutdata-exporter. This is Clerk's role-set idea and the entitlements-are-roles rule applied to assignability; the plan is an input, PermDock never reads billing.- Who may hand out which role is itself a permission (
permissions.member.assignRole) plus the rule that you cannot hand out what you do not hold:permdock.assignable()returns the intersection ofRoleSource.assignable(activeTenant)and the roles the current subject holds there, and the app's server action checks both before writing.
Interfaces
interface RoleSource {
rolesFor(tenant: string): CustomRole[] | Promise<CustomRole[]>
assignable?(tenant: string): string[] | Promise<string[]> // defaults to every declared assignable role
}
interface MembershipSource {
membershipsFor(principal: { id: string; kind?: string }, options: { tenant?: string }): Membership[] | Promise<Membership[]>
}Both are subject inputs, the same trust class as the policy's subject and context functions: they run once per createPermDock, their results are frozen into the subject, and they are the only two interfaces on the extension interfaces page that may influence an outcome. Neither is a store: PermDock never writes a membership or a custom role. memoryRoleSource(customRoles) and the default membership source (whatever subject and context returned) ship in the package. permdock/cloud does not implement either, so the Cloud never joins the decision path (invariant 15). Providers in Phase 4: Better Auth organizationRole (dynamicAccessControl), a Supabase role_permissions table, WorkOS organization roles, Clerk custom roles and role sets, Auth0 Organization Roles, your own table; each is a RoleSource whose conformance the testing runners check.
Every adapter's createPermDock accepts memberships (a MembershipSource) and customRoles (a RoleSource) next to store, sink and snapshots; core takes createPermDock(policy, user, { tenant, memberships, customRoles, actor, delegation }).
Where memberships come from
| Source | Tenant and active tenant | Roles per tenant | Teams | Notes |
|---|---|---|---|---|
| Clerk | Organizations; the session's active organization | org_role (and org_permissions) mapped to declared roles | None | One membership for the active organization from the session; all organizations through the Backend API when memberships: 'all' |
| Better Auth | organization plugin; activeOrganizationId | member.role; organizationRole rows resolve through RoleSource | teamMember rows become team memberships | subjectFromBetterAuth is async because it reads the member and team rows |
| Supabase | A hook-injected tenant_id claim; the active tenant from the URL compared against the claim | A hook-injected role claim, or a user_roles table read in context | Your tables | Never user_metadata; RLS reads the same claim |
| JWT issuers | claims.tenant (org_id, tid, hd, ...) | RFC 9068 roles; per-tenant objects such as Descope tenants.<id>.roles through a claim path | RFC 9068 groups become { team: value } memberships | Keyed on SCIM value, never display |
| Your own tables | Whatever you store | context or a MembershipSource | Same | The common case for resource roles (document_members) |
The authentication page carries the full provider table; each provider page has a "Memberships" section.
Instance methods for tenancy
The instance stays frozen and request-scoped; tenancy adds derived instances and read-only introspection:
await permdock.tenant('o_globex').assert(permissions.post.update, post) // a derived instance with another active tenant
permdock.team('t_design').can(permissions.post.publish) // scope a team for collection actions
permdock.memberships() // Membership[] for org lists and role chips
permdock.tenants() // string[] tenants the subject belongs to
permdock.roles({ tenant: 'o_acme' }) // string[] roles held there, custom roles resolved
permdock.assignable() // string[] roles the subject may hand out in the active tenanttenant() and team() return new frozen instances (invariant 7); they never mutate the original and they re-run nothing, because memberships were loaded once. Switching a tenant the subject does not belong to yields an instance where every tenant-scoped check is denied with no-membership. On the client the same methods exist on the snapshot-backed instance and back useTenant, useMemberships, useRoles and useAssignableRoles (UI).
Portable compilation
Scope matching is data, so it compiles like any other condition (ADR 0010, invariant 6). One node is added to the condition AST:
{ "op": "memberOf", "scope": "tenant", "field": "orgId", "roles": ["admin", "viewer"] }
{ "op": "memberOf", "scope": "team", "field": "teamId", "roles": ["lead"] }
{ "op": "memberOf", "scope": "resource", "resource": "folder", "field": "folderId", "roles": ["editor"], "parents": ["projectId"] }| Target | Compilation |
|---|---|
In memory, filter, snapshot | The row's scope field is compared against the subject's memberships holding one of roles; expired memberships excluded |
Drizzle, Prisma, Kysely toWhere | Active-tenant-only: eq(orgId, <active tenant>); otherwise inArray(orgId, [...tenants holding the role]); team and resource scopes as inArray over the ids the subject holds, ancestors through the declared parent field |
RLS supabase | Tenant: org_id = ((select auth.jwt()) ->> 'tenant_id')::uuid when the active tenant is a claim; otherwise exists (select 1 from <membership table> m where m.tenant_id = org_id and m.user_id = (select auth.uid()) and m.role = any(...)) |
RLS neon, guc | Same shape over auth.session() or current_setting('app.tenant_id', true); Nile's tenant session variable is the guc dialect with a fixed name |
| RLS, team and resource | exists (select 1 from <membership table> ...); parent derivation joins through the declared field, one join per hop |
memberOf resolves the RLS adapter open question on membership nodes: the AST gets the dedicated node and the membership table name is a per-scope mapping on the RLS adapter (tables: { tenant: 'org_member', team: 'team_member', resource: 'document_member' }). permdock rls import recognises both IN (select ...) and EXISTS (select 1 ...) forms as memberOf when the subquery matches a declared mapping. Custom roles compile through the same node after resolution: the declared role names are what reach SQL, never the tenant-defined name, so RLS stays a closed vocabulary.
Snapshots, decisions and the catalog
- Snapshot v2 adds
subject.memberships,subject.tenantand, per grant,scope('tenant' | 'team' | { resource }) and the membership it came from. A snapshot is scoped to the active tenant by default, so a member of ten organisations ships the grants of one;snapshot({ tenants: 'all' })opts into every membership for a local tenant switcher.simulated: truemarks a snapshot produced bysimulateso the decision endpoint refuses to act on its tokens (snapshots, wire formats). - Decision events gain
tenant,membership(the matching entry) andvia, so a sink can answer "which team grant let this happen" and a tenant-scoped audit log is a filter ontenant(audit). - Catalog entries gain
scopeandassignable, and the catalog lists the scope kinds a policy uses.permdock usagereports a scoped role that no membership source could ever fill (a team role with no team source configured). - AuthZEN: memberships travel under
subject.properties.memberships, the active tenant undercontext.tenant; theauthzenadapter and the hosted ADS publish per-tenant metadata at/.well-known/authzen-configuration/<tenant>(AuthZEN).
Validation and trust classes
| Data | Class | Validation |
|---|---|---|
Memberships and custom roles returned by a provider, subject, context, MembershipSource or RoleSource | Trusted server data, like a database row | None; malformed entries are dropped and reported by permdock doctor |
| A membership or custom-role edit arriving from a form, an API body or a model | Boundary data | Your validator, against the Standard JSON Schema PermDock publishes for Membership and CustomRole; policy.assignable is a readonly tuple type so z.enum(policy.assignable) types the includes array |
| The requested active tenant (URL segment, header) | Boundary data | Resolved server-side to a membership before it becomes principal.tenant; an unmatched value is no tenant |
| Custom claims on a provider token | Verified but shaped by the issuer | The provider's schema option validates and types them (extension interfaces) |
Adjacent SaaS features
| Feature | Where it lives | Recipe |
|---|---|---|
| Invitations, default role per tenant, role priority, seat counting, tenant creation | Upstream: the auth provider or your tables | PermDock reads the resulting membership |
| SSO and SCIM group-to-role | Upstream: IdP, auth layer or Directory Sync | Group ids arrive as groups claims or synced rows and become team memberships (authentication) |
| Impersonation and support access | Recipe | The customer is the principal, the support engineer the actor with a time-bound delegation naming the allowed scopes; every decision records both; the UI shows a banner from useSubject() |
| Tenant-scoped service accounts and API keys | Recipe | A kind: 'service' principal with one tenant membership; the key store is yours (subject service principals) |
| Platform super-admin | Recipe | A global role; document the blast radius, pair it with deny rows for destructive actions and approval: 'human' for the rest |
| Time-bound and just-in-time access | In scope | Membership.expiresAt; break-glass is approval: 'human' on the elevated role |
| Tenant-scoped audit | In scope | DecisionSink events carry tenant; filter by it |
| Plan-gated roles | In scope | RoleSource.assignable(tenant); the plan is an input, the roles are declared |
| Cross-tenant guests | Open | A membership in a tenant the principal's home is not; today it is just another membership; whether snapshots and RLS need a "home tenant" is an open question |
| Nested groups, arbitrary-depth trees, cross-tenant graphs | Not in core | permdock/pdp to OpenFGA or SpiceDB |
| Role-editing UI | Recipe | permdock catalog plus useAssignableRoles (UI); a hosted editor is a Cloud candidate |
Vocabulary for reviewers
For teams that audit against INCITS 359-2012 (the NIST RBAC standard): a request-scoped PermDock with an active tenant is a session with an activated role set; grant spread (...viewer.grants) is a limited role hierarchy (no general hierarchy is offered on purpose); memberships are user assignments with a scope; custom roles are administrator-composed roles constrained to permission assignments the code declared; static and dynamic separation of duty are not implemented (see open questions). On the token side, roles and groups follow RFC 9068 and SCIM (JWT authorization claims).
Open questions
- Exact typing of
parentonresource()(string name as above versus a reference), and whether two parents (a document in a folder and in a project) are allowed. - Cross-tenant guests: whether a principal has a home tenant that snapshots and RLS should know about.
exclusiveWithas a doctor lint (static separation of duty) and whether a dynamic variant (two roles not active in one session) is worth a runtime rule.- Whether
delegationwithout anactoris the right model for an API key attenuated below its owner's memberships. - The membership table mapping for RLS when a project has one table per scope versus one polymorphic table.
Subject
A subject is a principal, an optional actor and the authority delegated between them; a decision is principal grants intersected with delegation.
Authentication and PermDock
PermDock never authenticates: it consumes material something else has already verified, turns it into a subject, and decides. This page defines what counts as verified, which claims may feed grants, and how tokens map to principal, actor and delegation.