Infra Operations Audit Log — Operations Reference
Table: public.infra_operations
Purpose: Central audit log for all p24-infra operational events — credential rotations, deployments, restarts, config changes, migrations, archival runs.
Retention: 8 weeks rolling (older rows archived to Wasabi infra-ops/YYYY-MM.jsonl by infra_ops_archive audit action).
Grafana dashboard: infra-ops-audit-v1 → monitoring/grafana/provisioning/dashboards/infra-operations-audit.json
Implements: issue #1553
Schema
| Column | Type | Required | Description |
|---|---|---|---|
id | UUID | auto | Primary key — gen_random_uuid() |
ts | TIMESTAMPTZ | auto | Timestamp (UTC) — defaults to NOW() |
actor | TEXT | yes | Who performed the operation: claude, radieu, ci, n8n, etc. |
op_type | TEXT | yes | Type of operation (see enum below) |
resource | TEXT | yes | Affected resource name, e.g. GRAFANA_ADMIN_PASSWORD, monitoring-stack, bms-4 |
result | TEXT | yes | success, failed, or skipped |
detail | TEXT | no | Free-text description — max ~1000 chars. MUST NOT contain secret values. |
env | TEXT | no | Target environment: vps-i1, bms-4, vps-h1, bms-1…bms-3, ci, local |
gh_issue | INT | no | Related GitHub issue number |
gh_run_url | TEXT | no | GitHub Actions run URL |
op_type enum (non-exhaustive)
| Value | When to use |
|---|---|
credential_rotation | Rotating any secret, API key, or password |
deploy | Deploying a service, pushing new secrets to a server |
restart | Restarting a Docker container or service |
config_change | Modifying config files, docker-compose.yml, Caddyfile, etc. |
ssh | Significant SSH session (e.g. server hardening, key installation) |
migration | Running a database migration |
archival | Moving old rows to long-term storage |
other | Anything else worth auditing |
Access Control
- service_role — full INSERT/SELECT/UPDATE/DELETE (used by scripts, CI, audit-engine)
- grafana_readonly — SELECT only (Grafana dashboards)
- anon / authenticated roles — DENIED (no policy)
How to Write a Log Entry
Python (scripts or audit-engine)
# Import from the project lib
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="GRAFANA_ADMIN_PASSWORD",
result="success",
detail="Rotated via credential-rotation.yml; bcrypt updated in Caddyfile",
env="vps-i1",
gh_issue=1553,
gh_run_url="https://github.com/radieu/p24-infra/actions/runs/12345",
)Bash (shell scripts on Linux)
source /opt/p24-infra/scripts/lib/log_op.sh
log_op "claude" "restart" "monitoring-caddy-1" "success" \
"Restarted Caddy after Caddyfile update" "vps-i1"GitHub Actions (inline Python step)
- name: Log operation
continue-on-error: true # MANDATORY — never block deploy on logging failure
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
run: |
python3 - <<PYEOF
import os, json, urllib.request, ssl
url = os.environ["SUPABASE_URL"].rstrip("/")
key = os.environ["SUPABASE_SERVICE_KEY"]
payload = json.dumps({
"actor": "ci",
"op_type": "deploy",
"resource": "monitoring-stack",
"result": "success",
"detail": "secrets-sync.yml: deployed monitoring secrets to vps-i1",
"env": "vps-i1",
"gh_run_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}",
}).encode()
req = urllib.request.Request(f"{url}/rest/v1/infra_operations", data=payload, method="POST",
headers={"Content-Type": "application/json", "apikey": key, "Authorization": f"Bearer {key}", "Prefer": "return=minimal"})
try:
urllib.request.urlopen(req, timeout=3, context=ssl.create_default_context())
except Exception as e:
print(f"log_op: {e}")
PYEOFRequired Environment Variables
Both wrappers (log_op.py and log_op.sh) read from environment:
| Variable | Where to get it |
|---|---|
SUPABASE_URL | secrets/monitoring.env.sops → SUPABASE_URL |
SUPABASE_SERVICE_ROLE_KEY | secrets/monitoring.env.sops → SUPABASE_SERVICE_ROLE_KEY — preferred, checked first |
SUPABASE_SERVICE_KEY | secrets/monitoring.env.sops → SUPABASE_SERVICE_KEY — legacy fallback, only used if SUPABASE_SERVICE_ROLE_KEY is unset. Both wrappers currently resolve SUPABASE_SERVICE_ROLE_KEY first, then fall back to SUPABASE_SERVICE_KEY — same underlying credential either way (verified identical value in secrets/monitoring.env.sops, 2026-08-02). |
On Linux VPSes, these are available in /opt/p24-infra/bms-4/.env (bms-4) and /opt/p24-infra/monitoring/.env (vps-i1). As of 2026-08-02, /opt/p24-infra/bms-4/.env carries SUPABASE_SERVICE_ROLE_KEY only (no SUPABASE_SERVICE_KEY entry) — this is fine given the fallback order above, but do not assume both names are always present on every host.
Troubleshooting a 401 from PostgREST when calling log_op: see docs/playbooks/supabase-postgrest-jwt-auth-fix.md — specifically the 2026-08-02 correction. The sb_secret_* key format is rejected by Supabase’s gateway when the calling client’s User-Agent looks like a browser (e.g. PowerShell’s Invoke-WebRequest/Invoke-RestMethod default UA contains Mozilla). log_op.py (urllib.request) and log_op.sh (curl, no -A override) both send non-browser User-Agents and are verified working against the current key (live-tested via log_op.sh on bms-4, 2026-08-02) — do not diagnose this class of 401 using Invoke-WebRequest/Invoke-RestMethod without an explicit non-browser -UserAgent, or you will get a false “key is broken” reading.
Retention and Archival
- Active table: rows older than 8 weeks are archived by the
infra_ops_archiveaudit-engine action. - Archive destination: Wasabi
p24-infrabucket, pathinfra-ops/YYYY-MM.jsonl(JSON Lines format, one row per line). - Archive schedule: weekly, Sunday 02:00 UTC (defined in
audit.actions). - After archival: rows are DELETEd from Supabase to keep the table small and queries fast.
Secret Safety Rules
The detail field MUST NOT contain secret values. Both wrappers validate the field against common secret patterns before sending:
- Long hex strings (32+ chars)
- Long base64 strings (40+ chars)
- Patterns like
PASSWORD=<value>,TOKEN=<value> - Anthropic API key prefixes (
sk-ant-) - Supabase new-format keys (
sb_secret_,sb_publishable_)
If the pattern is matched, the detail is replaced with [REDACTED] and a warning is printed to stderr.
Indexes
| Index | Purpose |
|---|---|
idx_infra_operations_ts | Fast ORDER BY ts DESC queries (dashboard table panel) |
idx_infra_operations_actor_ts | Filter by actor over time |
idx_infra_operations_op_type_ts | Filter by op_type over time (bar chart) |
Migration
Applied by: monitoring/supabase/migrations/20260627_1553_infra_operations.sql
See Also
scripts/lib/log_op.py— Python wrapperscripts/lib/log_op.sh— Bash wrapperaudit-engine/actions/infra_ops_archive.py— archival actionmonitoring/grafana/provisioning/dashboards/infra-operations-audit.json— Grafana dashboardscripts/backfill-infra-ops-from-rotation-log.py— one-time backfill from rotation log
Mandatory Coverage
All playbooks that perform infra operations (SSH, Docker, config changes, credential rotations,
deployments, restarts, migrations) MUST include a log_op() call. This section lists the
mandatory playbooks and their required op_type.
Updated by issue #2730 (2026-07-05); extended by #3339 / Batch L (2026-07-08) to cover
sops-edit-operations.md, credential-rotation-policy.md, and the server-operation.md task
playbook. Each playbook listed below has an “Audit Log — Log to infra_operations” section (or,
for policy/task playbooks, a mandatory-audit clause) with copy-paste-ready code.
Note on
gh_issue: The valuegh_issue=2730in the snippets below is the issue that established this coverage requirement. When executing an operation, replacegh_issuewith the current working issue number if one exists (e.g. the rotation issue, incident issue, or deploy issue). If there is no related issue, omit the parameter entirely.
Note on
result:resultshould be"success"on completion,"failed"if the operation errored out before finishing, or"skipped"if it was intentionally bypassed (e.g. rotation deferred, no-op detected).
Credential Rotations — op_type: credential_rotation
| Playbook | Resource |
|---|---|
docs/playbooks/anthropic-api-key-rotation.md | ANTHROPIC_API_KEY |
docs/playbooks/brand-credential-rotation.md | brand-credentials |
docs/playbooks/clickup-api-key-rotation.md | CLICKUP_API_KEY |
docs/playbooks/cloudflare-credential-rotation.md | CF_GLOBAL_API_KEY / CLOUDFLARE_TOKEN_ZINTEGROWANA |
docs/playbooks/github-pat-rotation.md | GITHUB_PAT_ALL_WRITES |
docs/playbooks/gitlab-token-rotation.md | GITLAB_TOKEN |
docs/playbooks/heygen-api-key-rotation.md | HEYGEN_API_KEY |
docs/playbooks/hstgr-n8n-mcp-token-rotation.md | HSTGR_N8N_MCP_TOKEN |
docs/playbooks/ionos-api-token-rotation.md | IONOS_API_TOKEN |
docs/playbooks/mailgun-api-key-rotation.md | MAILGUN_API_KEY |
docs/playbooks/mezmo-key-rotation.md | MEZMO_INGESTION_KEY |
docs/playbooks/mongodb-admin-password-recovery.md | MONGODB_RS0_ADMIN_PASSWORD |
docs/playbooks/mongodb-credential-rotation.md | MONGODB_RS0_ADMIN_PASSWORD |
docs/playbooks/mysql-root-password-reset.md | MYSQL_ROOT_PASSWORD |
docs/playbooks/n8n/n8n-bms4-api-key-rotation.md | BMS4_N8N_API_KEY |
docs/playbooks/n8n/n8n-cloud-api-key-rotation.md | N8N_CLOUD_API_KEY |
docs/playbooks/n8n/n8n-db-password-rotation.md | N8N_DB_PASSWORD |
docs/playbooks/n8n/n8n-supabase-credential-rotation.md | N8N_SUPABASE_CREDENTIAL |
docs/playbooks/n8n/n8n-waha-monitor-401-credential-drift.md | WAHA_CONTROL_TOKEN |
docs/playbooks/nexcon-api-key-rotation.md | NEXCON_API_KEY |
docs/playbooks/openai-key-management.md | OPENAI_API_KEY |
docs/playbooks/resend-api-key-rotation.md | RESEND_API_KEY |
docs/playbooks/rotate-discord-bot-token.md | DISCORD_BOT_TOKEN |
docs/playbooks/sentry-token-rotation.md | SENTRY_TOKEN |
docs/playbooks/ssh-key-rotation.md | SSH_KEY |
docs/playbooks/static-api-key-incident-rotation.md | EXPOSED_KEY |
docs/playbooks/supabase-access-token-rotation.md | SUPABASE_ACCESS_TOKEN |
docs/playbooks/supabase-service-key-rotation.md | SUPABASE_SERVICE_KEY |
docs/playbooks/telegram-bot-token-rotation.md | TELEGRAM_BOT_TOKEN |
docs/playbooks/traccar-admin-key-rotation.md | TRACCAR_ADMIN_KEY |
docs/playbooks/v42-prod-credential-rotation.md | V42_PROD_CREDENTIALS |
docs/playbooks/vercel-token-rotation.md | VERCEL_TOKEN |
docs/playbooks/w3-mongodb-credential-rotation.md | W3_APP_MONGODB_PASSWORD |
docs/playbooks/w4-mongodb-credential-rotation.md | W4_APP_MONGODB_PASSWORD |
docs/playbooks/waha-healthcheck-401-gh-secret-drift.md | WAHA_CONTROL_TOKEN |
docs/playbooks/wasabi-iam-rotator.md | WASABI_IAM_KEY |
docs/playbooks/wasabi-key-rotation.md | WASABI_ACCESS_KEY |
docs/playbooks/secret-manager.md | (per operation) |
docs/playbooks/credential-rotation-policy.md | (per operation — §5a) |
docs/playbooks/sops-edit-operations.md | (per SOPS file / key edited) |
Server Restarts / Deployments
| Playbook | Resource | op_type |
|---|---|---|
docs/playbooks/adding-new-worker.md | claude-worker | deploy |
docs/playbooks/dispatcher-keyerror-stall.md | meta-dispatcher | restart |
docs/playbooks/dispatcher-metadata-json-crash.md | meta-dispatcher | restart |
docs/playbooks/dispatcher-oom-loop.md | meta-dispatcher | restart |
docs/playbooks/gh-runner-restart-offline.md | gh-runner-ionos | restart |
docs/playbooks/gitlab-runner-bms1-reregister.md | gitlab-runner-bms1 | deploy |
docs/playbooks/hourly-triage-outage.md | hourly-triage | restart |
docs/playbooks/hourly-triage-watchdog.md | hourly-triage-watchdog | restart |
docs/playbooks/monitoring-stack-operations.md | monitoring-stack | restart |
docs/playbooks/monitoring-standby-vps-h1.md | monitoring-standby-vps-h1 | deploy |
docs/playbooks/n8n/n8n-bms4-worker-crash-storm.md | n8n-bms4-worker | restart |
docs/playbooks/nc-alert-dispatch-outage.md | nc-alert-dispatch | restart |
docs/playbooks/oom-auto-remediation.md | oom-target | restart |
docs/playbooks/pinbox24-bms1-manual-deploy.md | pinbox24-bms1 | deploy |
docs/playbooks/pinbox24-container-runtime-hotfix.md | pinbox24-bms1 | deploy |
docs/playbooks/provision-bms-3-dispatch-node.md | bms-3-dispatch-node | deploy |
docs/playbooks/socat-supabase-watchdog.md | socat-supabase | restart |
docs/playbooks/socat-supabase-zombie-crash-loop.md | socat-supabase | restart |
docs/playbooks/v42-prod-memory-leak.md | v42-prod | restart |
docs/playbooks/vps-h1-stale-docker-iptables.md | vps-h1-docker | config_change |
docs/playbooks/vps-h1-traefik-iptables-stale-rules.md | traefik-vps-h1 | config_change |
docs/playbooks/vps-i1-crash-loop-recovery.md | vps-i1 | restart |
docs/playbooks/vps-i1-oom-outage.md | vps-i1 | restart |
docs/playbooks/vps-i1-outage.md | vps-i1 | restart |
docs/playbooks/waha-ingestion-failure.md | waha-ingestion | restart |
docs/playbooks/waha-session-stopped-after-reboot.md | waha-session | restart |
docs/playbooks/waha-traefik-down-ports-80-443.md | traefik-waha | restart |
Config Changes / Database Operations
| Playbook | Resource | op_type |
|---|---|---|
docs/playbooks/bms-server-root-ssh-lockout-recovery.md | bms-root-ssh | config_change |
docs/playbooks/cross-server-ssh-key-standard.md | ssh-authorized-keys | config_change |
.claude/task-playbooks/server-operation.md | (per server op) | restart / deploy / config_change / ssh |
docs/playbooks/incident-management.md | (per incident) | other |
docs/playbooks/mongodb-exporter-uri-special-chars.md | mongodb-exporter | config_change |
docs/playbooks/mongodb-rs-heartbeat-block.md | mongodb-rs0 | config_change |
docs/playbooks/mongodb-rs0-full-restore.md | mongodb-rs0 | other |
docs/playbooks/mongodb-rs0-heartbeat-drop-block.md | mongodb-rs0 | config_change |
docs/playbooks/mongodb-slow-queries-index.md | mongodb-rs0 | config_change |
docs/playbooks/gh-secret-sops-drift.md | gh-secret-sops | config_change |
docs/playbooks/n8n/n8n-bms4-exporter-missing-key.md | n8n-bms4-exporter | config_change |
docs/playbooks/n8n/n8n-bms4-stuck-execution-cleanup.md | n8n-bms4-executions | other |
docs/playbooks/n8n/n8n-crashed-executions-db-connection-drop.md | n8n-bms4 | restart |
docs/playbooks/n8n/n8n-workflow-db-query-hang.md | n8n-bms4-db | restart |
docs/playbooks/pinbox24-no-logs-bms1.md | pinbox24-bms1 | config_change |
docs/playbooks/pinbox24-s3-wasabi-bms1.md | pinbox24-s3-wasabi | config_change |
docs/playbooks/supabase-backup-stale.md | supabase-backup | other |
docs/playbooks/supabase-migrations.md | supabase-schema | migration |
docs/playbooks/supabase-migrations-ci.md | supabase-schema | migration |
docs/playbooks/vps-h1-cpu-throttle.md | vps-h1 | config_change |
docs/playbooks/wasabi-bucket-growth-spike.md | wasabi-bucket | other |
When in the Playbook Flow
- Credential rotations: log AFTER the new credential is verified working and all consumers updated, BEFORE closing the session.
- Restarts / deploys: log AFTER the service is confirmed healthy (uptime check passed).
- Config changes: log AFTER the change is applied and verified.
- Incident response: log at the END of the incident after the root cause is resolved.
- Migrations: log AFTER the migration succeeds and is confirmed on the live DB.
Rules for the detail Field
- NEVER include secret values — the wrapper will redact them to
[REDACTED]but do not rely on this. - Include the trigger reason: “Scheduled 180d rotation”, “Incident response nnnn”, “Drift detected”, etc.
- Max ~200 chars for readability in the Grafana table panel.
- Reference the GitHub issue number in
gh_issue— not indetail.