Phase 1 of moving the secret store to OpenBao. Nothing is cut over: TG_SECRETS_BACKEND defaults to sops for all three environments, so SOPS remains authoritative until an environment is flipped in a reviewed PR. The reason for the migration is one specific failure class. infra/CLAUDE.md requires that vps1 never hold prod credentials, but that rule is currently enforced by a caller-side shell flag (THERMOGRAPH_SECRETS_SKIP_COMMON) at three call sites. A fourth call site, or one by-hand render, writes both S3 keypairs, the VAPID private key, REGISTRY_TOKEN and the IndexNow/metrics tokens onto the Forgejo CI-runner box and exits 0. policies/tg-host-dev.hcl makes that a 403 instead: dev's identity cannot read the common path at all. Shape: - vps2 only, native systemd (not a container: a Swarm service is circular, and a plain docker run is one `prune -a` from gone), raft storage (2.6.0 deprecated the file backend), static auto-unseal so a reboot cannot block deploys, mesh-only self-signed TLS (not ACME — that would put DNS in the boot chain of the secret store). - AppRole per environment, CIDR-bound, 5-minute tokens. Each environment's secret-id is owned by its own deploy user, which is a boundary that does not exist today since prod and beta both reach the same root-owned age key. - Render still produces the same raw unquoted dotenv: Centralis compares /etc/thermograph.env textually and the Swarm entrypoint parses it by line. The backend dispatch in render-secrets.sh returns early rather than refactoring the shared write path, so the SOPS path stays byte-identical. Verified by rendering dev (12 keys) and prod (32 keys) through it, matching the live hosts. The duplicated write path is noted for consolidation once prod has been on OpenBao for a release cycle. The OpenBao renderer rejects values containing a newline or a shell metacharacter. /etc/thermograph.env is sourced by bash in six places, so such a value is a command-execution path on the deploy host; the SOPS path has always had this hazard and avoids it only because every current value happens to be alphanumeric. Also records that the age keypair is NOT retired by this migration: it is the backup encryption key for every off-box dump (ops-cron.yml:142,197, 30-day retention), so deleting it with the vault files would silently destroy a month of database recoverability. verify-parity.sh is the cutover gate — it renders both backends and diffs them, reporting key names and counts only, never values.
50 lines
2.4 KiB
HCL
50 lines
2.4 KiB
HCL
// Policy for the ops-cron backup job (.forgejo/workflows/ops-cron.yml).
|
|
//
|
|
// Exists to DELETE FOUR CI SECRETS. Today S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY and
|
|
// S3_SECRET_KEY are Forgejo repo secrets, even though ops-cron only ever uses them
|
|
// INSIDE the script it SSHes onto the host — they are passed through `envs:` and
|
|
// consumed as RCLONE_CONFIG_ARCHIVE_*. Nothing needs them on the runner. Moving them
|
|
// here means the host reads them directly and CI holds four fewer credentials.
|
|
//
|
|
// It also collapses a genuine duplication: the same values already live in the SOPS
|
|
// vault as THERMOGRAPH_S3_* in common.yaml, so there are currently two sources of
|
|
// truth for one credential pair, free to drift.
|
|
//
|
|
// The backup recipient belongs here too — see thermograph/data/ops/backup below.
|
|
// ops-cron.yml:142 and :197 currently HARDCODE the age recipient as a literal, in two
|
|
// places, and that same public key is duplicated in eleven places across the repo.
|
|
|
|
path "thermograph/data/ops/backup" {
|
|
capabilities = ["read"]
|
|
}
|
|
|
|
path "thermograph/metadata/ops/backup" {
|
|
capabilities = ["read", "list"]
|
|
}
|
|
|
|
// ============================================================================
|
|
// THE AGE KEY IS NOT RETIRED BY THIS MIGRATION. This path is why.
|
|
// ============================================================================
|
|
// ops-cron.yml streams every off-box database dump through
|
|
// `pg_dump | age -r <recipient> | rclone rcat`, with 30-day S3 retention, and restore
|
|
// depends on the private half at /etc/thermograph/age.key. The Forgejo backup path is
|
|
// the same shape.
|
|
//
|
|
// So the age keypair is not merely the SOPS transport — it is the BACKUP ENCRYPTION
|
|
// KEY. Deleting it alongside the SOPS vault files would silently destroy up to 30 days
|
|
// of database recoverability, and nothing would notice until a restore was attempted.
|
|
//
|
|
// Therefore: the age private key is stored at thermograph/data/legacy/age (operator
|
|
// access only, deliberately NOT granted by this policy) and kept on disk until either
|
|
// the newest age-encrypted object in S3 has aged out, or the backup path is re-pointed
|
|
// at a dedicated backup recipient. Only then may /etc/thermograph/age.key be removed.
|
|
//
|
|
// The `deny` is explicit so that widening this policy cannot hand the backup job the
|
|
// key to read everything else in the estate.
|
|
path "thermograph/data/legacy/age" {
|
|
capabilities = ["deny"]
|
|
}
|
|
|
|
path "auth/token/lookup-self" {
|
|
capabilities = ["read"]
|
|
}
|