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 domain-role; zero additional DB hits. - Resolves domain context from the
X-Domain-Idheader — looks up the user’s role in that domain via thedomain_memberstable. - Computes scopes by calling
resolve_scopes(org_role, domain_role)fromspectral.platform.domains.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). A contract test enforces parity across the two server-side
JWKS verifiers — the FastAPI Python verifier and the Operations app’s TypeScript verifier — so
both accept and reject the same tokens.
AuthContext
Section titled “AuthContext”The middleware produces an AuthContext dataclass available to every route handler:
@dataclassclass AuthContext: user_id: str org_id: str | None = None org_role: str | None = None # "operations", "owner", or None domain_id: str | None = None # from X-Domain-Id header domain_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:domain, admin:org,
read:operations). The OPERATIONS scope family identifiers match the role that holds them
(OrgRole.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:domain"))])def list_configs( tenant_id: str = Depends(get_tenant_id), db=Depends(get_db_connection),): # tenant_id is the org_id from auth context rows = db.execute("SELECT ... WHERE tenant_id = %s", (tenant_id,)) ...require_domain_match(permission)
Section titled “require_domain_match(permission)”Like require_permission, but additionally validates that the domain_id path parameter
matches auth.domain_id. This prevents a user from setting one X-Domain-Id header but
hitting a different domain’s URL.
from spectral_api.middleware.auth_v2 import require_domain_match
@router.get("/orgs/{org_id}/domains/{domain_id}/curation/status")def get_curation_status( domain_id: str, _auth: AuthContext = Depends(require_domain_match("read:domain")),): # domain_id is guaranteed to match auth context ...Helper dependencies
Section titled “Helper dependencies”| Dependency | Returns | Use when |
|---|---|---|
get_domain_id(request) | str | You need the domain ID without a path parameter |
get_tenant_id(request) | str | You need the org ID (legacy name) |
Domain isolation
Section titled “Domain isolation”Domain isolation is enforced at three levels:
- Middleware — resolves domain membership and computes scopes per-request from the database. Membership revocation takes effect immediately (no stale JWTs).
- Route handlers — all queries against domain-scoped data must include
domain_idfrom the auth context. Never accept a domain 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 domain-scoped tables compare againstcurrent_setting('app.user_id')::uuid. The application-layer domain filter is the primary defense; RLS is the structural backstop that prevents accidental cross-domain reads even when a query forgets the filter.
Implicit owner access
Section titled “Implicit owner access”Users with owner or operations org roles get implicit admin access to all domains
without needing a domain_members row. This is resolved in the middleware’s
_resolve_domain_role() method.
Fail-closed behavior
Section titled “Fail-closed behavior”If the database is unreachable during domain 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 org-level and domain-level role grants.
| Domain Role | Scopes |
|---|---|
observer | read:domain |
contributor | read:domain, write:domain, read:actions |
admin | read:domain, write:domain, admin:domain, read:actions |
| Org Role | Scopes |
|---|---|
owner | admin:org + all domain 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.domains.rbac.resolve_scopes() — pure domain logic
with no infrastructure dependencies.
API key authentication
Section titled “API key authentication”API keys use the format sp_live_{random}. They are hashed with SHA-256 and stored in the
api_keys table. Each key is permanently bound to one domain.
Scope restrictions: API keys carry read:actions (read the domain’s action registry, for
discovery) and decide:domain (call POST /decide, and the MCP equivalent, for the key’s
bound domain), per ADR-092. They cannot carry
admin, org, or operator scopes; those are reserved for user-authenticated sessions.
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’sdomain_id - Updating
last_used_at
Invite system
Section titled “Invite system”Domain invites live in a dedicated domain_invites table with a
tokenized acceptance flow — first-class server-managed state.
-
Admin issues invite (
POST /domains/{id}/members) — requiresadmin:domain.spectral.platform.infrastructure.shared.auth.invites.invite_to_domaingenerates a cryptographically random token, stores its sha256 hash indomain_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/invites/accept. -
Acceptance endpoint (
AcceptInviteUseCase) hashes the token, looks up the row, enforces email match, checks expiry and consumed state, materializes thedomain_membersrow viaON CONFLICT DO NOTHING, and marks the invite consumed. Idempotent — re-POSTing a token this user already accepted returns the same domain context withalready_accepted: true.
Admin surface (domain-scoped)
Section titled “Admin surface (domain-scoped)”| Route | Permission |
|---|---|
POST /domains/{id}/members | admin:domain |
GET /domains/{id}/members/invites | admin:domain |
DELETE /domains/{id}/members/invites/{invite_id} | admin:domain |
Acceptance surface (globally-scoped by token)
Section titled “Acceptance surface (globally-scoped by token)”POST /api/invites/accept is authenticated but has no domain
permission check — at acceptance time the caller is not yet a domain
member, so any require_permission("read:domain") dependency would
403 them before they could join. The token resolves the domain
server-side.
Why a dedicated domain_invites table
Section titled “Why a dedicated domain_invites table”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 domain_invites table itself.
Adding auth to a new endpoint
Section titled “Adding auth to a new endpoint”- Choose the right dependency:
require_permissionfor org/global routes,require_domain_matchfor domain-scoped routes with a path parameter. - Pick the correct scope:
read:domainfor GET,write:domainfor POST/PUT/DELETE,admin:domainfor management operations,read:operations/write:operationsfor staff-only,admin:operationsfor administrative operations,delete:operationsfor destructive operations. - Filter queries by domain: Use
auth.domain_idorget_tenant_id()in all data queries. Never query across domains.
# Minimal domain-scoped endpoint@router.get("/orgs/{org_id}/domains/{domain_id}/things")def list_things( domain_id: str, auth: AuthContext = Depends(require_domain_match("read:domain")), db=Depends(get_db_connection),): rows = db.execute( "SELECT * FROM things WHERE domain_id = %s", (auth.domain_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.