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.
67 lines
2.8 KiB
HCL
67 lines
2.8 KiB
HCL
// Policy for the Centralis control-plane container on vps2.
|
|
//
|
|
// Centralis is the tool an operator drives a rotation THROUGH, so it needs more than
|
|
// a host render does: it must enumerate key names across every environment to build
|
|
// `secrets_inventory`, and write values to perform `secrets_rotate`.
|
|
//
|
|
// The split below is the important part. Centralis gets:
|
|
// * METADATA read/list on every path -> it can build a names-and-versions
|
|
// inventory across all three environments WITHOUT being able to read a value.
|
|
// * DATA write on every path -> it can rotate.
|
|
// * DATA read on NOTHING.
|
|
//
|
|
// That is a genuine improvement on the SOPS design, not a port of it. Today
|
|
// `secrets_inventory` can list key names without a decryption key only because SOPS
|
|
// happens to leave key names as plaintext in the YAML — a property of the file
|
|
// format, relied on deliberately (tools/secrets.ts:199 `extractKeyNames`). Under
|
|
// OpenBao the same "names, never values" guarantee becomes an ENFORCED capability
|
|
// boundary instead of a fortunate accident. Centralis cannot print a secret it is
|
|
// not able to fetch.
|
|
//
|
|
// The `read` omission is load-bearing: Centralis holds the Docker socket (root
|
|
// -equivalent on prod) and an SSH private key to beta and dev. Granting it data read
|
|
// on prod's secrets would make a Centralis compromise equivalent to full estate
|
|
// credential disclosure. It already effectively is via the Docker socket — but there
|
|
// is no reason to hand it a second, easier path.
|
|
|
|
// Names and version history across the whole tree — no values.
|
|
path "thermograph/metadata/*" {
|
|
capabilities = ["read", "list"]
|
|
}
|
|
|
|
// Rotation: write a new version. Note "create" + "update" but NOT "read":
|
|
// OpenBao permits a blind write, which is exactly the shape secrets_rotate wants —
|
|
// it mints or receives a new value and stores it, and never needs the old one.
|
|
path "thermograph/data/*" {
|
|
capabilities = ["create", "update"]
|
|
}
|
|
|
|
// Its OWN configuration is the one place Centralis may read, because it must render
|
|
// /etc/centralis.env for itself. This is the direct analogue of centralis.prod.yaml.
|
|
path "thermograph/data/centralis/prod" {
|
|
capabilities = ["read", "create", "update"]
|
|
}
|
|
|
|
// Version rollback, so a bad rotation is recoverable through the control plane
|
|
// rather than requiring a shell on the box. This is the capability that replaces
|
|
// "revert the PR" in the SOPS model.
|
|
path "thermograph/undelete/*" {
|
|
capabilities = ["update"]
|
|
}
|
|
|
|
path "thermograph/destroy/*" {
|
|
capabilities = ["deny"]
|
|
}
|
|
|
|
path "auth/token/lookup-self" {
|
|
capabilities = ["read"]
|
|
}
|
|
|
|
path "auth/token/renew-self" {
|
|
capabilities = ["update"]
|
|
}
|
|
|
|
// Read its own AppRole role-id for self-diagnosis, but never the secret-id.
|
|
path "auth/approle/role/tg-centralis/role-id" {
|
|
capabilities = ["read"]
|
|
}
|