Skip to content
GitHub
Developer

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.


Every request to /api/* passes through AuthMiddleware (spectral_api.middleware.auth_v2), which:

  1. Extracts a token from the Authorization: Bearer header or access_token cookie.
  2. Verifies the token using JWKS-local validation (per ADR-039 D4a) — PyJWT’s PyJWKClient against <project>.supabase.co/auth/v1/.well-known/jwks.json. No per-request round-trip; JWKS cached locally with kid-bypass refetch on miss. API keys resolve by hash.
  3. Checks user-level revocation against the core.users mirror’s status column (per ADR-039 D4b). Same per-request DB lookup as workspace-role; zero additional DB hits.
  4. Resolves workspace context from the X-Workspace-Id header — looks up the user’s role in that workspace via the workspace_members table.
  5. Computes scopes by calling resolve_scopes(account_role, workspace_role) from spectral.platform.workspaces.rbac.
  6. Attaches an AuthContext to request.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).


The middleware produces an AuthContext dataclass available to every route handler:

@dataclass
class 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).


Two dependencies enforce authorization on routes. Use them via Depends().

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

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
...
DependencyReturnsUse when
get_workspace_id(request)strYou need the workspace ID without a path parameter
get_tenant_id(request)strYou need the account ID (legacy name)

Workspace isolation is enforced at three levels:

  1. Middleware — resolves workspace membership and computes scopes per-request from the database. Membership revocation takes effect immediately (no stale JWTs).
  2. Route handlers — all queries against workspace-scoped data must include workspace_id from the auth context. Never accept a workspace ID from the request body or query params without validation.
  3. 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 against current_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.

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.

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.


Scopes are computed as the union of account-level and workspace-level role grants.

Workspace RoleScopes
observerread:workspace
contributorread:workspace, write:workspace, read:agents
adminread:workspace, write:workspace, approve:agents, admin:workspace, read:agents
Account RoleScopes
owneradmin:account + all workspace admin scopes (implicit admin)
operationsAll 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 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:

  1. Hashing the token with SHA-256
  2. Looking up the hash in api_keys
  3. Checking is_active and expires_at
  4. Filtering scopes against the allowed set
  5. Returning an AuthContext with the key’s workspace_id
  6. Updating last_used_at

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.

  1. Admin issues invite (POST /api/v1/workspaces/{id}/members) — requires admin:workspace. spectral.platform.infrastructure.shared.auth.invites.invite_to_workspace generates a cryptographically random token, stores its sha256 hash in workspace_invites, and for new users calls Supabase Auth invite_user_by_email with options.redirect_to pointing 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.

  2. Invitee clicks the email link → Supabase confirms their email → redirects to /auth/accept-invite?token=... with an authenticated session.

  3. Dashboard accept page immediately strips the token from the URL via history.replaceState (and sends Referrer-Policy: no-referrer) before POSTing { token } to POST /api/v1/invites/accept.

  4. Acceptance endpoint (AcceptInviteUseCase) hashes the token, looks up the row, enforces email match, checks expiry and consumed state, materializes the workspace_members row via ON CONFLICT DO NOTHING, and marks the invite consumed. Idempotent — re-POSTing a token this user already accepted returns the same workspace context with already_accepted: true.

RoutePermission
POST /api/v1/workspaces/{id}/membersadmin:workspace
GET /api/v1/workspaces/{id}/members/invitesadmin: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.

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.


  1. Choose the right dependency: require_permission for account/global routes, require_workspace_match for workspace-scoped routes with a path parameter.
  2. Pick the correct scope: read:workspace for GET, write:workspace for POST/PUT/DELETE, admin:workspace for management operations, read:operations/write:operations for staff-only, admin:operations for administrative operations, delete:operations for destructive operations.
  3. Filter queries by workspace: Use auth.workspace_id or get_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]

  • docs/runbooks/auth.md — operator playbook for JWKS rotation, invite revocation, session-var debugging, and the auth-failure decision tree.