feat(credentials): mirror Forgejo crypto secrets into Vault (CONTRACT_002)

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) <noreply@anthropic.com>
This commit is contained in:
Andreas Niemann 2026-06-30 23:28:17 +02:00
parent f2ef9bc922
commit fbd1ad4d1d
2 changed files with 76 additions and 10 deletions

View file

@ -99,7 +99,6 @@ IFS= read -r PG_FORGEJO_PW
IFS= read -r RUSTFS_ADMIN_PW IFS= read -r RUSTFS_ADMIN_PW
IFS= read -r RUSTFS_SVC_ID IFS= read -r RUSTFS_SVC_ID
IFS= read -r RUSTFS_SVC_SECRET IFS= read -r RUSTFS_SVC_SECRET
IFS= read -r FORGEJO_ADMIN_PW
C=foundation-vault C=foundation-vault
VE="-e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=$ROOT_TOKEN" 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}' \ '{rustfsAdminUser:$u,rustfsAdminPassword:$p,rustfsServiceKeyId:$ki,rustfsServiceKeySecret:$ks}' \
| put rustfs/service-credentials | put rustfs/service-credentials
jq -n --arg u "$FORGEJO_ADMIN_USER" --arg p "$FORGEJO_ADMIN_PW" \ echo "vault: wrote postgres + rustfs service-credentials"`;
'{forgejoAdminUser:$u,forgejoAdminPassword:$p}' \ // NOTE: foundation/forgejo/service-credentials is NOT written here. Its crypto
| put forgejo/service-credentials // 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
echo "vault: wrote postgres + rustfs + forgejo service-credentials"`; // 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). * T06 distribute the generated data-plane credentials into Vault (CONTRACT_002).
@ -132,12 +132,11 @@ export function writeCredentialsToVault(
creds: FoundationCredentials, creds: FoundationCredentials,
vault: VaultOutputs, vault: VaultOutputs,
): command.remote.Command { ): 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. // token first, then the order the script `read`s them) arrive on stdin.
const create = pulumi.interpolate`PG_SUPER_USER='${creds.postgres.superUser}' const create = pulumi.interpolate`PG_SUPER_USER='${creds.postgres.superUser}'
PG_FORGEJO_USER='${creds.postgres.forgejoDbUser}' PG_FORGEJO_USER='${creds.postgres.forgejoDbUser}'
RUSTFS_ADMIN_USER='${creds.rustfs.adminUser}' RUSTFS_ADMIN_USER='${creds.rustfs.adminUser}'
FORGEJO_ADMIN_USER='${creds.forgejo.adminUser}'
${WRITE_CREDS}`; ${WRITE_CREDS}`;
return new command.remote.Command( return new command.remote.Command(
@ -152,7 +151,6 @@ ${creds.postgres.forgejoDbPassword}
${creds.rustfs.adminPassword} ${creds.rustfs.adminPassword}
${creds.rustfs.serviceKeyId} ${creds.rustfs.serviceKeyId}
${creds.rustfs.serviceKeySecret} ${creds.rustfs.serviceKeySecret}
${creds.forgejo.adminPassword}
`, `,
addPreviousOutputInEnv: false, addPreviousOutputInEnv: false,
triggers: [ triggers: [
@ -162,7 +160,6 @@ ${creds.forgejo.adminPassword}
creds.rustfs.adminPassword, creds.rustfs.adminPassword,
creds.rustfs.serviceKeyId, creds.rustfs.serviceKeyId,
creds.rustfs.serviceKeySecret, creds.rustfs.serviceKeySecret,
creds.forgejo.adminPassword,
], ],
}, },
{ dependsOn: [vault.init] }, { dependsOn: [vault.init] },
@ -230,3 +227,62 @@ ${backup.ageIdentity}
{ dependsOn: [vault.init] }, { 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] },
);
}

View file

@ -14,6 +14,7 @@ import {
generateCredentials, generateCredentials,
writeCredentialsToVault, writeCredentialsToVault,
writeBackupCredentialsToVault, writeBackupCredentialsToVault,
writeForgejoCredentialsToVault,
} from "./components/credentials"; } from "./components/credentials";
import { deployPostgres } from "./components/postgres"; import { deployPostgres } from "./components/postgres";
import { deployRustfs } from "./components/rustfs"; import { deployRustfs } from "./components/rustfs";
@ -75,12 +76,21 @@ const forgejoBootstrap = bootstrapForgejo(ctx, {
sshPublicKey, sshPublicKey,
}); });
const runner = cfg.features.runner ? deployRunner(ctx, forgejo) : undefined; 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). // Stack outputs (extended as phases land).
// vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet. // vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet.
void vaultCreds; void vaultCreds;
void backupCreds; // CONTRACT_002 backup/backup-credentials mirror; no secret output 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 phase = "T10-runner"; // forge + CI runner live
export const caddyImageId = proxy.imageId; export const caddyImageId = proxy.imageId;