Skip to content
GitHub
Foundations

Access Control

Access control in Spectral has two interlocking layers. The role + scope model defines who can do what across workspaces. The multi-tenancy isolation model — Postgres RLS keyed on session variables — defines what data each authenticated request can see. Both layers enforce at the API boundary; downstream code (workers, agents, projections) inherits the same guarantees by running under the same session-variable protocol.

Each Workspace is discrete — it has its own members, roles, change sets, and optimization history. A user can hold different roles in different workspaces.

Roles exist at two levels: account-level and workspace-level.

RoleScopesDescription
owneradmin:account + all workspace scopes implicitlyAccount owner. Manages users, billing, global settings. Has implicit admin-level access across all workspaces without needing per-workspace assignment.
operationsAll scopes implicitly + read:operations, write:operations, admin:operations, delete:operationsSpectral staff. Full access to everything across all accounts. Includes operations capabilities.
RoleScopesDescription
adminread:workspace, write:workspace, approve:agents, admin:workspace, read:agentsDecision-maker for this workspace. Approves Change Set lifecycle transitions (proposed → accepted). Manages workspace membership and API keys.
contributorread:workspace, write:workspace, read:agentsDoes the work — configures agents, reviews recommendations, iterates on the system.
observerread:workspaceViews change sets, explainability summaries, metrics, and optimization history. Designed for leadership visibility and stakeholders.
TypeScopesDescription
API keysread:agents, write:tracesScoped to a workspace. Fetch agent configuration and push OTEL traces. An API key for one workspace cannot access another.

Scopes follow the action:resource convention (11 scopes total):

  • Workspace scopes: read:workspace, write:workspace, approve:agents, admin:workspace
  • Account scopes: admin:account
  • Agent scopes: read:agents, write:traces
  • Operations scopes: read:operations, write:operations, admin:operations, delete:operations (held by AccountRole.OPERATIONS only; renamed from *_PLATFORM so the scope identifiers match the role that holds them)

This format is designed to scale. Finer-grained resource scoping (e.g., read:change_set, approve:change_set) is supported by the established pattern; no framework change is required to add new scopes.

  • A user can hold different roles in different workspaces (e.g., admin of Team A, contributor on Team B, observer on Team C)
  • Account owner does not need per-workspace assignment — they have implicit full access everywhere
  • operations has everything implicitly — scopes are not enumerated
  • Cross-workspace visibility for observers (e.g., leadership wanting to see all teams) is achieved by the account owner assigning the observer to each workspace — there is no special account-wide observer role

An account-wide observer role would bypass workspace isolation and create a privileged read path that circumvents the workspace permission model. Instead, account owners explicitly assign observers to the workspaces they need visibility into. This keeps the permission model simple and auditable: if someone can see a workspace, there’s an explicit assignment record.


Two-level enforcement: app-layer primary, RLS backstop (per ADR-033). FastAPI validates the Supabase JWT, loads tenant context, and builds queries via a typed TenantScopedQuery helper that the architecture validator refuses to let bypass. RLS policies stand as a backstop against app-layer regression — never as the only line of defense.

Account (tenant)
└── Workspace
├── Workspace Agents
├── Change Sets
├── Traces
├── Samples / Sample Sets
└── Evaluation Framework (workspace instance)
  • Workspace-scoped tables: both account_id and workspace_id; plus deleted_at timestamptz NULL per data retention — derived state via views
  • Account-scoped tables (users, workspace membership, API keys): account_id only
  • Operations-scoped tables (world model content, operator-only state): neither — operations role only

RLS policies read from current_setting('app.account_id') / current_setting('app.workspace_id') / current_setting('app.user_id') — set by the connection-pool checkout per connection pooling D3. Policies must NOT reference auth.uid() or auth.jwt() (those hardcode Supabase Auth into DB policy). Provider-agnostic policies preserve the migration-is-re-work property.

The session-var contract lives in spectral.core.db.session_vars:

SESSION_VAR_ACCOUNT_ID: Final[str] = "app.account_id"
SESSION_VAR_WORKSPACE_ID: Final[str] = "app.workspace_id"
SESSION_VAR_USER_ID: Final[str] = "app.user_id"

Session vars name identity, not capability. account_id and workspace_id name customer-tenancy scopes; user_id is the authenticated user. Capability classes (operator, admin) live in JWT scopes — not in session vars.

  • JWT contains: account_id, user_id, role (account-level: operations / owner / null)
  • Workspace context: passed per request (header or path parameter), validated against workspace_members table in RLS policy
  • Workspace is NOT embedded in the JWT — avoids stale token issues on membership changes
RoleRLS behavior
operationsBypass RLS — the Supabase service_role connection role is exempt from policies; disciplined to worker / ops-only contexts. Customer-request paths never use it.
Account owner (account-scoped tables)Filter by account_id from session var
Account owner (workspace-scoped tables)Implicit admin across all workspaces in the account; filter by account_id + workspace membership
Workspace admin / contributor / observerFilter by workspace_id from session var, validated against membership table
API keysFilter by workspace_id from key’s workspace binding

Why not workspace in JWT: Membership revocation is immediate. No token refresh needed on workspace switch. Users with multiple workspace memberships work naturally.

JWT verification is JWKS-local + mirror-based revocation (per ADR-039 D4):

  • JWKS-local validation. Signature + expiry via PyJWT’s PyJWKClient against the Supabase JWKS endpoint (FastAPI middleware), getClaims() against the same JWKS (Operations Start middleware), and kid-bypass KV-cached JWKS validation in the Codex Pages Function. A contract test enforces parity across the three verifiers.
  • User-level revocation via the core.users mirror with a status column. Middleware’s per-request workspace-role lookup also reads status. Zero additional DB hits.

/version/detail and other deploy-tier endpoints use a dual-path key-exchange middleware: extract key from Authorization: Bearer or X-API-Key, validate against an env-var-sourced registry, mint an internal JWT with the existing scope/issuer taxonomy. The registry includes env-var-sourced keys (machine identities like deploy-bot) alongside DB-backed keys (human/customer identities). Key rotation is a deploy side-effect — keys regenerate per deploy in Render Env Groups; no rotation runbook for this key class. Key format: sk_deploy_<32chars> prefix+random.