Secrets management runbook
How Spectral’s secrets are sourced, published, rotated, and audited. The model is ADR-110: secrets live in 1Password as op://Spectral/<item>/<field> references in infra/environments.toml; tools/provision/provision.sh --env <name> resolves them and publishes to the matching GitHub Environment; the GitHub Actions deploy reads that Environment and applies it to the live systems (wrangler sets the container’s secrets/variables; the Supabase CLI applies migrations). CI secrets handling is ADR-062.
Secret classes
- Platform operational secrets — provider/observability keys, Supabase keys + DSNs, the Cloudflare deploy token. Stored as
op://refs ininfra/environments.toml; published to the GitHub Environment byprovision.sh. - Customer BYO credentials — post-alpha; tenant data, not operational config. Not handled here (lands in
core.domain_secretsvia Supabase Vault; the ADR-035 D4 / ADR-037 reservation). - CI secrets — scoped to GitHub Environments (
production;stagingwhen the stage project lands) per ADR-062; live-secret runs gated to non-PR triggers. - Local dev — local Supabase + dev-tier keys in a gitignored
.env;provision.sh --env devpublishes the[dev]table to it. Dev laptops never hold production secrets.
The model (ADR-110)
infra/environments.tomlis the single source: non-secret values are literals (committed); secrets areop://Spectral/<item>/<field>refs (resolved at use, never committed).provision.sh --env productionresolves the table (op-injected) and publishes — anop://-sourced value becomes a GitHub secret; a literal becomes a GitHub variable.- The deploy workflow reads the Environment and pushes to the live systems (wrangler sets the container’s secrets/variables; the Supabase CLI applies migrations from the direct/session-pooler DSN).
- There is no
.env.provisioncache, no per-secret--modeflow, no Render Environment Group, no GCP Secret Manager.
Standard rotation
To rotate a value (e.g. ANTHROPIC_API_KEY, the Supabase service key, the Cloudflare token):
- Mint the new value at the vendor.
- Update the value in 1Password (the item/field the
op://ref points at). The committedenvironments.tomlref does not change. - Re-publish:
tools/provision/provision.sh --env production(re-resolves every key and overwrites the GitHub secret/variable). Dry-run first with--env production --dry-run. - Redeploy (push
production) so the new value reaches the live systems. - Revoke the old value at the vendor.
A value shared across environments lives in one 1Password item; re-run provision.sh for each environment that references it.
Generation + rotation with --rotate
For a Spectral-minted secret (one we generate rather than fetch from a vendor) — or any secret you’d rather not mint and paste by hand — provision.sh --rotate <KEY> does the whole loop: produce a new value, store it in 1Password, and re-publish the affected environment(s). First-time generation is just the first rotation.
tools/provision/provision.sh --rotate SPECTRAL_DELEGATION_SIGNING_KEY # mint + store + republishtools/provision/provision.sh --rotate SPECTRAL_DELEGATION_SIGNING_KEY --dry-run # preview; writes nothingHow a key behaves is declared in the reserved [rotation] table in infra/environments.toml:
[rotation]# KEY = "<generator|prompt>:<shared|per-env>"SPECTRAL_DELEGATION_SIGNING_KEY = "es256-jwk:shared"- Generator — a registered generator in
tools/provision/generators/(e.g.es256-jwk, which mints the ES256 delegation signing keypair) produces the value;promptmeans enter it interactively (for vendor-minted secrets you paste in). - Scope —
sharedrotates one 1Password item used by every referencing environment and republishes them all;per-envrotates a distinct item per environment and requires--env <name>, republishing that one.
--rotate overwrites a live secret, so it shows the plan (key, generator, scope, op target, affected envs) and prompts before writing (--yes to skip). Generation is explicit-only — a normal provision.sh --env <e> publish re-publishes existing values and never regenerates.
Rolling restart: rotating updates 1Password + the GitHub Environment, not the running system. The new value reaches services on the next deploy (fast-forward-push the production branch). A secret read once at boot — the delegation signing key — requires the container to recycle before the new key takes effect.
Generators
A generator is a script under tools/provision/generators/ that emits the new secret value on stdout (nonzero exit = fail loud). Python generators run in the project venv via uv run python <file> (so dependencies like cryptography resolve — no execute bit needed). The first is es256_jwk.py (the ES256 delegation signing key, SPEC-720 / SPEC-555); add a new one by dropping in a script and naming it in the key’s [rotation] spec. The metadata reads (scope, op target, referencing envs) live in tools/provision/_rotation.py — the shell owns the op/gh calls.
Emergency rotation
On suspected compromise / leaked value / vendor-reported anomalous usage:
- Identify blast radius — rotate every secret the suspected identity could reach.
- For each: mint new → update in 1Password →
provision.sh --env <env>→ redeploy. - Revoke old values at the vendors immediately (revocation is idempotent with rotation).
- Log an incident in Linear with the rotated secrets + the provisioning/deploy run.
- Review the audit surfaces below for anomalous reads in the suspected window.
Audit trail
| Surface | Records | Where |
|---|---|---|
| Git history | environments.toml + provision/deploy changes | GitHub commits |
| 1Password item history | secret value changes | 1Password (vault Spectral) |
| GitHub audit log | Environment secret/variable mutations, Environment access, deploy runs | GitHub org admin |
| Supabase logs | dashboard config + DB access events | Supabase dashboard |
| Cloudflare audit log | token use, Worker/secret changes | Cloudflare dashboard |
Ownership
The builder running provision.sh is the sole operator for platform secrets at alpha; expand to a named rotation roster (two minimum) as the team grows. Known op coordinates: the Cloudflare deploy token is the consolidated op://Spectral/cloudflare/api-token (shared with the Codex Pages deploy, so it carries the union of scopes — Pages + Workers/Containers/R2/Routes); the Supabase production DSN/keys are under op://Spectral/supabase-production/*.
BYO customer credentials (post-alpha)
Not handled by this runbook or provision.sh — customer-provided keys are tenant data. When BYOK ships: encrypted blobs in a platform-scoped core.domain_secrets table, access via a SECURITY DEFINER function checking auth.uid() against domain_members, AEAD via Supabase Vault’s libsodium backing with domain_id as AAD (the ADR-037 reservation).