// components/credentials.ts // // CONTRACT_002 single owner of credential GENERATION + Vault distribution. // // Generation (`generateCredentials`) is pure @pulumi/random — no Vault, no // network — so it can be created in Phase 3 and consumed by the data-plane // components (postgres.ts needs its password at container boot). Distribution // (`writeCredentialsToVault`, T06) is the half that depends on Vault being // unsealed (Gate A); it writes every value to the KV paths in CONTRACT_002 §2.3. // Splitting the two halves resolves the ordering tension between "Postgres up in // Phase 3" and "secrets in Vault in Phase 5" without giving up single ownership. // // camelCase keys, no exceptions (CONTRACT_002 §2.2): the Vault write JSON-encodes // these objects, so the key names ARE the camelCase Vault keys downstream reads. 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"; /** `foundation/postgres/service-credentials` (CONTRACT_002 §2.3). */ export interface PostgresCredentials { superUser: string; // "postgres" — image default superuser (deterministic) superPassword: pulumi.Output; forgejoDbUser: string; // "forgejo" (deterministic) forgejoDbPassword: pulumi.Output; } /** `foundation/rustfs/service-credentials` (CONTRACT_002 §2.3). */ export interface RustfsCredentials { adminUser: string; // "rustfsadmin" — root access key name (deterministic) adminPassword: pulumi.Output; // root secret key (RUSTFS_SECRET_KEY) serviceKeyId: pulumi.Output; // scoped S3 key Forgejo/backup use serviceKeySecret: pulumi.Output; } /** * `foundation/forgejo/service-credentials` — the admin slice (CONTRACT_002 §2.3). * The crypto secrets (forgejoSecretKey/InternalToken/Jwt*) are auto-generated by * Forgejo into its app.ini (format-constrained — JWTs, not free random), so they * are not generated here; capturing them into Vault is a later refinement. */ export interface ForgejoCredentials { adminUser: string; // cfg.forgejo.adminUser (deterministic) adminPassword: pulumi.Output; } /** Everything generateCredentials() produces; grows as Wave-2 tasks land. */ export interface FoundationCredentials { postgres: PostgresCredentials; rustfs: RustfsCredentials; forgejo: ForgejoCredentials; } /** * High-entropy alphanumeric secret. special:false keeps values safe to drop into * connection strings / app.ini / psql literals without escaping (len 28 ≈ 166 bits). */ function secret(name: string, length = 28): pulumi.Output { return new RandomPassword(name, { length, special: false }).result; } /** Generate all egg credentials (pure; no dependencies). CONTRACT_002 writer. */ export function generateCredentials(ctx: DeployCtx): FoundationCredentials { return { postgres: { superUser: "postgres", superPassword: secret("postgres-super-password"), forgejoDbUser: "forgejo", forgejoDbPassword: secret("forgejo-db-password"), }, rustfs: { adminUser: "rustfsadmin", adminPassword: secret("rustfs-admin-password"), serviceKeyId: secret("rustfs-service-key-id", 20), // S3 access-key id serviceKeySecret: secret("rustfs-service-key-secret", 40), // S3 secret }, forgejo: { adminUser: ctx.cfg.forgejo.adminUser, // "platform-admin" adminPassword: secret("forgejo-admin-password"), }, }; } // Writes the generated data-plane credentials into Vault KV v2 at the CONTRACT_002 // paths. Like every control-plane op the egg can't reach 8200 directly, so this // runs `vault kv put` inside foundation-vault over SSH (ADR-007). Secret VALUES go // in as a JSON object on the container's stdin (never in argv); the root token // authenticates via -e (transient, VM-trusted). Idempotent: kv-v2 enable is // guarded and `vault kv put` overwrites. Forgejo crypto secrets, the runner token, // registry tokens, and backup creds are written by their own tasks (T08/T10/T12) — // this is the Phase-5 data-plane slice. dependsOn vault.init = GATE A. const WRITE_CREDS = `set -eu IFS= read -r ROOT_TOKEN IFS= read -r PG_SUPER_PW IFS= read -r PG_FORGEJO_PW IFS= read -r RUSTFS_ADMIN_PW IFS= read -r RUSTFS_SVC_ID IFS= read -r RUSTFS_SVC_SECRET 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 put() { docker exec -i $VE "$C" vault kv put "foundation/$1" - >/dev/null; } jq -n --arg u "$PG_SUPER_USER" --arg p "$PG_SUPER_PW" --arg fu "$PG_FORGEJO_USER" --arg fp "$PG_FORGEJO_PW" \ '{postgresSuperUser:$u,postgresSuperPassword:$p,forgejoDbUser:$fu,forgejoDbPassword:$fp}' \ | put postgres/service-credentials jq -n --arg u "$RUSTFS_ADMIN_USER" --arg p "$RUSTFS_ADMIN_PW" --arg ki "$RUSTFS_SVC_ID" --arg ks "$RUSTFS_SVC_SECRET" \ '{rustfsAdminUser:$u,rustfsAdminPassword:$p,rustfsServiceKeyId:$ki,rustfsServiceKeySecret:$ks}' \ | put rustfs/service-credentials echo "vault: wrote postgres + rustfs service-credentials"`; // NOTE: foundation/forgejo/service-credentials is NOT written here. Its crypto // secrets (SECRET_KEY/INTERNAL_TOKEN/JWT) only exist after Forgejo first-starts // and writes app.ini, so the whole forgejo path (admin + crypto) is single-owned // by writeForgejoCredentialsToVault at GATE B — keeping one writer per Vault path // avoids a put/patch race on re-runs (CONTRACT_002 "single source of truth"). /** * T06 — distribute the generated data-plane credentials into Vault (CONTRACT_002). * Depends on Vault being unsealed (GATE A) via vault.init. */ export function writeCredentialsToVault( ctx: DeployCtx, creds: FoundationCredentials, vault: VaultOutputs, ): command.remote.Command { // Non-secret usernames are prepended as shell vars; the 5 secret values (root // token first, then the order the script `read`s them) arrive on stdin. const create = pulumi.interpolate`PG_SUPER_USER='${creds.postgres.superUser}' PG_FORGEJO_USER='${creds.postgres.forgejoDbUser}' RUSTFS_ADMIN_USER='${creds.rustfs.adminUser}' ${WRITE_CREDS}`; return new command.remote.Command( "foundation-vault-credentials", { connection: vmConnection(ctx), create, update: create, stdin: pulumi.interpolate`${vault.rootToken} ${creds.postgres.superPassword} ${creds.postgres.forgejoDbPassword} ${creds.rustfs.adminPassword} ${creds.rustfs.serviceKeyId} ${creds.rustfs.serviceKeySecret} `, addPreviousOutputInEnv: false, triggers: [ vault.init.id, creds.postgres.superPassword, creds.postgres.forgejoDbPassword, creds.rustfs.adminPassword, creds.rustfs.serviceKeyId, creds.rustfs.serviceKeySecret, ], }, { 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] }, ); } // Single owner of foundation/forgejo/service-credentials (CONTRACT_002 §2.3). The // admin user/pw are generated (@pulumi/random); the crypto secrets (SECRET_KEY, // INTERNAL_TOKEN, the LFS + OAuth2 JWT secrets) are auto-generated by Forgejo into // app.ini on first start — they only exist post-boot, so the whole path is written // here at GATE B (dependsOn forgejo.ready), read straight off the live app.ini via // docker-exec (ADR-007). Admin pw on stdin (D2); crypto values are read on the VM // and never transit the Pulumi command string. Idempotent put. // // NOTE: SECRET_KEY can be EMPTY when the bootstrap skips the web installer // (INSTALL_LOCK) — it is mirrored as-is (faithful), and flagged for hardening. const WRITE_FORGEJO_CREDS = `set -eu IFS= read -r ROOT_TOKEN IFS= read -r ADMIN_PW C=foundation-vault F=foundation-forgejo VE="-e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=$ROOT_TOKEN" gv() { docker exec "$F" sh -c "sed -n 's/^$1 *= *//p' /data/gitea/conf/app.ini" | head -1; } SECRET_KEY=$(gv SECRET_KEY) INTERNAL_TOKEN=$(gv INTERNAL_TOKEN) LFS_JWT=$(gv LFS_JWT_SECRET) OAUTH2_JWT=$(gv JWT_SECRET) jq -n --arg au "$ADMIN_USER" --arg ap "$ADMIN_PW" \ --arg sk "$SECRET_KEY" --arg it "$INTERNAL_TOKEN" --arg jt "$LFS_JWT" --arg oj "$OAUTH2_JWT" \ '{forgejoAdminUser:$au,forgejoAdminPassword:$ap,forgejoSecretKey:$sk,forgejoInternalToken:$it,forgejoJwtSecret:$jt,forgejoOauth2JwtSecret:$oj}' \ | docker exec -i $VE "$C" vault kv put foundation/forgejo/service-credentials - >/dev/null echo "vault: wrote forgejo/service-credentials (admin + crypto secrets)"`; /** * Mirror the Forgejo admin + crypto secrets into Vault (CONTRACT_002 §2.3). * Runs at GATE B: needs Vault unsealed (vault.init) AND Forgejo healthy * (forgejoReady) so app.ini exists to read the crypto secrets from. */ export function writeForgejoCredentialsToVault( ctx: DeployCtx, vault: VaultOutputs, forgejoCreds: ForgejoCredentials, forgejoReady: command.remote.Command, ): command.remote.Command { const create = pulumi.interpolate`ADMIN_USER='${forgejoCreds.adminUser}' ${WRITE_FORGEJO_CREDS}`; return new command.remote.Command( "foundation-forgejo-credentials", { connection: vmConnection(ctx), create, update: create, stdin: pulumi.interpolate`${vault.rootToken} ${forgejoCreds.adminPassword} `, addPreviousOutputInEnv: false, // forgejoReady.id changes when the container is (re)created → app.ini (hence // the crypto secrets) regenerated → re-mirror. triggers: [vault.init.id, forgejoReady.id, forgejoCreds.adminPassword], }, { dependsOn: [vault.init, forgejoReady] }, ); }