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 domain-role; zero additional DB hits.
  4. Resolves domain context from the X-Domain-Id header — looks up the user’s role in that domain via the domain_members table.
  5. Computes scopes by calling resolve_scopes(org_role, domain_role) from spectral.platform.domains.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). 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.


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

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


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

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

Domain isolation is enforced at three levels:

  1. Middleware — resolves domain membership and computes scopes per-request from the database. Membership revocation takes effect immediately (no stale JWTs).
  2. Route handlers — all queries against domain-scoped data must include domain_id from the auth context. Never accept a domain 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 domain-scoped tables compare against current_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.

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.

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.


Scopes are computed as the union of org-level and domain-level role grants.

Domain RoleScopes
observerread:domain
contributorread:domain, write:domain, read:actions
adminread:domain, write:domain, admin:domain, read:actions
Org RoleScopes
owneradmin:org + all domain 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.domains.rbac.resolve_scopes() — pure domain logic with no infrastructure dependencies.


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:

  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 domain_id
  6. Updating last_used_at

Domain invites live in a dedicated domain_invites table with a tokenized acceptance flow — first-class server-managed state.

  1. Admin issues invite (POST /domains/{id}/members) — requires admin:domain. spectral.platform.infrastructure.shared.auth.invites.invite_to_domain generates a cryptographically random token, stores its sha256 hash in domain_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/invites/accept.

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

RoutePermission
POST /domains/{id}/membersadmin:domain
GET /domains/{id}/members/invitesadmin: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.

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.


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

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