Access Control
Access control in Spectral has two interlocking layers. The role + scope model defines who can do what across domains. 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.
Domains
Section titled “Domains”Each Domain is discrete and scoped to exactly one world model (a one-to-one link) — it has its own members, roles, action registry, deployed action modules, and audit history. A user can hold different roles in different domains.
Roles exist at two levels: org-level and domain-level, per ADR-086 D1/D2/D5.
Org-Level Roles
Section titled “Org-Level Roles”| Role | Scopes | Description |
|---|---|---|
| owner | admin:org + all domain scopes implicitly | Org owner. Manages users, billing, global settings. Has implicit admin-level access across all domains in the org without needing per-domain assignment. |
| operations | All scopes implicitly + read:operations, write:operations, admin:operations, delete:operations | Spectral staff. Members of a single Spectral-internal org — never of customer orgs, so they never appear in a customer’s domain membership or settings. Their reach across all orgs is a capability, exercised by assuming one customer tenancy at a time under RLS (see RLS Policy Behavior), not a standing membership or a blanket RLS exemption. Carried by app_metadata.organization_role. |
Domain-Level Roles
Section titled “Domain-Level Roles”| Role | Scopes | Description |
|---|---|---|
| admin | read:domain, write:domain, admin:domain, read:actions | Decision-maker for this domain. Manages domain membership and API keys. Module approval (approve:modules) is operator-only — no customer role carries it, per ADR-092 D5. |
| contributor | read:domain, write:domain, read:actions | Does the work — configures action context, reviews rule candidates, iterates on the domain’s world model. |
| observer | read:domain | Views decisions, audit-chain entries, System Card, and version history. Designed for leadership visibility and stakeholders. |
Programmatic Access
Section titled “Programmatic Access”| Type | Scopes | Description |
|---|---|---|
| API keys | read:actions, decide:domain | Scoped to (org_id, domain_id) per ADR-086 D3. An API key minted for one domain cannot call /decide against another. |
Scope Format
Section titled “Scope Format”Scopes follow the action:resource convention:
- Domain scopes:
read:domain,write:domain,admin:domain - Org scopes:
admin:org - Agent scopes:
read:actions,decide:domain - Decision scopes:
read:decisions(read the decision audit chain),write:decisions(submit decision feedback — request operator review, mark noteworthy) - Operations scopes:
approve:modules(module enshrinement gate),read:operations,write:operations,admin:operations,delete:operations(held by theoperationsorg-level role only)
This format is designed to scale. Finer-grained resource scoping (e.g., read:rule,
approve:rule_candidate) 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 domains (e.g., admin of Domain A, contributor on Domain B, observer on Domain C)
- Org
ownerdoes not need per-domain assignment — they have implicit full access across every domain in the org operationshas everything implicitly — scopes are not enumerated- Cross-domain visibility for observers (e.g., leadership wanting to see all domains) is achieved by the org owner assigning the observer to each domain — there is no special org-wide observer role
Why No Org-Wide Observer?
Section titled “Why No Org-Wide Observer?”An org-wide observer role would bypass domain isolation and create a privileged read path that circumvents the domain permission model. Instead, org owners explicitly assign observers to the domains they need visibility into. This keeps the permission model simple and auditable: if someone can see a domain, 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, keyed at the domain level per ADR-086 D6). 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”Org (tenant) └── Domain ├── World Model versions (context schema · configuration · action registry) ├── Action modules (per (org, domain, action, version)) ├── Decisions + audit-chain entries └── System Card (deployment-scoped)Column Strategy
Section titled “Column Strategy”- Domain-scoped tables: both
org_idanddomain_id; plusdeleted_at timestamptz NULLper data retention — derived state via views - Org-scoped tables (users, domain membership, API keys):
org_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.org_id') / current_setting('app.domain_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_ORG_ID: Final[str] = "app.org_id"SESSION_VAR_DOMAIN_ID: Final[str] = "app.domain_id"SESSION_VAR_USER_ID: Final[str] = "app.user_id"SESSION_VAR_WORLD_ID: Final[str] = "app.world_id"Session vars name identity and tenancy scope, not capability. org_id and
domain_id name customer-tenancy scopes; user_id is the authenticated user; world_id
names the worlds-context scope. Capability classes (operator, admin) live in JWT scopes —
not in session vars.
Worlds-context artifacts are world-scoped
Section titled “Worlds-context artifacts are world-scoped”A world model and its children (rules, relationships, source materials, distillation runs,
release notes, the authoring audit, approval decisions) are shared worlds-context
artifacts — a world’s value spans a market, so it carries no customer-tenancy columns. The
domain↔world link is the soft platform.domains.world_id; the world is reached by its own
id. RLS on worlds.* therefore back-stops on world_id = current_setting('app.world_id')
(the world’s own id on world_models, the parent world_id on its children) rather than on
domain_id. Authorship columns (created_by, operator_id, …) are provenance, not access
boundaries — any operator authorized for a world reviews its artifacts regardless of who
authored them. The operator surface resolves (org, domain) → world_id and binds
app.world_id for the request. Only the per-user-owned platform records (audit_log,
domain_members, api_keys, agent-memory) back-stop on app.user_id.
Auth Context
Section titled “Auth Context”- JWT contains:
org_id,user_id, the customorganization_roleclaim (org-level: operations / owner / absent — custom becauseroleis reserved by the auth provider for the database role), plus the RFC 8693actclaim shape per ADR-087 D2 for delegation scenarios.app_metadata.organization_roleis injected by the Supabase custom access-token hook from the persisted org-level role store; operators are gated onapp_metadata.organization_role === "operations"— the same claim path the Operations Start middleware and the Codex Pages Function use, so all three verifiers stay at parity. Audience is per token source: Supabase-issued session tokens carryaud: authenticated(the provider default — the hook cannot rewrite the registered claim), while internally-minted delegation tokens carryaud: urn:spectral:api. - Domain context: passed per request (header or path parameter), validated against
domain_memberstable in RLS policy - Domain 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 | Assumed-identity, RLS on. An operator acting in a customer org assumes that single (org_id, domain_id) tenancy — app.org_id / app.domain_id are set to the target, exactly as a customer request sets its own — and the operations capability admits the request, confined to that one tenancy. No standing RLS exemption over customer data: a filtering bug cannot leak across tenants because the session vars pin one tenancy and the policy enforces it. The service_role / table-owner roles stay disciplined to worker / system jobs and access classes that are not customer-tenanted (worlds-context authoring, which is world-scoped; platform-owned cross-tenant aggregates, which carry no customer RLS); customer-request paths never use them. |
Org owner (org-scoped tables) | Filter by org_id from session var |
Org owner (domain-scoped tables) | Implicit admin across all domains in the org; filter by org_id + domain membership |
Domain admin / contributor / observer | Filter by domain_id from session var, validated against membership table |
| API keys | Filter by domain_id from key’s domain binding |
Why not domain in JWT: Membership revocation is immediate. No token refresh needed on domain switch. Users with multiple domain 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 domain-role lookup also readsstatus. Zero additional DB hits.