Documentation source
Authentication and Permissions
JWT-based auth, role-based access control, and 64 granular permissions shared between users and agents.
# Authentication and Permissions
The platform uses Supabase Auth for authentication and a layered RBAC system for authorization. All auth checks go through a centralized adapter in `features/tenant/auth.ts` — never raw Supabase client calls. Users and agents share the same role and permission system.
## Overview
### Authentication
Auth is JWT-based. The middleware (`proxy.ts`) calls `supabase.auth.getClaims()` on every request — this validates the JWT locally via JWKS with zero network calls. If the access token is expired, `getSession()` triggers a silent refresh.
All protected pages redirect to `/login` for unauthenticated requests. API routes return 401 without redirect. The `/share/[token]` route is intentionally public.
### Auth adapter functions
Always import from `@/features/tenant/auth` — never call Supabase auth directly:
| Function | What it does | DB calls |
| ------------------------------- | --------------------------------------------------------------------------- | ------------ |
| `getUserId()` | Returns current user ID from JWT. Returns `null` if unauthenticated. | 0 |
| `requireAuth()` | Returns full `TenantContext`. Throws 401 if not authenticated. | 1 (cached) |
| `requireAdmin()` | Returns `TenantContext`. Throws 403 if not owner/admin. | 1 (cached) |
| `hasPermission(perm)` | Returns `boolean`. Checks the `user_permissions` view. | 1–2 (cached) |
| `requirePermission(perm)` | Returns `TenantContext`. Throws 403 if permission missing. | 1–2 (cached) |
| `getPermissionsForRole(roleId)` | Returns permission array for an agent's role. Used in autonomous execution. | 1 |
All functions are cached per request via React's `cache()` — calling them multiple times in one render does not multiply DB round trips.
## Roles
Five app roles, mapped to DB slugs:
| App role | DB slug | Typical use |
| -------- | ------------------- | ------------------------------------------ |
| `owner` | `system_admin` | Full access, can promote to admin |
| `admin` | `tenant_admin` | Manage workspace settings, agents, members |
| `member` | `editor` / `member` | Create, read, update records |
| `viewer` | `viewer` | Read-only access to records |
| `guest` | `guest` | Default on signup — read-only |
New signups are provisioned as `guest` in the default tenant via `ensureUserProvisioned()`. Promote via Admin > Members.
## Permissions
The `app_permission` enum has 64 granular permissions in the format `{resource}.{level}.{action}`:
- **Resource examples:** `entities`, `entity_types`, `comments`, `agents`, `tools`, `documents`
- **Level:** `own` (personal), `team` (tenant-wide), `admin`
- **Action:** `read`, `create`, `update`, `delete`
Permissions are stored in the `role_permissions` table and denormalized into `user_permissions` for fast lookup.
### Key permissions
| Permission | Who needs it |
| -------------------------- | ---------------------------------------------------------------------- |
| `entities.own.read` | Minimum — all roles |
| `entities.team.read` | View other users' records |
| `entities.own.create` | Create records |
| `entities.team.update` | Edit any record (not just own) |
| `responses.team.create` | Submit versioned responses without promoting canonical fields |
| `entity_types.team.update` | Edit data type schemas and views |
| `agents.team.read` | See agents in chat selector |
| `tools.team.execute` | Execute tools |
| `admin.tenant.manage` | Access Admin panel |
| `custom_pages.team.manage` | Create, edit, delete custom pages (tenant-default or workspace-scoped) |
## How It Works
### Supervised vs. autonomous execution
**Supervised (user in the loop — chat):**
- Agent inherits the current user's permissions.
- Call `getUserPermissions()` and pass to `resolveAgentTools(config, permissions)`.
**Autonomous (heartbeat, triggers, extraction):**
- Agent uses its own role's permissions.
- Call `getPermissionsForRole(agent.role_id)` and pass to `resolveAgentTools`.
- Agents never see tools they don't have permission to call.
### API key execution
**External systems and MCP tool servers** authenticate via API key. Effective permissions are computed by `resolveApiKeyPermissions()` in `features/api-keys/lib/permissions.ts`:
```
effective permissions = role_permissions(creating_user.role) ∩ scope_permissions(key.scopes)
```
- The key can never exceed the permissions of the user who created it.
- The `*` scope grants all of the creating user's permissions with no further filtering.
- If the creating user cannot be resolved (e.g., a key predating the `created_by` column), the system falls back to `ROLE_IDS.member` permissions.
Resolved permissions are passed to `resolveAgentTools()` the same way as for supervised and autonomous agents. See [API Keys](/docs/features/api-keys) for the full scope-to-permission mapping.
### Tool permission gating
Every tool bundle declares required permissions:
- Entity tools: `ENTITY_TOOL_PERMISSIONS` map in `entity-tools.ts`
- Response tools: `RESPONSE_TOOL_PERMISSIONS` map in `response-tools.ts`
- User-facing tools: optional `requiredPermission` field on `ToolDefinition`
- `getEntityTools(permissions)` excludes tools the caller cannot use
- `executeTool()` checks `requiredPermission` when `options.permissions` is provided
### Supabase client selection
- **Authenticated client** (`createClient`) — user-scoped reads/writes, RLS applies
- **Admin client** (`createAdminClient`) — cross-user/system ops only: tenant management, provisioning, background jobs
- **Security-definer RPCs** — allowed only when the SQL function repeats the same tenant/entity authorization checks or is `service_role`-only. `promote_field_value` is the model: authenticated callers must match the active tenant, the response's entity, and `can_access_entity(entity_id, 'update', auth.uid())`; service-role callers are expected to pre-authorize in application code.
### Entity access and sharing
Entity RLS funnels through `public.can_access_entity(entity_id, action, user_id)`.
That predicate is the source of truth for dynamic entity sharing:
1. Resolve the target entity and require `entity.tenant_id = get_active_tenant_id()` for authenticated users.
2. Normalize `share` to `update`, and `respond` / `export` to `read`.
3. Grant `entities.all.*` and `entities.team.*` across the active tenant.
4. Grant `entities.own.*` only when the caller owns the entity or has an explicit `entity_shares` row.
5. Interpret share roles as: `viewer` / `commenter` / `editor` can read; only `editor` can update.
6. Allow anonymous read only for `visibility = 'public'`.
Associated tables (`entity_responses`, `criteria_sets`, `entity_relations`, comments, favorites, and recent views) reference `can_access_entity()` in their RLS policies so a share or ownership change applies consistently to the whole record surface. Application routes and server actions must still call `requireAuth()` / `requirePermission()` before using admin-client helpers; RLS is the final database boundary, not a replacement for route auth.
Share-management routes currently treat `share` as `update`, so an editor share can manage share-token endpoints. That behavior is covered by tests and should not be changed casually; an owner-only share-management policy is tracked as backlog until the product semantics are explicitly changed.
### Workspace-scoped settings resolution
The `tenant_settings` table is scope-aware under the canonical 4-tier resolver (ADR-0013 D12): `user > workspace > tenant > platform`. Four partial unique indexes guarantee one row per tier branch for any given `(tenant_id, key)` pair:
- Tenant default — `workspace_id IS NULL AND user_id IS NULL`
- Workspace default — `workspace_id IS NOT NULL AND user_id IS NULL`
- User-on-tenant override — `workspace_id IS NULL AND user_id IS NOT NULL`
- User-on-workspace override — `workspace_id IS NOT NULL AND user_id IS NOT NULL`
`getResolvedSetting(tenantId, key, { workspaceId?, userId? })` from `features/context/server/get-resolved-setting.ts` is the canonical single-value reader. It runs through the admin client with an explicit, UUID-validated `eq("tenant_id", …)` filter, reads across all four branches in one round trip, and picks the most-specific match via `pickResolvedRow` from `features/admin/lib/scope-resolver.ts`. Tenant isolation comes from the explicit filter (the admin client bypasses RLS); the resolver also re-filters rows in TS as defense-in-depth.
For LAYERED cascade reads (object-merge across tiers, used by dashboard preferences and similar), `getSettingsForKey` in `features/settings/server/cached-queries.ts` remains the right reader. It is workspace-unaware today (pins `workspace_id IS NULL`); the 4-tier upgrade is tracked as a follow-up. New code that needs the full resolver should use `getResolvedSetting`.
A startup guard probes `pg_indexes` and emits a one-time warning if the four 4-tier indexes are missing (e.g., before the migration applies) — the resolver still returns the right answer because tier resolution happens in TS regardless of the underlying uniqueness shape.
### Scope-aware authorization (ADR-0013 D11)
`hasPermission(perm, opts?)` and `requirePermission(perm, opts?)` accept an optional `{ workspaceId }` argument. Default behavior (no opts) is unchanged: tenant-role check via the cached `user_permissions` denorm. When `{ workspaceId }` is passed, the check is **OR'd with a workspace-role grant** read live from `workspace_memberships → role_permissions` — the SQL counterpart is `authorize_in_workspace(perm, workspace_id)`. This lets workspace admins (a `tenant_admin` role attached to a `workspace_memberships` row, not the tenant-wide membership) authorize writes against scope-aware tables without holding the tenant-wide admin role. Tenant-role grants stay cached for performance; workspace-role grants are a live join — the staleness asymmetry is accepted for v1. Server actions on the 10 scope-aware tables (`agents`, `agent_connections`, `webhook_endpoints`, `inbound_webhook_endpoints`, `external_data_sources`, `views`, `criteria_sets`, `chats`, `tenant_settings`, `custom_pages`) resolve the active workspace via `getActiveWorkspace()` and forward `{ workspaceId }`. Pages outside a workspace URL pass `undefined` and the call falls back to the tenant-only branch.
Both TS and SQL workspace branches must pin the membership row to the active tenant. The TS adapter queries `workspace_memberships` with `(workspace_id, tenant_id, user_id)`, mirroring `authorize_in_workspace()`. Workspace update and member-management actions pass `{ workspaceId }` when the operation targets an existing workspace. Workspace creation, deletion, and bundle install remain tenant-level administrative operations unless the product explicitly moves those flows under an existing workspace scope.
### System-agent boundary
Global system agents are shared platform rows (`is_system = true`, `tenant_id IS NULL`). Tenant admins can read and fork them into tenant/workspace-owned agents, but they must not mutate the global row. Routes that mutate an existing system agent require the `owner` app role (`system_admin` DB role); helpers that use the admin client must accept tenant/system context and re-check it before writing. The rollback route and `rollbackToVersion()` helper enforce this boundary so a tenant admin can roll back tenant-owned agents but cannot roll back a global system agent.
### `custom_pages.team.manage` permission
Added in migration `20260429100000_custom_pages.sql`. Granted to:
| Role | Rationale |
| -------------- | ------------------------------------------------ |
| `system_admin` | Full platform control |
| `tenant_admin` | Manage tenant-default and workspace-scoped pages |
The `editor` role is **not** granted `custom_pages.team.manage` at the tenant level — doing so would let any tenant editor edit tenant-default custom pages (`workspace_id IS NULL`), because `authorize_in_workspace()` accepts the editor's tenant-role grant on the tenant-default branch. The workspace-admin persona still administers workspace-scoped pages: they hold `tenant_admin` on their `workspace_memberships` row, which the helper picks up from the workspace-role branch. Promoting an editor to manage workspace-scoped pages requires explicit workspace membership, not a tenant-wide grant.
The RLS write policies enforce scope boundaries: a workspace-role-only member can insert/update/delete rows where `workspace_id = get_active_workspace_id()` but cannot touch rows where `workspace_id IS NULL` (tenant defaults).
### `assignWorkspaceRole` anti-escalation contract
`assignWorkspaceRole(workspaceId, userId, newRoleId)` in `features/workspaces/server/actions.ts` enforces:
1. `newRoleId` must be one of the canonical six UUIDs in `ROLE_IDS` (validated against `VALID_ROLE_IDS` set). Unknown UUIDs are rejected before any DB call — without this, a non-canonical role_id would slip past the anti-escalation cap (`roleLevel()` returns 0 for unknown UUIDs) and corrupt the membership row.
2. Caller must hold `workspaces.team.manage` (tenant-wide or workspace-scoped).
3. The workspace must belong to the caller's active tenant — defends against foreign-UUID cross-tenant writes.
4. The role being assigned must not exceed `max(callerTenantRoleLevel, callerWorkspaceRoleLevel)`. A workspace admin who is `viewer` at the tenant but `tenant_admin` on this workspace can administer their own workspace; tenant-only `editor` cannot promote anyone to `tenant_admin`.
5. The membership UPDATE is atomic (`.update().select()`): if no `(workspace_id, user_id, tenant_id)` row matches, the empty array surfaces "Membership not found" — no separate existence check, no TOCTOU window.
This prevents privilege escalation via workspace membership even when the caller is a legitimate workspace admin.
Member removal follows the same anti-escalation shape. A caller can remove lower-ranked members from a workspace they administer, but cannot remove a peer or higher-ranked membership unless they first operate from a higher tenant/workspace role. The delete itself is filtered by `(workspace_id, user_id, tenant_id)` so a workspace UUID or user UUID from another tenant cannot be used as a write target.
## Manual Test Script
### Prerequisites
- At least two accounts: one `admin`, one `guest`
- Logged into the guest account
### Happy Path (guest role)
1. **Access a read-only page**
- Go to `/{typeSlug}` as a guest
- Expected: Records visible; no **New** button; no edit controls
2. **Attempt to access Admin**
- Navigate to `/admin`
- Expected: Redirect to dashboard or 403 page
3. **Attempt to create a record via API**
- `POST /api/entities` with a valid body
- Expected: 403 response — guest lacks `entities.own.create`
### Happy Path (admin role)
4. **Access Admin panel**
- Go to `/admin` as admin
- Expected: All tabs visible and functional
5. **Verify permission-gated tool**
- A tool with `requiredPermission: "tools.team.execute"` should be accessible to members but not guests
- Expected: Tool card enabled for member, disabled for guest
6. **Agent autonomous permissions**
- Trigger a heartbeat run for an agent with `viewer` role
- Expected: Agent can read records but cannot create or update them
### Regression Checks
- [ ] `getUserId()` returns `null` for unauthenticated requests (not throw)
- [ ] `requireAuth()` throws with status 401, not 403, for unauthenticated requests
- [ ] Promoting a guest to member via Admin > Members immediately grants create permissions
- [ ] `getPermissionsForRole()` returns only permissions assigned in `role_permissions`, not the calling user's permissions
## Guest View Isolation
Invited guest users can be confined to a single fullscreen view (e.g. a quiz or intake form) without access to the rest of the platform.
### How It Works
1. **Invite with `viewId`** — Admin calls `POST /api/auth/invite` with `{ email, role: "guest", tenantId, viewId }`. The `viewId` is written into the new user's `app_metadata.assigned_view_id` in Supabase Auth.
2. **JWT carries the assignment** — On every authenticated request, `proxy.ts` reads `app_metadata.assigned_view_id` from the JWT claims (zero network calls via `getClaims()`).
3. **Proxy enforces the redirect** — If `assignedViewId` is set and the request path is not in the allowed list, the proxy issues a 307 redirect to `/present/{assignedViewId}`.
Allowed paths for confined guests:
- `/present/` — the assigned view itself
- `/api/` — API calls (embed response persistence, form completion)
- `/login`, `/auth/`, `/reset-password` — auth flow
4. **Presentation route renders the view** — `/present/[id]` fetches the view via admin client, validates tenant ownership, and renders it through the standard `resolveView()` → `SurfaceRenderer` pipeline with no app shell.
### Invite Email
When `viewId` is provided to `POST /api/auth/invite`:
- The email subject becomes `"You've been invited to complete {viewName}"`.
- The body references the view name and description (if set).
- The CTA changes from "Sign In" to "Get Started".
- The login URL becomes `/login?next=/present/{viewId}` — after password reset + login, the user lands directly on the assigned view.
### Security Properties
| Property | Enforcement |
| ----------------------------------------------- | ---------------------------------------------------------------------- |
| Guest cannot browse other pages | Proxy redirect on every non-allowed path |
| Guest cannot access another tenant's view | `/present/[id]` checks `view.tenant_id === ctx.tenantId` |
| Unauthenticated users cannot access `/present/` | `PresentLayout` calls `getUserId()` and redirects to `/login` |
| Entity creation from form is tenant-scoped | `POST /api/form-flow/complete` validates `publishToken` and `tenantId` |
### Removing the Assignment
To lift a guest's view confinement, remove `assigned_view_id` from their Supabase Auth `app_metadata` (via Admin > Members or the Supabase Dashboard). The next JWT refresh will no longer contain the field and the proxy will stop redirecting.
## Design Decisions
**`requireAdmin()` vs `requirePermission()` for admin-only routes.** The `requireAdmin()` function checks membership in `user_tenants` directly (role slug `system_admin` or `tenant_admin`), while `requirePermission()` consults the `user_permissions` materialized view. The view can lag on role promotion — a user just promoted to admin via Admin > Members may have correct role data in `user_tenants` but stale data in `user_permissions` until the view refreshes. For routes where the intent is "only admins" (not fine-grained per-permission expressiveness), `requireAdmin()` is preferred because it is always current. The audit log route (`GET /api/audit`) uses `requireAdmin()` for this reason.
## Troubleshooting
| Symptom | Likely Cause | Fix |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 401 on every API request | JWT expired and session refresh failed | Sign out and back in; check Supabase project status |
| User has admin role but 403 on Admin page | Role not reflected in `user_tenants` | Check `user_tenants` row; `role_id` may not match `tenant_admin` UUID |
| Agent tool calls failing with permission error | Agent `role_id` lacks the required permission | Update agent role in Admin > Agents; ensure role has correct permissions in `role_permissions` |
| `hasPermission()` returns false for admin | `user_permissions` view not updated | Run `pnpm db:types` after any role_permissions migration; check the view definition |
| Admin user gets 403 on audit log immediately after promotion | `user_permissions` view has not refreshed yet | This should no longer occur — the audit route uses `requireAdmin()` which reads `user_tenants` directly. If it persists, check that the user's `role_id` in `user_tenants` matches a `system_admin` or `tenant_admin` slug. |
| MCP write operations fail with "Permission denied" even for admin API keys | Key was created before the `created_by` column was tracked, or `resolveApiKeyPermissions()` is not called in the route handler | Revoke and recreate the key (so `created_by` is populated). Ensure the route calls `resolveApiKeyPermissions()` and passes the result to `resolveAgentTools()`. |
| API key has `*` scope but still cannot create records | The creating user's role lacks create permissions | `*` scope returns all of the creator's permissions — not all system permissions. Recreate the key as an owner or admin user. |