From fbd1ad4d1d42598066ef997383de5334a6c4c113 Mon Sep 17 00:00:00 2001 From: Andreas Niemann Date: Tue, 30 Jun 2026 23:28:17 +0200 Subject: [PATCH] feat(credentials): mirror Forgejo crypto secrets into Vault (CONTRACT_002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the known gap: foundation/forgejo/service-credentials held only the admin user/pw; the crypto secrets Forgejo auto-generates into app.ini were never captured. Make that path single-owned at GATE B and write admin + crypto together. - credentials.ts: drop the forgejo block from the GATE-A writer (its crypto secrets don't exist until Forgejo first-starts) and add writeForgejoCredentialsToVault — runs after forgejo.ready, reads SECRET_KEY, INTERNAL_TOKEN, LFS_JWT_SECRET ([server]) and oauth2 JWT_SECRET straight off the live app.ini via docker-exec (ADR-007), and puts the full path. One writer per Vault path avoids a put/patch race on re-runs. - index.ts: wire it at GATE B (dependsOn vault.init + forgejo.ready). Keys: forgejoAdminUser, forgejoAdminPassword, forgejoSecretKey, forgejoInternalToken, forgejoJwtSecret, forgejoOauth2JwtSecret. Validated live: forgejo path now has all six; postgres/rustfs paths intact through the GATE-A writer replacement; idempotent at 43 unchanged. FINDING: forgejoSecretKey mirrors EMPTY — skipping the web installer (INSTALL_LOCK) left Forgejo's [security] SECRET_KEY unset. Fixed next commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- bootstrap/components/credentials.ts | 76 +++++++++++++++++++++++++---- bootstrap/index.ts | 10 ++++ 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/bootstrap/components/credentials.ts b/bootstrap/components/credentials.ts index 8cd1d9d..3818e54 100644 --- a/bootstrap/components/credentials.ts +++ b/bootstrap/components/credentials.ts @@ -99,7 +99,6 @@ IFS= read -r PG_FORGEJO_PW IFS= read -r RUSTFS_ADMIN_PW IFS= read -r RUSTFS_SVC_ID IFS= read -r RUSTFS_SVC_SECRET -IFS= read -r FORGEJO_ADMIN_PW C=foundation-vault VE="-e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=$ROOT_TOKEN" @@ -117,11 +116,12 @@ jq -n --arg u "$RUSTFS_ADMIN_USER" --arg p "$RUSTFS_ADMIN_PW" --arg ki "$RUSTFS_ '{rustfsAdminUser:$u,rustfsAdminPassword:$p,rustfsServiceKeyId:$ki,rustfsServiceKeySecret:$ks}' \ | put rustfs/service-credentials -jq -n --arg u "$FORGEJO_ADMIN_USER" --arg p "$FORGEJO_ADMIN_PW" \ - '{forgejoAdminUser:$u,forgejoAdminPassword:$p}' \ - | put forgejo/service-credentials - -echo "vault: wrote postgres + rustfs + forgejo 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). @@ -132,12 +132,11 @@ export function writeCredentialsToVault( creds: FoundationCredentials, vault: VaultOutputs, ): command.remote.Command { - // Non-secret usernames are prepended as shell vars; the 6 secret values (root + // 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}' -FORGEJO_ADMIN_USER='${creds.forgejo.adminUser}' ${WRITE_CREDS}`; return new command.remote.Command( @@ -152,7 +151,6 @@ ${creds.postgres.forgejoDbPassword} ${creds.rustfs.adminPassword} ${creds.rustfs.serviceKeyId} ${creds.rustfs.serviceKeySecret} -${creds.forgejo.adminPassword} `, addPreviousOutputInEnv: false, triggers: [ @@ -162,7 +160,6 @@ ${creds.forgejo.adminPassword} creds.rustfs.adminPassword, creds.rustfs.serviceKeyId, creds.rustfs.serviceKeySecret, - creds.forgejo.adminPassword, ], }, { dependsOn: [vault.init] }, @@ -230,3 +227,62 @@ ${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] }, + ); +} diff --git a/bootstrap/index.ts b/bootstrap/index.ts index 6d925fb..72d7039 100644 --- a/bootstrap/index.ts +++ b/bootstrap/index.ts @@ -14,6 +14,7 @@ import { generateCredentials, writeCredentialsToVault, writeBackupCredentialsToVault, + writeForgejoCredentialsToVault, } from "./components/credentials"; import { deployPostgres } from "./components/postgres"; import { deployRustfs } from "./components/rustfs"; @@ -75,12 +76,21 @@ const forgejoBootstrap = bootstrapForgejo(ctx, { sshPublicKey, }); const runner = cfg.features.runner ? deployRunner(ctx, forgejo) : undefined; +// Mirror Forgejo's admin + app.ini crypto secrets into Vault (CONTRACT_002 §2.3); +// GATE B — needs app.ini, which exists only once Forgejo has started. +const forgejoCreds = writeForgejoCredentialsToVault( + ctx, + vault, + credentials.forgejo, + forgejo.ready, +); // ============================================================================= // Stack outputs (extended as phases land). // vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet. void vaultCreds; void backupCreds; // CONTRACT_002 backup/backup-credentials mirror; no secret output +void forgejoCreds; // CONTRACT_002 forgejo/service-credentials mirror; no secret output export const phase = "T10-runner"; // forge + CI runner live export const caddyImageId = proxy.imageId;