Skip to main content
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_members
  • workspace_role_permissions
  • workspace_default_permissions
  • workspace creator fallback logic in getPermissions()
workspace_default_permissions is typed by member_type:
  • MEMBER rows are the normal workspace defaults for members and API keys.
  • GUEST rows are signed-in guest defaults. Missing guest rows mean denied.
Guest defaults use the same 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 is getPermissions() in packages/utils/src/workspace-helper.ts. It evaluates permissions in this order:
  1. Authenticate the current user with a request-scoped or cookie-scoped Supabase client.
  2. Normalize the incoming workspace identifier so personal and internal become real workspace IDs.
  3. Verify caller membership with verifyWorkspaceMembershipType(..., requiredType: 'ANY') so the helper can distinguish MEMBER from GUEST.
  4. For MEMBER, load role-derived permissions from workspace_role_members -> workspace_roles -> workspace_role_permissions.
  5. Load workspace-wide defaults from workspace_default_permissions using the resolved member_type.
  6. Load the workspace creator and treat that member as an implicit superuser.
If the user is the workspace creator, 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
If the user has no effective permissions and is not the creator, the helper returns 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:
That means creator and admin behave similarly at call sites, even though they come from different sources:
  • creator access is implicit and computed in code
  • admin access is explicit and stored like any other permission
For guests, 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 in packages/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
The root workspace adds an extra infrastructure-only group. Some catalog entries are also conditionally exposed for root or @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 in apps/web:
  1. Resolve the workspace with WorkspaceWrapper or getWorkspace().
  2. Call getPermissions({ wsId }).
  3. Redirect or notFound() when the required permission is missing.
  4. Pass containsPermission / withoutPermission into the page composition.
Examples:
  • apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/roles/page.tsx
  • apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/members/page.tsx
  • apps/web/src/app/[locale]/(dashboard)/[wsId]/(workspace-settings)/settings/page.tsx
  • apps/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 in apps/web routes.

1. Session auth only

Some routes only verify that the caller is signed in, usually through createClient(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 with normalizeWorkspaceId(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 call getPermissions() 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:
Do not load a child row by ID with service-role privileges and then separately validate only the supplied parent route data. A mismatched child ID can expose cross-workspace data before later related lookups return empty results. Education authoring and review APIs are also feature-gated. Routes that expose quiz answer keys, learner attempt metadata, flashcards, quiz sets, or other Education admin surfaces must use 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 use withApiAuth(...) 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
That keeps permission IDs consistent across browser/session routes and external API routes, even though the authentication mechanism is different. Legacy routes that validate an API_KEY header and then switch to an admin client must also prove that every path resource belongs to [wsId] before any other query, and keep ws_id on every later query. The dataset routes (/api/v1/workspaces/[wsId]/datasets, .../[datasetId] and .../[datasetId]/full) require ai_lab for API keys and return 404 for a dataset outside the key’s workspace. Dataset updates accept only name, description and url (ws_id and unknown fields are rejected). Every request made with a verified key, allowed or denied (401, 403, 404, 400), is recorded in workspace_api_key_usage_logs under the key’s own workspace with endpoint, method, status, timing, IP and user agent, but no request body or key material. Session callers resolve a verified server-side actor and use the shared getPermissions helper for workspace membership and ai_lab. Anonymous requests return 401, non-members return 404, and members without ai_lab return 403 before any dataset query. Pages use 401/404 render interrupts. Authorized calls use the admin client because 20260312113000_lock_description_tables_to_server_apis removed client table grants and policies in March 2026; those grants and policies remain closed. This applies to dataset list/detail/explore/settings pages, metrics, imports, duplicate handling, and every dataset API method. The dataset parent lookup is bound to the verified workspace (workspace_datasets.ws_id). Child queries are bound to that verified parent through dataset_id; column/row ids also retain the dataset predicate. Inserts stamp the verified dataset or workspace, never a body-supplied owner. API keys retain their existing workspace and ai_lab authority and usage logging. Usage statistics keep masking values when the viewer lacks ai_lab. A dataset cell’s row and column must belong to the cell’s dataset. The cell and row write routes check this before writing, a database trigger rejects mismatched cells and dataset moves for rows and columns, and the workspace_dataset_row_cells view only aggregates cells and columns of the row’s own dataset. Regression coverage: route.test.ts and [datasetId]/route.test.ts and [datasetId]/cell-parent-binding.test.ts under apps/web/src/app/api/v1/workspaces/[wsId]/datasets/, including session-access.test.ts for every endpoint and foreign parent/child ids; apps/web/src/lib/datasets/{access,page-access}.test.ts; the opt-in local-chain.test.ts for real local Auth, shared permissions, PostgREST and admin writes while authenticated table grants remain revoked; and apps/database/supabase/tests/workspace-dataset-cell-parent.sql.

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.role represents the effective permission model in apps/web.
  • Treat admin as 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.

Database staff identity checks

SQL policies and definer functions never treat a Tuturuuu-domain email as proof of staff identity. They call private.is_verified_tuturuuu_staff(user, allow_xwf) (policies use the caller-bound public.is_verified_tuturuuu_staff, which only answers for auth.uid() unless the caller is service_role). It requires the auth.users email to match the gate’s original domain set, a confirmed email, at least one provider identity, and every azure identity’s tid (or issuer directory) to equal private.security_config.azure_staff_tenant_id. A missing or empty tenant pin denies Azure-linked users; non-Azure staff are unaffected. Domains are never widened: gates default to exact @tuturuuu.com; only the workspace-creation quota bypass (formerly public.is_tuturuuu_email) admits xwf.tuturuuu.com. Mail push keeps its own *.tuturuuu.com mailbox rule and adds the identity check through private.staff_identity_verified. Covered gates: shortened_links team policies, the workspace quota bypass, online meeting creation, mail push recipients, meeting programming elevation, AI memory backfill, employee administrator and product-feedback staff assertions. Regression evidence: apps/database/supabase/tests/20261027151470_verified-staff-domain-gates.sql runs foreign-tenant, home-tenant, no-Azure, unconfirmed, external, xwf and zero-identity users through each gate. SQL fixtures that insert auth.users directly must also insert an auth.identities row, as GoTrue does.

Notification email recipient eligibility

The Web immediate and batched notification dispatchers admit confirmed external account destinations as well as existing internal destinations. A public account is not excluded because its address is outside tuturuuu.com. External admission requires the notification’s user ID to resolve to a confirmed Auth email matching the selected destination. Missing confirmation, stale profile or batch addresses, and external email-only targets remain undeliverable. Existing internal email-only invitations retain their transactional behavior. The deliberate root-workspace email rollout remains in force. This recipient rule does not enable notification email for other workspaces, activate a provider, backfill old notifications, or retry previously skipped deliveries. Push keeps its existing workspace and personal Mail rules. At dispatch, each queued email event rechecks the current account email-channel preference, the account workspace-activity preference for workspace events, and the event’s workspace preference with account-event fallback. Missing preferences retain the existing RPC default of enabled. Explicit opt-outs stop delivery; failed or malformed preference lookups fail closed. Queued security_alert events also respect the account security_alerts category. Transactional account updates and invitations do not acquire a marketing preference gate. Dedicated login, recovery, and security mail outside the notification queue keep their own policy. Age limits, current workspace membership, blacklist checks and the EmailService suppression boundary still apply. Notification senders never request a blacklist bypass. Unsubscribe, complaint, bounce and suppression-lookup failures remain owned by EmailService. Provider acceptance and application delivery status do not prove recipient inbox delivery. Regression coverage: apps/web/src/lib/notifications/email-eligibility.test.ts, cron-helpers.test.ts, and the immediate and batched notification route tests cover confirmed and unconfirmed external destinations, identity mismatch, account/workspace/event/channel opt-outs, suppression and transactional/security distinctions. These fixtures prove source behavior, not production delivery.

Staff-session verification

Staff-only server operations use isVerifiedTuturuuuStaffSession from @tuturuuu/utils/email/staff-session after a current, subject-bound Auth lookup. A staff address alone is insufficient: email confirmation and complete provider identity evidence are required. TUTURUUU_AZURE_TENANT_ID is server-only configuration; leaving it unset denies Azure-linked staff sessions. Confirmed staff accounts using other providers retain access. Existing workspace roles, mailbox membership, consent and feature gates continue to apply independently. This containment covers internal Mail sending and drafting, inquiries, task embedding administration, Mira tools and backfill, Infrastructure controls, staff pages, import access, meeting exemptions and signed Colab staff assertions. Address syntax validation and UI visibility checks do not grant server access. The long-term authorization model is a server-verified staff role. Regression evidence lives in packages/utils/src/email/__tests__/staff-session.test.ts and the affected routes’ staff-session.test.ts suites. These fixtures verify denials before protected reads, writes, sends or generation; hosted deployment configuration and production acceptance require separate operator verification. AI calendar generation and temporary-auth Mail drafting resolve the requested workspace and require MEMBER membership before memory or provider work. Memory attribution uses the canonical authorized workspace id, including default scopes; membership lookup failures stop the operation. The route scope regressions cover nonmembers, guests, lookup failures and canonical workspace attribution.

Workspace resource access

Boards

Capacity rules are scoped to the board’s own workspace. A board that belongs to another workspace than the route’s wsId, or that does not exist, returns 404 for reads and writes before any permission lookup, so callers cannot tell the two apart. Configuring rules also requires manage_projects in the route workspace and edit access to the board; reading keeps view access. Creating or disabling a board’s public link requires a workspace member with manage_projects (the board resolver’s member edit access). A guest share, even with edit permission, cannot create or disable the link. Reading the link keeps view access, so a guest can still read it. Adding, changing or removing a board’s guest shares follows the same rule: a workspace member with manage_projects, never a guest share with edit permission. Listing shares keeps view access. Regression coverage: the Tasks board capacity-rules/access.test.ts, public-link/access.test.ts and shares/access.test.ts.