feat(credentials): mirror backup creds + age key into Vault (CONTRACT_002)

foundation/backup/backup-credentials was never populated in Vault. Add a
writer (same ADR-007 docker-exec-over-SSH pattern, GATE A / dependsOn
vault.init) that mirrors the config-seeded offsite S3 creds and the age key
into Vault, completing CONTRACT_002 §2.3 for in-Vault consumers (Layer-1
ESO, the weekly backup-verify job).

- config.ts: loadBackupSecrets() — single reader of the backup secret slice
  (offsite creds + age recipient/identity), keeping components off raw Config.
- credentials.ts: writeBackupCredentialsToVault() — idempotent vault kv put;
  secret values on stdin (D2), non-secrets as shell vars.
- index.ts: wire it beside the data-plane creds writer.

Keys written: offsiteEndpoint, offsiteAccessKey, offsiteSecretKey,
backupAgeRecipient, backupAgeIdentity. Validated live: +1 resource, then
42 unchanged (idempotent); vault kv get shows all five keys populated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Niemann 2026-06-30 23:23:38 +02:00
parent 92e8f978a5
commit f2ef9bc922
3 changed files with 95 additions and 1 deletions

View file

@ -15,6 +15,7 @@
import * as pulumi from "@pulumi/pulumi";
import * as command from "@pulumi/command";
import { RandomPassword } from "@pulumi/random";
import { BackupSecrets } from "../config";
import { DeployCtx } from "../lib/context";
import { vmConnection } from "../lib/remote";
import { VaultOutputs } from "./vault";
@ -167,3 +168,65 @@ ${creds.forgejo.adminPassword}
{ dependsOn: [vault.init] },
);
}
// Mirrors the config-seeded backup credentials (offsite S3 creds + the age key)
// into Vault at foundation/backup/backup-credentials (CONTRACT_002 §2.3). Unlike
// the generated data-plane creds these are seeded once into passphrase-encrypted
// config (the age IDENTITY MUST also live there so {repo + passphrase} can decrypt
// a bundle after total Vault loss — CONTRACT_004 §4.3); this writer makes them
// available to in-Vault consumers (Layer-1 ESO, the backup-verify job). Secret
// values on stdin (ADR-007 D2); non-secrets (endpoint, recipient) as shell vars.
const WRITE_BACKUP_CREDS = `set -eu
IFS= read -r ROOT_TOKEN
IFS= read -r OFF_AK
IFS= read -r OFF_SK
IFS= read -r AGE_IDENTITY
C=foundation-vault
VE="-e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=$ROOT_TOKEN"
if ! docker exec $VE "$C" vault secrets list -format=json 2>/dev/null | jq -e 'has("foundation/")' >/dev/null; then
docker exec $VE "$C" vault secrets enable -path=foundation kv-v2 >/dev/null
fi
jq -n --arg ep "$OFF_EP" --arg ak "$OFF_AK" --arg sk "$OFF_SK" --arg ar "$AGE_RECIPIENT" --arg ai "$AGE_IDENTITY" \
'{offsiteEndpoint:$ep,offsiteAccessKey:$ak,offsiteSecretKey:$sk,backupAgeRecipient:$ar,backupAgeIdentity:$ai}' \
| docker exec -i $VE "$C" vault kv put foundation/backup/backup-credentials - >/dev/null
echo "vault: wrote backup/backup-credentials (offsite + age key)"`;
/**
* Mirror the backup credentials (incl. the age key) into Vault (CONTRACT_002 §2.3).
* Depends on Vault being unsealed (GATE A) via vault.init same pattern as the
* data-plane creds writer above.
*/
export function writeBackupCredentialsToVault(
ctx: DeployCtx,
vault: VaultOutputs,
backup: BackupSecrets,
): command.remote.Command {
const create = pulumi.interpolate`OFF_EP='${backup.offsiteEndpoint}'
AGE_RECIPIENT='${backup.ageRecipient}'
${WRITE_BACKUP_CREDS}`;
return new command.remote.Command(
"foundation-backup-credentials",
{
connection: vmConnection(ctx),
create,
update: create,
stdin: pulumi.interpolate`${vault.rootToken}
${backup.offsiteAccessKey}
${backup.offsiteSecretKey}
${backup.ageIdentity}
`,
addPreviousOutputInEnv: false,
triggers: [
vault.init.id,
backup.offsiteAccessKey,
backup.offsiteSecretKey,
backup.ageIdentity,
],
},
{ dependsOn: [vault.init] },
);
}