PermDock
Research

SaaS tenancy and roles, September 2026

How authorization engines, auth providers and entitlement platforms model tenants, teams, scoped roles, tenant-defined custom roles and the SaaS features around them; which standards apply (RFC 9068 claims, SCIM groups, AuthZEN, INCITS 359); and what PermDock adopts, adapts and leaves upstream.

Source: a survey run in September 2026 asking how PermDock should support the role structures real multi-tenant SaaS applications need (roles held in a tenant, roles held by a team and inherited by its members, roles on one document, roles a tenant admin defines at runtime) without taking over authentication, membership storage or invitations from the auth provider. Sources were vendor documentation, SDK references, modelling guides and the standards named in the last section, read in September 2026; product names and tiers drift, the modelling patterns are the finding. The survey produced ADR 0024, the tenancy concept page, the JWT authorization claims standards page, the extension interfaces page and the UI page.

1. The problem as SaaS teams state it

Every B2B application ends up with the same four questions, usually in this order:

  1. "Alice is an admin of Acme but only a viewer of Globex." A role held in a tenant, not globally.
  2. "The design team can edit every file in the design folder." A role held by a team and inherited by its members.
  3. "Bob shared this one document with Carol as an editor." A role held on a resource, sometimes flowing to children.
  4. "Acme wants a 'Billing Manager' role we never defined." A tenant-defined role composed from capabilities the code knows about.

Today PermDock answers the first with a convention (orgId on the principal, where: { orgId: subject.orgId } on every grant), the second with context.teamIds, the third with "use OpenFGA through permdock/pdp" and the fourth not at all (policies, subject). The convention works and is repetitive; the repetition is where tenant-isolation bugs come from (one grant forgets the where, the threat-model row on cross-tenant reads is the result).

2. What authorization engines do

EngineTenant rolesTeam rolesResource roles and derivationCustom rolesNotes
Permit.ioTenants are first-class; a user has roles per tenantThrough ReBAC relationshipsResource roles (Folder#editor) with role derivation along a declared parent relation (Folder#editor grants File#editor)Roles created per environment through the API or UI, bound to declared resources and actionsShips role-management UI components ("Permit Elements")
CerbosScoped policies: a policy carries a hierarchical scope such as acme.hr, and a tenant's scope overrides the parent'sAttributes on the principal; no group objectDerived roles computed from principal and resource attributes (owner if resource.ownerId == principal.id)Scoped policies are how a tenant gets different rules; roles themselves stay in the principalClosest to PermDock's "policy is data" stance; decision engine stays stateless
Oso Cloud (Polar)has_role(user, "admin", org) factshas_role(team, "editor", folder) plus has_group(user, team)has_relation(file, "parent", folder) and role if role on "parent"grants_permission(role, permission) facts: a tenant-defined role is a set of facts over declared permissionsFacts are the data plane, Polar the code plane; same split PermDock wants
OpenFGA / SpiceDB (Zanzibar)organization#member, organization#admin relationsteam#member as a userset that other relations accept (viewer: [user, team#member])viewer from parent on a parent relation; arbitrary depthA role type whose assignee relation is referenced by per-permission relationsHandles nested groups and deep trees; the bridge PermDock keeps for that case
Amazon Verified PermissionsOne policy store per tenant, or one store with the tenant as an entity attributeGroup entities as principalsCedar when conditions over entity attributes; hierarchy through parent entitiesTemplates instantiated per tenantCedar's deny-overrides-permit matches PermDock's rule
Keycloak / Zitadel / LogtoRealms or organisations; roles granted per organisation (Zitadel project grants, Logto organization roles)Groups with role mappingsNone (attribute-based only)Admin-defined roles per realm or organisation, surfaced in the tokenZitadel's role claim carries the organisation id next to each role; Logto issues a separate organization token

Shared pattern: a role is a relation between a subject and a scope, never a bare string; the scope is a tenant, a team or a resource; tenant-defined roles are always composed from a vocabulary the code declared; and parent derivation is declared explicitly, one hop at a time.

3. What auth providers do

ProviderTenant object and active-tenant signalRoles per tenantTeamsCustom rolesWhere PermDock reads it
ClerkOrganizations; the session names one active organization (org_id, org_role, org_permissions; compact o claim in v2 tokens)org: prefixed roles and permissions per organizationNo team objectCustom roles and permissions in the dashboard (B2B add-on); "role sets" limit which roles an organization or plan may useClerk provider: active organization, org_role, fea
Auth0Organizations; org_id and org_name in the tokenOrganization Roles: a role assigned to a member inside one organization; Core RBAC roles are globalNo team objectRoles and permissions defined per API in the dashboardsubjectFromJwt with claims.tenant: 'org_id'; organization roles through the Management API or a post-login Action that writes a claim
WorkOSOrganizations; org_id in the AuthKit tokenEnvironment roles plus organization-level custom roles with a role slug and a permissions claim; a role priority order and a default roleNo team object; Directory Sync groups map to rolesOrganization-level roles created in the dashboard or APIsubjectFromJwt with claims: { tenant: 'org_id', roles: 'role' }
Better Authorganization plugin; activeOrganizationId on the sessionmember.role, several roles per memberteams inside an organization, teamMember rowsdynamicAccessControl: organization-defined roles stored in an organizationRole table, matched against the createAccessControl statementBetter Auth provider
SupabaseNone built in; tenant id is a column and, through a custom access token hook, a claimA user_roles table and an authorize() SQL function in the official RBAC guide; roles as a hook-injected claimYour tablesYour role_permissions tableSupabase provider: hook claims, never user_metadata
Stytch B2BOrganizations; the member session names oneRBAC roles per organization, default stytch_member and stytch_admin; implicit assignment by email domain or SSO groupNo team objectRoles and permissions per project, assignable per organizationsubjectFromJwt
KindeOrganizations; org_code claimRoles and permissions assigned per organization; permissions claimNo team objectRoles in the dashboardsubjectFromJwt, permissions under the entitlements-are-roles rule
DescopeTenants; the token carries a tenants object keyed by tenant idtenants.<id>.roles per tenantNo team objectRoles per project; SSO group-to-role mapping per tenantsubjectFromJwt with a per-tenant claim path
Frontegg, PropelAuthAccounts or organizations, sub-accounts (Frontegg hierarchical accounts)Roles per account; PropelAuth role hierarchies and, recently, per-organization custom rolesNo team object (Frontegg sub-accounts approximate it)DashboardsubjectFromJwt
Okta, Microsoft Entra ID, Google WorkspaceTenant is the issuer or tid / hdApp roles (roles) or directory groups (groups)Directory groups, nested at the directory levelAdmin-defined app rolesauthentication single sign-on

Shared pattern: providers own the tenant object, the membership rows, invitations, the active-tenant selection and the group-to-role mapping, and expose the result as claims or a session. None of them evaluates a condition on a row, which is where PermDock starts.

4. What entitlement and tenant-infrastructure platforms do

  • Stigg and Schematic model plans, features and per-customer overrides and answer isEntitled(feature) from an SDK, with Stripe as the billing source of truth. Neither expresses roles; both are a source of "this tenant may use this capability". PermDock already treats entitlements as roles selected in subject (policies); the new piece is that plan gating also decides which roles a tenant may assign (a Starter plan without a "Billing Manager" role), which is exactly Clerk's role-set idea.
  • Nile is a Postgres built for multi-tenant SaaS: a tenants table, a tenant_id column on tenant-aware tables and a session variable the database uses to isolate rows. It is a where target and an RLS target like any Postgres, with the useful property that the active tenant already has a database-side representation PermDock's RLS output can reference.

5. Adjacent features every SaaS eventually asks for

FeatureWho owns it in the surveyed productsPermDock stance
Invitations, default role per tenant, role priority, seat counting, tenant creationAuth provider or the app's tablesUpstream; PermDock reads the resulting membership
SSO and SCIM group-to-roleIdP, auth layer or Directory SyncUpstream; group ids arrive as team memberships or roles (JWT authorization claims)
Plan-gated rolesClerk role sets, Frontegg, Stigg, SchematicRoleSource.assignable(tenant) answers it; the plan is an input
Impersonation and support accessProvider "impersonate user" features, app-builtRecipe: customer is the principal, support user is the actor with a time-bound delegation, audited
Tenant-scoped service accounts and API keysClerk M2M and API keys, WorkOS, Unkey-style key storesRecipe: a service principal with a tenant membership
Platform super-adminEvery product, rarely documentedA global role; document the blast radius and pair it with deny rows
Time-bound and just-in-time accessEnterprise IdPs, PAM productsMembership.expiresAt; break-glass is approval: 'human' on the elevated role
Tenant-scoped auditEvery provider's audit log; WorkOS Audit LogsDecisionSink events carry tenant and via
Nested groups, arbitrary-depth folder trees, cross-tenant graphsZanzibar enginespermdock/pdp bridge; not core
Role-editing UIPermit Elements, provider dashboardsRecipe over permdock catalog and useAssignableRoles (UI); a hosted editor is a PermDock Cloud candidate

6. Standards that apply

  • RFC 9068 section 2.2.3.1 registers the JWT claims roles, groups and entitlements, with the value encoding of the SCIM User schema (RFC 7643 section 4.1.2): multi-valued complex attributes whose value sub-attribute is the identifier, optionally with display, type, primary and $ref. Many issuers emit plain string arrays instead. This is the standard home for role and group material on an access token, and PermDock's default claim mapping follows it (JWT authorization claims).
  • draft-gazitt-oauth-authzen-claims binds those three claims to AuthZEN Resource Search so an authorization server can obtain them from a PDP rather than a directory. It makes an AuthZEN endpoint, including PermDock's, a legitimate claim source. Tracked on the watch list.
  • OpenID AuthZEN 1.0 (final, January 2026) shapes a subject as type, id and properties; group memberships are named as an example property, and multi-tenant PDPs publish per-tenant metadata at /.well-known/authzen-configuration/<tenant> (AuthZEN).
  • SCIM 2.0 (RFC 7643, RFC 7644): a Group has displayName and members[].value; there is no tenant attribute (tenancy is per endpoint or per token); nested groups are provider-specific (Entra does not expand them in members.value filters). The IPSIE AL1 SCIM profile is a draft (watch list).
  • INCITS 359-2012 (NIST RBAC) gives the vocabulary reviewers know: Core RBAC (users, roles, permissions, sessions with an activated role set), Hierarchical RBAC (role inheritance as a partial order), Constrained RBAC (static and dynamic separation of duty). PermDock's request-scoped instance with an active tenant is a session; grant spread is a limited hierarchy; separation of duty is not built.
  • No standard tenant claim exists. Entra uses tid, Auth0, WorkOS and Clerk org_id, Google hd, Supabase deployments conventionally tenant_id, Descope a tenants object, Zitadel an organisation id nested under each role. The mapping stays configuration (claims.tenant) and the threat-model rule stays: an absent tenant claim means no tenant, never a default.
  • Zanzibar tuples (type:id#relation@user) are the interchange for the pdp bridge; a resource membership maps to one tuple (pdp adapter).

7. Developer experience across the survey

  • Fluent builders (CASL's AbilityBuilder, the accesscontrol package's grant().createOwn()) read well in a tutorial and poorly in review: the resulting rules are not diffable data, cannot be statically collected, and inference across long chains degrades hover types. Every engine in section 2 that survived at scale represents policy as data (Polar facts, OpenFGA models, Cedar policies, Cerbos YAML).
  • The repetitive part of tenant policies is not the declaration syntax, it is the tenant condition repeated on every grant. Scoping the role removes the repetition without a builder.
  • Typed extension of provider shapes is handled three ways in the TypeScript ecosystem: global module augmentation (Hono's ContextVariableMap, next-intl's AppConfig), an inference accessor (Better Auth's $Infer), or generics on the factory (Hono's Env). Hono's own documentation now discourages the global form because it types values that may not exist at runtime; Better Auth's issue tracker shows $Infer cannot see configuration TypeScript cannot link statically. Generics filled from a Standard Schema give validation and types in one step (extension interfaces).
  • Every provider ships a "current organization" hook or store for the client (Clerk useOrganization, Better Auth useActiveOrganization, WorkOS AuthKit organizationId) and none ships a permission-aware one; the UI still hand-rolls "show this button if the active org role is admin" (UI).

Adopt / adapt / avoid

Adopt:

  • Roles as relations between a subject and a scope (Permit, Oso, OpenFGA): a Membership that names a tenant, a team or a resource and the roles held there, with plain global roles kept as sugar.
  • Scoping declared on the role, not on every grant (Cerbos scoped policies, Oso has_role on a resource): role(name, grants, { on: 'tenant' | 'team' | resource }).
  • Custom roles as data composed from a code-declared vocabulary (Oso grants_permission, OpenFGA role type, Better Auth dynamicAccessControl): a tenant can compose, never widen.
  • One-hop declared parent derivation (Permit role derivation, Oso role on "parent"), typed so the chain is finite.
  • RFC 9068 roles, groups, entitlements as the default claim mapping, keyed on SCIM value.
  • Role sets (Clerk) as RoleSource.assignable(tenant), so plan gating and assignability are one question.
  • Providers as the owners of tenant objects, memberships, invitations and active-tenant selection (ADR 0018); PermDock reads the result through subjectFrom*.
  • AuthZEN properties for memberships and context for the active tenant; per-tenant well-known metadata for the hosted ADS.

Adapt:

  • Better Auth's "cannot grant a role you do not hold" rule becomes permdock.assignable() on the instance and useAssignableRoles on the client.
  • Nile's session-variable tenant becomes one more RLS compilation target for the active tenant, next to auth.jwt() claims.
  • Zanzibar tuples as the pdp interchange for resource memberships; the object form stays on PermDock's wire.
  • INCITS 359 vocabulary in the docs (session, activated roles, limited hierarchy) so auditors can map the model; separation of duty recorded as a possible lint, not a feature.
  • Provider "current organization" hooks are consumed by the tenant-switcher recipe; PermDock adds the permission-aware half (useTenant, useMemberships).

Avoid:

  • A fluent policy builder or can as a definer (ADR 0005, ADR 0010).
  • Storing memberships, invitations or tenant objects in PermDock, or letting permdock/cloud implement a membership source (a hosted store would sit in the decision path, against invariant 15).
  • Nested groups, arbitrary-depth trees and cross-tenant graphs in the in-memory evaluator; the pdp bridge exists for them.
  • A default tenant when the claim is absent, display names as group identifiers, or memberships from a model argument, an unsigned header or a CLI flag.
  • Global module augmentation or a $Infer accessor for provider principal types; generics filled from a Standard Schema instead.

Decisions informed

On this page