PermDock
Concepts

Tenants, teams and scoped roles

Memberships put roles in a scope (an instance of a declared named scope, or 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.

A role in a real SaaS application is rarely global. Alice is an admin of Acme and a viewer of Globex; a portal contact sees only their own customer's invoices; 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; PermDock reads the result. Why this model compares it with authorization engines and auth providers.

Memberships

type Membership = {
  scope?: string; // a declared scope name: 'organization', 'customer'
  id?: string; // the instance of that scope
  within?: Record<string, string>; // the id of every ancestor scope: { organization: 'o_acme' }
  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; // the membership kind: 'staff', 'contact', 'group:<scim id>'; a role's `for` lists the kinds that hold it
  expiresAt?: number; // Unix seconds; time-bound access
  x?: AppData; // the application's own data: a department, a seat label
};

type Principal = {
  id: string;
  kind?: "user" | "service" | "workload";
  roles?: string[]; // global roles, unchanged
  memberships?: Membership[];
  tenant?: string; // the active tenant for this request
  issuer?: string; // `iss`; identity is issuer + id
  assurance?: { acr?: string; amr?: string[]; authTime?: number };
  binding?: Binding; // RFC 7800 `cnf` members
  [k: string]: unknown;
};
  • roles on the principal keeps meaning global roles, so every existing sample and every single-tenant app is unchanged.
  • A membership names one instance of a named scope (scope, id, and the ancestors' ids in within) or one resource (on). The { tenant } and { tenant, team } input shapes still work: they are the first and second declared scope and are normalised when the subject is resolved. A malformed entry is dropped (fail-closed, reported by permdock doctor PD025).
  • tenant on the principal is the active tenant, an instance of the first scope: 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.
  • x is data the application owns about a membership. It is plain JSON from the same trusted sources, checked by definePolicy(…, { x: { membership } }). An invalid x is dropped with an on('auth') event of reason schema, and the membership stays. Conditions, RLS and role listings never read it, and the Supabase token hook never writes it into claims, so it costs nothing against the claim size budget. It reaches the client in the snapshot's principal.memberships and through permdock.memberships(). The Supabase sources read it from a jsonb column: fromTable({ columns: { x: 'meta' } }) or fromJunction({ x: 'meta' }).
  • 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: [
    { scope: 'tenant', id: 'o_acme',   roles: ['admin'] },
    { scope: 'tenant', id: 'o_globex', roles: ['viewer'] },
    { scope: 'team', id: 't_design', within: { tenant: 'o_acme' }, 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 } 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 first scope: what the active tenant selects
    team: { key: "teamId", within: "tenant" }, // every later scope names its parent
  },
  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
a scope name ('tenant', 'customer')A membership of exactly that scope holds the role, inside the active tenantHave the scope's key equal to the membership's id, and each declared ancestor key equal to its within entry
a resource referenceA membership on that resource holds the roleBe that row (id equal), or a descendant through a declared parent

Scope names, their order, the aliases and the no-cascade rule are on named scopes. 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 data on role() / RoleBinding (INCITS 359 static separation of duty). Evaluation does not change. permdock doctor check PD018 and separationConflicts(policy, memberships) report conflicts.

Scope keys and typing

Every resource an instance grant on scope S references declares a memberOf: S relation on the scope's key; definePolicy throws when one is missing, so where() and RLS always narrow the rows. A resource no scoped grant touches is global by construction (a plan catalogue everyone reads). 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: principal.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. Matching is keyed by the membership's resource: the row's own id field identifies it only when the row is of that resource, and a descendant row matches through the parent field of that resource on its chain, so a document whose id happens to be f_1 does not match. A membership on the role's resource or one of its ancestors counts (a project editor edits the documents of its folders when the document row carries projectId); a membership on an unrelated resource never does. The chain is finite because it is typed: document to folder to project, three hops, declared once. parent.resource is the parent name string, not a reference; a resource declares at most one parent; the name is resolved at definePolicy after any mergePermissions. When the membership's resource is self-parented (folders in folders) and the instance has a relations source, a membership on a folder also matches its subfolders and rows whose parent is one of them, walked up to the default depth and stopped by restricted rows; without a source it matches that folder and its typed descendants only. RLS applies the membership to the named instance and its typed descendants, not to subfolders (relationships). The row must carry the parent field; a missing parent field is a non-match, never a walk-up query.

A share link is a principal holding exactly one such membership, { on, roles, via: 'link' }, from a signed capability (link capabilities).

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: principal.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 a membership of exactly that scope matches the request (no cascade): the membership sits inside the active tenant; the row's key for the scope equals the membership id and each declared ancestor key equals its within entry; or, for a resource role, the row is the membership's resource or a descendant through declared parents. Expired memberships (expiresAt in the past) contribute nothing: no roles, no tenant in tenants(), no active tenant, and no authority in assignableRoles() or decideRoleChange. 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, bounded by the assignable ceiling 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).

Organisation roles with team reach

A role held at the organisation that reaches only the rows of the principal's teams is an organisation role with an in condition on the team key. It needs no team-scoped role, and holding a team membership alone grants nothing through it.

role(roles.manager, [
  allow([permissions.job.read, permissions.job.update], {
    where: { teamId: { in: principal.claims.team_ids } },
  }),
]); // on: 'organization'
  • In-process, the list comes from the subject: principal.claims.team_ids from a verified token, or a principal attribute such as principal.teamIds that subject() loads. A missing or non-array value matches no row.

  • Snapshots bind the list to the subject's value when they are built, because the snapshot principal carries only id, roles, plans, tenant and memberships. fromSnapshot(snapshot).decide therefore agrees with decide; a team change reaches the client with the next snapshot.

  • RLS compiles it in set form in both database and jwt mode: "teamId" = any (array(... auth.jwt() -> 'team_ids' ...)), one uncorrelated array per statement, never a per-row helper call. Only principal.claims.* refs compile to SQL, so name the claim, not an attribute.

  • Supabase writes the claim through supabase.hook.claims with a function that reads the same membership rule as the helpers (custom claims):

    create function app.team_ids(user_id uuid) returns jsonb
    language sql stable as $$
      select to_jsonb(array(select permdock.member_team_ids_for(user_id)))
    $$;

    member_team_ids_for exists when the team scope has a memberships source. The claim is as fresh as the token: a change to that source table bumps authz_ver, so a fresh permission denies with stale-credentials until the next refresh carries the new list (authorization version).

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, composed from declared roles, from single permissions, or both:

type CustomRole = {
  tenant: string; // the instance of the first scope that owns it
  scope?: string; // the scope it is held at; default the first scope
  id?: string; // pins it to one instance of `scope`
  name: string; // 'billing-manager'; unique within the tenant
  includes?: string[]; // declared roles: ['billing-viewer', 'invoice-payer']
  grants?: { permission: string; effect?: "allow" | "deny" }[]; // declared permission keys
  meta?: Record<string, unknown>;
};
  • A custom role never exceeds its ceiling: the allows of the declared roles marked assignable in its scope (scope, the first scope by default; the input shape team means the second). Its effective grants are its included roles' grants plus its own allows, minus its own denies, intersected with the ceiling. Anything outside is dropped (fewer grants, never more), and validateCustomRole and permdock doctor PD023 name each dropped key.
  • A custom-role grant carries no condition, approval or limit of its own. It inherits those of the declared grant it comes from, so every condition was reviewed in code.
  • 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.assignableRoles() and permdock.assignablePermissions() intersect the ceiling (narrowed by RoleSource.assignable) with what the subject holds there. Holding a role or a permission marked meta.manageRoles: true lifts that intersection.

Custom roles has the resolution rules, the ceiling, the matrix-editor recipe and how snapshots carry the result.

Interfaces

interface RoleSource {
  rolesFor(
    tenant: string,
    context?: { held: string[] }, // the role names the subject holds in that tenant
  ): 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[]>;
  list?(query: {
    scope: string;
    id: string;
  }): MemberEntry[] | Promise<MemberEntry[]>;
  version?(principal: {
    id: string;
  }): number | undefined | Promise<number | undefined>;
  readonly claimsFirst?: boolean;
}

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). Provider role sources: 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, or an array composed and de-duplicated by composeMemberships), customRoles (a RoleSource) and entitlements (an EntitlementSource whose plan names join principal.plans for the active tenant) next to store and sink; the shared set is the InstanceOptions type. claimsFirst(sources) keeps the verified token's memberships and reads the sources only when the token was truncated; with a version, token memberships behind it deny the policy's fresh permissions with stale-credentials, or with onStale: 'reread' are replaced by the sources' memberships. A membership may carry managedBy: 'idp' (the identity provider owns it; decideRoleChange refuses to change it with externally-managed) and entitlements (seats that plan() grantees match inside the active tenant). The Supabase token hook page shows the SQL sources and the claims they compile to; 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 memberships of the active tenant with via: 'group:<value>' and groupRolesKeyed on SCIM value, never display
PermDock Cloud (Cloud-native directory)The tenant claim on the Cloud-issued token, kept only with a matching membershipThe memberships claim, [{ scope, id, within?, roles, via?, expiresAt? }] (the { tenant, team? } form is still accepted), minted from declared assignable rolesA membership of the second scope, or groupsRead through subjectFromJwt with claims: { tenant: 'tenant', memberships: 'memberships' }; never a MembershipSource
Your own tablesWhatever you storecontext or a MembershipSourceSameThe common case for resource roles (document_members); on Postgres, fromTable / fromJunction from permdock/supabase, which the token hook compiles too
Share linksNone: a link is never in a tenantNoneOne resource membership { on, roles, via: 'link' } from the verified capabilitysubjectFromCapability; a role named in the link applies only if it is declared on that resource

A membership table that stores a role id instead of a role name works too: the permdock/supabase sources and the RLS mappings read the key through the app's roles table (token hook). The same goes for a membership table that names a profile instead of a login, such as portal contacts whose login is contact_profiles.user_id: the sources read the user id through the profile table, and re-linking the profile to another login moves its memberships.

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 and checks without a row

permdock.memberships(); // Membership[]           for org lists and role chips
permdock.tenants(); // string[]               tenants the subject belongs to
permdock.heldRoles({ tenant: "o_acme" }); // Role[]        roles held there, custom roles resolved
permdock.heldRoles({ scope: "customer", id: "c_7" }); // Role[]  roles on one scope instance's memberships
permdock.audiences(); // string[]              meta.audience of the roles held in the active tenant
permdock.assignableRoles(); // Role[]                roles the subject may hand out in the active tenant
permdock.assignablePermissions(); // Permission[]          the custom-role ceiling the subject may hand out there
permdock.decideRoleChange(change); // RoleChangeDecision    may the subject assign, revoke or transfer this role

heldRoles and assignableRoles are ranked by the policy's assigns graph, so a role chip list shows rank without hard-coded names. decideRoleChange and the ownership rules behind it are on ownership.

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, useAssignableRoles and useAssignablePermissions (UI).

Portable compilation

Scope matching is data, so it compiles like any other condition (invariant 6). One node is added to the condition AST:

{ "op": "memberOf", "scope": "tenant",   "field": "orgId", "roles": ["admin", "viewer"] }
{ "op": "memberOf", "scope": "customer", "field": "customer_id", "roles": ["contact"] }
{ "op": "memberOf", "scope": "resource", "resource": "folder", "field": "folderId", "roles": ["editor"], "parents": [{ "field": "projectId", "resource": "project" }] }

A parents entry is either keyed ({ field, resource }: only a membership on that resource matches the field) or a bare field name (a membership on any resource whose id equals the field). Prefer the keyed form; the bare form exists for conditions written before it and matches the older behaviour. Membership expiresAt is Unix seconds, in memory and in a mapped expires_at column.

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>); with no active tenant, tenant- and team-scoped grants contribute nothing, so only unscoped grants match; team and resource scopes as inArray over the ids the subject holds, keyed parents over the ids held on that resource. With a memberships table (Drizzle, Kysely), an exists join with the role list, expires_at > now in bound Unix seconds, the active tenant, and the resource column when the table stores several resources
RLS supabaseA scoped role: org_id in (select permdock.permitted_<scope>_ids('<grant key>')), one helper per declared scope, a security definer helper that reads the membership table (database) or the memberships claim (jwt), narrowed to the active-tenant claim when set (every member tenant with rls.tenants: 'all'), and runs once per statement. A memberOf with no roles: org_id in (select permdock.member_<scope>_ids()) when the helpers read rls.membershipSources. A memberOf inside a condition: org_id = ((select auth.jwt()) ->> 'tenant_id')::uuid (cast to --tenant-type) with no table, 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, nested scope and resourceexists (select 1 from <membership table> ...); a resource role ORs one join per mapped resource on the chain (the role's resource and each ancestor), each keyed by the row field that holds that resource's id

Membership checks have a dedicated memberOf AST node, and the membership table is a per-scope mapping on the RLS adapter, one table per scope and one per mapped resource (memberships: { scopes: { organization: ..., customer: ... }, resource: { document: ... } }). permdock rls import recognises both IN (select ...) and EXISTS (select 1 ...) forms as memberOf when the subquery matches a declared mapping. Custom roles do not add a node: with permdock rls generate --custom-roles, the helpers resolve a custom role name to grant keys through the ceiling of assignable declared roles, so the generated policies stay a closed vocabulary (custom roles).

Snapshots, decisions and the catalog

  • The snapshot carries subject.memberships, subject.tenant, vocabulary.roles / vocabulary.plans, the ordered scopes, and per grant to plus scope (a scope name or { 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 roles carry on (a scope name or resource) and assignable, and the catalog lists the policy's scopes in order. 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; permdock.assignableRoles() is the live Role[] so z.enum of those keys 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 Sync; or permdock/scim writing a DirectoryStore you own (the PermDock Cloud relay feeds the same handler)Group ids arrive as groups claims, synced rows or SCIM Group resources and become memberships of the tenant { tenant, roles, via: 'group:<id>' } through subjectFromJwt or directoryMembershipSource (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). A key attenuated below its owner is the owner as principal plus a delegation naming the key's scopes; delegation narrows a subject with or without an actor
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 guestsIn scopeA guest is a principal with a membership in the host tenant. There is no home tenant: snapshots and RLS treat the guest membership like any other
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 separation of duty is reported by PD018 and separationConflicts, never enforced at evaluation, and dynamic separation of duty (two roles not active in one session) is not planned. On the token side, roles and groups follow RFC 9068 and SCIM (JWT authorization claims).

Why this model

Authorization engines converge on the same shape. Permit.io, Oso Cloud (has_role(user, "admin", org)), OpenFGA and SpiceDB (organization#admin, team#member usersets) and Cerbos (scoped policies) all treat a role as a relation between a subject and a scope, never a bare string. Tenant-defined roles are always composed from a vocabulary the code declared (Oso grants_permission facts, an OpenFGA role type, Better Auth dynamicAccessControl), and parent derivation is declared one hop at a time (Permit role derivation, Oso role if role on "parent"). PermDock takes all three: a Membership names the scope, role(name, grants, { on }) puts the scope on the role instead of on every grant, a custom role composes and never widens, and parent is typed so the chain is finite. Repeating the tenant condition on every grant, not the declaration syntax, is where cross-tenant bugs come from; scoping the role removes the repetition without a fluent builder.

Auth providers own the rest. Clerk, Auth0, WorkOS, Stytch, Kinde, Descope and Better Auth all own the tenant object, membership rows, invitations, the active-tenant selection and the group-to-role mapping, and expose the result as claims or a session; none evaluates a condition on a row. Clerk's role sets (which roles an organization or plan may assign) map to RoleSource.assignable(tenant), and Better Auth's "cannot grant a role you do not hold" rule maps to permdock.assignableRoles(). Entitlement platforms such as Stigg and Schematic answer "may this tenant use this capability" and are an input to subject and assignable, not a role model.

Scope and tenant ids compare as exact text, and PermDock never lower-cases them. Lowering would merge ids that differ only in case, such as Clerk's case-sensitive org_… ids, and give one tenant's members another tenant's rows; exact comparison fails closed instead. Postgres already prints uuid::text in lower case, so a producer that emits uuid::text into claims and membership rows gets the same id decide and the SQL helpers compare (RLS).

No standard tenant claim exists: Entra uses tid, Auth0, WorkOS and Clerk org_id, Google hd, Supabase deployments conventionally tenant_id, and Descope a tenants object keyed by tenant id. The mapping stays configuration (claims.tenant), and an absent tenant claim means no tenant, never a default. SCIM groups have no tenant attribute either, and nested groups are provider-specific (Entra does not expand them in members.value filters).

PermDock deliberately leaves out storing memberships, invitations or tenant objects (a hosted store would sit on the decision path); nested groups, arbitrary-depth trees and cross-tenant graphs, which permdock/pdp hands to a Zanzibar engine with one tuple per resource membership; and global module augmentation or a $Infer accessor for provider principal types, which generics filled from a Standard Schema replace.

Last updated on

On this page