apps/web uses workspace-scoped permissions, not a single coarse role string, to decide what a signed-in user can see or mutate.
The important implementation detail is that modern permission checks do not read workspace_members.role. The effective permission set is computed from:
workspace_role_membersworkspace_role_permissionsworkspace_default_permissions- workspace creator fallback logic in
getPermissions()
workspace_default_permissions is typed by member_type:
MEMBERrows are the normal workspace defaults for members and API keys.GUESTrows are signed-in guest defaults. Missing guest rows mean denied.
workspace_role_permission catalog as member defaults, including management and admin bits, but guests are still denied on dashboard routes that have no mapped permission.
For the full workspace model, including URL resolution and API-key auth, see platform/architecture/workspaces-permissions.
Core Helper
The main entry point isgetPermissions() in packages/utils/src/workspace-helper.ts.
It evaluates permissions in this order:
- Authenticate the current user with a request-scoped or cookie-scoped Supabase client.
- Normalize the incoming workspace identifier so
personalandinternalbecome real workspace IDs. - Verify caller membership with
verifyWorkspaceMembershipType(..., requiredType: 'ANY')so the helper can distinguishMEMBERfromGUEST. - For
MEMBER, load role-derived permissions fromworkspace_role_members -> workspace_roles -> workspace_role_permissions. - Load workspace-wide defaults from
workspace_default_permissionsusing the resolvedmember_type. - Load the workspace creator and treat that member as an implicit superuser.
getPermissions() returns the full permission catalog from packages/utils/src/permissions.tsx.
If the user is not the creator, getPermissions() returns the deduplicated union of:
- enabled permissions from every assigned workspace role
- enabled default workspace permissions for the resolved membership type
null.
For GUEST, role-derived permissions and creator fallback are skipped. The helper only reads workspace_default_permissions.member_type = 'GUEST', so a guest with no enabled guest defaults receives no effective permissions.
admin Is A Permission Bit
admin is part of the normal permission catalog. It is not a separate evaluator or a special membership table.
Once containsPermission() sees admin in the effective permission list, it treats every permission check as allowed:
admin behave similarly at call sites, even though they come from different sources:
- creator access is implicit and computed in code
adminaccess is explicit and stored like any other permission
admin can satisfy a mapped permission route, but it does not open routes with no permission mapping. The dashboard shell stays default-deny for guest routes that cannot be tied to a permission ID.
Permission Catalog
The permission catalog lives inpackages/utils/src/permissions.tsx.
The non-root workspace groups currently include permissions for:
- workspace administration
- AI
- calendar
- projects
- documents
- time tracking
- drive
- users
- user groups
- leads
- inventory
- finance
- workforce
- transactions
- invoices
@tuturuuu.com users.
This matters because creator fallback uses the catalog directly. If a permission is added to the product but not added to the catalog, creator fallback and roles UI drift immediately.
Where Web Pages Use It
Typical server-page flow inapps/web:
- Resolve the workspace with
WorkspaceWrapperorgetWorkspace(). - Call
getPermissions({ wsId }). - Redirect or
notFound()when the required permission is missing. - Pass
containsPermission/withoutPermissioninto the page composition.
apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/roles/page.tsxapps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/members/page.tsxapps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/settings/page.tsxapps/web/src/app/[locale]/(dashboard)/[wsId]/navigation.tsx
navigation.tsx is especially important because a large part of the UX is permission-driven before the user even clicks into a page. Sidebar items are disabled or hidden from withoutPermission(...), and some cross-workspace admin affordances also compare root-workspace permissions.
Where API Routes Use It
There are three common patterns inapps/web routes.
1. Session auth only
Some routes only verify that the caller is signed in, usually throughcreateClient(request) or withSessionAuth(...).
Use this when the operation is about the current signed-in user or the route does not grant privileged workspace mutations by itself.
2. Membership + workspace normalization
Many workspace-scoped routes first convert the route param into a canonical workspace ID withnormalizeWorkspaceId(wsId, supabase) and then verify membership.
This is the baseline for routes that accept personal or internal in the URL but must query by UUID in the database.
For non-permission routes that still require protected workspace access, use verifyWorkspaceMembershipType(...) from packages/utils/src/workspace-helper.ts and keep the default requiredType (MEMBER).
workspace_members now supports MEMBER and GUEST. If a route is not explicitly guest-capable, it should require MEMBER and avoid ad-hoc .from('workspace_members') checks that only validate row existence.
normalizeWorkspaceId('personal', ...) also enforces MEMBER membership on the personal-workspace join path by filtering workspace_members.type = 'MEMBER'.
Course consumption routes are the exception that combine both models: MEMBER workspace rows keep normal course access, while GUEST rows must pass workspace_guest_permissions resource checks such as course:view.
3. Membership + explicit permission gate
Privileged routes callgetPermissions() after normalization and reject when containsPermission(...) fails.
This is the pattern to use for settings, finance, roles, infrastructure, and any other restricted mutation surface.
If a route writes workspace membership, invitations, roles, or other authorization
data with createAdminClient() / service-role privileges, enforce the relevant
permission before the admin write. A workspace_members row alone is never
enough for those flows: invite creation and membership-type changes require
manage_workspace_members, and workspace_members.type is protected at the
database layer so non-managers cannot self-promote from GUEST to MEMBER.
When a server page or API route uses createAdminClient() after a workspace
permission gate, the protected row lookup must still prove the row belongs to
the authorized route scope. For nested resources, include every trusted parent
identifier in the same query or RPC:
apps/web/src/lib/education/access.ts so ENABLE_EDUCATION and ai_lab are
enforced server-side before any workspace-scoped read or mutation. Mobile and
other clients may hide Education affordances, but API routes remain the source
of truth for this gate.
API Keys Reuse The Same Permission IDs
External SDK routes usewithApiAuth(...) in apps/web/src/lib/api-middleware.ts.
The API key evaluator:
- authenticates a workspace API key from
workspace_api_keys - binds the request to exactly one workspace
- computes the effective permissions as
role permissions ∪ MEMBER default permissions - rejects requests whose
[wsId]route param does not match the API key’s workspace
Current Design Rules
- Use
getPermissions()for feature-level authorization in server pages and session-auth routes. - Use
normalizeWorkspaceId()before querying workspace-scoped tables from route params. - Do not assume
workspace_members.rolerepresents the effective permission model inapps/web. - Treat
adminas a permission that short-circuits checks, not as a separate role system. - When adding new privileged features, update both the permission catalog and the UI/routes that consume it.