Sprinter Docs

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:

FunctionWhat it doesDB 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 roleDB slugTypical use
ownersystem_adminFull access, can promote to admin
admintenant_adminManage workspace settings, agents, members
membereditor / memberCreate, read, update records
viewerviewerRead-only access to records
guestguestDefault 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

PermissionWho needs it
entities.own.readMinimum — all roles
entities.team.readView other users' records
entities.own.createCreate records
entities.team.updateEdit any record (not just own)
responses.team.createSubmit versioned responses without promoting canonical fields
entity_types.team.updateEdit data type schemas and views
agents.team.readSee agents in chat selector
tools.team.executeExecute tools
admin.tenant.manageAccess Admin panel
custom_pages.team.manageCreate, 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 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:

RoleRationale
system_adminFull platform control
tenant_adminManage 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)

  1. Access Admin panel

    • Go to /admin as admin
    • Expected: All tabs visible and functional
  2. 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
  3. 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

PropertyEnforcement
Guest cannot browse other pagesProxy 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-scopedPOST /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

SymptomLikely CauseFix
401 on every API requestJWT expired and session refresh failedSign out and back in; check Supabase project status
User has admin role but 403 on Admin pageRole not reflected in user_tenantsCheck user_tenants row; role_id may not match tenant_admin UUID
Agent tool calls failing with permission errorAgent role_id lacks the required permissionUpdate agent role in Admin > Agents; ensure role has correct permissions in role_permissions
hasPermission() returns false for adminuser_permissions view not updatedRun pnpm db:types after any role_permissions migration; check the view definition
Admin user gets 403 on audit log immediately after promotionuser_permissions view has not refreshed yetThis 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 keysKey was created before the created_by column was tracked, or resolveApiKeyPermissions() is not called in the route handlerRevoke 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 recordsThe 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.

On this page