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:
- "Alice is an admin of Acme but only a viewer of Globex." A role held in a tenant, not globally.
- "The design team can edit every file in the design folder." A role held by a team and inherited by its members.
- "Bob shared this one document with Carol as an editor." A role held on a resource, sometimes flowing to children.
- "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
| Engine | Tenant roles | Team roles | Resource roles and derivation | Custom roles | Notes |
|---|---|---|---|---|---|
| Permit.io | Tenants are first-class; a user has roles per tenant | Through ReBAC relationships | Resource 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 actions | Ships role-management UI components ("Permit Elements") |
| Cerbos | Scoped policies: a policy carries a hierarchical scope such as acme.hr, and a tenant's scope overrides the parent's | Attributes on the principal; no group object | Derived 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 principal | Closest to PermDock's "policy is data" stance; decision engine stays stateless |
| Oso Cloud (Polar) | has_role(user, "admin", org) facts | has_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 permissions | Facts are the data plane, Polar the code plane; same split PermDock wants |
| OpenFGA / SpiceDB (Zanzibar) | organization#member, organization#admin relations | team#member as a userset that other relations accept (viewer: [user, team#member]) | viewer from parent on a parent relation; arbitrary depth | A role type whose assignee relation is referenced by per-permission relations | Handles nested groups and deep trees; the bridge PermDock keeps for that case |
| Amazon Verified Permissions | One policy store per tenant, or one store with the tenant as an entity attribute | Group entities as principals | Cedar when conditions over entity attributes; hierarchy through parent entities | Templates instantiated per tenant | Cedar's deny-overrides-permit matches PermDock's rule |
| Keycloak / Zitadel / Logto | Realms or organisations; roles granted per organisation (Zitadel project grants, Logto organization roles) | Groups with role mappings | None (attribute-based only) | Admin-defined roles per realm or organisation, surfaced in the token | Zitadel'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
| Provider | Tenant object and active-tenant signal | Roles per tenant | Teams | Custom roles | Where PermDock reads it |
|---|---|---|---|---|---|
| Clerk | Organizations; the session names one active organization (org_id, org_role, org_permissions; compact o claim in v2 tokens) | org: prefixed roles and permissions per organization | No team object | Custom roles and permissions in the dashboard (B2B add-on); "role sets" limit which roles an organization or plan may use | Clerk provider: active organization, org_role, fea |
| Auth0 | Organizations; org_id and org_name in the token | Organization Roles: a role assigned to a member inside one organization; Core RBAC roles are global | No team object | Roles and permissions defined per API in the dashboard | subjectFromJwt with claims.tenant: 'org_id'; organization roles through the Management API or a post-login Action that writes a claim |
| WorkOS | Organizations; org_id in the AuthKit token | Environment roles plus organization-level custom roles with a role slug and a permissions claim; a role priority order and a default role | No team object; Directory Sync groups map to roles | Organization-level roles created in the dashboard or API | subjectFromJwt with claims: { tenant: 'org_id', roles: 'role' } |
| Better Auth | organization plugin; activeOrganizationId on the session | member.role, several roles per member | teams inside an organization, teamMember rows | dynamicAccessControl: organization-defined roles stored in an organizationRole table, matched against the createAccessControl statement | Better Auth provider |
| Supabase | None built in; tenant id is a column and, through a custom access token hook, a claim | A user_roles table and an authorize() SQL function in the official RBAC guide; roles as a hook-injected claim | Your tables | Your role_permissions table | Supabase provider: hook claims, never user_metadata |
| Stytch B2B | Organizations; the member session names one | RBAC roles per organization, default stytch_member and stytch_admin; implicit assignment by email domain or SSO group | No team object | Roles and permissions per project, assignable per organization | subjectFromJwt |
| Kinde | Organizations; org_code claim | Roles and permissions assigned per organization; permissions claim | No team object | Roles in the dashboard | subjectFromJwt, permissions under the entitlements-are-roles rule |
| Descope | Tenants; the token carries a tenants object keyed by tenant id | tenants.<id>.roles per tenant | No team object | Roles per project; SSO group-to-role mapping per tenant | subjectFromJwt with a per-tenant claim path |
| Frontegg, PropelAuth | Accounts or organizations, sub-accounts (Frontegg hierarchical accounts) | Roles per account; PropelAuth role hierarchies and, recently, per-organization custom roles | No team object (Frontegg sub-accounts approximate it) | Dashboard | subjectFromJwt |
| Okta, Microsoft Entra ID, Google Workspace | Tenant is the issuer or tid / hd | App roles (roles) or directory groups (groups) | Directory groups, nested at the directory level | Admin-defined app roles | authentication 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 insubject(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
tenantstable, atenant_idcolumn on tenant-aware tables and a session variable the database uses to isolate rows. It is awheretarget 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
| Feature | Who owns it in the surveyed products | PermDock stance |
|---|---|---|
| Invitations, default role per tenant, role priority, seat counting, tenant creation | Auth provider or the app's tables | Upstream; PermDock reads the resulting membership |
| SSO and SCIM group-to-role | IdP, auth layer or Directory Sync | Upstream; group ids arrive as team memberships or roles (JWT authorization claims) |
| Plan-gated roles | Clerk role sets, Frontegg, Stigg, Schematic | RoleSource.assignable(tenant) answers it; the plan is an input |
| Impersonation and support access | Provider "impersonate user" features, app-built | Recipe: customer is the principal, support user is the actor with a time-bound delegation, audited |
| Tenant-scoped service accounts and API keys | Clerk M2M and API keys, WorkOS, Unkey-style key stores | Recipe: a service principal with a tenant membership |
| Platform super-admin | Every product, rarely documented | A global role; document the blast radius and pair it with deny rows |
| Time-bound and just-in-time access | Enterprise IdPs, PAM products | Membership.expiresAt; break-glass is approval: 'human' on the elevated role |
| Tenant-scoped audit | Every provider's audit log; WorkOS Audit Logs | DecisionSink events carry tenant and via |
| Nested groups, arbitrary-depth folder trees, cross-tenant graphs | Zanzibar engines | permdock/pdp bridge; not core |
| Role-editing UI | Permit Elements, provider dashboards | Recipe 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,groupsandentitlements, with the value encoding of the SCIM User schema (RFC 7643 section 4.1.2): multi-valued complex attributes whosevaluesub-attribute is the identifier, optionally withdisplay,type,primaryand$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,idandproperties; 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
GrouphasdisplayNameandmembers[].value; there is no tenant attribute (tenancy is per endpoint or per token); nested groups are provider-specific (Entra does not expand them inmembers.valuefilters). 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 Clerkorg_id, Googlehd, Supabase deployments conventionallytenant_id, Descope atenantsobject, 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 thepdpbridge; a resource membership maps to one tuple (pdp adapter).
7. Developer experience across the survey
- Fluent builders (CASL's
AbilityBuilder, theaccesscontrolpackage'sgrant().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'sAppConfig), an inference accessor (Better Auth's$Infer), or generics on the factory (Hono'sEnv). 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$Infercannot 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 AuthuseActiveOrganization, WorkOS AuthKitorganizationId) 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
Membershipthat names a tenant, a team or a resource and the roles held there, with plain globalroleskept as sugar. - Scoping declared on the role, not on every grant (Cerbos scoped policies, Oso
has_roleon a resource):role(name, grants, { on: 'tenant' | 'team' | resource }). - Custom roles as data composed from a code-declared vocabulary (Oso
grants_permission, OpenFGAroletype, Better AuthdynamicAccessControl): 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,entitlementsas the default claim mapping, keyed on SCIMvalue. - 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
propertiesfor memberships andcontextfor 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 anduseAssignableRoleson 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
pdpinterchange 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
canas a definer (ADR 0005, ADR 0010). - Storing memberships, invitations or tenant objects in PermDock, or letting
permdock/cloudimplement 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
pdpbridge 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
$Inferaccessor for provider principal types; generics filled from a Standard Schema instead.
Decisions informed
- ADR 0024: Scoped roles and memberships
- ADR 0018: Authentication is upstream (providers own tenants and memberships)
- ADR 0010: Policy as data, portable conditions (no builder;
memberOfas a portable node) - ADR 0023: Compose, do not wrap (Stigg, Schematic, Nile, Zitadel and the providers above are rows, not entries)
- Pages shaped: tenancy, policies, subject, conditions, snapshots, wire formats, authentication, extension interfaces, UI; standards JWT authorization claims, AuthZEN; adapters rls, clerk, better-auth, supabase, jwt, pdp; threat model; roadmap.
Commercial landscape, September 2026
How open-core authorization vendors, auth providers, MCP authorization servers, agent tool-auth products (including AgentCore Policy), approval-delivery surfaces (Vercel Chat SDK, durable runtimes, n8n), observability and compliance buyers and distribution channels position in September 2026, what the Vercel Marketplace requires, and what all of that changed in the PermDock plan.
OpenAPI ecosystem, September 2026
How PermDock's Overlay and standard security output fit the TypeScript OpenAPI toolchain (next-openapi-gen, hono-openapi, TypeSpec, Hey API, Orval, Kubb, Redocly, Bump.sh, Scalar, Mintlify, Fern, Speakeasy, Schemathesis, Arazzo runners, OpenAPI-to-MCP bridges), which tools are producers, appliers or consumers, and what PermDock adopts, adapts and avoids.