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 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.

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.

RoleScopesDescription
owneradmin:org + all domain scopes implicitlyOrg owner. Manages users, billing, global settings. Has implicit admin-level access across all domains in the org without needing per-domain assignment.
operationsAll scopes implicitly + read:operations, write:operations, admin:operations, delete:operationsSpectral 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.
RoleScopesDescription
adminread:domain, write:domain, admin:domain, read:actionsDecision-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.
contributorread:domain, write:domain, read:actionsDoes the work — configures action context, reviews rule candidates, iterates on the domain’s world model.
observerread:domainViews decisions, audit-chain entries, System Card, and version history. Designed for leadership visibility and stakeholders.
TypeScopesDescription
API keysread:actions, decide:domainScoped to (org_id, domain_id) per ADR-086 D3. An API key minted for one domain cannot call /decide against another.

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 the operations org-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.

  • A user can hold different roles in different domains (e.g., admin of Domain A, contributor on Domain B, observer on Domain C)
  • Org owner does not need per-domain assignment — they have implicit full access across every domain in the org
  • operations has 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

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.


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.

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)
  • Domain-scoped tables: both org_id and domain_id; plus deleted_at timestamptz NULL per data retention — derived state via views
  • Org-scoped tables (users, domain membership, API keys): org_id only
  • Operations-scoped tables (world model content, operator-only state): neither — operations role only

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.

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.

  • JWT contains: org_id, user_id, the custom organization_role claim (org-level: operations / owner / absent — custom because role is reserved by the auth provider for the database role), plus the RFC 8693 act claim shape per ADR-087 D2 for delegation scenarios. app_metadata.organization_role is injected by the Supabase custom access-token hook from the persisted org-level role store; operators are gated on app_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 carry aud: authenticated (the provider default — the hook cannot rewrite the registered claim), while internally-minted delegation tokens carry aud: urn:spectral:api.
  • Domain context: passed per request (header or path parameter), validated against domain_members table in RLS policy
  • Domain is NOT embedded in the JWT — avoids stale token issues on membership changes
RoleRLS behavior
operationsAssumed-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 / observerFilter by domain_id from session var, validated against membership table
API keysFilter 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.

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 domain-role lookup also reads status. Zero additional DB hits.