Role defaults, typed permissions, per-organization overrides, JWT hints, and enforcement layers.
CentraKit's role-based access control has a single source of truth, a six-tier role model, typed permissions, per-organization overrides, and two enforcement layers: fast UI hints and authoritative DB checks.
The feature registry in packages/identity/src/rbac/features.ts is the single source of truth for permission keys. Each feature declares its scope and actions; CRUD actions (view/create/update/delete) are generated by a crud() helper and never hand-written. OrgPerm, SysPerm, and UserPerm expose the typed keys (e.g. OrgPerm.tasks.create), and ALL_PERMISSION_KEYS / FEATURE_GROUPS are derived from the registry.
packages/identity/src/rbac/permissions.ts derives the display catalog (PERMISSION_CATALOG) and the role-to-permission defaults (RoleDefaults, ROLE_CATALOG) from that registry. The permission seed in supabase/schemas/055_functions_rbac.sql (ensure_all_permissions + ensure_system_roles) mirrors it.
The database tables public.permissions, public.role_permissions, and public.organization_permission_overrides are the durable projection of that catalog.
Never add a permission directly in SQL without also adding the feature/action to features.ts, and vice versa. Add it in TypeScript first, regenerate types, and update the SQL seed in the same change.
Every permission key is {scope}.{feature}.{action}:
organization.tasks.view, organization.quotes.send, organization.general.transfer-ownershipsystem.admin.access, system.users.manage, system.roles.manageuser.manage-own-profile.Following GitLab's permission conventions, prefer specific actions over a broad manage; reuse a single action where subject + action match; domain-language actions (send, mark-accepted, transfer-ownership) are allowed where the action is a distinct lifecycle step. Settings-panel features (billing, api-keys, branding, integrations) keep a single manage action.
| Scope | Role | Purpose |
|---|---|---|
system | system-admin | Full system access. |
system | system-support | Limited system access for support workflows. |
organization | owner | Final authority in an organization; can delete it. |
organization | admin | Manage users, roles, workflows, and settings. |
organization | member | Day-to-day operational access. |
organization | viewer | Read-only. |
public.organization_users.is_owner does not exist. A user is an owner of an organization when they hold the owner role there. The organization_users_owner_invariant trigger enforces that every organization has at least one owner at transaction commit.
Permissions are typed string constants grouped by scope:
system.* for platform-level permissions, such as system.admin.access.organization.* for tenant-level permissions, such as organization.general.update.user.* for reserved future per-user permissions.Audit logs use operation-specific permissions at both scopes:
organization.audit-log.view|export|reveal for tenant events.system.audit-log.view|export|reveal for platform and cross-organization events.The sidebar and page guards use the matching view permission. Export and restricted-detail
actions are checked independently; a user who can read events does not automatically receive
sensitive context or CSV access. See Audit Logs.
Per-organization overrides can grant or revoke a specific permission from a role within a single organization. Override precedence in public.authorize_scope and public.has_org_permission is:
granted = false override denies access.granted = true override allows access.public.authorize_scope(scope, scope_id, permission) is the general DB check used by RLS and permission wrappers.public.has_org_permission(org_id, permission) is the planner-friendly tenant-table fast path used in hot RLS policies.withOrgPermission or withSystemPermission.requireOrganizationAccess({ permission }) is called inside each tenant page's Suspense island. A missing permission triggers notFound() (404) rather than a redirect, so feature existence is not leaked to members without access. The static page shell still paints while the guard resolves.
The guard reuses the organization permission set loaded by getCurrentAppAccess() and only falls back to an authoritative batch check if a requested key was not in that payload. This keeps page guards from issuing an extra single-permission RPC after the app access payload has already loaded.
<PermissionGate scope scopeId permission> is a server component that runs the authoritative hasPermission check; use it to gate server-rendered buttons and sections.<Can> and usePermission are client hints hydrated from the app-layout PermissionProvider.getCurrentAppAccess() payload from the authenticated app layout.canCreate, canUpdate, canDelete) computed server-side from the loaded permission set via getOrganizationPermissionFlags() / getSystemPermissionFlags(), with one batch fallback only when needed.UI hints are decorative only. Writes must still go through a permission-wrapped action, API handler, or RLS policy.
The role create/edit form renders a feature-grouped PermissionMatrix: one card per feature with a checkbox per action, a per-feature "select all", and a global select-all toggle. Groups and labels derive from the feature registry. The same form serves tenant and admin (system) roles.
custom_access_token_hook enriches Supabase access tokens with system_roles and organization_roles so the proxy can do coarse /admin gating without a database round trip. Deeper authorization always goes through authorize_scope, has_org_permission, or a permission wrapper.
Invitations live in public.organization_invitations, separate from organization_users because pending invitees may not have an auth user yet. public.accept_organization_invitation creates the membership row on acceptance.
tests/unit/core/rbac-with-permission.test.ts covers wrapper denial, allow, and ctx.onSuccess.tests/unit/ui/permission-gates.test.tsx covers <Can> and PermissionProvider.tests/integration/saas/admin-roles.test.tsx and tests/integration/saas/organization-role-form.test.tsx cover role forms.