PermDock
Concepts

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
}
  • roles on the principal keeps meaning global roles, so every existing sample and every single-tenant app is unchanged.
  • A membership with only tenant is a tenant role; with tenant and team a team role; with on a resource role. Exactly one of the three shapes applies per entry; a membership with none is ignored (fail-closed, reported by permdock doctor).
  • tenant on 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's subject or context function, or a MembershipSource. 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"
})
onThe role applies whenThe grant's row must
omittedThe name is in principal.rolesNothing extra: global
'tenant'A membership with that tenant holds the role and the tenant is the active oneHave scopes.tenant.key equal to the membership's tenant
'team'A membership with that team holds the role, inside the active tenantHave scopes.team.key equal to the membership's team
a resource referenceA membership on that resource holds the roleBe that row (id equal), or a descendant through a declared parent

Two more options:

  • assignable (default true for scoped roles, false for global roles) marks a role a tenant admin may hand out and compose into custom roles. owner above is held but never handed out.
  • exclusiveWith: ['approver'] is recorded as an open question (INCITS 359 separation of duty); it would be a permdock doctor lint 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:

  1. 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).
  2. 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 tenant and the tenant is active; the row's team key equals the membership team; the row is the membership's resource or a descendant through declared parents. Expired memberships (expiresAt in the past) contribute nothing. For collection actions, the active tenant must be one of the membership tenants holding the role.
  3. Any matching deny wins. Deny overrides allow across scopes: a global deny beats a tenant allow; a tenant deny beats a team allow.
  4. Any matching allow grants (or approval-required). Allows OR together across scopes.
  5. Nothing matched: denied. Reasons gain tenant-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) and expired-membership.
  6. Intersect with delegation, unchanged.
subjectFrom* / subject() / context() principal.roles + memberships RoleSource: custom roles -> assignable declared roles collect candidate grants active tenant (request) scope match: tenant / team / resource + parents any deny -> denied any allow -> granted / approval-required 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 the RoleSource for that tenant and replaced by the declared roles it includes. 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, assignable roles (billing-viewer, invoice-payer, member-inviter) make good building blocks; a monolithic admin makes a poor one. permdock catalog lists assignable roles with their meta.title and 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 without billing-manager, a regulated tenant without data-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 of RoleSource.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

SourceTenant and active tenantRoles per tenantTeamsNotes
ClerkOrganizations; the session's active organizationorg_role (and org_permissions) mapped to declared rolesNoneOne membership for the active organization from the session; all organizations through the Backend API when memberships: 'all'
Better Authorganization plugin; activeOrganizationIdmember.role; organizationRole rows resolve through RoleSourceteamMember rows become team membershipssubjectFromBetterAuth is async because it reads the member and team rows
SupabaseA hook-injected tenant_id claim; the active tenant from the URL compared against the claimA hook-injected role claim, or a user_roles table read in contextYour tablesNever user_metadata; RLS reads the same claim
JWT issuersclaims.tenant (org_id, tid, hd, ...)RFC 9068 roles; per-tenant objects such as Descope tenants.<id>.roles through a claim pathRFC 9068 groups become { team: value } membershipsKeyed on SCIM value, never display
Your own tablesWhatever you storecontext or a MembershipSourceSameThe 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 tenant

tenant() 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"] }
TargetCompilation
In memory, filter, snapshotThe row's scope field is compared against the subject's memberships holding one of roles; expired memberships excluded
Drizzle, Prisma, Kysely toWhereActive-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 supabaseTenant: 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, gucSame 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 resourceexists (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.tenant and, 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: true marks a snapshot produced by simulate so the decision endpoint refuses to act on its tokens (snapshots, wire formats).
  • Decision events gain tenant, membership (the matching entry) and via, so a sink can answer "which team grant let this happen" and a tenant-scoped audit log is a filter on tenant (audit).
  • Catalog entries gain scope and assignable, and the catalog lists the scope kinds a policy uses. permdock usage reports 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 under context.tenant; the authzen adapter and the hosted ADS publish per-tenant metadata at /.well-known/authzen-configuration/<tenant> (AuthZEN).

Validation and trust classes

DataClassValidation
Memberships and custom roles returned by a provider, subject, context, MembershipSource or RoleSourceTrusted server data, like a database rowNone; malformed entries are dropped and reported by permdock doctor
A membership or custom-role edit arriving from a form, an API body or a modelBoundary dataYour 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 dataResolved server-side to a membership before it becomes principal.tenant; an unmatched value is no tenant
Custom claims on a provider tokenVerified but shaped by the issuerThe provider's schema option validates and types them (extension interfaces)

Adjacent SaaS features

FeatureWhere it livesRecipe
Invitations, default role per tenant, role priority, seat counting, tenant creationUpstream: the auth provider or your tablesPermDock reads the resulting membership
SSO and SCIM group-to-roleUpstream: IdP, auth layer or Directory SyncGroup ids arrive as groups claims or synced rows and become team memberships (authentication)
Impersonation and support accessRecipeThe 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 keysRecipeA kind: 'service' principal with one tenant membership; the key store is yours (subject service principals)
Platform super-adminRecipeA 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 accessIn scopeMembership.expiresAt; break-glass is approval: 'human' on the elevated role
Tenant-scoped auditIn scopeDecisionSink events carry tenant; filter by it
Plan-gated rolesIn scopeRoleSource.assignable(tenant); the plan is an input, the roles are declared
Cross-tenant guestsOpenA 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 graphsNot in corepermdock/pdp to OpenFGA or SpiceDB
Role-editing UIRecipepermdock 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 parent on resource() (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.
  • exclusiveWith as 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 delegation without an actor is 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.

On this page