foundation/bootstrap/components/credentials.ts
Andreas Niemann fbd1ad4d1d 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>
2026-06-30 23:28:17 +02:00

288 lines
12 KiB
TypeScript

// 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<string>;
forgejoDbUser: string; // "forgejo" (deterministic)
forgejoDbPassword: pulumi.Output<string>;
}
/** `foundation/rustfs/service-credentials` (CONTRACT_002 §2.3). */
export interface RustfsCredentials {
adminUser: string; // "rustfsadmin" — root access key name (deterministic)
adminPassword: pulumi.Output<string>; // root secret key (RUSTFS_SECRET_KEY)
serviceKeyId: pulumi.Output<string>; // scoped S3 key Forgejo/backup use
serviceKeySecret: pulumi.Output<string>;
}
/**
* `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<string>;
}
/** 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<string> {
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] },
);
}