feat(secrets): file-based production secrets via compose secrets (BUNYIP-38) #40

Merged
nrupard merged 2 commits from feat/bunyip-38-file-based-secrets into main 2026-06-02 21:59:10 +02:00
Owner

Closes BUNYIP-38. Raised in the BUNYIP-32 (PR #33) review: every production secret was a plain compose environment variable, visible to anyone with docker socket access (docker inspect bunyip-api) and to any process able to read /proc//environ.

What

Mechanism: docker compose file-based secrets: (the same family as the existing OIDC key mount). Each secret is one value in one file under ./secrets/ (gitignored), mounted read-only at /run/secrets/<name>.

Config layer: secret_env(name) helper (crates/bunyip-domain/src/config.rs). Resolution order: {NAME}_FILE (trimmed file contents; unreadable file panics at startup) then plain {NAME} env var. Empty values are treated as unset, so compose ${VAR:-} defaults and empty secret files both mean "not configured". Applied to every secret read:

Secret Where read
DATABASE_URL Config::from_env (embeds the postgres password)
JWT_SECRET bunyip-api main.rs
TOTP_ENCRYPTION_KEY (+_PREV) Config::load_totp_encryption_key
STRIPE_ENCRYPTION_KEY (+_PREV) Config::load_stripe_encryption_key
SETUP_DEFAULT_ADMIN bunyip-api main.rs
FORGEJO_API_TOKEN DownloadConfig::from_env

compose.yml: top-level secrets: block (7 files), api consumes them via *_FILE env vars, postgres uses its native POSTGRES_PASSWORD_FILE. No secret value remains in any environment: section. Quick-start header documents the file-creation commands; all files must exist (empty = not configured).

Docs: .env.example (secrets section note + production subsection listing the 7 files; the secret vars are now dev-only) and dev-docs/oci-registry-verification.md (production notes: token lives in ./secrets/forgejo_api_token).

Dev workflow unchanged: compose.dev.yml untouched; the env-var fallback path keeps just dev / just dev-sso working with plain .env values.

Behavioral edge cases (intentional improvements)

  • Empty TOTP_ENCRYPTION_KEY / STRIPE_ENCRYPTION_KEY in dev now falls back to the zero dev key (matching the documented intent) instead of panicking with a confusing "must be exactly 32 bytes" error.
  • Empty SETUP_DEFAULT_ADMIN now skips admin seeding instead of panicking on first boot ("must be in format email:password").
  • Empty JWT_SECRET in production now fails fast at startup instead of silently signing tokens with an empty/placeholder secret.

Acceptance criteria

  • docker inspect bunyip-api shows no secret values: verified via docker compose -f compose.yml config; the rendered api environment contains only /run/secrets/* paths.
  • Dev workflow unchanged: verified live; the dev stack (just dev-detach, cargo-watch rebuild with this change) starts clean, /health returns ok, and DATABASE_URL / JWT_SECRET / SETUP_DEFAULT_ADMIN all resolve through the env-var fallback path.
  • All secrets one mechanism: every secret in compose.yml is a file-based compose secret; none remain as env-var values.

Verification (rust-builder-glibc 1.94.1 container)

  • cargo clippy --workspace --all-targets -- -D warnings: clean
  • cargo fmt --all --check: clean
  • cargo test --workspace --lib: 206 passed, 0 failed (5 new secret_env tests)
  • docker compose -f compose.yml config: valid; rendered environment has no secret values
Closes BUNYIP-38. Raised in the BUNYIP-32 (PR #33) review: every production secret was a plain compose environment variable, visible to anyone with docker socket access (`docker inspect bunyip-api`) and to any process able to read /proc/<pid>/environ. ## What **Mechanism: docker compose file-based `secrets:`** (the same family as the existing OIDC key mount). Each secret is one value in one file under `./secrets/` (gitignored), mounted read-only at `/run/secrets/<name>`. **Config layer: `secret_env(name)` helper** (`crates/bunyip-domain/src/config.rs`). Resolution order: `{NAME}_FILE` (trimmed file contents; unreadable file panics at startup) then plain `{NAME}` env var. Empty values are treated as unset, so compose `${VAR:-}` defaults and empty secret files both mean "not configured". Applied to every secret read: | Secret | Where read | |--------|------------| | `DATABASE_URL` | `Config::from_env` (embeds the postgres password) | | `JWT_SECRET` | bunyip-api `main.rs` | | `TOTP_ENCRYPTION_KEY` (+`_PREV`) | `Config::load_totp_encryption_key` | | `STRIPE_ENCRYPTION_KEY` (+`_PREV`) | `Config::load_stripe_encryption_key` | | `SETUP_DEFAULT_ADMIN` | bunyip-api `main.rs` | | `FORGEJO_API_TOKEN` | `DownloadConfig::from_env` | **compose.yml**: top-level `secrets:` block (7 files), api consumes them via `*_FILE` env vars, postgres uses its native `POSTGRES_PASSWORD_FILE`. No secret value remains in any `environment:` section. Quick-start header documents the file-creation commands; all files must exist (empty = not configured). **Docs**: `.env.example` (secrets section note + production subsection listing the 7 files; the secret vars are now dev-only) and `dev-docs/oci-registry-verification.md` (production notes: token lives in `./secrets/forgejo_api_token`). **Dev workflow unchanged**: compose.dev.yml untouched; the env-var fallback path keeps `just dev` / `just dev-sso` working with plain `.env` values. ## Behavioral edge cases (intentional improvements) - Empty `TOTP_ENCRYPTION_KEY` / `STRIPE_ENCRYPTION_KEY` in dev now falls back to the zero dev key (matching the documented intent) instead of panicking with a confusing "must be exactly 32 bytes" error. - Empty `SETUP_DEFAULT_ADMIN` now skips admin seeding instead of panicking on first boot ("must be in format email:password"). - Empty `JWT_SECRET` in production now fails fast at startup instead of silently signing tokens with an empty/placeholder secret. ## Acceptance criteria - `docker inspect bunyip-api` shows no secret values: verified via `docker compose -f compose.yml config`; the rendered api environment contains only `/run/secrets/*` paths. - Dev workflow unchanged: verified live; the dev stack (just dev-detach, cargo-watch rebuild with this change) starts clean, /health returns ok, and DATABASE_URL / JWT_SECRET / SETUP_DEFAULT_ADMIN all resolve through the env-var fallback path. - All secrets one mechanism: every secret in compose.yml is a file-based compose secret; none remain as env-var values. ## Verification (rust-builder-glibc 1.94.1 container) - `cargo clippy --workspace --all-targets -- -D warnings`: clean - `cargo fmt --all --check`: clean - `cargo test --workspace --lib`: 206 passed, 0 failed (5 new `secret_env` tests) - `docker compose -f compose.yml config`: valid; rendered environment has no secret values
feat(secrets): file-based production secrets via compose secrets + {NAME}_FILE convention (BUNYIP-38)
Some checks failed
Check / fmt / clippy / build / test (pull_request) Failing after 15m2s
ae84d2ba0b
- Add secret_env() to the config layer: reads {NAME}_FILE (trimmed contents of a compose secret mounted under /run/secrets, panicking on an unreadable file) with fallback to the plain {NAME} env var; empty values count as unset so ${VAR:-} compose defaults and empty secret files both mean "not configured".
- Apply it to every secret: DATABASE_URL, JWT_SECRET, TOTP/STRIPE_ENCRYPTION_KEY (+_PREV rotation keys), SETUP_DEFAULT_ADMIN, FORGEJO_API_TOKEN. Dev (.env via just dev / dev-sso) is unchanged through the fallback path.
- compose.yml: top-level secrets: block with 7 file-based secrets under ./secrets/ (gitignored); the api consumes them via *_FILE env vars, postgres via its native POSTGRES_PASSWORD_FILE. No secret value remains in any environment: section, so docker inspect and /proc/<pid>/environ never expose them.
- Intentional edge-case improvements: empty TOTP/STRIPE keys in dev now use the documented zero dev key instead of panicking; empty SETUP_DEFAULT_ADMIN skips seeding instead of panicking; empty JWT_SECRET in production fails fast instead of silently signing with a placeholder.
- Docs: .env.example (production secrets are file-based, the secret vars are dev-only) and dev-docs/oci-registry-verification.md production notes (token lives in ./secrets/forgejo_api_token).

Verified in the rust-builder 1.94.1 container (clippy -D warnings clean, fmt clean, 206 lib tests pass incl. 5 new secret_env tests), compose config renders no secret values, and the live dev stack rebuilds + starts clean with the env-var fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: nrupard <natrsmith11@gmail.com>
fix(secrets): address PR #40 review findings
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt / clippy / build / test (pull_request) Successful in 2m32s
6cfd6639f9
- Convert the remaining plain-env secret reads to secret_env: STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET (StripeConfig::from_env + StripeConfigResponse::from_env), SMTP_PASSWORD (EmailConfig::from_env), and BUNYIP_UPDATE_CHECK_TOKEN (main.rs), closing the acceptance-criterion-3 gap; compose.yml grows matching smtp_password / stripe_secret_key / stripe_webhook_secret / update_check_token secrets plus the SMTP_* / BUNYIP_UPDATE_CHECK_URL companion env passthroughs those features need.
- Stop exposing the secret value files at a second path inside the container: the OIDC signing keys move to ./secrets/oidc/ and only that subdir is bind-mounted at /run/secrets/oidc (compose.yml, compose.dev.yml, justfile ensure-oidc-keys with automatic migration of the old flat layout).
- Add scripts/init-secrets.sh (+ just init-secrets): one idempotent command that creates all 11 secret files with generated values, migrates values out of an existing .env (the upgrade path for pre-BUNYIP-38 deployments, now documented in the compose.yml header), derives database_url from postgres_password so the two cannot drift at creation time, and never leaves postgres_password empty (the documented exception to the empty-file-means-disabled rule).
- Loud tracing::warn when TOTP/STRIPE encryption keys fall back to the all-zero development key, so a blank key in a non-production environment is no longer silent.
- Trim FORGEJO_BASE_URL like its paired token so a stray trailing newline cannot produce malformed upstream URLs.
- Test hygiene: secret_env file tests use per-process temp file names and clean up before asserting, so a failed assertion cannot leak files or contaminate later runs.
- Docs updated across .env.example, compose.yml header, oci-registry-verification.md, and the dev-sso runbook (OIDC key paths). Filed PSA-37 to move secret_env into dunite-core as the long-term home.

Verified: clippy -D warnings clean, fmt clean, 206 lib tests pass; prod compose config renders only /run/secrets paths; init-secrets.sh run live on the dev box (migrated .env values + OIDC keys); dev stack recreated with the new oidc mount and rebuilt with this code: OIDC provider initializes and /health returns ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: nrupard <natrsmith11@gmail.com>
nrupard deleted branch feat/bunyip-38-file-based-secrets 2026-06-02 21:59:11 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
psa-systems/bunyip!40
No description provided.