> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuturuuu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authorization & Permissions

> How apps/web evaluates workspace-scoped permissions and where those checks are enforced

`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:

```ts theme={null}
const isAdmin = permissions.includes('admin');

const containsPermission = (permission: PermissionId) => {
  return isCreator || isAdmin || permissions.includes(permission);
};
```

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`).

```ts theme={null}
const membership = await verifyWorkspaceMembershipType({
  wsId: normalizedWsId,
  userId: user.id,
  supabase,
});

if (membership.error === 'membership_lookup_failed') {
  return NextResponse.json({ message: 'Failed to verify workspace access' }, { status: 500 });
}

if (!membership.ok) {
  return NextResponse.json({ message: "You don't have access to this workspace" }, { status: 403 });
}
```

`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:

```ts theme={null}
await admin
  .schema('private')
  .from('user_group_posts')
  .select('id, group_id, workspace_user_groups!inner(ws_id)')
  .eq('id', postId)
  .eq('group_id', groupId)
  .eq('workspace_user_groups.ws_id', normalizedWsId)
  .maybeSingle();
```

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.

## 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.
