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.
Workspaces
Section titled “Workspaces”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.
Account-Level Roles
Section titled “Account-Level Roles”| Role | Scopes | Description |
|---|---|---|
| owner | admin:account + all workspace scopes implicitly | Account owner. Manages users, billing, global settings. Has implicit admin-level access across all workspaces without needing per-workspace assignment. |
| operations | All scopes implicitly + read:operations, write:operations, admin:operations, delete:operations | Spectral staff. Full access to everything across all accounts. Includes operations capabilities. |
Workspace-Level Roles
Section titled “Workspace-Level Roles”| Role | Scopes | Description |
|---|---|---|
| admin | read:workspace, write:workspace, approve:agents, admin:workspace, read:agents | Decision-maker for this workspace. Approves Change Set lifecycle transitions (proposed → accepted). Manages workspace membership and API keys. |
| contributor | read:workspace, write:workspace, read:agents | Does the work — configures agents, reviews recommendations, iterates on the system. |
| observer | read:workspace | Views change sets, explainability summaries, metrics, and optimization history. Designed for leadership visibility and stakeholders. |
Programmatic Access
Section titled “Programmatic Access”| Type | Scopes | Description |
|---|---|---|
| API keys | read:agents, write:traces | Scoped to a workspace. Fetch agent configuration and push OTEL traces. An API key for one workspace cannot access another. |
Scope Format
Section titled “Scope Format”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 byAccountRole.OPERATIONSonly; renamed from*_PLATFORMso 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.
Access Model
Section titled “Access Model”Key Properties
Section titled “Key Properties”- A user can hold different roles in different workspaces (e.g., admin of Team A, contributor on Team B, observer on Team C)
- Account
ownerdoes not need per-workspace assignment — they have implicit full access everywhere operationshas 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
Why No Account-Wide Observer?
Section titled “Why No Account-Wide Observer?”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.
Multi-Tenancy Isolation
Section titled “Multi-Tenancy Isolation”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.
Data Ownership Hierarchy
Section titled “Data Ownership Hierarchy”Account (tenant) └── Workspace ├── Workspace Agents ├── Change Sets ├── Traces ├── Samples / Sample Sets └── Evaluation Framework (workspace instance)Column Strategy
Section titled “Column Strategy”- Workspace-scoped tables: both
account_idandworkspace_id; plusdeleted_at timestamptz NULLper data retention — derived state via views - Account-scoped tables (users, workspace membership, API keys):
account_idonly - Operations-scoped tables (world model content, operator-only state): neither — operations role only
Session-var convention (RLS contract)
Section titled “Session-var convention (RLS contract)”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.
Auth Context
Section titled “Auth Context”- JWT contains:
account_id,user_id,role(account-level: operations / owner / null) - Workspace context: passed per request (header or path parameter), validated against
workspace_memberstable in RLS policy - Workspace is NOT embedded in the JWT — avoids stale token issues on membership changes
RLS Policy Behavior
Section titled “RLS Policy Behavior”| Role | RLS behavior |
|---|---|
operations | Bypass 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 / observer | Filter by workspace_id from session var, validated against membership table |
| API keys | Filter 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.
Server-side verification
Section titled “Server-side verification”JWT verification is JWKS-local + mirror-based revocation (per ADR-039 D4):
- JWKS-local validation. Signature + expiry via PyJWT’s
PyJWKClientagainst the Supabase JWKS endpoint (FastAPI middleware),getClaims()against the same JWKS (Operations Start middleware), andkid-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.usersmirror with astatuscolumn. Middleware’s per-request workspace-role lookup also readsstatus. Zero additional DB hits.
Key-exchange middleware (deploy-tier)
Section titled “Key-exchange middleware (deploy-tier)”/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.