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 toresolveAgentTools(config, permissions).
Autonomous (heartbeat, triggers, extraction):
- Agent uses its own role's permissions.
- Call
getPermissionsForRole(agent.role_id)and pass toresolveAgentTools. - 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_bycolumn), the system falls back toROLE_IDS.memberpermissions.
Resolved permissions are passed to resolveAgentTools() the same way as for supervised and autonomous agents. See API Keys for the full scope-to-permission mapping.
Tool permission gating
Every tool bundle declares required permissions:
- Entity tools:
ENTITY_TOOL_PERMISSIONSmap inentity-tools.ts - Response tools:
RESPONSE_TOOL_PERMISSIONSmap inresponse-tools.ts - User-facing tools: optional
requiredPermissionfield onToolDefinition getEntityTools(permissions)excludes tools the caller cannot useexecuteTool()checksrequiredPermissionwhenoptions.permissionsis 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_valueis the model: authenticated callers must match the active tenant, the response's entity, andcan_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:
- Resolve the target entity and require
entity.tenant_id = get_active_tenant_id()for authenticated users. - Normalize
sharetoupdate, andrespond/exporttoread. - Grant
entities.all.*andentities.team.*across the active tenant. - Grant
entities.own.*only when the caller owns the entity or has an explicitentity_sharesrow. - Interpret share roles as:
viewer/commenter/editorcan read; onlyeditorcan update. - 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:
newRoleIdmust be one of the canonical six UUIDs inROLE_IDS(validated againstVALID_ROLE_IDSset). 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.- Caller must hold
workspaces.team.manage(tenant-wide or workspace-scoped). - The workspace must belong to the caller's active tenant — defends against foreign-UUID cross-tenant writes.
- The role being assigned must not exceed
max(callerTenantRoleLevel, callerWorkspaceRoleLevel). A workspace admin who isviewerat the tenant buttenant_adminon this workspace can administer their own workspace; tenant-onlyeditorcannot promote anyone totenant_admin. - 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, oneguest - Logged into the guest account
Happy Path (guest role)
-
Access a read-only page
- Go to
/{typeSlug}as a guest - Expected: Records visible; no New button; no edit controls
- Go to
-
Attempt to access Admin
- Navigate to
/admin - Expected: Redirect to dashboard or 403 page
- Navigate to
-
Attempt to create a record via API
POST /api/entitieswith a valid body- Expected: 403 response — guest lacks
entities.own.create
Happy Path (admin role)
-
Access Admin panel
- Go to
/adminas admin - Expected: All tabs visible and functional
- Go to
-
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
- A tool with
-
Agent autonomous permissions
- Trigger a heartbeat run for an agent with
viewerrole - Expected: Agent can read records but cannot create or update them
- Trigger a heartbeat run for an agent with
Regression Checks
-
getUserId()returnsnullfor 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 inrole_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
-
Invite with
viewId— Admin callsPOST /api/auth/invitewith{ email, role: "guest", tenantId, viewId }. TheviewIdis written into the new user'sapp_metadata.assigned_view_idin Supabase Auth. -
JWT carries the assignment — On every authenticated request,
proxy.tsreadsapp_metadata.assigned_view_idfrom the JWT claims (zero network calls viagetClaims()). -
Proxy enforces the redirect — If
assignedViewIdis 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
-
Presentation route renders the view —
/present/[id]fetches the view via admin client, validates tenant ownership, and renders it through the standardresolveView()→SurfaceRendererpipeline 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. |
Multi-Tenant Workspaces
Create and switch between isolated organizations — each with their own data, members, roles, and agents. The URL is the sole source of truth for the active tenant, giving per-tab and per-device independence for free.
Realtime
Supabase Realtime subscriptions for live data updates, entity presence tracking, chat message delivery, and typing indicators.