PermDock permissions and roles, the token claims, and how pages, actions, routes and RLS decide access.
CentraKit decides access with PermDock. One policy declares the permissions and roles, the database helpers and the token hook are generated from it, and the server decides in process from the verified session claims.
The policy lives in packages/identity/src/permdock/:
permissions.ts declares every resource and action with its title and description, for example customer.read, quote.send and platform.user.manage.roles.ts declares the built-in roles.policy.ts says which role allows which permissions and on which scope. The scopes are organization and customer (inside an organization); platform roles are global.pnpm --filter @workspace/identity permdock:generate writes the SQL helpers (supabase/schemas/identity/permdock/helpers.sql) and the token hook (supabase/schemas/identity/permdock/hook.sql), and a new <timestamp>_permdock_seeds.sql migration whenever the seeded rows change. The helper and hook files keep their grants inline, and CentraKit's own service_role grants on the PermDock functions live in supabase/schemas/identity/permdock/grants.sql; pg-delta carries all of them into migrations. permdock:check fails when the files drift from the policy.
Custom roles follow PermDock's custom role model. public.roles keeps each role's identity: its key, name, description and owning organization, and roles_custom_key_not_builtin_check keeps custom keys apart from the built-in ones. What a role grants lives in PermDock's tables: the seeded permdock.role_permissions for built-in roles and permdock.custom_role_permissions for custom roles, keyed by the role key.
scope = 'organization'). A platform custom role has no organization (scope = 'system') and is stored with scope = 'global'.billing.manage never fit in an organization custom role.permdock.assignablePermissions(): the ceiling, narrowed to what the editor holds in that organization. A platform admin holds platform.role.manage, which lifts that narrowing.validateCustomRole and refuses every key outside the assignable set, then calls public.replace_role_permissions(role_id, permissions). A signed-in caller goes through PermDock's generated permdock.permdock_replace_custom_role_grants for organization and platform roles alike: it checks that the caller holds role.update in the organization or platform.role.manage (rls.customRoleWrites.requires), membership, the ceiling, and that the caller may hand out every key the new and the stored definition allow. A service_role caller goes through permdock_trusted_replace_custom_role_grants, which keeps the definition checks. The function then writes the audit event.outside-ceiling, not-assignable-by, not-member, manage-roles, declared-role, role-exists); customRoleRefusal in @workspace/identity/rbac/service maps it to the role editor's messages.public.roles moves or removes its PermDock rows through the generated permdock_custom_role_cascade trigger (rls.customRoleWrites.roles). A signed-in caller passes the same checks as the write functions; migrations, jobs and nested triggers move the rows directly.RbacService.listRolePermissions(roleIds) reads the keys a visible role grants, and RbacService.replaceRolePermissions(roleId, permissions) saves them. The legacy public.permissions, public.role_permissions and organization_permission_overrides tables are gone.
| Scope | Role | Purpose |
|---|---|---|
| global | platform-admin | Every platform.* permission, and nothing in an organization |
| global | platform-support | Support operations with read-only billing |
organization | owner | Final authority; exactly one per organization, changed only through a transfer |
organization | admin | Members, roles, settings and integrations |
organization | member | Day-to-day operational access |
organization | viewer | Read-only |
customer | customer | Portal contacts: published quotes, invoices, orders and assets of one customer |
Organizations define custom roles from the assignable built-in roles, as described above.
A platform admin reaches an organization's data only as a member of it. Admin features read organization data through explicit platform.* branches in RLS, such as platform.organization.read and platform.role.manage.
The owner role is transferOnly: the organizations block's transfer_ownership promotes the new owner and demotes the old one in one statement, and PermDock's triggers refuse any other change to the number of owners. The organizations block's deferred bs_organization_owner trigger refuses a commit that leaves an organization without an owner, such as its only owner leaving.
A member's role changes through decideRoleChange, which applies each built-in role's assigns, transferOnly and min rules: an owner assigns Admin, Member and Viewer, an admin assigns Admin, Member and Viewer, Platform Admin and Platform Support assign Admin, Member and Viewer in any organization, Platform Admin also assigns Platform Admin and Platform Support, and the owner role only moves through the ownership transfer. Custom roles go through the same decideRoleChange and assignableRoles(): permDockWithOrganizationRoles from @workspace/core/auth/custom-roles derives the caller's PermDock with every custom role of the organization (permdock.derive), and PermDock offers a custom role when the editor may assign a built-in role there and may hand out every permission the custom role allows. Role pickers list the same set.
The database repeats the check. PermDock's generated permdock_assignment trigger (rls.assignments) refuses a client write to organization_users that assigns, changes or removes a role the caller may not assign there, by the assigns graph or, for a custom role, by what the caller may hand out. Writes from security definer functions, such as accepting an invitation, are trusted by that trigger. Clients cannot write organization_invitations at all; the invitations block checks the role ceiling itself. Members are created, changed and removed through the organizations block (api.create_organization, api.update_member_role, api.remove_member), whose bs_organization_role_guard trigger checks can_assign for the old and the new role and refuses a change to the caller's own role inside those functions too; the platform admin tool changes roles on organization_users directly. api.create_organization makes the caller the owner and seeds the starter templates through the after_organization_create hook. PermDock's assignment trigger also guards user_roles: a client may write only global roles it may assign and never its own rows (ownRole), and better-supabase's bs_role_scope triggers keep organization roles in organization_users and system roles in user_roles for every writer, including the service role. Accepting an invitation requires that the inviter may still assign its role (permdock_can_assign_for, or permdock_can_assign_custom_role_for for a custom role).
The admin user tool (platform.user.manage) lists every organization's built-in roles except Owner, plus that organization's custom roles. It never assigns or removes Owner; the row of an owner is read-only and points to Transfer ownership, which only the owner can start.
permdock.custom_access_token_hook writes:
user_role and roles: the global roles from user_roles.memberships: one entry per organization, { "scope": "organization", "id": "<organization id>", "roles": ["owner"], "via": "staff" }. The claim stays within PermDock's byte budget, and memberships_truncated is set when entries were left out.tenant_id. The organization comes from the URL, and PermDock's database helpers narrow permitted_<scope>_ids to the tenant_id claim when a token carries one, so the hook config leaves out activeFrom and RLS admits every organization in which the user holds the permission. Nothing may write app_metadata.active_organization, the hook's default source for the claim.authz_ver: the user's authorization version, bumped on every membership or role change.datetime_preferences: the display preferences from profiles.Suspended users and organizations get no memberships. supabase/tests/database/claims.test.sql checks the shape and the size for the heaviest seeded user.
RequestContext.getPermDock() builds one PermDock per request from the verified claims, with no database read on the usual path. When the token was truncated, the memberships are read from the database (claimsFirst over postgrestSources), and custom roles are loaded only when a membership names one. Both come from the generated permdock.subject_for(p_user), which the server reads through public.permdock_subject_for with the service role; a job or an approval acting for a stored user builds its PermDock from the same function (createPermDockForUser).
requireAccess({ tenant, permission }) guards pages and layouts. The tenant is the route's orgSlug, resolved through the shared public slug lookup. A signed-out request goes to /login, and an unknown slug or a missing permission renders not found.withTenantAccess(permission, handler) and withPlatformAccess(permission, handler) wrap Server Actions and route handlers and return a Result with AccessDeniedError on a denial. decideAccess answers one check.checkAccess reports not-member instead of denied when the user holds no staff membership in the organization, which pages render as not found.RLS policies call the generated helpers in the init-plan form, organization_id in (select permdock.permitted_organization_ids('<grant key>')) and (select permdock.permdock_has('<grant key>')), so each statement evaluates them once. A permission split by condition is named by permission key (permitted_organization_ids_by_permission). Trusted SQL that checks a stored user, such as a calendar feed, a notification recipient or an agent run, calls the generated _for(p_user, ...) forms, which service_role may execute.
The thread resource (chat_threads) declares two PermDock relations: owner (the owner_user_id field) and participant (the kind = 'user' rows of chat_thread_participants). Two grants give thread.read on a thread to its owner and to its user participants, and permdock:generate writes permdock.permitted_thread_ids(relation). No role holds thread.read, so chat.read keeps only role grants and stays usable in the Realtime topic policies, which PermDock refuses for a permission with relation grants (PD037). The chat_threads select policy admits the owner, the ids from permitted_thread_ids('participant'), and organization threads in permitted_organization_ids('chat.read'); the policies on messages, participants and feedback read through chat_threads, so its policy decides them all. supabase/tests/database/chat_access.test.sql checks the policy against that rule for every seeded user and thread.
File drives and nodes, calendars, knowledge collections and workflows share the same model. Each has its own resource (fileDrive, fileNode, agendaCalendar, knowledgeCollection, workflowDefinition) whose grant table is a PermDock edge: a row names a user in user_id or a team in team_id, and the team group reads its members from team_members through the team resource's member relation. File and calendar shares use one edge per level with match: { role } and includes, so an editor is also a commenter and a viewer. File nodes walk their parent_id chain through permdock.permdock_closure, so a share on a folder reaches everything below it, up to 32 levels. Every share grant carries requires with the feature permission (file.read, agenda.read, knowledge.read, workflow.read), so it counts only while the member still holds that permission in the organization; the organization-wide arms (the company drive, public calendars, managers with agenda.delete, knowledge.update or workflow.update) are authenticated() grants with requires. rls.rowHelpers generates permdock.permitted_<resource>_rows(permission), and the policies and trusted functions ask id::text in (select permdock.permitted_file_drive_rows('fileDrive.update')) instead of restating the graph. A level that needs several permissions lists them all in requires (a calendar editor needs agenda.read and agenda.update, the customers drive file.read and customer.read), and a drive level includes its read rule, so fileDrive.update alone answers whether a drive is readable and editable. A file node inherits each level from its drive with inherit(permissions.fileDrive.read, { through: ["drive"] }) (and the comment and update levels), so permitted_file_node_rows alone decides a node. Policies still AND the operation permission (file.update, agenda.update) for writes, because an API key passes a grant's key check with any of its requires keys. The agenda_collections, knowledge_collections and workflow_definitions select policies call the row-valued helper (permdock.permitted_knowledge_collection_row(knowledge_collections, 'knowledgeCollection.read')), which decides from the row's columns, so insert ... returning admits the row the statement creates. API keys get no scopes for permissions that only requires grants reach (fileDrive, fileNode, agendaCalendar); the key's feature scopes stand in for them. No role holds these permissions, so the feature permissions keep only role grants. public.can_edit_file_node answers the file inspector, and supabase/tests/database/resource_access.test.sql compares the policies and helpers with the previous rules for every seeded user, row and level, with shares through teams, nested folders and a trashed drive.
The client reads PermDock snapshots, built in "use cache: private" functions in apps/saas/lib/organizations/access-snapshot.ts from the request's PermDock (permdock.snapshot()), cached for cacheLifeFor(snapshot) and tagged with permdock:<user id> and the role tags, so a role or membership change drops them with updateTag.
tenants: 'all') and renders the sidebar and command center under PermDockProvider from permdock/react. Navigation hints ask checkAccess against the derived organization instance.[orgSlug] layout passes params.then(...) into OrganizationPermissionProvider, which hands the organization's snapshot promise to PermDockProvider without awaiting it. Client components read it with useTenantPermission(permissions.task.update) or <Protected permission={...} data={{ organization_id }}>.endpoint={false}: the policy has no closure grants, and its graph grants (thread.read for chat owners and participants) are server-only in the snapshot, which no hint needs because hints never ask about one thread. Every hint is answered from the snapshot.An organization-level check without a row asks PermDock with scope: "organization", so only staff memberships answer it. Hints are decorative; writes go through a guarded action, route handler or RLS policy.
Invitations live in public.organization_invitations, separate from organization_users because pending invitees may not have an auth user yet. Rows without an organization are platform invitations, which assign a system role in user_roles. The better-supabase invitations block owns the writes: invite_member creates an invitation and returns its token once, and the table keeps only the token's SHA-256 hash. resend_invitation issues a new token, revoke_invitation sets revoked_at, and accept_invitation (by token, from the email link) or accept_invitation_by_id (from the notification inbox) creates the membership for the signed-in user whose confirmed email the invitation names. Accepting checks the inviter again: an inviter who lost member.invite or may no longer assign the role, including a custom role, can no longer bring someone in. update_invitation changes an open invitation's email, role or prefill with the same checks as invite_member and keeps its token. my_invitations lists the caller's open invitations with their organization, and decline_invitation_by_id declines one. The accept page reads invitation_preview. Both carry the role's key and name and the organization's brand images from CentraKit's public.invitation_preview_extra hook, which no client may call. An organization invitation resolves its role only among organization roles (roleThrough.where), and a platform invitation only among system roles (platformRoles.through.where).
supabase/tests/database/permdock.test.sql holds the role matrix across the schema folders, custom roles and their ceiling, suspension, D10 and the ownership transfer.supabase/tests/database/organizations.test.sql covers creating an organization, the slug rules, role changes and removals through the organizations block, platform updates and the owner invariant.supabase/tests/database/chat_access.test.sql compares the chat thread and message policies with the owner, participant and organization rule for every seeded user and thread, and packages/identity/tests/permdock-thread-relations.test.ts checks can(thread.read) against the same owner and participant rows.supabase/tests/database/resource_access.test.sql compares file, calendar, knowledge and workflow access with the previous has_*_access rules for every seeded user and row.supabase/tests/database/invitations.test.sql covers organization, platform and custom role invitations, the inviter re-check, the inbox functions, the ownership transfer and an only owner's account deletion.packages/identity/tests/permdock-custom-roles.test.ts covers the custom role ceiling, role assignment and the pickers, and apps/saas/tests/permission-gates.test.tsx covers the client snapshot.pnpm --filter @workspace/identity permdock:verify runs the policy fixtures against the database.tests/integration/supabase/permdock-claims-live.test.ts signs in the seeded users and checks the token shape, its size and the in-process decisions.Last updated on