Wasabi IAM Rotator — Operational Runbook
Service: infra-src/wasabi-iam-rotator/
Type: Vercel TypeScript serverless function
Purpose: Rotate the p24-infra Wasabi IAM user’s access key on demand
Canonical rotation path (#2407). For the
p24-infraIAM user, prefer this HTTP endpoint over any local boto3 script. Rotation runs server-side on Vercel; the admin key never leaves SOPS/Vercel and nothing sensitive materializes in the local session, so it sidesteps the command-safety classifier that blocked the #2401 local rotation attempt. Invoke it classifier-safe (token via env, check status code only) — seesops-rotation-classifier-patterns.mdTask 3.Scope gap. This function is hard-scoped to the
p24-infrauser + Vercel/vps-i1 consumers. It does not rotate other Wasabi keys (e.g.pinbox24Public*in/root/s3v2-prod/*.envon bms-1, from #2401). Those need the classifier-safe local pattern (Task 2) until the endpoint is generalized to accept aniam_username+ pluggable consumer.
What this service does
POST /api/rotate (Bearer auth):
- Lists current access keys for the
p24-infraWasabi IAM user viaiam.wasabisys.com - Creates a new IAM access key
- Updates four Vercel env vars with the new key:
WASABI_ACCESS_KEY(alias)WASABI_SECRET_KEY(alias)P24_INFRA_WASABI_ACCESS_KEY(primary)P24_INFRA_WASABI_SECRET_KEY(primary)
- Triggers
secrets-sync.ymlworkflow dispatch ondevbranch (deploys new key to vps-i1 monitoring stack) - Deletes the old access key
- Returns
{"rotated": true, ...}— new secret key is NOT in the response body, only stored in Vercel
Required Vercel environment variables
These must be set in Vercel project settings before the first deploy. See human-action GH issue for step-by-step.
| Variable | Source | Required |
|---|---|---|
WASABI_ADMIN_ACCESS_KEY | secrets/administration.env.sops | Yes |
WASABI_ADMIN_SECRET_KEY | secrets/administration.env.sops | Yes |
ROTATOR_API_KEY | Generate: openssl rand -hex 32, store in secrets/administration.env.sops | Yes |
VERCEL_TOKEN | From secrets/monitoring.env.sops (key: VERCEL_TOKEN) | Yes |
VERCEL_PROJECT_ID | Vercel project ID of the deployed function | Yes |
VERCEL_TEAM_ID | Vercel team ID (if using a team) | No |
GH_ROTATION_TOKEN | GitHub fine-grained PAT with Actions: write scope on radieu/p24-infra | Yes |
WASABI_IAM_USERNAME | Default: p24-infra — override only if rotating a different user | No |
How to get WASABI_ADMIN_* keys (Windows dev machine)
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
# Check key names only (never print values):
sops -d --input-type dotenv --output-type dotenv secrets\administration.env.sops | Select-String "^WASABI_ADMIN"Deploy
vercel --cwd infra-src/wasabi-iam-rotator deploy --prodNote the deployed URL — it is needed for calling the endpoint and for VERCEL_PROJECT_ID.
How to trigger rotation
Acquire the rotation lock FIRST — MANDATORY (ADR 004). POST /api/rotate makes the service mint
a new Wasabi key in place (a non-idempotent live write), so per
ADR 004 hold the per-secret advisory lock before
firing it. Two sessions triggering a rotate at once each mint a different key and silently diverge
(live Wasabi IAM + Vercel vs SOPS) — the #5925 race.
Run from the repo root so the repo-relative script path resolves on the Windows dev machine (the
absolute /opt/p24-infra/... path does not exist there):
# Acquire before triggering. Exit 3 = another session holds it (STOP, reconcile, do NOT trigger);
# exit 4 = Supabase unreachable (fail-closed, do NOT proceed). $LOCK_ID replaces an `open` id.
$env:LOCK_ID = python scripts/rotation-log-entry.py acquire --secret WASABI_ACCESS_KEY --repo p24-infra --reason "#<issue>" --rotation-type auto
if ($LASTEXITCODE -ne 0) { throw "acquire denied/unverified — STOP, reconcile, do NOT trigger a rotate" }
# Layer-2 re-check immediately before firing the trigger — lock still held?
python scripts/rotation-log-entry.py check $env:LOCK_ID
if ($LASTEXITCODE -ne 0) { throw "lock lost — abort the rotate and reconcile" }
curl -X POST https://<deployed-url>/api/rotate `
-H "Authorization: Bearer $env:ROTATOR_API_KEY"On a Linux worker run the equivalent bash: LOCK_ID=$(python3 scripts/rotation-log-entry.py acquire … ) || exit 1, then python3 scripts/rotation-log-entry.py check "$LOCK_ID" || exit 1.
Release the lock after the SOPS update below is committed:
rotation-log-entry.py close "$LOCK_ID" (or fail "$LOCK_ID" --error "…") — never leave it
pending (it blocks the next rotation and trips the 2 h stalled-rotation alert).
Expected response:
{
"rotated": true,
"user": "p24-infra",
"old_key_id": "ABCDEF...",
"new_key_id": "XYZ123...",
"next_steps": [
"New key deployed to Vercel env vars",
"secrets-sync triggered — VPS will receive new key on next deployment",
"Update secrets/monitoring.env.sops manually: sops edit pattern with $env:WASABI_ACCESS_KEY from Vercel dashboard"
]
}What happens after rotation
| Step | Automatic? | Details |
|---|---|---|
| New Wasabi key created | Yes | Via iam.wasabisys.com |
| Vercel env vars updated | Yes | All 4 aliases updated atomically via Vercel API |
secrets-sync.yml triggered | Yes | Dispatched to dev branch — deploys to vps-i1 |
| vps-i1 monitoring stack restarts | Yes (via secrets-sync) | Containers pick up new key on next docker compose up -d |
| Old Wasabi key deleted | Yes | Happens after new key is stored and VPS trigger is sent |
secrets/monitoring.env.sops updated | No — manual step | See SOPS update section below |
SOPS update (manual step — required after rotation)
The function cannot update SOPS files at runtime (no age key access from Vercel). After a successful rotation:
- Open Vercel dashboard → project → Settings → Environment Variables
- Note the new values for
WASABI_ACCESS_KEYandWASABI_SECRET_KEY - Update
secrets/monitoring.env.sopsusing the standard SOPS edit pattern (Windows):
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
# 1. Decrypt to temp file (LF-only, no BOM)
$plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops
[System.IO.File]::WriteAllText("$PWD\secrets\monitoring-edit.env.sops",
($plain -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
# 2. Edit the file: update WASABI_ACCESS_KEY, WASABI_SECRET_KEY,
# P24_INFRA_WASABI_ACCESS_KEY, P24_INFRA_WASABI_SECRET_KEY
# (use your editor or PowerShell string replacement -- never echo values in chat)
# 3. Re-encrypt
$enc = sops --encrypt --input-type dotenv --output-type dotenv secrets\monitoring-edit.env.sops
[System.IO.File]::WriteAllText("$PWD\secrets\monitoring.env.sops", ($enc -join "`n") + "`n",
[System.Text.UTF8Encoding]::new($false))
# 4. Canary check before git add
sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit. See docs/playbooks/sops-windows-crlf.md" }
Write-Host "Canary OK"
# 5. Remove temp plaintext
[System.IO.File]::Delete("$PWD\secrets\monitoring-edit.env.sops")
# 6. Commit
git add secrets/monitoring.env.sops
git commit -m "chore: rotate Wasabi p24-infra IAM key"- Also update GH Secrets (for CI/CD parity):
# Read new values safely first (never print them):
$env:NEW_ACCESS = (sops -d --input-type dotenv --output-type dotenv secrets\monitoring.env.sops | Select-String "^P24_INFRA_WASABI_ACCESS_KEY=").ToString().Split("=",2)[1]
$env:NEW_SECRET = (sops -d --input-type dotenv --output-type dotenv secrets\monitoring.env.sops | Select-String "^P24_INFRA_WASABI_SECRET_KEY=").ToString().Split("=",2)[1]
gh secret set P24_INFRA_WASABI_ACCESS_KEY --body "$env:NEW_ACCESS" --repo radieu/p24-infra
gh secret set P24_INFRA_WASABI_SECRET_KEY --body "$env:NEW_SECRET" --repo radieu/p24-infra
gh secret set WASABI_ACCESS_KEY --body "$env:NEW_ACCESS" --repo radieu/p24-infra
gh secret set WASABI_SECRET_KEY --body "$env:NEW_SECRET" --repo radieu/p24-infra
$env:NEW_ACCESS = ""; $env:NEW_SECRET = ""- Add a row to
docs/secrets-rotation-log.mdwith “Confirmed in sync” = yes only when all consumers are updated.
Verification after rotation
# Test that the new key works (replace with new key values from Vercel dashboard)
aws s3 ls s3://p24-infra \
--endpoint-url https://s3.eu-central-2.wasabisys.com \
--region eu-central-2Expected: bucket listing without errors.
Also check vps-i1 monitoring stack logs (wait ~2 minutes after rotation for secrets-sync to complete):
ssh root@217.154.82.162 "cd /opt/p24-infra/monitoring && docker compose logs --tail=20 thanos-sidecar"Expected: no authentication errors.
Trigger: when to rotate
- Routine rotation: every 90 days (see
docs/secrets-rotation-log.mdfor last rotation date) - Emergency: immediately on suspected credential exposure — follow
docs/playbooks/static-api-key-incident-rotation.md
Escalation path
- Rotation API returns 500 — check Vercel function logs; most likely
WASABI_ADMIN_*keys are expired or wrong - Vercel env vars not updated —
VERCEL_TOKENorVERCEL_PROJECT_IDmay be wrong; update manually in Vercel dashboard - secrets-sync not triggered —
GH_ROTATION_TOKENexpired; rotate it and update Vercel env vars; run workflow dispatch manually:gh workflow run secrets-sync.yml --repo radieu/p24-infra --ref dev - Old key already deleted but VPS still fails — the new key may not have propagated yet; wait for secrets-sync or restart containers manually:
ssh root@217.154.82.162 "cd /opt/p24-infra/monitoring && docker compose up -d"
Prevention
- Store
ROTATOR_API_KEYinsecrets/administration.env.sops— never in code or chat - Never return the new secret key in API responses — it is stored in Vercel env vars only
- Complete the SOPS update within the same session as the rotation; do not defer it
- Record every rotation in
docs/secrets-rotation-log.md
Audit Log — Log to infra_operations
After this operation completes, log it to the infra_operations audit table.
Python (Linux server — bms-4, vps-i1, vps-h1, or similar):
import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(
actor="claude", # "radieu" for manual human ops, "claude" for agent
op_type="credential_rotation",
resource="WASABI_IAM_KEY",
result="success", # "success" | "failed" | "skipped"
detail="Scheduled rotation — Wasabi IAM key pair rotated via wasabi-iam-rotator and SOPS updated",
env="vps-i1",
gh_issue=2730,
)PowerShell (Windows dev machine):
$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'credential_rotation', 'WASABI_IAM_KEY', 'success', 'Scheduled rotation — Wasabi IAM key pair rotated via wasabi-iam-rotator and SOPS updated', 'vps-i1')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''