Authentication & Authorization
How authentication and authorization work inside the Spectral codebase. For the product-level role model, see Access Control. This page covers the implementation — what you need to know when adding or modifying endpoints.
Auth flow overview
Section titled “Auth flow overview”Every request to /api/* passes through AuthMiddleware
(spectral_api.middleware.auth_v2), which:
- Extracts a token from the
Authorization: Bearerheader oraccess_tokencookie. - Verifies the token using JWKS-local validation (per ADR-039 D4a) — PyJWT’s
PyJWKClientagainst<project>.supabase.co/auth/v1/.well-known/jwks.json. No per-request round-trip; JWKS cached locally withkid-bypass refetch on miss. API keys resolve by hash. - Checks user-level revocation against the
core.usersmirror’sstatuscolumn (per ADR-039 D4b). Same per-request DB lookup as workspace-role; zero additional DB hits. - Resolves workspace context from the
X-Workspace-Idheader — looks up the user’s role in that workspace via theworkspace_memberstable. - Computes scopes by calling
resolve_scopes(account_role, workspace_role)fromspectral.platform.workspaces.rbac. - Attaches an
AuthContexttorequest.state.auth.
Frontend-side, @supabase/supabase-js getClaims() is the canonical claim extraction path
(per ADR-039 D5) — never getSession() (unsafe server-side) or getUser() (unnecessary
round-trip once JWKS-local lands). The Cloudflare Pages Function on codex.runspectral.com
reuses the same JWKS-local pattern with a Cloudflare KV-backed cache (kid-miss bypass);
a contract test enforces parity across the three verifiers (FastAPI Python, Operations Start
TypeScript, Pages Function TypeScript).
AuthContext
Section titled “AuthContext”The middleware produces an AuthContext dataclass available to every route handler:
@dataclassclass AuthContext: user_id: str account_id: str | None = None account_role: str | None = None # "operations", "owner", or None workspace_id: str | None = None # from X-Workspace-Id header workspace_role: str | None = None # "admin", "contributor", "observer" scopes: frozenset[str] = field(default_factory=frozenset) auth_type: str = "jwt" # "jwt" or "api_key"Scopes use the format action:resource (e.g., read:workspace, admin:account,
read:operations). The OPERATIONS scope family identifiers match the role that holds them
(AccountRole.OPERATIONS).
FastAPI dependencies
Section titled “FastAPI dependencies”Two dependencies enforce authorization on routes. Use them via Depends().
require_permission(permission)
Section titled “require_permission(permission)”Checks that permission is in the user’s scopes. Returns AuthContext on success, raises 401
(not authenticated) or 403 (missing scope).
from spectral_api.middleware.auth_v2 import require_permission
@router.get("/api/configs", dependencies=[Depends(require_permission("read:workspace"))])def list_configs( tenant_id: str = Depends(get_tenant_id), db=Depends(get_db_connection),): # tenant_id is the account_id from auth context rows = db.execute("SELECT ... WHERE tenant_id = %s", (tenant_id,)) ...require_workspace_match(permission)
Section titled “require_workspace_match(permission)”Like require_permission, but additionally validates that the workspace_id path parameter
matches auth.workspace_id. This prevents a user from setting one X-Workspace-Id header but
hitting a different workspace’s URL.
from spectral_api.middleware.auth_v2 import require_workspace_match
@router.get("/api/workspaces/{workspace_id}/curation/status")def get_curation_status( workspace_id: str, _auth: AuthContext = Depends(require_workspace_match("read:workspace")),): # workspace_id is guaranteed to match auth context ...Helper dependencies
Section titled “Helper dependencies”| Dependency | Returns | Use when |
|---|---|---|
get_workspace_id(request) | str | You need the workspace ID without a path parameter |
get_tenant_id(request) | str | You need the account ID (legacy name) |
Workspace isolation
Section titled “Workspace isolation”Workspace isolation is enforced at three levels:
- Middleware — resolves workspace membership and computes scopes per-request from the database. Membership revocation takes effect immediately (no stale JWTs).
- Route handlers — all queries against workspace-scoped data must include
workspace_idfrom the auth context. Never accept a workspace ID from the request body or query params without validation. - Database RLS via SET LOCAL — the connection pool sets
app.user_id(and related session variables) at checkout per ADR-041; RLS policies on workspace-scoped tables compare againstcurrent_setting('app.user_id')::uuid. The application-layer workspace filter is the primary defense; RLS is the structural backstop that prevents accidental cross-workspace reads even when a query forgets the filter.
Implicit owner access
Section titled “Implicit owner access”Users with owner or operations account roles get implicit admin access to all workspaces
without needing a workspace_members row. This is resolved in the middleware’s
_resolve_workspace_role() method.
Fail-closed behavior
Section titled “Fail-closed behavior”If the database is unreachable during workspace role resolution, the middleware returns 503 rather than granting access. The system fails closed — no access on error.
Roles and scopes
Section titled “Roles and scopes”Scopes are computed as the union of account-level and workspace-level role grants.
| Workspace Role | Scopes |
|---|---|
observer | read:workspace |
contributor | read:workspace, write:workspace, read:agents |
admin | read:workspace, write:workspace, approve:agents, admin:workspace, read:agents |
| Account Role | Scopes |
|---|---|
owner | admin:account + all workspace admin scopes (implicit admin) |
operations | All scopes (Spectral staff only) — including read:operations, write:operations, admin:operations, delete:operations for the operations-staff surface |
Scope resolution lives in spectral.platform.domain.workspaces.rbac.resolve_scopes() — pure domain logic
with no infrastructure dependencies.
API key authentication
Section titled “API key authentication”API keys use the format sk_live_{random}. They are hashed with SHA-256 and stored in the
api_keys table. Each key is permanently bound to one workspace.
Scope restrictions: API keys can only have read:agents and write:traces. They cannot
have workspace, admin, account, or platform scopes.
The middleware resolves API keys by:
- Hashing the token with SHA-256
- Looking up the hash in
api_keys - Checking
is_activeandexpires_at - Filtering scopes against the allowed set
- Returning an
AuthContextwith the key’sworkspace_id - Updating
last_used_at
Invite system
Section titled “Invite system”Workspace invites live in a dedicated workspace_invites table with a
tokenized acceptance flow. The legacy app_metadata.invited_to_workspace
path has been retired in favor of first-class server-managed state.
-
Admin issues invite (
POST /api/v1/workspaces/{id}/members) — requiresadmin:workspace.spectral.platform.infrastructure.shared.auth.invites.invite_to_workspacegenerates a cryptographically random token, stores its sha256 hash inworkspace_invites, and for new users calls Supabase Authinvite_user_by_emailwithoptions.redirect_topointing at{site_url}/auth/accept-invite?token=.... The plaintext token lives only in the email body — never in the database, logs, or telemetry. Existing Supabase users get the row without an email; they discover it via the dashboard’s pending-invites surface. -
Invitee clicks the email link → Supabase confirms their email → redirects to
/auth/accept-invite?token=...with an authenticated session. -
Dashboard accept page immediately strips the token from the URL via
history.replaceState(and sendsReferrer-Policy: no-referrer) before POSTing{ token }toPOST /api/v1/invites/accept. -
Acceptance endpoint (
AcceptInviteUseCase) hashes the token, looks up the row, enforces email match, checks expiry and consumed state, materializes theworkspace_membersrow viaON CONFLICT DO NOTHING, and marks the invite consumed. Idempotent — re-POSTing a token this user already accepted returns the same workspace context withalready_accepted: true.
Admin surface (workspace-scoped)
Section titled “Admin surface (workspace-scoped)”| Route | Permission |
|---|---|
POST /api/v1/workspaces/{id}/members | admin:workspace |
GET /api/v1/workspaces/{id}/members/invites | admin:workspace |
DELETE /api/v1/workspaces/{id}/members/invites/{invite_id} | admin:workspace |
Acceptance surface (globally-scoped by token)
Section titled “Acceptance surface (globally-scoped by token)”POST /api/v1/invites/accept is authenticated but has no workspace
permission check — at acceptance time the caller is not yet a workspace
member, so any require_permission("read:workspace") dependency would
403 them before they could join. The token resolves the workspace
server-side.
Why not store the token in app_metadata?
Section titled “Why not store the token in app_metadata?”The previous implementation wrote invited_to_workspace to
auth.users.app_metadata and relied on a signup hook consumer that was
never wired up. The dedicated table makes admin read/revoke a
first-class operation, supports token-based acceptance for both new and
existing users, and is enforced by RLS policies on the
workspace_invites table itself.
Adding auth to a new endpoint
Section titled “Adding auth to a new endpoint”- Choose the right dependency:
require_permissionfor account/global routes,require_workspace_matchfor workspace-scoped routes with a path parameter. - Pick the correct scope:
read:workspacefor GET,write:workspacefor POST/PUT/DELETE,admin:workspacefor management operations,read:operations/write:operationsfor staff-only,admin:operationsfor administrative operations,delete:operationsfor destructive operations. - Filter queries by workspace: Use
auth.workspace_idorget_tenant_id()in all data queries. Never query across workspaces.
# Minimal workspace-scoped endpoint@router.get("/api/workspaces/{workspace_id}/things")def list_things( workspace_id: str, auth: AuthContext = Depends(require_workspace_match("read:workspace")), db=Depends(get_db_connection),): rows = db.execute( "SELECT * FROM things WHERE workspace_id = %s", (auth.workspace_id,), ).fetchall() return [dict(r) for r in rows]Related runbooks
Section titled “Related runbooks”docs/runbooks/auth.md— operator playbook for JWKS rotation, invite revocation, session-var debugging, and the auth-failure decision tree.