Credential Rotation Policy

Owner: p24-infra · Review: every 90 days or after any rotation incident
Audit trail: docs/secrets-rotation-log.md + GH issue lifecycle (created → closed)


1. Triggers and response SLA

TriggerConditionSLASeverity
ExposureSecret value visible in chat, logs, git, or any unsecured channel1 hourcritical
Schedulednext_due date reached in dev_r_services24 hoursper key criticality
PreventiveBefore sharing access, before offboarding, before key handoffsame dayper key criticality

2. Rotation categories

Typerotation_typeWho runs itTrigger
Automatedautorotate-credentials.py via GH ActionsMonday 06:00 UTC or manual workflow dispatch
PlaywrightplaywrightClaude Code + Playwright MCP; human pastes value via $env:NEW_VALUEManual — create GH issue with label human-action
ManualmanualHuman at provider dashboard; Claude distributes to SOPS + GH Secrets + VPSManual — create GH issue with label human-action
Not applicableNULLNon-rotatable credential or non-credential service element (container, server)N/A

2a. Decision tree

Secret rotation triggered
│
├─ rotation_type = 'auto' in dev_r_services?
│   ├── YES → run credential-rotation.yml (workflow_dispatch, force_all=true for exposure)
│   │          Script rotates key + updates GH Secret + restarts service + closes issue
│   └── NO  → rotation_type = 'playwright'?
│               ├── YES → follow docs/playbooks/playwright-rotation-template.md
│               └── NO  → rotation_type = 'manual': create GH issue (see §4 format) + rotate manually following §3
│
└─ After rotation (all paths)
    ├── Update docs/secrets-rotation-log.md (desktop sessions) OR dev_r_rotation_log in Supabase (VPS workers)
    ├── Canary decrypt SOPS if SOPS was touched
    ├── Update dev_r_services.last_rotated + next_due
    └── Verify old key is revoked at provider

3. Which playbook to follow

ScenarioPlaybook
Exposure — triage scopedocs/playbooks/static-api-key-incident-rotation.md
Scheduled — auto keysscripts/rotate-credentials.py (GH Actions, Monday 06:00 UTC)
Scheduled — manual keysdocs/playbooks/credential-rotation-180d.md
Manual per-servicedocs/playbooks/mezmo-key-rotation.md etc.
SOPS file editing (any key)docs/playbooks/sops-edit-operations.md
Windows CRLF / BOM issuedocs/playbooks/sops-windows-crlf.md
Master key compromisedocs/playbooks/master-keys-bootstrap.md

4. Human-action issue format (MANDATORY)

Every human-action rotation must produce a GH issue using this template.
Issue is the audit record — do not skip it, even for urgent exposure rotations.

Created automatically by: scripts/rotate-credentials.py for scheduled rotations.
Created manually by: Claude or the operator for exposure/preventive rotations.

Labels

ConditionLabels
Exposure or criticality = critical/highhuman-action + critical
Scheduled + criticality = mediumhuman-action + patch
Scheduled + criticality = lowhuman-action

Title format

sec: rotate KEY_NAME — [exposure|scheduled|preventive] YYYY-MM-DD

Body template

## Credential Rotation Required
 
**Rotation requested:** YYYY-MM-DDTHH:MM:SSZ (UTC)
**Trigger:** exposure | scheduled | preventive
**Criticality:** critical | high | medium | low
**Overdue by:** N days (0 for exposure/preventive)
 
| Field | Value |
|---|---|
| Credential | `KEY_NAME` |
| SOPS file | `secrets/xxx.env.sops` (or `none — GH Secret only`) |
| Rotation frequency | 180d |
| Last rotated | YYYY-MM-DD (or `never`) |
| Provider dashboard | URL |
 
## Rotation checklist
 
- [ ] Generate new credential at provider dashboard
- [ ] Update SOPS file (`docs/playbooks/sops-edit-operations.md`)
- [ ] Canary decrypt:
      `sops --decrypt --input-type dotenv --output-type dotenv secrets/xxx.env.sops | Out-Null`
- [ ] Update GH Secret:
      `gh secret set KEY_NAME --body "<new>" --repo radieu/p24-infra`
- [ ] Restart affected services (list which ones)
- [ ] Revoke old credential at provider
- [ ] Append row to `docs/secrets-rotation-log.md`
- [ ] Verify `dev_r_services.last_rotated` + `next_due` updated (run SQL from per-key playbook §N)
- [ ] Close this issue
 
See `docs/playbooks/credential-rotation-180d.md §<KEY_NAME>` for per-key procedure.

5. Audit trail — secrets-rotation-log.md standard

Every rotation must append a row to docs/secrets-rotation-log.md before the issue is closed.

| YYYY-MM-DD HH:MM UTC | #ISSUE | KEY_NAME | reason: exposure/scheduled/preventive | rotator (radieu/claude/AI-Dev-IO1) | confirmed in: SOPS / GH Secret / vps-i1 live |

HH:MM is mandatory for exposure incidents (1-hour SLA). Use 00:00 for scheduled/bootstrap. Use for GH Issue if no issue was created.

Exception — VPS worker rotations: Workers (AI-Dev-IO1, AI-Dev-BMS4-1) write to dev_r_rotation_log in Supabase instead. Desktop Claude sessions should summarise these in the markdown file when confirmed complete.

Do not close the GH issue until the log row exists.


5a. Machine audit trail — infra_operations (MANDATORY)

In addition to the human-readable secrets-rotation-log.md row (§5), every rotation MUST emit a machine-readable log_op() entry to the infra_operations table. This feeds the infra-ops-audit-v1 Grafana dashboard and the 8-week rolling audit archive. Reference the key NAME only — never the value. Full reference: docs/infra-operations-audit-operations.md.

# VPS worker (bash) — call AFTER the new key is verified working and all consumers updated:
source /opt/p24-infra/scripts/lib/log_op.sh
log_op "claude" "credential_rotation" "KEY_NAME" "success" \
  "Scheduled 180d rotation; SOPS + GH Secret + live envs updated; old key revoked" "vps-i1" 2730
# result: "failed" if rotation aborted, "skipped" if intentionally deferred.
# Python scripts / audit-engine:
import sys; sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(actor="claude", op_type="credential_rotation", resource="KEY_NAME",
       result="success", detail="Exposure rotation #NNNN; all consumers updated", env="vps-i1", gh_issue=2730)

continue-on-error semantics: log_op is fail-open — a logging failure never blocks or reverts the rotation. Do NOT close the GH issue on a logging failure; retry the log entry instead.


6. Post-rotation verification

After rotating any credential:

  1. SOPS canary (if SOPS was touched):

    sops --decrypt --input-type dotenv --output-type dotenv secrets\<file>.env.sops | Out-Null
    if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit" }
  2. Service smoke test — confirm the service using the key is still healthy:

    • Prometheus target still UP
    • n8n workflow still runs
    • scripts/mezmo-manage.py usage returns data
  3. Old key revoked at provider — confirm in provider audit log or by testing the old key (expect 401/403).

  4. GH issue closed with a closing comment:

    Rotated on YYYY-MM-DD HH:MM UTC by <who>.
    Old key revoked. Services confirmed healthy. Log updated.
    

7. Auto-rotation coverage (current)

Keys with auto_rotate=true in dev_r_services — rotated by credential-rotation.yml:

KeyFrequencyScript
GRAFANA_ADMIN_PASSWORD180dbcrypt hash + Caddyfile + GH Secret
SUPABASE_GRAFANA_PASSWORD180dALTER ROLE via Management API
OPENAI_MONITORING_TOKEN90dOpenAI admin API create + delete stale
CLOUDFLARE_TOKEN_ZINTEGROWANA180dCF API roll via Global Key
EMAIL_SENDER_API_KEY180dCF Worker secret + GH Secret
MYSQL_PASSWORD180dALTER USER + traccar restart
WASABI_KEYS_P24_INFRA180dIAM create + delete + all consumers restarted
BMS4_N8N_API_KEY90dn8n REST API + GH Secret
RADEK_BRAND_RESEND_API_KEY180dResend API create + delete stale + GH Secret

8. Human-action only — no API available (reference)

These keys cannot be auto-rotated. auto_rotate=false in dev_r_services.
A GH issue is created automatically when next_due is reached.

Note (2026-06-27): Several items previously classified as human-action-only were successfully automated via Playwright MCP browser automation in a Claude Code session. See §8a for the updated classification.

KeyReason no APIIssue auto-created?
SUPABASE_SERVICE_ROLE_KEYsb_secret value masked in Management API responseYES (scheduled)
GH_TOKEN / PATsGitHub PAT creation requires browser loginYES
CLOUDFLARE_GLOBAL_API_KEYCF Global Key has no rotation APIYES
DISCORD_BOT_TOKENDiscord Developer Portal is UI-onlyYES
grog_api_keyGroq Console has no key management APIYES
p24_infra_mezmo_service_keyMezmo REST API: POST /v1/config/keys returns 400YES
Server root passwordspasswd requires interactive TTYYES
MongoDB passwordsmongosh session requiredYES
LinkedIn OAuth tokenRequires browser OAuth flowYES (monthly alert)
Discord webhooksDiscord API requires MANAGE_WEBHOOKS bot scopeYES
OVH consumer keysOVH OAuth requires browser authorizationYES
WASABI_ADMIN_ACCESS_KEYWasabi Console UI only — no IAM self-rotation APIYES
CF_GLOBAL_API_KEYCF dashboard only — Global Key has no rotation endpointYES
AGE_KEY_GHARequires age-keygen + sops updatekeys + manual GH Secret updateNO (manual procedure in master-keys-bootstrap.md)

8a. Playwright-automatable (previously human-action)

These were listed as human-action but were successfully automated on 2026-06-27 using Playwright MCP browser automation from a Claude Code session. They do not need a human present but require the Playwright MCP server to be running in the session.

KeyHow automatedProcedure
Telegram bot tokens (TELEGRAM_BOT_TOKEN, TELEGRAM_P24_CLAUDE_BOT_API_KEY)Navigate web.telegram.org/k/#@BotFather/mybots → select bot → API Token → RevokeCapture new token from BotFather message; store silently to SOPS + GH Secret
OPENAI_ADMIN_KEYNavigate platform.openai.com/api-keys → delete old row → Create new secret keyCapture key from “Save your key” dialog; store silently to SOPS + GH Secret
SSH root key (VPS_ROOT_SSH_KEY)ssh-keygen locally → ssh-copy-id style append → verify → swap id_ed25519 → remove oldAll via PowerShell + SSH; no browser needed. Update GH Secret via gh secret set
SSH claude-admin key (VPS_SSH_PRIVATE_KEY)Same as root key pattern; vps-i1 claude-admin onlyUpdate GH Secret VPS_SSH_PRIVATE_KEY via gh secret set
Claude Max OAuth (claude-runner on vps-i1 + bms-4)auth_v4.py sentinel pattern + Playwright Google OAuth flowSee docs/playbooks/claude-runner-oauth-rotation.md

Prerequisite for Playwright automation: Google account radieu@gmail.com must be logged in to the Playwright browser profile. If Google session has expired, a Google login (email + password

  • 2FA push to phone) is required first — typically takes ~2 min.

9. Delivering new values to SOPS (replaces .env.local workflow)

.env.local was removed 2026-06-27. All credentials flow through SOPS only.

Option A — Single key (preferred)

  1. Human pastes new value in terminal ONLY: $env:NEW_VALUE = "paste-here"
  2. Claude runs decrypt → regex replace → re-encrypt → canary → delete temp (see playwright-rotation-template.md for the full PowerShell pattern)
  3. Claude uses $env:NEW_VALUE for GH Secret update, then clears it

Option B — Multi-key edit

  1. Claude decrypts SOPS file to secrets/*-edit.env.sops (temp file inside secrets/ to match .sops.yaml path_regex)
  2. Human opens the temp file in VSCode and edits
  3. Claude re-encrypts + canary + deletes temp

Note on sops —set

sops --set uses JSON path notation designed for YAML/JSON files. It does NOT work for dotenv-format SOPS files. Do not use it.


10. Exposure incident checklist (quick reference)

When .env.local, a chat session, or a log file contains a secret value:

[ ] 1. Identify all exposed keys (search transcript for sk-, ghp_, sb_secret_, cfut_, etc.)
[ ] 2. Create one GH issue per key (label: human-action + critical, trigger=exposure)
     OR run: python scripts/create-exposure-issues.py (if implemented)
[ ] 3. Trigger auto-rotation immediately:
     gh workflow run credential-rotation.yml --field force_all=true --field dry_run=false
[ ] 4. Rotate all human-action keys within 1 hour (follow §4 checklist per key)
[ ] 5. Revoke old values at provider for each key
[ ] 6. Append rows to docs/secrets-rotation-log.md (reason: exposure, date: today)
[ ] 7. Close all GH issues with closing comment
[ ] 8. Investigate how the exposure occurred → fix root cause → commit playbook update