From b5a37aa8144d957bb4aae55c60a8802eb534e83d Mon Sep 17 00:00:00 2001
From: John Gruber
Date: Fri, 21 Aug 2026 09:05:11 -0500
Subject: [PATCH 01/16] Integrate the #177 follow-up stack (#179 #180 #181 #182
#183 #186 #188)
Consolidated landing of seven interdependent PRs whose shared credential and
release/CI surfaces prevented merging in any order (see issue #192's conflict
matrix). Merged in dependency order 179, 180, 181, 188, 186, 183, 182; the
#186/#188 credential surface was reconciled once (single reserved-name guard;
provenance + migrations + stale-disable combined with rotation + backend MCP
wiring + threadpool). Squashed to one commit; per-PR history retained on the
seven archived branches.
Validated on the merged tree: ruff + mypy clean; 172 auth/credential/startup/
migration tests pass; single alembic head v2_155; openapi + frontend types fresh;
helm lint/template and docker compose config green on all modes; version and
detector self-tests green; commit-message lint clean.
Closes #192.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
---
.env.example | 19 +-
.githooks/pre-push | 43 ++
.github/workflows/ci.yml | 266 ++++++-
.github/workflows/e2e-tests.yml | 6 +
.github/workflows/release.yml | 650 ++++++++++++++++--
.github/workflows/secret-baseline.yml | 39 ++
.gitignore | 6 +
.gitleaks.toml | 15 +-
.trivyignore | 33 +-
AGENTS.md | 23 +
CHANGELOG.md | 47 +-
Makefile | 143 +++-
README.md | 17 +-
The_BNK_Forge_Developers_Guide.md | 4 +-
.../v2_154_user_is_service_account.py | 32 +
.../v2_155_backfill_is_service_account.py | 92 +++
backend/core/auth_middleware.py | 61 +-
backend/core/config.py | 51 +-
backend/models/system.py | 8 +-
backend/openapi.json | 15 +
backend/routes/auth.py | 39 +-
backend/routes/benchmarks.py | 44 +-
backend/routes/dpus_websocket.py | 8 +
backend/routes/k8s_websocket.py | 8 +
backend/schemas/auth.py | 7 +
backend/services/auth_service.py | 473 ++++++++++++-
.../services/execution/container_runner.py | 7 +-
backend/startup_steps.py | 93 ++-
backend/tests/component/test_auth_service.py | 463 ++++++++++++-
backend/tests/component/test_k8s_websocket.py | 47 +-
backend/tests/component/test_startup_steps.py | 37 +-
backend/tests/integration/test_routes_auth.py | 184 +++++
.../integration/test_routes_k8s_websocket.py | 4 +-
backend/tests/test_migrations.py | 109 +++
backend/tests/test_startup_seed_auth.py | 156 +++++
backend/tests/unit/test_auth_middleware.py | 42 ++
.../tests/unit/test_benchmark_agent_auth.py | 145 +++-
backend/tests/unit/test_core_config.py | 26 +
bin/roadmap-add.py | 2 +-
bin/roadmap-gen.py | 3 +-
bnk-operator/charts/bnk-operator/Chart.yaml | 2 +-
bnk-operator/charts/bnk-operator/values.yaml | 4 +-
dist/.env.example | 20 +-
dist/README.md | 55 +-
dist/docker-compose.local.yml | 11 +-
dist/docker-compose.yml | 56 +-
dist/install.sh | 4 +-
docker-bake.hcl | 47 +-
docker-compose.adr424.yml | 2 +-
docker-compose.local.yml | 19 +-
docker-compose.yml | 24 +-
docs/DEPLOYMENT.md | 35 +-
docs/DOCKER.md | 35 +-
docs/E2E-CRITICAL-004_MCP_SANITY.md | 4 +-
...er modules and blueprints for BNK Forge.md | 11 +-
docs/INSTALLATION.md | 46 +-
docs/ROADMAP.md | 6 +-
docs/ROADMAP_PROCESS.md | 4 +-
docs/roadmap.html | 4 +-
docs/roadmap.yaml | 12 +-
frontend-v2/package.json | 2 +-
frontend-v2/src/types/api-generated.ts | 15 +
helm/bnk-forge/Chart.yaml | 2 +-
helm/bnk-forge/templates/NOTES.txt | 8 +-
helm/bnk-forge/templates/_helpers.tpl | 36 +
helm/bnk-forge/templates/api.yaml | 6 +
helm/bnk-forge/templates/beat.yaml | 5 +
helm/bnk-forge/templates/mcp.yaml | 6 +
helm/bnk-forge/templates/secrets.yaml | 70 +-
helm/bnk-forge/templates/worker.yaml | 5 +
helm/bnk-forge/values.yaml | 21 +-
mcp-server/README.md | 16 +-
mcp-server/src/bnk_forge_mcp/healthcheck.py | 21 +-
mcp-server/tests/test_healthcheck.py | 64 +-
scripts/compute_version_bump.sh | 234 ++++++-
scripts/e2e/steps.py | 9 +-
scripts/extract-breaking-changes.sh | 285 +++++++-
scripts/get_dpu_pwd.sh | 1 +
scripts/ibm_cloud_bnk_forge.sh | 39 +-
scripts/lint-commit-markers.sh | 152 ++++
scripts/mcp_live_smoke.py | 8 +-
scripts/publish-signed-images.sh | 14 +-
scripts/registry-tag-probe.sh | 137 ++++
scripts/secret-scan.sh | 91 +++
scripts/sync-version-artifacts.sh | 193 ++++++
scripts/test-backup-restore.sh | 4 +-
scripts/tests/registry-tag-probe.test.sh | 104 +++
tests/e2e/E2E_STRATEGY.md | 2 +-
tests/e2e/config/test-config.ts | 2 +-
tests/e2e/pages/login.page.ts | 2 +-
user-pack/install-guide.html | 154 +++--
vm-bnk-forge/README.md | 5 +-
92 files changed, 5099 insertions(+), 482 deletions(-)
create mode 100644 .github/workflows/secret-baseline.yml
create mode 100644 backend/alembic/versions/v2_154_user_is_service_account.py
create mode 100644 backend/alembic/versions/v2_155_backfill_is_service_account.py
create mode 100644 backend/tests/test_startup_seed_auth.py
create mode 100644 scripts/lint-commit-markers.sh
create mode 100644 scripts/registry-tag-probe.sh
create mode 100644 scripts/secret-scan.sh
create mode 100644 scripts/sync-version-artifacts.sh
create mode 100644 scripts/tests/registry-tag-probe.test.sh
diff --git a/.env.example b/.env.example
index 1c024c7e..f3f9e423 100644
--- a/.env.example
+++ b/.env.example
@@ -58,7 +58,7 @@
# Set HOST_REPO_PATH in docker-compose.yml to enable the GUI upgrade button.
# Without this, use SSH + ./upgrade.sh for server upgrades.
#
-# HOST_REPO_PATH=/home/jarrodl/bnk-forge-v2
+# HOST_REPO_PATH=/path/to/bnk-forge
# ============================================================================
# ENVIRONMENT (development/staging/production)
@@ -81,12 +81,21 @@
# affect MCP (they are distinct env vars and distinct accounts).
#
# DEFAULT_ADMIN_PASSWORD controls the seeded human admin account (first-boot only,
-# must_change_password=True). In production set this to a strong initial value
-# that operators change on first login.
+# must_change_password=True, enforced server-side). If left UNSET, a strong
+# random password is generated and written to /app/keys/initial_admin_password
+# (mode 600, on the bnk-forge-keys volume) -- retrieve it with:
+# docker exec bnk-forge-backend cat /app/keys/initial_admin_password
+# Set it here only if you want to choose the initial value yourself.
#
# MCP_SERVICE_USERNAME=mcp
-# MCP_SERVICE_PASSWORD=mcp-service-changeme
-# DEFAULT_ADMIN_PASSWORD=changeme
+# Choose your own value — there is NO shipped default (#186/#187): the old
+# mcp-service-changeme is refused as a seed value and can no longer authenticate.
+# Set the SAME value on the backend and the MCP server. Keep the value on its own
+# line — an inline comment here would become part of the password when uncommented.
+# Leave it unset and MCP stays unavailable until you configure it (the backend
+# disables any stale service account rather than seed a guessable one).
+# MCP_SERVICE_PASSWORD=
+# DEFAULT_ADMIN_PASSWORD=
# ============================================================================
# BENCHMARK AGENT AUTHENTICATION
diff --git a/.githooks/pre-push b/.githooks/pre-push
index f3f50c01..026ea80e 100755
--- a/.githooks/pre-push
+++ b/.githooks/pre-push
@@ -16,6 +16,49 @@ if [ $? -ne 0 ]; then
exit 1
fi
+echo ""
+
+# ─── Commit-message marker lint (bonnyr-f5 #182 r3) ───────────────────────────
+# Fail fast, before the heavy suite, if any commit about to be pushed carries a
+# CI-control marker (which would suppress the workflow run) or a spurious major-
+# bump prose line. Same script the ci.yml commit-lint gate runs, so local == CI.
+#
+# RANGE from the pre-push stdin protocol (bonnyr-f5 #182 r5, Minor). git feeds
+# this hook one "" line per ref
+# being pushed. Scanning the script's default `@{upstream}..HEAD` misses every
+# non-tip commit when the branch has no upstream yet (a FIRST push) -- exactly
+# when a bad commit is most likely to slip in. Deriving `..` from stdin scans precisely the commits this push introduces. A new remote
+# branch reports an all-zero remote sha (no merge-base to diff against); there we
+# fall back to the script's own default rather than scanning all of history.
+# Deletions (all-zero local sha) contribute no commits. When stdin is empty (the
+# hook run by hand, not by git) we leave RANGE unset so the script default runs.
+zero="0000000000000000000000000000000000000000"
+prepush_range=""
+while read -r _localref localsha _remoteref remotesha; do
+ [ -z "${localsha:-}" ] && continue
+ [ "$localsha" = "$zero" ] && continue # branch deletion: nothing to lint
+ if [ "${remotesha:-$zero}" = "$zero" ]; then
+ prepush_range="__DEFAULT__" # new branch: no base -> script default
+ break
+ fi
+ prepush_range="${remotesha}..${localsha}" # normal update: exactly the pushed commits
+ break
+done
+
+echo "=== Commit message marker lint (pre-push) ==="
+if [ -n "$prepush_range" ] && [ "$prepush_range" != "__DEFAULT__" ]; then
+ lint_status() { RANGE="$prepush_range" bash scripts/lint-commit-markers.sh; }
+else
+ lint_status() { bash scripts/lint-commit-markers.sh; }
+fi
+if ! lint_status; then
+ echo ""
+ echo "PUSH BLOCKED: a commit message carries a CI-control / spurious-major marker."
+ echo "Reword it (see AGENTS.md 'Commit conventions') and try again."
+ exit 1
+fi
+
echo ""
echo "========================================="
echo " Pre-push: Running local checks"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5d8f62e3..3fdcdc40 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,33 +24,28 @@ name: CI
on:
pull_request:
branches: [main, staging, develop]
- paths-ignore:
- - '**.md'
- - 'docs/**'
- - '.agent/**'
- - '.opencode/**'
- - 'LICENSE'
- - '.gitignore'
- - '.trivyignore'
- - 'USER_GUIDE.md'
+ # No workflow-level paths-ignore: secret scanning (gitleaks) and the CI Gate
+ # must see EVERY change, doc-only PRs included — a secret lands in a .md as
+ # easily as in code, and this is a public repo (#182 review). Expensive jobs
+ # still skip on irrelevant paths via the per-job `changes` filter below; path
+ # filtering lives there (one source of truth), not at the trigger.
push:
# main (deploy trigger) + staging (release-automation preflight needs a
# push-triggered CI run to match by SHA — see release.yml preflight);
- # develop skipped.
+ # develop skipped. No paths-ignore, same reason as above.
branches: [main, staging]
- paths-ignore:
- - '**.md'
- - 'docs/**'
- - '.agent/**'
- - '.opencode/**'
- - 'LICENSE'
- - '.gitignore'
- - '.trivyignore'
- - 'USER_GUIDE.md'
concurrency:
- group: ci-${{ github.ref }}
- cancel-in-progress: true
+ # bonnyr-f5 #182 r2: on main/staging give every push its OWN group (append the
+ # SHA) so a later docs-only push can't cancel -- even as a PENDING run -- the CI
+ # run a release polls by SHA. Feature branches keep the per-ref group so rapid
+ # pushes still supersede each other and save minutes.
+ group: ci-${{ github.ref }}${{ (github.ref_name == 'main' || github.ref_name == 'staging') && github.sha || '' }}
+ # bonnyr-f5 #182: never cancel an in-flight CI run on the release branches --
+ # release.yml preflight polls that exact run by SHA, so a docs-only push (which
+ # triggers CI but not Release) would otherwise cancel it and strand the earlier
+ # commit's release. Feature branches still cancel to save minutes.
+ cancel-in-progress: ${{ github.ref_name != 'main' && github.ref_name != 'staging' }}
permissions:
contents: read
@@ -126,6 +121,183 @@ jobs:
- name: Run lint
run: make lint-backend
+ version-consistency:
+ name: "P1 · Version Consistency"
+ needs: changes
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Assert version-bearing artifacts agree with VERSION
+ # Helm chart tag/appVersion and frontend package.json must equal VERSION,
+ # or the release (which publishes only :${VERSION}) yields ImagePullBackOff
+ # / silent drift (#177 Blocker 2). Goes through `make version-check` so
+ # this job and `make pre-push` run the identical command (#182 r3).
+ run: make version-check
+
+ shellcheck:
+ name: "P1 · ShellCheck"
+ needs: changes
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Install shellcheck
+ run: sudo apt-get update && sudo apt-get install -y shellcheck
+ - name: Run shellcheck
+ run: make shellcheck
+
+ secret-scan:
+ name: "P1 · Secret Scan (gitleaks)"
+ needs: changes
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # full history so the range scan sees add-then-remove
+ - name: gitleaks
+ run: |
+ # bonnyr-f5 #182 r2: scan the PR/push COMMIT RANGE in git mode, not the
+ # working tree. --no-git misses a secret added then REMOVED within the
+ # branch, which stays fetchable forever from a public clone -- the main
+ # thing a public repo needs a history-aware scan for.
+ #
+ # All of the scan + assertion logic (the r3 BLOCKER fix, the
+ # dubious-ownership safe.directory fix, the archive-depth fix, and the
+ # digest pin) lives in scripts/secret-scan.sh so `make secret-scan` and
+ # this job run byte-identical commands (#166 / ci.yml header: local==CI).
+ # We only compute the range from the event here and hand it to the
+ # script; RANGE is exported (even when empty => full history).
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ RANGE="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
+ elif [ -n "${{ github.event.before }}" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
+ RANGE="${{ github.event.before }}..${{ github.sha }}"
+ else
+ RANGE="" # first push / no base — scan all reachable history
+ fi
+ export RANGE
+ make secret-scan
+
+ commit-lint:
+ name: "P1 · Commit Message Lint"
+ needs: changes
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # need the whole PR range of commit messages
+ - name: Lint commit messages for CI-control / spurious-bump markers
+ run: |
+ # bonnyr-f5 #182 r3 (Minor -> enforcement): the AGENTS.md rule against
+ # CI-control markers in commit messages was documentation only, and
+ # "documentation is not enforcement" (#166). This gate FAILS a PR/push
+ # whose commit range carries a marker (which would suppress CI for that
+ # commit -- the #179/#181 case) or an accidental line-start BREAKING
+ # CHANGE prose that spuriously majors a release. The .githooks/pre-push
+ # hook runs the SAME script locally so it is caught before push too.
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ RANGE="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
+ elif [ -n "${{ github.event.before }}" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
+ RANGE="${{ github.event.before }}..${{ github.sha }}"
+ else
+ RANGE="" # first push / no base — script scans just the tip commit
+ fi
+ export RANGE
+ make commit-lint
+
+ script-selftests:
+ name: "P1 · Script Self-Tests"
+ needs: changes
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: compute_version_bump SELF_TEST
+ run: |
+ # Fail on a non-zero exit OR a FAIL: line. The harness historically
+ # printed FAIL: but still exited 0, so trusting the exit code alone
+ # made this job unable to catch a broken self-test until the exit-code
+ # fix landed (#182 review). Checking both decouples the two.
+ set +e
+ out="$(SELF_TEST=1 bash scripts/compute_version_bump.sh 2>&1)"; rc=$?
+ echo "$out"
+ if [ "$rc" -ne 0 ]; then
+ echo "::error::compute_version_bump self-test exited $rc"; exit "$rc"
+ fi
+ if grep -qE '(^|[[:space:]])FAIL:' <<< "$out"; then
+ echo "::error::compute_version_bump self-test reported FAIL: but exited 0"; exit 1
+ fi
+ # bonnyr-f5 #182: silence must not pass -- require positive evidence the
+ # harness actually ran (renaming its SELF_TEST guard produced empty
+ # output + rc=0, i.e. green with zero assertions).
+ # bonnyr-f5 #182 r2: require the END marker AND >=1 PASS. The marker
+ # prints only after the LAST assertion, so an early exit (the #179 shape,
+ # 5 of 6 unrun) is caught without hardcoding a per-branch test count.
+ if ! grep -qE '(^|[[:space:]])PASS:' <<< "$out"; then
+ echo "::error::self-test produced no PASS lines -- the harness did not run"; exit 1
+ fi
+ if ! grep -qE '=== END SELF-TEST ===' <<< "$out"; then
+ echo "::error::self-test did not reach its END marker -- it exited early with assertions unrun"; exit 1
+ fi
+ - name: BREAKING CHANGE detector parity + extractor self-test
+ run: |
+ # bonnyr-f5 #179 r3 (cross-PR / INV-15): the BREAKING CHANGE detector
+ # MUST be byte-identical between the two scripts -- if they drift, a
+ # major bump ships with empty notes (or a note ships with no bump). This
+ # asserts identity in code, replacing the "MUST stay identical" comment.
+ #
+ # The extraction is version-agnostic on purpose: it pulls the regex out
+ # of whichever `grep -qE '...BREAKING...CHANGE...'` detector each script
+ # uses, so it enforces parity whether the tree is pre- or post-#179
+ # (both scripts use the SAME form as each other in either state). That
+ # keeps this gate meaningful on this PR today AND on the merged stack.
+ set -euo pipefail
+ E=scripts/extract-breaking-changes.sh
+ C=scripts/compute_version_bump.sh
+ # Adaptive check. #179 factors detection into _is_breaking_subject and
+ # _is_breaking_body FUNCTIONS (the body one is a paragraph-aware awk, not
+ # a single grep). When those functions exist, diff their full bodies --
+ # that guards the awk detector too, and answers bonnyr-f5 #179 r4's nit
+ # that compute had no function to diff. On the pre-#179 tree (inline
+ # greps, no functions) fall back to extracting the detector regex, so the
+ # gate stays meaningful on this PR before the stack merges.
+ _fn() { sed -n "/^$2()/,/^}/p" "$1"; } # print a function definition
+ _regex() {
+ grep -oE "grep -qE '[^']*BREAKING[^']*CHANGE[^']*'" "$1" \
+ | sed -E "s/^grep -qE '//; s/'\$//" | sort -u
+ }
+ if grep -q '^_is_breaking_body()' "$E" && grep -q '^_is_breaking_body()' "$C"; then
+ for fn in _is_breaking_subject _is_breaking_body; do
+ if [ "$(_fn "$E" "$fn")" != "$(_fn "$C" "$fn")" ]; then
+ echo "::error::INV-15 violated: $fn differs between the two scripts"
+ diff <(_fn "$E" "$fn") <(_fn "$C" "$fn") || true
+ exit 1
+ fi
+ done
+ echo "INV-15 OK: _is_breaking_subject + _is_breaking_body are byte-identical functions in both scripts"
+ else
+ ex="$(_regex "$E")"; cv="$(_regex "$C")"
+ if [ -z "$ex" ] || [ -z "$cv" ]; then
+ echo "::error::could not extract a BREAKING CHANGE detector from one of the scripts (extract='$ex' compute='$cv')"; exit 1
+ fi
+ if [ "$ex" != "$cv" ]; then
+ echo "::error::INV-15 violated: the BREAKING CHANGE detector regex differs between the two scripts"
+ echo " extract: $ex"; echo " compute: $cv"; exit 1
+ fi
+ echo "INV-15 OK (pre-#179 tree): detector regex identical across both scripts -> $ex"
+ fi
+ # Run the extractor's own self-test once #179's anchored extractor (which
+ # adds --self-test) is in the tree. Until #179 merges to staging, this
+ # PR's base carries the older extractor; warn LOUDLY (not a silent skip)
+ # so the pending activation is visible in the log.
+ if grep -q -- '--self-test' scripts/extract-breaking-changes.sh; then
+ bash scripts/extract-breaking-changes.sh --self-test
+ else
+ echo "::warning::extract-breaking-changes.sh has no --self-test yet (it lands with #179); the parity gate above is still enforced this run"
+ fi
+
lint-frontend:
name: "P1 · Lint Frontend"
needs: changes
@@ -668,12 +840,17 @@ jobs:
# dependencies. No `|| true` here: if this cannot run, the job has
# nothing to say and must fail loudly rather than silently continue.
#
- # Known fragility, accepted deliberately: the floor's models are
- # imported under CURRENT pins, so the gap widens every time a
- # dependency moves. v3.0.1 pins cryptography 44 and staging is on 50 —
- # six majors — and it holds only because the floor tree touches just
- # Fernet, hazmat.primitives.serialization and Ed25519PrivateKey, all
- # unchanged across that range. When it does bite, it bites as a
+ # Known limitation on this repo: f5devcentral/bnk-forge is a squashed
+ # public mirror and carries exactly ONE final tag, v3.1.6, so the floor
+ # is currently that tag and the upgrade window is one release wide —
+ # the degenerate case this check otherwise warns against. It can't be
+ # widened by naming an older tag (v3.0.1 etc. from the upstream history
+ # aren't reachable here); it widens only as more finals are cut on this
+ # repo. Accepted deliberately.
+ #
+ # The floor's models are imported under CURRENT pins, so a dependency
+ # gap can still bite once the window does widen. When it does, it bites
+ # as a
# MANDATORY gate failing hard on a commit that changed nothing
# relevant. The fix then is to raise MIN_UPGRADE_FROM to a release
# whose models import cleanly, not to add `|| true` here: a floor that
@@ -1063,6 +1240,11 @@ jobs:
- changes
# Phase 1
- lint-backend
+ - version-consistency
+ - shellcheck
+ - secret-scan
+ - commit-lint
+ - script-selftests
- lint-frontend
- typecheck-backend
- openapi-check
@@ -1094,8 +1276,32 @@ jobs:
# Collect all job results (skipped jobs are OK — they were filtered by path)
failed=false
+
+ # bonnyr-f5 #182 r3 (Major): the aggregator must verify the change-
+ # detection job itself SUCCEEDED. If `changes` fails/cancels, ~21 of the
+ # gates below resolve to `skipped` (their `needs: changes` was never
+ # satisfied), the old loop accepted skipped as success, and the required
+ # check printed "CI Gate PASSED" while nothing had actually run. Same
+ # class as the secret-scan blocker: cannot distinguish "passed" from
+ # "never evaluated". A non-success `changes` fails the gate outright.
+ changes_result="${{ needs.changes.result }}"
+ echo "changes (change-detection): $changes_result"
+ if [ "$changes_result" != "success" ]; then
+ echo "::error::change-detection job did not succeed ($changes_result) -- every downstream gate was skipped, so the gate cannot certify anything. Failing."
+ failed=true
+ fi
+
+ # bonnyr-f5 #182 r2/r3: these gates run `if: always()` on every change, so
+ # `skipped` for them means a future path-filter silently disabled the
+ # check. Treat skipped as a failure for exactly these.
+ ALWAYS_RUN="version-consistency shellcheck secret-scan commit-lint script-selftests"
for job in \
"lint-backend:${{ needs.lint-backend.result }}" \
+ "version-consistency:${{ needs.version-consistency.result }}" \
+ "shellcheck:${{ needs.shellcheck.result }}" \
+ "secret-scan:${{ needs.secret-scan.result }}" \
+ "commit-lint:${{ needs.commit-lint.result }}" \
+ "script-selftests:${{ needs.script-selftests.result }}" \
"lint-frontend:${{ needs.lint-frontend.result }}" \
"typecheck-backend:${{ needs.typecheck-backend.result }}" \
"openapi-check:${{ needs.openapi-check.result }}" \
@@ -1119,10 +1325,14 @@ jobs:
; do
name="${job%%:*}"
result="${job##*:}"
- # 'success' and 'skipped' are both acceptable
+ # 'success' and 'skipped' are acceptable, EXCEPT skipped for an
+ # always-run gate, which means the check silently didn't execute.
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
echo "::error::$name: $result"
failed=true
+ elif [ "$result" = "skipped" ] && case " $ALWAYS_RUN " in *" $name "*) true;; *) false;; esac; then
+ echo "::error::$name was SKIPPED but is an always-run gate — the check never executed"
+ failed=true
else
echo "$name: $result"
fi
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index 9efd0f67..e39a6c50 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -108,6 +108,12 @@ jobs:
# ── Start app (local mode) ──────────────────────────────────────────
- name: Start application stack
if: inputs.environment != 'staging'
+ env:
+ # bonnyr-f5 #186 r2: the seeded admin is now generated + must-change, so
+ # the suite's hardcoded login would fail. Seed a KNOWN, non-default admin
+ # with the gate off -- ephemeral CI stack only, never a real deployment.
+ DEFAULT_ADMIN_PASSWORD: e2e-Admin-Pass-1
+ DEFAULT_ADMIN_MUST_CHANGE: "false"
run: |
docker compose up -d
echo "Waiting for application to be healthy..."
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0ded64e0..4fa132ba 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,8 +11,10 @@
# ║ feat → minor ║
# ║ fix / other → patch ║
# ║ ║
-# ║ Infinite-loop guard: commits starting with "release: " have [skip ci] ║
-# ║ appended and are filtered out by the head-commit check below. ║
+# ║ Loop guard: our automated release commits carry [skip ci] in the SUBJECT, ║
+# ║ so GitHub drops their push runs before this workflow even starts. The job ║
+# ║ below is a backstop that FAILS the run on any OTHER suppressed commit — a ║
+# ║ release that published nothing must never read as green. ║
# ╚══════════════════════════════════════════════════════════════════════════════╝
name: Release
@@ -22,9 +24,13 @@ on:
branches:
- staging
- main
- # MUST stay in sync with ci.yml's push-trigger paths-ignore list — a
- # divergence lets a push trigger Release without a matching CI run,
- # which then times out the preflight SHA poll below (PR #297 review).
+ # ci.yml has NO push paths-ignore any more: it runs CI on EVERY push to
+ # main/staging (ci.yml:31-36, #182). That is a strict superset of the pushes
+ # that reach Release here, so any push that starts a release is guaranteed a
+ # matching CI run for the preflight SHA poll below to find — regardless of
+ # what this list ignores. This paths-ignore therefore only spares docs-only
+ # pushes from kicking off a release at all (PR #297 review; premise updated
+ # for #182, which removed ci.yml's paths-ignore).
paths-ignore:
- '**.md'
- 'docs/**'
@@ -47,18 +53,44 @@ on:
- major # 2.10.49 → 3.0.0
run_e2e:
description: "Run E2E tests before release"
- required: true
+ # Not required: a publish_only recovery run ignores it (it has a default
+ # and never gates the republish path) — bonnyr-f5 #181 round 2.
+ required: false
default: true
type: boolean
release_notes:
- description: "Release notes (one-line summary)"
- required: true
+ description: "Release notes (one-line summary). Ignored when publish_only is set."
+ # Not required for the same reason: publish_only recovery runs ignore it.
+ required: false
+ default: ""
+ type: string
+ publish_only:
+ description: "Recovery: republish images for an EXISTING tag (e.g. v4.0.0). Leave empty for a normal release. When set, version_bump / run_e2e / release_notes are ignored."
+ required: false
type: string
+ default: ""
+ force:
+ description: "Overwrite images that ALREADY exist in the registry for this tag. Off by default: publishing refuses when the :VERSION manifest is already present, so a republish can't silently move an immutable tag and orphan its cosign/SBOM/SLSA attestations. Only a recovery of a tag whose publish never completed needs this off; set it on to deliberately re-push."
+ required: false
+ type: boolean
+ default: false
+ sign_only:
+ description: "Recovery for a publish that pushed all images but failed at signing (e.g. cosign/OIDC error AFTER the bake succeeded). Re-runs cosign sign + SBOM + provenance against the ALREADY-pushed :VERSION digests WITHOUT rebuilding or re-pushing — cosign sign is idempotent and the immutable tag never moves. Requires publish_only=. Use this instead of force=true for a sign-only recovery: force rebuilds all images to possibly-different digests and moves the immutable tag (the exact INV-24 harm the overwrite guard exists to prevent)."
+ required: false
+ type: boolean
+ default: false
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false # Never cancel a release in progress
+# Least-privilege default so guard/preflight don't inherit the repo-default
+# token scope (bonnyr-f5 #181 round 2). Jobs that need more (contents: write to
+# push tags, packages/id-token to publish) declare it themselves below; a job's
+# own permissions: block replaces this one rather than merging.
+permissions:
+ contents: read
+
jobs:
# ── Guard: skip release: commits on main (prevents infinite loop) ────────────
guard:
@@ -73,13 +105,48 @@ jobs:
HEAD_COMMIT_MSG: ${{ github.event.head_commit.message }}
run: |
MSG="$HEAD_COMMIT_MSG"
- # Skip if triggered by our own release commit or [skip ci] sentinel
- if echo "$MSG" | grep -qE '^release: |^\[skip ci\]|\[skip ci\]$'; then
- echo "Skipping: head commit is a release commit or has [skip ci]"
+ FIRST="${MSG%%$'\n'*}"
+ # Match the SUBJECT line only. grep is line-oriented, so testing the
+ # whole message let a [skip ci] line buried in the body — a quoted CI
+ # snippet, a changelog paste — suppress a legitimate release while the
+ # message below quoted a subject carrying no marker (bonnyr-f5 #181
+ # round 2). On a normal push GitHub's own [skip ci] handling already
+ # drops our release commits (their subject is "release: vX.Y.Z
+ # [skip ci]") before this workflow starts, and on workflow_dispatch
+ # head_commit is null so FIRST is empty and nothing matches — this
+ # guard is a backstop, not the primary suppressor.
+ if grep -qE '^release: |^\[skip ci\]|\[skip ci\]$' <<< "$FIRST"; then
echo "should_run=false" >> "$GITHUB_OUTPUT"
- else
- echo "should_run=true" >> "$GITHUB_OUTPUT"
+ # Only OUR OWN automated release commit is exempt (a silent, green
+ # skip). It has a distinguishing fingerprint that a human commit does
+ # not: the subject is EXACTLY "release: vX.Y.Z [skip ci]" — a version
+ # AND the skip marker we ourselves append (see the two `git commit`
+ # calls below). Matching "release:" + a version alone was wrong: a
+ # hand-written commit like "release: v3.2.0 notes" (a human writing
+ # release notes, no marker) also matched and was silently skipped
+ # green, publishing nothing while reporting success — the exact
+ # silent-green class this guard exists to prevent (bonnyr-f5 #181
+ # round 4). Requiring the trailing [skip ci] marker restricts the
+ # exemption to commits we minted.
+ if grep -qE '^release: v[0-9]+\.[0-9]+\.[0-9]+' <<< "$FIRST" && grep -qE '\[skip ci\]$' <<< "$FIRST"; then
+ # Our own automated release commit reached the guard anyway (e.g.
+ # a re-tag or a replay that kept the marker). The release it names
+ # was already published by the run that created it, so suppressing
+ # it is expected -- a notice, and the run stays green.
+ echo "::notice::Loop guard: skipping our own release commit \"$FIRST\" (expected -- it was published by the previous run)."
+ exit 0
+ fi
+ # Any OTHER suppressed commit is unexpected: a hand-written
+ # [skip ci], a human "release: ..." subject with no marker, or a
+ # squash-merged PR titled "release: …(#N)". The run publishes
+ # nothing, so it must NOT report success -- a release that did not
+ # happen has to be loud, not a silent green (bonnyr-f5 #181 round 3
+ # and 4). Fail the guard; downstream jobs are gated on should_run and
+ # stay skipped, so nothing is published either way.
+ echo "::error::Release suppressed by the loop guard: head commit \"$FIRST\" begins with 'release: ' or carries [skip ci], so nothing was published. To cut a NEW release, push a normal commit whose subject does NOT begin with 'release: ' and carries no skip marker (it will release on merge to main). To re-publish images for an EXISTING tag, dispatch this workflow with publish_only= — do not manually dispatch a final release on a skip-marked head, GitHub creates no CI run for it and the CI-status check cannot pass."
+ exit 1
fi
+ echo "should_run=true" >> "$GITHUB_OUTPUT"
# ── Pre-flight: verify CI passed + derive version ────────────────────────────
preflight:
@@ -87,11 +154,20 @@ jobs:
needs: guard
if: needs.guard.outputs.should_run == 'true'
runs-on: ubuntu-latest
+ permissions:
+ contents: read # checkout + read VERSION/tags
+ actions: read # gh run list --workflow=ci.yml (the CI-status poll)
outputs:
current_version: ${{ steps.version.outputs.current }}
new_version: ${{ steps.version.outputs.new }}
bump_type: ${{ steps.version.outputs.bump_type }}
release_kind: ${{ steps.kind.outputs.kind }}
+ env:
+ # Env-indirect the ref name (bonnyr-f5 #181 round 2): a branch name can
+ # legally carry $(), backticks, ; and | (workflow_dispatch targets any
+ # branch), so it's read from env in run: blocks, never interpolated —
+ # same rule this PR applies to release_notes/publish_only.
+ REF_NAME: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v6
with:
@@ -99,21 +175,82 @@ jobs:
- name: Determine release kind
id: kind
+ env:
+ PUBLISH_ONLY: ${{ inputs.publish_only }}
+ SIGN_ONLY: ${{ inputs.sign_only }}
run: |
- if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ # sign_only is a recovery MODE of publish_only (re-sign already-pushed
+ # images), not a release path of its own. Refuse it without a target
+ # tag so it can never be dispatched against a from-scratch build
+ # (bonnyr-f5 #181 round 5, F2).
+ if [ "$SIGN_ONLY" = "true" ] && [ -z "$PUBLISH_ONLY" ]; then
+ echo "::error::sign_only requires publish_only=: it re-signs the images already published for an EXISTING tag, it does not build a release."
+ exit 1
+ fi
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "$PUBLISH_ONLY" ]; then
+ echo "kind=publish_only" >> "$GITHUB_OUTPUT"
+ elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "kind=manual" >> "$GITHUB_OUTPUT"
- elif [ "${{ github.ref_name }}" = "main" ]; then
+ elif [ "$REF_NAME" = "main" ]; then
echo "kind=final" >> "$GITHUB_OUTPUT"
else
echo "kind=rc" >> "$GITHUB_OUTPUT"
fi
+ - name: Restrict manual release to main
+ if: steps.kind.outputs.kind == 'manual'
+ env:
+ DISPATCH_REF: ${{ github.ref }}
+ run: |
+ # A manual (non-publish_only) dispatch builds THIS ref's tree, commits
+ # the version bump to it, tags it, and publishes it as :latest.
+ # Dispatching from staging or a side branch would ship an unreviewed
+ # tree as the released :latest, and the recency guard would not catch
+ # it (a numerically higher version passes: measured ALLOW new=5.0.0
+ # highest=v4.0.5).
+ #
+ # The dispatched ref must BE main itself (refs/heads/main). An earlier
+ # "ancestor of main" exemption was WRONG: staging sits 0-ahead/1-behind
+ # main, so `git merge-base --is-ancestor origin/staging origin/main` is
+ # true, and a manual dispatch on staging slipped through — the exact ref
+ # this step names as the hazard (release commit+tag+GitHub Release land
+ # on staging, :latest built from it, main never bumped) (bonnyr-f5 #181
+ # round 4). Any ancestor of main is by definition already ON main's
+ # first-parent history if it was merged, so requiring the ref to be main
+ # loses nothing legitimate. publish_only is exempt (kind != manual): it
+ # republishes an existing tag by checking that tag out, independent of
+ # the dispatched ref.
+ if [ "$DISPATCH_REF" = "refs/heads/main" ]; then
+ echo "Dispatched from main."
+ exit 0
+ fi
+ echo "::error::A manual final release must be dispatched from main itself (refs/heads/main). '$REF_NAME' (ref '$DISPATCH_REF') is not main — dispatching it would publish an unreviewed tree as :latest. Merge to main and dispatch from there, or use publish_only to republish an existing tag."
+ exit 1
+
- name: Check CI status on this branch
+ if: steps.kind.outputs.kind != 'publish_only'
run: |
- BRANCH="${{ github.ref_name }}"
+ BRANCH="$REF_NAME"
SHA="${{ github.sha }}"
echo "Checking CI status for branch '$BRANCH' at commit $SHA"
+ # Fail FAST on a CI-skip-marked head instead of polling for 2700s.
+ # After every automated release, main's head is
+ # "release: vX.Y.Z [skip ci]"; GitHub creates NO CI run for a commit
+ # carrying a skip marker, so a manual final dispatch on that head would
+ # poll for a run that will never appear and only surface the problem
+ # after the full 45-minute timeout. There is no CI to wait for and none
+ # is coming, so refuse immediately with the real recovery, rather than
+ # advising a wait (bonnyr-f5 #181 round 4). A genuine change never
+ # reaches here skip-marked: GitHub drops skip-marked pushes before the
+ # workflow starts, so this only trips on a manual dispatch of a
+ # skip-marked head.
+ HEAD_SUBJECT="$(git log -1 --format=%s HEAD)"
+ if grep -qiE '\[skip ci\]|\[ci skip\]|\[skip actions\]' <<< "$HEAD_SUBJECT"; then
+ echo "::error::The head commit \"$HEAD_SUBJECT\" carries a CI-skip marker, so GitHub created no CI run for it and none ever will — waiting would only time out after ${CI_POLL_TIMEOUT_SECONDS}s. This is the state main is left in immediately after an automated release. To cut a NEW release, push a normal (non-skip-marked) commit and let it release on merge to main. To re-publish images for the EXISTING tag, dispatch this workflow with publish_only=, which skips this CI check."
+ exit 1
+ fi
+
# Match the CI run by the exact commit SHA that triggered this
# release, not by "--limit=1 --branch=X" (which is racy: on main,
# ci.yml and release.yml fire on the same push so --limit=1 can
@@ -148,12 +285,16 @@ jobs:
exit 0
fi
if [ "$RUN_CONCLUSION" = "cancelled" ]; then
- # ci.yml runs with cancel-in-progress: true, so a rapid
- # follow-up push to the same branch cancels this SHA's CI
- # run. That's not a CI failure for this SHA — it means a
- # newer push superseded it, so fail fast instead of
- # reporting a generic non-success error.
- echo "::error::CI run for $SHA was cancelled — superseded by a newer push; this release attempt is stale, the newer push will release instead."
+ # A cancelled CI run is not a success for this SHA, so fail
+ # fast rather than reporting a generic non-success error.
+ # NOTE (premise updated for #182): ci.yml does NOT cancel
+ # in-progress runs on main/staging — cancel-in-progress is
+ # false for exactly these release branches (ci.yml:48), each
+ # push gets its own concurrency group. So a cancellation here
+ # is no longer necessarily "a newer push superseded it"; it may
+ # have been cancelled by other means (e.g. a manual cancel).
+ # Either way the run is stale — do not release on it.
+ echo "::error::CI run for $SHA was cancelled — this release attempt is stale (a newer push, if any, will release instead)."
exit 1
fi
echo "::error::CI run for commit $SHA on branch '$BRANCH' completed with conclusion '$RUN_CONCLUSION'."
@@ -176,14 +317,34 @@ jobs:
- name: Derive version from conventional commits
id: version
+ env:
+ PUBLISH_ONLY: ${{ inputs.publish_only }}
run: |
chmod +x scripts/compute_version_bump.sh
RELEASE_KIND="${{ steps.kind.outputs.kind }}"
CURRENT=$(cat VERSION)
+ # Validate before it reaches $GITHUB_OUTPUT: a newline in VERSION would
+ # inject a second output key, and current_version is interpolated into
+ # several later run: blocks (bonnyr-f5 #181 round 2).
+ if [[ ! "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "::error::VERSION file contents '$CURRENT' are not a valid MAJOR.MINOR.PATCH version."
+ exit 1
+ fi
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
- if [ "$RELEASE_KIND" = "manual" ]; then
+ if [ "$RELEASE_KIND" = "publish_only" ]; then
+ # Recovery path: no derivation, no bump. Republish an existing tag.
+ # publish_only is free-text, so read it from env (never inline it into
+ # the script) and validate its shape before use (#181 review).
+ TAG="$PUBLISH_ONLY"
+ if [[ ! "$TAG" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "::error::publish_only='$TAG' is not a final release tag (vMAJOR.MINOR.PATCH). Republish repoints :latest across all images and must not point it at an rc/pre-release or an arbitrary string."
+ exit 1
+ fi
+ NEW="${TAG#v}"
+ BUMP="republish"
+ elif [ "$RELEASE_KIND" = "manual" ]; then
# Manual override: use the dropdown input directly
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
case "${{ inputs.version_bump }}" in
@@ -203,12 +364,50 @@ jobs:
BUMP="$BUMP_TYPE"
fi
+ # :latest recency guard — covers the paths that both MINT a new tag and
+ # repoint :latest to it: final and manual. A version below the highest
+ # final tag would drag :latest backward (bonnyr-f5 #181 round 2 —
+ # release-manual from a maintenance branch could tag+publish a version
+ # below the highest final tag). rc tags are pre-releases and never touch
+ # :latest, so they're exempt.
+ #
+ # publish_only is NOT hard-failed here: a republish re-emits the
+ # IMMUTABLE :VERSION tags of an ALREADY-released version whose original
+ # publish half-completed, and it owes those tags to consumers regardless
+ # of what has been released since. Failing it on recency stranded it
+ # forever — once any newer tag existed, the half-published version's
+ # images could never be produced by ANY path (bonnyr-f5 #181 round 4).
+ # A republish must still never move :latest backward, but that is a
+ # decision about the FLOATING tag only, and it is made authoritatively
+ # inside the publish job's critical section (see "Re-check recency"),
+ # not here. :latest is set by whatever release-publish last ran, not by
+ # this ref's VERSION file, so that check compares against the highest
+ # FINAL tag at push time.
+ if [ "$RELEASE_KIND" = "final" ] || [ "$RELEASE_KIND" = "manual" ]; then
+ HIGHEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)"
+ HIGHEST="$(printf '%s\n%s\n' "${HIGHEST_TAG#v}" "$NEW" | sort -V | tail -1)"
+ if [ -n "$HIGHEST_TAG" ] && [ "$NEW" != "$HIGHEST" ]; then
+ echo "::error::Release v$NEW is older than the highest released tag $HIGHEST_TAG; publishing would move :latest backward."
+ exit 1
+ fi
+ fi
+
echo "new=$NEW" >> "$GITHUB_OUTPUT"
echo "bump_type=$BUMP" >> "$GITHUB_OUTPUT"
echo "Version: $CURRENT -> $NEW (bump: $BUMP)"
+ - name: Check republish tag exists
+ if: steps.kind.outputs.kind == 'publish_only'
+ run: |
+ TAG="v${{ steps.version.outputs.new }}"
+ if ! git tag -l "$TAG" | grep -q "^${TAG}$"; then
+ echo "::error::publish_only requested tag $TAG, which does not exist. Republish only targets an already-created tag."
+ exit 1
+ fi
+ echo "Republishing images for existing tag $TAG"
+
- name: Check final tag doesn't already exist
- if: steps.kind.outputs.kind != 'rc'
+ if: steps.kind.outputs.kind != 'rc' && steps.kind.outputs.kind != 'publish_only'
run: |
TAG="v${{ steps.version.outputs.new }}"
if git tag -l "$TAG" | grep -q "^${TAG}$"; then
@@ -234,6 +433,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
+ env:
+ REF_NAME: ${{ github.ref_name }} # env-indirected — see preflight
steps:
- uses: actions/checkout@v6
with:
@@ -249,9 +450,22 @@ jobs:
id: rc
run: |
TARGET="${{ needs.preflight.outputs.new_version }}"
- # Count existing rc tags for this target version
- RC_COUNT=$(git tag -l "v${TARGET}-rc.*" | wc -l | tr -d ' ')
- RC_NUM=$((RC_COUNT + 1))
+ # Highest existing rc number for this target + 1. Max-based, not
+ # count-based (#177 review): counting breaks if any rc tag is ever
+ # deleted -- the count drops and the next push recomputes an existing
+ # tag, which then fails to create.
+ # NB: this step must NOT `set -o pipefail`. `grep -E` returns 1 when a
+ # version has no rc tags yet; without pipefail the substitution takes
+ # tail's status and ${RC_MAX:-0} yields the first rc as 1. Adding
+ # pipefail here would fail rc.1 of every new version.
+ # Escape TARGET's dots so the sed anchor matches them literally, not
+ # as "any char" (bonnyr-f5 #181 round 2). The grep -l glob already
+ # pre-filters to real tags, but a literal-dot pattern is correct.
+ TARGET_RE="${TARGET//./\\.}"
+ RC_MAX=$(git tag -l "v${TARGET}-rc.*" \
+ | sed -E "s|^v${TARGET_RE}-rc\.([0-9]+)$|\1|" \
+ | grep -E '^[0-9]+$' | sort -n | tail -1)
+ RC_NUM=$(( ${RC_MAX:-0} + 1 ))
echo "rc_num=${RC_NUM}" >> "$GITHUB_OUTPUT"
echo "rc_tag=v${TARGET}-rc.${RC_NUM}" >> "$GITHUB_OUTPUT"
echo "RC tag will be: v${TARGET}-rc.${RC_NUM}"
@@ -265,7 +479,7 @@ jobs:
git tag -a "$RC_TAG" \
-m "Pre-release ${RC_TAG}
Target: v${TARGET} (${BUMP} bump)
- Branch: ${{ github.ref_name }}
+ Branch: ${REF_NAME}
Commit: ${{ github.sha }}"
git push origin "$RC_TAG"
@@ -285,19 +499,29 @@ jobs:
NOTES="## Pre-release ${RC_TAG}
**Target release:** v${TARGET} (${BUMP} bump)
- **Branch:** ${{ github.ref_name }}
+ **Branch:** ${REF_NAME}
**Commit:** ${{ github.sha }}
### Commits since ${LAST_FINAL:-initial}
"
+ # Cap the commit list, but say so when it truncates -- a silent `head`
+ # dropped 17 of 67 commits from published notes with no indication
+ # (bonnyr-f5 #179 r3). CAP is generous enough that normal ranges are
+ # complete; a larger range appends an explicit "and N more" line.
+ CAP=300
if [ -n "$LAST_FINAL" ]; then
- COMMIT_LOG=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s" | head -40)
- BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD || true)
+ FULL_LOG=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s")
+ BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD)
else
- COMMIT_LOG=$(git log --pretty=format:"- %s" | head -40)
+ FULL_LOG=$(git log --pretty=format:"- %s")
BREAKING=""
fi
+ COMMIT_LOG=$(printf '%s\n' "$FULL_LOG" | head -"$CAP")
+ TOTAL=$(printf '%s\n' "$FULL_LOG" | grep -c '^-' || true)
+ if [ "$TOTAL" -gt "$CAP" ]; then
+ COMMIT_LOG=$(printf '%s\n- … and %d more commit(s) — see the full compare view' "$COMMIT_LOG" "$((TOTAL - CAP))")
+ fi
if [ -n "$BREAKING" ]; then
printf '%s\n\n%s\n\n%s' "$NOTES" "$BREAKING" "$COMMIT_LOG" > /tmp/rc_notes.md
@@ -312,7 +536,7 @@ jobs:
--title "${{ steps.rc.outputs.rc_tag }}" \
--notes-file "${{ steps.notes.outputs.notes_file }}" \
--prerelease \
- --target "${{ github.ref_name }}"
+ --target "${REF_NAME}"
env:
GH_TOKEN: ${{ github.token }}
@@ -325,7 +549,7 @@ jobs:
echo "| RC Tag | ${{ steps.rc.outputs.rc_tag }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Target Version | v${{ needs.preflight.outputs.new_version }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Bump Type | ${{ needs.preflight.outputs.bump_type }} |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Branch | ${{ github.ref_name }} |" >> "$GITHUB_STEP_SUMMARY"
+ echo "| Branch | ${REF_NAME} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Commit | ${{ github.sha }} |" >> "$GITHUB_STEP_SUMMARY"
# ── Final Release (main push) ─────────────────────────────────────────────────
@@ -336,6 +560,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
+ env:
+ REF_NAME: ${{ github.ref_name }} # env-indirected — see preflight
steps:
- uses: actions/checkout@v6
with:
@@ -355,6 +581,10 @@ jobs:
if git ls-files --error-unmatch dist/VERSION 2>/dev/null; then
echo "$NEW" > dist/VERSION
fi
+ # Keep the Helm chart tag/appVersion and frontend package.json in
+ # lockstep so the chart never pins an image tag the release doesn't
+ # publish (#177 Blocker 2).
+ bash scripts/sync-version-artifacts.sh --write "$NEW"
- name: Update CHANGELOG.md
run: |
@@ -367,15 +597,28 @@ jobs:
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -1)
+ # Cap the commit list but say so on truncation -- a silent `head -50`
+ # dropped 17 of 67 commits from published notes (bonnyr-f5 #179 r3).
+ # The `|| true` here guards the FILTER only (an all-"release:" range
+ # leaves grep -v with no output, rc 1 under pipefail). It does NOT
+ # claim extract-breaking-changes.sh is fail-closed: on this ref that
+ # script still ends its own range query with `|| true` (line 33), so a
+ # bogus range there yields rc 0 and an empty section. The fail-closed
+ # fix for that script lands with #179, which owns it — this PR does not
+ # touch scripts/extract-breaking-changes.sh (bonnyr-f5 #181 round 5, F4).
+ CAP=300
if [ -n "$LAST_FINAL" ]; then
- COMMITS=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s" \
- | grep -v "^- release: " | head -50)
- BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD || true)
+ FULL_LOG=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s" | { grep -v "^- release: " || true; })
+ BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD)
else
- COMMITS=$(git log --pretty=format:"- %s" \
- | grep -v "^- release: " | head -50)
+ FULL_LOG=$(git log --pretty=format:"- %s" | { grep -v "^- release: " || true; })
BREAKING=""
fi
+ COMMITS=$(printf '%s\n' "$FULL_LOG" | head -"$CAP")
+ TOTAL=$(printf '%s\n' "$FULL_LOG" | grep -c '^-' || true)
+ if [ "$TOTAL" -gt "$CAP" ]; then
+ COMMITS=$(printf '%s\n- … and %d more commit(s) — see the full compare view' "$COMMITS" "$((TOTAL - CAP))")
+ fi
# Build the entry in a temp file rather than interpolating COMMITS
# (multi-line, and any commit subject containing |, &, \, or
@@ -413,8 +656,32 @@ jobs:
NEW="${{ needs.preflight.outputs.new_version }}"
BUMP="${{ needs.preflight.outputs.bump_type }}"
- # Stage VERSION, dist/VERSION (if tracked), CHANGELOG
+ # Stage VERSION, dist/VERSION (if tracked), CHANGELOG, and the
+ # version-bearing artifacts synced above (#177 Blocker 2).
git add VERSION CHANGELOG.md
+ # Stage EXACTLY the artifacts sync-version-artifacts.sh owns, from its
+ # own --list, so a newly-synced file can never be left unstaged and die
+ # with the runner (bonnyr-f5 #180 r3, BLOCKER 1: --write rewrote five
+ # files, the hard-coded `git add` staged three).
+ staged=0
+ while IFS= read -r f; do git add "$f"; staged=$((staged + 1)); done < <(bash scripts/sync-version-artifacts.sh --list)
+ # Vacuity floor mirroring the script's own `--check` `total < 5` guard:
+ # if --list ever yields fewer paths (script broke / was truncated) the
+ # add + verify loops both go silent and we would commit a bare VERSION
+ # bump with every image pin left unsynced — the exact BLOCKER-1 failure
+ # the staging logic exists to prevent (bonnyr-f5 #180 r5, F2). The
+ # per-file "not fully staged" check below cannot catch this: it runs the
+ # same possibly-empty --list, so an empty list makes it vacuously pass.
+ if [ "$staged" -lt 5 ]; then
+ echo "::error::sync-version-artifacts.sh --list yielded only $staged path(s) (expected >=5) — refusing to commit an unsynced release"; exit 1
+ fi
+ # Verify the INDEX, not the files: --write's post-write check re-reads
+ # the files (correct on disk even when unstaged), so assert each synced
+ # artifact has no unstaged residue — i.e. the sync is actually in the
+ # commit we are about to make.
+ while IFS= read -r f; do
+ git diff --quiet -- "$f" || { echo "::error::$f was synced but is not fully staged"; exit 1; }
+ done < <(bash scripts/sync-version-artifacts.sh --list)
git ls-files --error-unmatch dist/VERSION 2>/dev/null && git add dist/VERSION || true
git commit -m "release: v${NEW} [skip ci]
@@ -429,7 +696,7 @@ jobs:
- name: Push commit and tag
run: |
- git push origin "${{ github.ref_name }}"
+ git push origin "${REF_NAME}"
git push origin "v${{ needs.preflight.outputs.new_version }}"
- name: Generate final release notes
@@ -442,15 +709,28 @@ jobs:
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -2 | head -1)
+ # Cap the commit list but say so on truncation -- a silent `head -50`
+ # dropped 17 of 67 commits from published notes (bonnyr-f5 #179 r3).
+ # The `|| true` here guards the FILTER only (an all-"release:" range
+ # leaves grep -v with no output, rc 1 under pipefail). It does NOT
+ # claim extract-breaking-changes.sh is fail-closed: on this ref that
+ # script still ends its own range query with `|| true` (line 33), so a
+ # bogus range there yields rc 0 and an empty section. The fail-closed
+ # fix for that script lands with #179, which owns it — this PR does not
+ # touch scripts/extract-breaking-changes.sh (bonnyr-f5 #181 round 5, F4).
+ CAP=300
if [ -n "$LAST_FINAL" ]; then
- COMMITS=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s" \
- | grep -v "^- release: " | head -50)
- BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD || true)
+ FULL_LOG=$(git log "${LAST_FINAL}..HEAD" --pretty=format:"- %s" | { grep -v "^- release: " || true; })
+ BREAKING=$(bash scripts/extract-breaking-changes.sh "$LAST_FINAL" HEAD)
else
- COMMITS=$(git log --pretty=format:"- %s" \
- | grep -v "^- release: " | head -50)
+ FULL_LOG=$(git log --pretty=format:"- %s" | { grep -v "^- release: " || true; })
BREAKING=""
fi
+ COMMITS=$(printf '%s\n' "$FULL_LOG" | head -"$CAP")
+ TOTAL=$(printf '%s\n' "$FULL_LOG" | grep -c '^-' || true)
+ if [ "$TOTAL" -gt "$CAP" ]; then
+ COMMITS=$(printf '%s\n- … and %d more commit(s) — see the full compare view' "$COMMITS" "$((TOTAL - CAP))")
+ fi
{
echo "## v${NEW} — ${BUMP} bump"
@@ -469,7 +749,7 @@ jobs:
gh release create "v${{ needs.preflight.outputs.new_version }}" \
--title "v${{ needs.preflight.outputs.new_version }}" \
--notes-file "${{ steps.notes.outputs.notes_file }}" \
- --target "${{ github.ref_name }}"
+ --target "${REF_NAME}"
env:
GH_TOKEN: ${{ github.token }}
@@ -481,7 +761,7 @@ jobs:
echo "|------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Version | ${{ needs.preflight.outputs.current_version }} -> v${{ needs.preflight.outputs.new_version }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Bump | ${{ needs.preflight.outputs.bump_type }} |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Branch | ${{ github.ref_name }} |" >> "$GITHUB_STEP_SUMMARY"
+ echo "| Branch | ${REF_NAME} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Commit | ${{ github.sha }} |" >> "$GITHUB_STEP_SUMMARY"
# ── Manual Release (workflow_dispatch) ───────────────────────────────────────
@@ -498,6 +778,11 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
+ env:
+ # bonnyr-f5 #181: release_notes is free-text; read it from env so a value
+ # with " or $() can't break out of a run: script (same rule as publish_only).
+ RELEASE_NOTES: ${{ inputs.release_notes }}
+ REF_NAME: ${{ github.ref_name }} # env-indirected — see preflight
steps:
- uses: actions/checkout@v6
with:
@@ -515,12 +800,13 @@ jobs:
if git ls-files --error-unmatch dist/VERSION 2>/dev/null; then
echo "${{ needs.preflight.outputs.new_version }}" > dist/VERSION
fi
+ bash scripts/sync-version-artifacts.sh --write "${{ needs.preflight.outputs.new_version }}"
- name: Update changelog
run: |
NEW_VERSION="${{ needs.preflight.outputs.new_version }}"
DATE=$(date +%Y-%m-%d)
- NOTES="${{ inputs.release_notes }}"
+ NOTES="${RELEASE_NOTES}"
# See the "Update CHANGELOG.md" step in release-final for why this
# goes through a temp file + awk instead of an inline sed s-command
@@ -548,11 +834,34 @@ jobs:
- name: Commit and tag
run: |
git add VERSION CHANGELOG.md
+ # Stage EXACTLY the artifacts sync-version-artifacts.sh owns, from its
+ # own --list, so a newly-synced file can never be left unstaged and die
+ # with the runner (bonnyr-f5 #180 r3, BLOCKER 1: --write rewrote five
+ # files, the hard-coded `git add` staged three).
+ staged=0
+ while IFS= read -r f; do git add "$f"; staged=$((staged + 1)); done < <(bash scripts/sync-version-artifacts.sh --list)
+ # Vacuity floor mirroring the script's own `--check` `total < 5` guard:
+ # if --list ever yields fewer paths (script broke / was truncated) the
+ # add + verify loops both go silent and we would commit a bare VERSION
+ # bump with every image pin left unsynced — the exact BLOCKER-1 failure
+ # the staging logic exists to prevent (bonnyr-f5 #180 r5, F2). The
+ # per-file "not fully staged" check below cannot catch this: it runs the
+ # same possibly-empty --list, so an empty list makes it vacuously pass.
+ if [ "$staged" -lt 5 ]; then
+ echo "::error::sync-version-artifacts.sh --list yielded only $staged path(s) (expected >=5) — refusing to commit an unsynced release"; exit 1
+ fi
+ # Verify the INDEX, not the files: --write's post-write check re-reads
+ # the files (correct on disk even when unstaged), so assert each synced
+ # artifact has no unstaged residue — i.e. the sync is actually in the
+ # commit we are about to make.
+ while IFS= read -r f; do
+ git diff --quiet -- "$f" || { echo "::error::$f was synced but is not fully staged"; exit 1; }
+ done < <(bash scripts/sync-version-artifacts.sh --list)
git ls-files --error-unmatch dist/VERSION 2>/dev/null && git add dist/VERSION || true
git commit -m "release: v${{ needs.preflight.outputs.new_version }} [skip ci]
- ${{ inputs.release_notes }}
+ ${RELEASE_NOTES}
Bump: ${{ inputs.version_bump }}
Previous: v${{ needs.preflight.outputs.current_version }}
@@ -560,19 +869,19 @@ jobs:
E2E: ${{ needs.e2e-gate.result || 'skipped' }}"
git tag -a "v${{ needs.preflight.outputs.new_version }}" \
- -m "Release v${{ needs.preflight.outputs.new_version }}: ${{ inputs.release_notes }}"
+ -m "Release v${{ needs.preflight.outputs.new_version }}: ${RELEASE_NOTES}"
- name: Push
run: |
- git push origin "${{ github.ref_name }}"
+ git push origin "${REF_NAME}"
git push origin "v${{ needs.preflight.outputs.new_version }}"
- name: Create GitHub Release
run: |
gh release create "v${{ needs.preflight.outputs.new_version }}" \
--title "v${{ needs.preflight.outputs.new_version }}" \
- --notes "${{ inputs.release_notes }}" \
- --target "${{ github.ref_name }}"
+ --notes "${RELEASE_NOTES}" \
+ --target "${REF_NAME}"
env:
GH_TOKEN: ${{ github.token }}
@@ -584,10 +893,10 @@ jobs:
echo "|------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Version | ${{ needs.preflight.outputs.current_version }} -> ${{ needs.preflight.outputs.new_version }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Bump | ${{ inputs.version_bump }} |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Branch | ${{ github.ref_name }} |" >> "$GITHUB_STEP_SUMMARY"
+ echo "| Branch | ${REF_NAME} |" >> "$GITHUB_STEP_SUMMARY"
echo "| CI | passed |" >> "$GITHUB_STEP_SUMMARY"
echo "| E2E | ${{ needs.e2e-gate.result || 'skipped' }} |" >> "$GITHUB_STEP_SUMMARY"
- echo "| Notes | ${{ inputs.release_notes }} |" >> "$GITHUB_STEP_SUMMARY"
+ echo "| Notes | ${RELEASE_NOTES} |" >> "$GITHUB_STEP_SUMMARY"
# ── Publish images to ghcr (final releases only) ──────────────────────────────
# Two problems, one job:
@@ -609,10 +918,24 @@ jobs:
- release-manual
if: |
always() &&
- (needs.preflight.outputs.release_kind == 'final' || needs.preflight.outputs.release_kind == 'manual') &&
- (needs.release-final.result == 'success' || needs.release-manual.result == 'success')
+ (
+ (
+ (needs.preflight.outputs.release_kind == 'final' || needs.preflight.outputs.release_kind == 'manual') &&
+ (needs.release-final.result == 'success' || needs.release-manual.result == 'success')
+ )
+ ||
+ (needs.preflight.outputs.release_kind == 'publish_only' && needs.preflight.result == 'success')
+ )
runs-on: ubuntu-latest
timeout-minutes: 90
+ # The workflow-level group above is per-ref, but :latest is a single global
+ # registry resource: a republish from one ref and a final release from
+ # another could otherwise push :latest concurrently, a TOCTOU that defeats
+ # the recency guard. Serialize the actual :latest writer on one global queue
+ # so those pushes can never interleave (bonnyr-f5 #181 round 2).
+ concurrency:
+ group: release-publish-latest
+ cancel-in-progress: false
permissions:
contents: read
packages: write
@@ -629,9 +952,40 @@ jobs:
ref: v${{ needs.preflight.outputs.new_version }}
fetch-depth: 0
+ - name: Fetch the tag-safety probe from the workflow ref
+ # The step above checks out the RELEASE TAG so bake builds that tree.
+ # But scripts/registry-tag-probe.sh must be run from the CURRENT
+ # workflow ref, not the tag: a publish_only recovery of a tag cut BEFORE
+ # this probe existed would otherwise find no script in the tag's tree and
+ # the guard would error. github.sha (the ref running this workflow) always
+ # carries the probe once these changes land, so source it from there into
+ # a side path, leaving the tag checkout at the workspace root untouched.
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.sha }}
+ path: .release-tooling
+ sparse-checkout: |
+ scripts/registry-tag-probe.sh
+ sparse-checkout-cone-mode: false
+
- name: Resolve release commit
id: rev
- run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+ run: |
+ # Pinned build inputs, NOT a reproducibility guarantee. The release
+ # commit's committer date is fixed for a given tag, so baking with it
+ # (CREATED) instead of the removed timestamp() default, plus
+ # SOURCE_DATE_EPOCH, removes two obvious sources of build-to-build
+ # variance. It does NOT make the rebuild byte-identical: the images run
+ # apt/apk/pip/npm against live indexes and there is no buildkit
+ # rewrite-timestamp pass, so a rebuild of the same tag can and does
+ # resolve to a DIFFERENT digest (bonnyr-f5 #181 round 4). That is
+ # exactly why a republish is refused by default and gated behind
+ # force= — see the "Refuse to overwrite an already-published tag" step.
+ {
+ echo "sha=$(git rev-parse HEAD)"
+ echo "created=$(git log -1 --format=%cI HEAD)"
+ echo "epoch=$(git log -1 --format=%ct HEAD)"
+ } >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -652,7 +1006,151 @@ jobs:
- name: Install syft
uses: anchore/sbom-action/download-syft@v0
+ - name: Re-check recency inside the publish critical section
+ if: inputs.sign_only != true
+ env:
+ RELEASE_KIND: ${{ needs.preflight.outputs.release_kind }}
+ run: |
+ # preflight's recency guard runs OUTSIDE this job's concurrency group,
+ # so two releases can both pass it and then race to write the single
+ # global :latest. This job is serialized on release-publish-latest, so
+ # re-checking the highest final tag HERE — after acquiring the slot,
+ # immediately before the push — is the authoritative gate: a release
+ # that is no longer the newest must not repoint :latest backward
+ # (bonnyr-f5 #181 round 3). rc never reaches this job.
+ #
+ # This step OWNS the :latest-move decision: it writes ROLLING_TAG to
+ # GITHUB_ENV, and the bake step below tags :$ROLLING_TAG (empty ==>
+ # push only the immutable :VERSION, see docker-bake.hcl). That lets a
+ # publish_only republish still emit its :VERSION tags when it is behind
+ # the newest release — it just does NOT move :latest — instead of being
+ # stranded (bonnyr-f5 #181 round 4).
+ # The tag probe must fail CLOSED when it is indeterminate. `git fetch
+ # … || true` swallows a network/permission failure; if the local tag
+ # set is then empty or stale, the else-branch below would set
+ # ROLLING_TAG=latest and move the floating tag on an UNVERIFIED guess
+ # that this release is the newest — the same fail-OPEN class the round-4
+ # lesson closed for the registry probe, on the adjacent line (bonnyr-f5
+ # #181 round 5, F5). So capture the fetch result and, when it fails,
+ # never move :latest on a guess: a publish_only republish still owes
+ # consumers the immutable :VERSION tags (proceed with ROLLING_TAG="",
+ # which does NOT touch :latest), while a final/manual release that
+ # cannot prove recency goes RED.
+ if git fetch --tags --force --quiet origin; then
+ FETCH_OK=1
+ else
+ FETCH_OK=0
+ fi
+ if [ "$FETCH_OK" != "1" ]; then
+ if [ "$RELEASE_KIND" = "publish_only" ]; then
+ echo "::warning::Could not fetch tags to re-check recency; republishing the immutable :$VERSION tags but NOT moving :latest."
+ echo "ROLLING_TAG=" >> "$GITHUB_ENV"
+ exit 0
+ fi
+ echo "::error::Could not fetch tags to confirm v$VERSION is still the newest release before repointing :latest. Refusing to move the floating tag on an unverifiable tag set — re-run once origin is reachable."
+ exit 1
+ fi
+ HIGHEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)"
+ HIGHEST="$(printf '%s\n%s\n' "${HIGHEST_TAG#v}" "$VERSION" | sort -V | tail -1)"
+ if [ -n "$HIGHEST_TAG" ] && [ "$VERSION" != "$HIGHEST" ]; then
+ if [ "$RELEASE_KIND" = "publish_only" ]; then
+ # Behind the newest tag: re-emit the immutable :VERSION tags but do
+ # NOT touch the floating :latest (ROLLING_TAG="").
+ echo "::notice::v$VERSION is behind the highest tag $HIGHEST_TAG; republishing the immutable :$VERSION tags but NOT moving :latest."
+ echo "ROLLING_TAG=" >> "$GITHUB_ENV"
+ else
+ echo "::error::Refusing to publish v$VERSION: the highest released tag is now $HIGHEST_TAG, so repointing :latest would move it backward."
+ exit 1
+ fi
+ else
+ echo "v$VERSION is the highest final tag; safe to repoint :latest."
+ echo "ROLLING_TAG=latest" >> "$GITHUB_ENV"
+ fi
+
+ - name: Refuse to overwrite an already-published tag
+ if: inputs.sign_only != true
+ env:
+ FORCE: ${{ inputs.force }}
+ # Basic creds for the registry's Bearer-token challenge, so the probe
+ # reads manifests as this authenticated identity (packages: write),
+ # not anonymously — an anonymous probe of a not-yet-existing package
+ # returns 401/denied, which fails closed and would strand the first
+ # release in a namespace.
+ REGISTRY_USERNAME: ${{ github.actor }}
+ REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # docker-bake pushes ${REGISTRY}/:${VERSION} unconditionally and
+ # the tag is declared IMMUTABLE. Probe the registry first: if the
+ # :VERSION manifest already exists, republishing moves that immutable
+ # tag under anyone who pinned it AND orphans the cosign / SBOM / SLSA
+ # attestations bound to the OLD digest. Refuse by default; require the
+ # force input to overwrite deliberately (bonnyr-f5 #181 round 3). The
+ # normal recovery case — a tag whose publish never completed — has no
+ # manifest yet, so it passes through untouched.
+ #
+ # The probe is scripts/registry-tag-probe.sh, which classifies each
+ # image by the registry's HTTP STATUS, not by grepping the docker CLI's
+ # error text. That text CANNOT tell "package does not exist yet" (the
+ # first release in a fork/mirror namespace — SAFE) apart from "no
+ # permission on an existing package" (MUST fail closed): both print
+ # `denied` (bonnyr-f5 #181 round 5, F1). The HTTP API is unambiguous —
+ # 200 exists, 404 absent (tag OR repo not found → safe), 401/403 auth,
+ # everything else transient. Only a definitive 404 is "safe to
+ # publish"; auth / network / rate-limit / 5xx all classify UNKNOWN and
+ # fail CLOSED — refuse unless force=true (INV-24). This same probe
+ # guards the operator `make push-images` path (F3), so the class is
+ # fixed everywhere, not at one call site.
+ # Capture the probe's OUTPUT and EXIT STATUS. A bare
+ # `while … done < <(probe)` would ignore a non-zero exit — if the
+ # script itself failed (missing dependency, bad REGISTRY, no output)
+ # the loop would read zero lines, find nothing existing, and conclude
+ # "safe to publish": a fail-OPEN. So treat any probe that does not
+ # cleanly classify EVERY image as inconclusive and fail CLOSED.
+ IMAGES_N="$(bash .release-tooling/scripts/registry-tag-probe.sh --images | grep -c . || true)"
+ if ! PROBE_OUT="$(bash .release-tooling/scripts/registry-tag-probe.sh)"; then
+ PROBE_OUT=""
+ fi
+ PROBE_N="$(printf '%s\n' "$PROBE_OUT" | grep -c . || true)"
+ EXISTING=""
+ UNKNOWN=""
+ if [ "$PROBE_N" -ne "$IMAGES_N" ]; then
+ UNKNOWN=" the registry existence probe did not classify all ${IMAGES_N} images (classified ${PROBE_N}); treating as inconclusive"$'\n'
+ else
+ while IFS=$'\t' read -r status ref detail; do
+ case "$status" in
+ exists) EXISTING="${EXISTING} ${ref}"$'\n' ;;
+ absent) : ;; # 404 — the immutable tag is free
+ unknown) UNKNOWN="${UNKNOWN} ${ref}: ${detail}"$'\n' ;;
+ esac
+ done <<< "$PROBE_OUT"
+ fi
+ if [ -n "$UNKNOWN" ]; then
+ printf 'Registry existence probe was inconclusive (a non-not-found error) for:\n%s' "$UNKNOWN"
+ if [ "$FORCE" = "true" ]; then
+ echo "::warning::force=true — publishing despite an inconclusive existence probe; if any of these tags were in fact already published this overwrites the immutable :${VERSION} tag and orphans its cosign/SBOM/SLSA attestations."
+ else
+ echo "::error::Could not confirm whether the :${VERSION} tag is free: the registry probe failed with a non-not-found error (auth / network / rate-limit / bad ref). Refusing to publish because an existing immutable tag cannot be ruled out. Re-run once the registry is reachable, or re-dispatch with force=true only if you intend to overwrite."
+ exit 1
+ fi
+ fi
+ if [ -z "$EXISTING" ]; then
+ echo "No existing :${VERSION} manifests found — safe to publish."
+ exit 0
+ fi
+ printf 'Images already published for this tag:\n%s' "$EXISTING"
+ if [ "$FORCE" = "true" ]; then
+ echo "::warning::force=true — overwriting the existing :${VERSION} manifests; the cosign/SBOM/SLSA attestations bound to the previous digests are now orphaned."
+ else
+ echo "::error::Images for v${VERSION} already exist in ${REGISTRY}. Republishing would move the immutable :${VERSION} tag and orphan its attestations. Re-dispatch with force=true only if you intend to overwrite them."
+ exit 1
+ fi
+
- name: Build and push all images (docker-bake.hcl)
+ # Skipped in sign_only recovery: the images are already pushed, and the
+ # signing step below re-signs those exact digests. Rebuilding would
+ # produce possibly-different digests and move the immutable tag — the
+ # INV-24 harm sign_only exists to avoid (bonnyr-f5 #181 round 5, F2).
+ if: inputs.sign_only != true
run: docker buildx bake --push default
env:
REGISTRY: ${{ env.REGISTRY }}
@@ -661,7 +1159,17 @@ jobs:
# OCI source label tracks whichever remote actually built the image.
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}
GIT_REVISION: ${{ steps.rev.outputs.sha }}
- ROLLING_TAG: latest
+ # Pinned build inputs: fixed created label + SOURCE_DATE_EPOCH. These
+ # reduce build-to-build variance but do NOT guarantee an identical
+ # digest (see docker-bake.hcl and "Resolve release commit") — which is
+ # why the immutable-tag probe above refuses a republish by default.
+ CREATED: ${{ steps.rev.outputs.created }}
+ SOURCE_DATE_EPOCH: ${{ steps.rev.outputs.epoch }}
+ # ROLLING_TAG is NOT hardcoded here: the "Re-check recency" step wrote
+ # it to GITHUB_ENV — "latest" to move the floating tag, or "" for a
+ # publish_only republish that is behind the newest release (push only
+ # the immutable :VERSION tags). A step-level env: entry would override
+ # that GITHUB_ENV value, so it is intentionally omitted.
- name: Sign, SBOM, and attest all images (keyless cosign)
run: bash scripts/publish-signed-images.sh --execute
@@ -670,11 +1178,27 @@ jobs:
BNK_FORGE_VERSION: ${{ env.VERSION }}
- name: Publish summary
+ env:
+ SIGN_ONLY: ${{ inputs.sign_only }}
run: |
+ # ROLLING_TAG comes from GITHUB_ENV (set by "Re-check recency"): empty
+ # for a publish_only republish behind the newest release, which pushed
+ # only the immutable :VERSION tags and did NOT move :latest. Report the
+ # tags actually pushed rather than always claiming :latest. In
+ # sign_only recovery nothing was pushed at all — the existing digests
+ # were only (re-)signed — so say exactly that.
+ if [ "$SIGN_ONLY" = "true" ]; then
+ TAGS=":${{ needs.preflight.outputs.new_version }} (re-signed existing digests — no image pushed, :latest NOT moved)"
+ elif [ -n "${ROLLING_TAG:-}" ]; then
+ TAGS=":${{ needs.preflight.outputs.new_version }}, :${ROLLING_TAG}"
+ else
+ TAGS=":${{ needs.preflight.outputs.new_version }} (floating :latest NOT moved — this republish is behind the newest release)"
+ fi
echo "## Published images v${{ needs.preflight.outputs.new_version }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Image | Tags |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|------|" >> "$GITHUB_STEP_SUMMARY"
- for name in bnk-forge-api bnk-forge-worker bnk-forge-beat bnk-forge-frontend bnk-forge-proxy bnk-forge-mcp bnk-forge-operator; do
- echo "| ${name} | ${{ env.REGISTRY }}/${name}:${{ needs.preflight.outputs.new_version }}, :latest |" >> "$GITHUB_STEP_SUMMARY"
- done
+ # Single-source the image list from the probe script (F6).
+ while IFS= read -r name; do
+ echo "| ${name} | ${{ env.REGISTRY }}/${name}${TAGS} |" >> "$GITHUB_STEP_SUMMARY"
+ done < <(bash .release-tooling/scripts/registry-tag-probe.sh --images)
diff --git a/.github/workflows/secret-baseline.yml b/.github/workflows/secret-baseline.yml
new file mode 100644
index 00000000..bdb01fcf
--- /dev/null
+++ b/.github/workflows/secret-baseline.yml
@@ -0,0 +1,39 @@
+# ╔══════════════════════════════════════════════════════════════════════════╗
+# ║ Secret Scan — Full-History Baseline ║
+# ║ ║
+# ║ bonnyr-f5 #182 r3 (Major): the per-PR/push gate in ci.yml scans only the ║
+# ║ COMMIT RANGE of each change. Anything already in history before that gate ║
+# ║ landed — or a secret that slips in via a path the range scan misses — is ║
+# ║ never re-examined. This workflow runs gitleaks over ALL reachable history ║
+# ║ on a weekly schedule and on demand, so the whole repo stays monitored. ║
+# ║ ║
+# ║ It reuses scripts/secret-scan.sh (the SAME scan + assertion backstop as ║
+# ║ CI and `make secret-scan`), with RANGE="" meaning "full history". The ║
+# ║ gate still FAILS on a git error or a 0-commit non-scan, so a broken ║
+# ║ baseline is caught, not silently green. ║
+# ╚══════════════════════════════════════════════════════════════════════════╝
+
+name: Secret Scan Baseline
+
+on:
+ schedule:
+ # Mondays 06:17 UTC — weekly full-history sweep (off the hour to avoid the
+ # scheduler's top-of-hour congestion).
+ - cron: "17 6 * * 1"
+ workflow_dispatch: {}
+
+permissions:
+ contents: read
+
+jobs:
+ baseline:
+ name: "Full-history gitleaks baseline"
+ runs-on: ubuntu-latest
+ env:
+ RANGE: "" # explicit empty => scan all reachable history
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # the whole history is the point
+ - name: gitleaks (full history)
+ run: make secret-scan
diff --git a/.gitignore b/.gitignore
index 4959da45..8145aa89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -278,3 +278,9 @@ next-session-prompt
agent-selection
handoffs/
*.code-workspace
+
+# Transient sed backup files from scripts/sync-version-artifacts.sh --write
+# (removed on success; gitignored so an interrupted run leaves no tracked litter)
+*.syncbak
+# Generated secret material (JWT/encryption keys, initial admin password)
+backend/keys/
diff --git a/.gitleaks.toml b/.gitleaks.toml
index d6b977b8..bbe27e25 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -34,15 +34,16 @@ regexes = [
# "signature_here"; used to test _looks_like_jwt().
'''eyJhbGciOiJIUzI1NiJ9\.eyJzdWIiOiJ0ZXN0In0\.signature_here''',
]
+
+[[rules]]
+id = "private-key"
+# bonnyr-f5 #182: scope the fixture private keys to the private-key rule only, so
+# a real AWS/GitHub/generic secret hidden in these same files is still caught --
+# a top-level [allowlist].paths would have disabled EVERY rule for them. The
+# .pyc/__pycache__ blanket entries were dropped (zero tracked files).
+[rules.allowlist]
paths = [
- # Throwaway RSA/Ed25519 keypairs generated solely to exercise paramiko key
- # parsing. They authenticate nothing — no corresponding public key is
- # deployed anywhere. See the note in the file header.
'''backend/tests/unit/test_paramiko_utils\.py''',
- # PEM headers wrapped around placeholder bodies, not key material:
- # test_agent_host_candidates.py -> "MIIEowIBAAKCAQEA000000..."
- # test_routes_project_secrets.py -> "fake"
- # test_infrastructure_access_service.py-> "MIIEowIBAAKCAQEAuTestKeyMaterial"
'''backend/tests/component/test_agent_host_candidates\.py''',
'''backend/tests/integration/test_routes_project_secrets\.py''',
'''backend/tests/unit/test_infrastructure_access_service\.py''',
diff --git a/.trivyignore b/.trivyignore
index 46fa12f8..829c581b 100644
--- a/.trivyignore
+++ b/.trivyignore
@@ -4,26 +4,28 @@
# projects rebuild with a patched Go version.
#
# Review this file periodically and remove entries when upstream fixes are available.
+# Each entry carries an `exp:` review-by date — Trivy drops the suppression after it,
+# forcing a re-check. Extend an entry only after re-confirming no upstream fix exists.
# CVE-2025-68121: Go stdlib crypto/tls - Unexpected session resumption
# Fixed in Go >= 1.24.13 / 1.25.7 / 1.26.0-rc.3
# Affects: helm (Go 1.25.0), kubectl, tofu (Go 1.25.6), infracost (Go 1.25.4)
# All current latest releases use Go < 1.25.7 — no upstream fix available yet
# Added: 2026-02-23
-CVE-2025-68121
+CVE-2025-68121 exp:2026-11-30
# CVE-2024-45337: golang.org/x/crypto/ssh - Misuse of ServerConfig.PublicKeyCallback
# Present in infracost binary's bundled dependencies
# Not exploitable in our context (we don't run an SSH server via infracost)
# Added: 2026-02-23
-CVE-2024-45337
+CVE-2024-45337 exp:2026-11-30
# CVE-2026-33186: gRPC authorization bypass (google.golang.org/grpc < 1.79.3)
# Affects: helm and tofu binaries in Docker image (grpc v1.76.0)
# Status: Waiting for upstream helm/tofu releases with fixed grpc
# Tracked: GitHub issue #50
# Added: 2026-04-15
-CVE-2026-33186
+CVE-2026-33186 exp:2026-11-30
# CVE-2026-7598: libssh2 — integer overflow via large username/password
# Affects: libssh2-1t64 1.11.1-1 in Debian trixie base image
@@ -32,11 +34,16 @@ CVE-2026-33186
# The vulnerable code path requires libssh2 to negotiate auth with a
# malicious remote SSH server, which our HTTP backend never does.
# Upstream: no Debian backport yet (Trivy reports empty fix column).
-# REVISIT: monthly via https://security-tracker.debian.org/tracker/CVE-2026-7598
-# escalate to pin-from-sid if no fix by 2026-08-12
+# REVISIT: monthly. Do not extend this entry on the assertion that no fix exists —
+# confirm it: re-run `trivy image` (or check the tracker) and only keep
+# the ignore while the fix column is still empty for our base image's
+# libssh2. https://security-tracker.debian.org/tracker/CVE-2026-7598
+# The 2026-08-12 deadline lapsed without that re-check; next check by
+# 2026-09-12, and escalate to pin-from-sid if a fixed version is then
+# available and we're still ignoring it.
# Tracked: memory/followup_trivyignore_cve_2026_7598_revisit.md
# Added: 2026-05-12
-CVE-2026-7598
+CVE-2026-7598 exp:2026-09-12
# CVE-2026-42010: GnuTLS Authentication Bypass via NUL Character in DN parsing
# Affects: libgnutls30t64 in our Debian Trixie base image (3.8.9-3+deb13u2)
@@ -49,7 +56,7 @@ CVE-2026-7598
# REVISIT: monthly via https://security-tracker.debian.org/tracker/CVE-2026-42010
# Pattern mirror of CVE-2026-33845 / CVE-2026-7598 suppressions.
# Added: 2026-05-14
-CVE-2026-42010
+CVE-2026-42010 exp:2026-11-30
# CVE-2026-42496: perl — Archive::Tar < 3.08 extracts symlinks unsafely
# CVE-2026-8376: perl — heap buffer overflow in the interpreter (<= 5.43.10)
@@ -69,8 +76,8 @@ CVE-2026-42010
# https://security-tracker.debian.org/tracker/CVE-2026-8376
# Drop once Debian ships a trixie point-release with patched perl.
# Added: 2026-06-02
-CVE-2026-42496
-CVE-2026-8376
+CVE-2026-42496 exp:2026-11-30
+CVE-2026-8376 exp:2026-11-30
# CVE-2026-13221: libperl5.40 — silently incorrect results in Perl <= 5.43.9
# Affects: libperl5.40 5.40.1-6 in the python:3.11-slim (Debian trixie) base image
@@ -80,7 +87,7 @@ CVE-2026-8376
# REVISIT: monthly via https://security-tracker.debian.org/tracker/CVE-2026-13221
# drop once Debian ships a trixie update with a patched libperl5.40.
# Added: 2026-07-15
-CVE-2026-13221
+CVE-2026-13221 exp:2026-11-30
# CVE-2026-60002: openssh-client — memory corruption in SSH client
# Affects: openssh-client in the python:3.11-slim (Debian trixie) base image
@@ -91,7 +98,7 @@ CVE-2026-13221
# REVISIT: monthly via https://security-tracker.debian.org/tracker/CVE-2026-60002
# drop once Debian ships a trixie update with a patched openssh-client.
# Added: 2026-07-15
-CVE-2026-60002
+CVE-2026-60002 exp:2026-11-30
# CVE-2026-33845: GnuTLS DTLS — reachable-assert / auth bypass in DN parsing
# Affects: libgnutls30t64 in our Debian Trixie base image (3.8.9-3+deb13u2)
@@ -105,7 +112,7 @@ CVE-2026-60002
# Check: https://security-tracker.debian.org/tracker/CVE-2026-33845
# Tracked: GitHub issue #103
# Added: 2026-05-06
-CVE-2026-33845
+CVE-2026-33845 exp:2026-11-30
# CVE-2026-57433: perl Storable signed-integer flaw (Storable < 3.41)
# Affects: libperl5.40, perl-base (5.40.1-6) in our Debian Trixie base image.
@@ -117,4 +124,4 @@ CVE-2026-33845
# trixie-security. Check: https://security-tracker.debian.org/tracker/CVE-2026-57433
# Tracked: GitHub issue #492
# Added: 2026-07-22
-CVE-2026-57433
+CVE-2026-57433 exp:2026-11-30
diff --git a/AGENTS.md b/AGENTS.md
index c2289cd1..972aa5b7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -82,5 +82,28 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
+## Commit conventions
+
+Conventional Commits (`type: subject`, optional body, `BREAKING CHANGE:` footer for a
+major). One repo-specific trap worth stating outright:
+
+- **Never write a CI-control marker as literal text anywhere in a commit message —
+ subject *or* body — even when quoting it in prose.** GitHub scans the whole message,
+ so a `[skip ci]` / `[ci skip]` sitting in a sentence suppresses the run for that
+ commit. This has bitten us twice, most recently on a shell-script change where the
+ gates that got skipped (ShellCheck, Script Self-Tests, Secret Scan) were exactly the
+ ones that mattered. Refer to it indirectly instead: "CI suppressed", "the skip-CI
+ marker", or split it across backticks. The release job's *deliberate* skip is the
+ only legitimate use, and it lands on the subject line where the release loop reads it.
+ This is now **enforced**, not just documented: the `commit-lint` CI gate and the
+ `.githooks/pre-push` hook both run `scripts/lint-commit-markers.sh`, which fails a
+ push/PR whose commit range carries any CI-control marker (bonnyr-f5 #182 r3, #166:
+ documentation is not enforcement).
+- **Declare a major bump with a real `BREAKING CHANGE: ` footer**, not a
+ bold `**BREAKING CHANGE**` heading or a bare colon-less line. `compute_version_bump.sh`
+ majors on the phrase, so a prose line that *looks* like a footer ships a spurious major
+ release; `commit-lint` rejects the line-start prose forms while allowing the plain
+ footer.
+
---
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 294cdf6e..03118a87 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,54 @@
# Changelog
-All notable changes to BNK-Forge v2.
+All notable changes to BNK-Forge.
---
+## v3.1.6 (2026-08-10) — 3.1.x line
+
+Milestone `v3.1.6` — the last release before 4.0.0, and the initial public
+release tag on `f5devcentral`. This mirror is squashed: the `v3.1.6` tag is a
+single `feat: initial public release` commit, so there is no per-change history
+behind it to link here. Work that came *after* this tag — including the
+container-runner hardening series (#2, #123, #161) and the ADR-424 bare-metal/DPU
+work — is part of 4.0.0, not v3.1.6, and is recorded under the 4.0.0 entry when
+that release is cut.
+
+> **Heads-up for the 4.0.0 upgrade — two breaking changes:**
+>
+> 1. **Container runner non-root gate:** it now refuses *named* users — an image
+> using the distroless-standard `USER nonroot` is rejected. Switch it to a
+> numeric uid. Use **`USER 1000`**: the workspace is mounted from the host
+> and chowned `1000:1000`, so uid 1000 is the only value that clears the gate
+> *and* can write it. A higher uid such as `65532` passes the non-root gate but
+> cannot write the workspace, so the step fails on its first write.
+> 2. **`MCP_SERVICE_PASSWORD` becomes required in 4.0.0 (via bonnyr-f5 #188):**
+> starting with 4.0.0 the backend refuses to boot in staging/production if it
+> is unset or still a shipped default (`changeme` / `mcp-service-changeme`).
+> That boot-time check ships in #188 — it is *not* in the 3.1.x line and is
+> called out here only so the upgrade step is ready before #188 lands. Every
+> existing install still carries one of those defaults, so before upgrading to
+> 4.0.0 **set `MCP_SERVICE_PASSWORD` to a real secret** (the same value the MCP
+> server receives as `BNK_FORGE_PASSWORD`); once #188 is in the tree, leaving
+> it at a default will `SystemExit` the stack at startup.
+>
+> **Merge ordering (integration dependency).** The dist-bundle wiring these two
+> steps assume — the dedicated `mcp` service account for the bundled MCP server,
+> and the `MCP_SERVICE_PASSWORD` boot check — arrives in **bonnyr-f5 #186** (service
+> account + removal of the shipped `changeme` / `mcp-service-changeme` defaults) and
+> **#188** (boot check). This release documents them forward-looking and is therefore
+> sequenced to merge **with or after #186 + #188**. Merged ahead of them, the
+> `MCP_SERVICE_PASSWORD` guidance is inert for the dist stack (the compose file does
+> not pass that variable to the backend) and #186 will conflict in
+> `user-pack/install-guide.html` — resolve by taking #186's credential model, not by
+> re-adding the `changeme` default this guide describes as a stopgap.
+
+## v3.0.1 — 3.0.x line
+
+The first 3.x release after the 2.x line below (upstream tag dated 2026-04-09).
+Bridged entry; this repo is a squashed public mirror, so the `v3.0.1` tag and its
+per-change history live upstream, not here.
+
## v2.10.74 (2026-03-04) — TMM Debug Panel Enhancements: F5 Docs Commands, Netkvest, Bug Fix
### Bug Fixes
diff --git a/Makefile b/Makefile
index 75528feb..77867fb9 100644
--- a/Makefile
+++ b/Makefile
@@ -84,7 +84,7 @@ AWSBNKCTL_STAMP := bin/.awsbnkctl-$(AWSBNKCTL_VERSION).stamp
test test-backend test-backend-unit test-backend-component test-backend-legacy test-frontend \
test-proxy test-operator test-db test-contracts test-e2e test-e2e-tier1 test-e2e-tier2 \
test-integration test-integration-full build-frontend-check smoke-mcp-live mcp-readiness mcp-recreate \
- lint lint-backend lint-frontend shellcheck coverage quick-check pre-push push install-hooks setup-hooks \
+ lint lint-backend lint-frontend shellcheck coverage quick-check version-check pre-push push install-hooks setup-hooks \
dev-setup security-audit docker-check docker-verify docker-validate \
openapi openapi-types openapi-check openapi-types-check typecheck-backend typecheck-frontend \
build build-retry build-backend build-frontend build-worker build-agent build-all \
@@ -206,7 +206,7 @@ _install-info:
echo " (accept the self-signed certificate warning)"; \
fi; \
echo ""; \
- echo " Login: admin (initial password: DEFAULT_ADMIN_PASSWORD, default 'changeme' — change on first login)"; \
+ echo " Login: admin (password: DEFAULT_ADMIN_PASSWORD if set, else the generated one at /app/keys/initial_admin_password — change on first login)"; \
echo ""; \
echo " Next steps:"; \
echo " 1. Change your password on first login"; \
@@ -426,7 +426,7 @@ deploy: build ensure-artifact-network
@echo ""
ifeq ($(UNAME_S),Darwin)
@echo " Open: https://localhost"
- @echo " Login: admin (initial password: DEFAULT_ADMIN_PASSWORD, default 'changeme'; change on first login)"
+ @echo " Login: admin (password: DEFAULT_ADMIN_PASSWORD if set, else the generated one at /app/keys/initial_admin_password; change on first login)"
endif
@echo " Recommended next step: make mcp-readiness"
@echo "========================================="
@@ -464,7 +464,82 @@ test-upgrade:
shellcheck:
@echo ""
@echo "=== ShellCheck: linting shell scripts ==="
- @shellcheck --severity=warning upgrade.sh scripts/*.sh vm-bnk-forge/*.sh vm-bnk-forge/lib/*.sh
+ @# bonnyr-f5 #182: drive from git ls-files so the WHOLE corpus is gated
+ @# (the hardcoded globs missed 14 tracked scripts incl. dist/install.sh).
+ @# bonnyr-f5 #182 r2: include the (extensionless) git hooks, and fail on an
+ @# EMPTY list -- `xargs shellcheck` with no files exits 0 on BSD (blind).
+ @files="$$(git ls-files '*.sh' .githooks/pre-commit .githooks/pre-push 2>/dev/null)"; \
+ n=$$(printf '%s\n' "$$files" | grep -c .); \
+ [ "$$n" -ge 1 ] || { echo "::error::shellcheck found no files to lint"; exit 1; }; \
+ printf '%s\n' "$$files" | xargs shellcheck --severity=warning
+
+# ── CI-parity gates (bonnyr-f5 #182 r3, Major) ──────────────────────────────
+# The four gates ci.yml added were not runnable locally: `make pre-push` ran
+# none of them and `make shellcheck` had no dependents, yet ci.yml's header
+# claims `make pre-push` == CI. #166: "a local gate that does not run the CI
+# command is not a gate." These targets ARE the CI command (ci.yml calls the
+# same `make` targets / same scripts), and `pre-push` now depends on `ci-gates`.
+.PHONY: ci-gates version-check secret-scan commit-lint script-selftests
+
+# Helm chart tag/appVersion + frontend package.json must equal VERSION.
+version-check:
+ @echo ""
+ @echo "=== Version artifacts consistency (sync-version-artifacts.sh --check) ==="
+ @bash scripts/sync-version-artifacts.sh --check
+
+# gitleaks range-aware secret scan + assertion backstop (single source of truth,
+# shared with ci.yml's secret-scan job and the scheduled baseline workflow).
+# Honours RANGE from the environment; unset => scan since the upstream merge-base.
+secret-scan:
+ @echo ""
+ @echo "=== Secret scan (gitleaks) ==="
+ @bash scripts/secret-scan.sh
+
+# Commit-message marker enforcement (shared with ci.yml's commit-lint job and
+# .githooks/pre-push). Honours RANGE; unset => @{upstream}..HEAD.
+commit-lint:
+ @echo ""
+ @echo "=== Commit message marker lint ==="
+ @bash scripts/lint-commit-markers.sh
+
+# The paired self-test harnesses ci.yml's script-selftests job runs.
+# ci.yml's compute step has FOUR anti-vacuity assertions and this target must
+# mirror ALL of them, or a broken harness passes locally while CI goes red
+# (bonnyr-f5 #182 r4/r5, Major-3: a local gate that diverges from the CI command
+# is not a gate). The four (in ci.yml order):
+# 1. non-zero exit -> the harness itself errored
+# 2. a "FAIL:" line (rc still 0) -> an assertion failed but exit was swallowed
+# 3. NO "PASS:" line -> the guard was silenced / renamed: green with
+# zero assertions actually run
+# 4. NO "=== END SELF-TEST ===" -> the harness exited early (deleted END marker
+# or an early `exit 0`) with assertions unrun
+# r5 landed 3+4 here; r4 had only 1+2, so the "silenced guard" and "early exit"
+# harness-break modes were CI-red but `make`-GREEN.
+script-selftests:
+ @echo ""
+ @echo "=== Script self-tests ==="
+ @set +e; out="$$(SELF_TEST=1 bash scripts/compute_version_bump.sh 2>&1)"; rc=$$?; \
+ echo "$$out"; \
+ if [ "$$rc" -ne 0 ]; then echo "::error::compute_version_bump self-test exited $$rc"; exit "$$rc"; fi; \
+ if printf '%s\n' "$$out" | grep -qE '(^|[[:space:]])FAIL:'; then \
+ echo "::error::compute_version_bump self-test reported FAIL: but exited 0"; exit 1; \
+ fi; \
+ if ! printf '%s\n' "$$out" | grep -qE '(^|[[:space:]])PASS:'; then \
+ echo "::error::self-test produced no PASS lines -- the harness did not run"; exit 1; \
+ fi; \
+ if ! printf '%s\n' "$$out" | grep -qE '=== END SELF-TEST ==='; then \
+ echo "::error::self-test did not reach its END marker -- it exited early with assertions unrun"; exit 1; \
+ fi
+ @if grep -q -- '--self-test' scripts/extract-breaking-changes.sh; then \
+ bash scripts/extract-breaking-changes.sh --self-test; \
+ else \
+ echo " (extract-breaking-changes.sh has no --self-test yet; it lands with #179)"; \
+ fi
+
+# Aggregate: every CI gate that is not already covered by quick-check/tests.
+ci-gates: shellcheck version-check commit-lint script-selftests secret-scan
+ @echo ""
+ @echo "=== CI-parity gates passed ==="
# Convenience: start/stop/restart all (platform-aware)
up: ensure-artifact-network
@@ -524,7 +599,7 @@ smoke-mcp-live:
@echo ""
@echo "=== MCP Live Smoke Validation ==="
@echo " NOTE: ping/tools-list reachability != runtime readiness; tool calls require valid MCP backend credentials."
- @echo " Configure MCP_USERNAME/MCP_PASSWORD if backend admin password was rotated."
+ @echo " Configure MCP_USERNAME=mcp / MCP_PASSWORD (backend MCP_SERVICE_PASSWORD) — the dedicated MCP service account, never the admin login (#187)."
@python3 scripts/mcp_live_smoke.py --mcp-url "$${MCP_SMOKE_URL:-http://localhost:8081/mcp}" $${MCP_SMOKE_INSECURE_TLS:+--insecure-tls}
mcp-readiness:
@@ -684,9 +759,19 @@ check-migrations:
@echo "=== Migration Chain Validator ==="
@python3 scripts/check-migrations.py
+# ── Version-artifact consistency ─────────────────────────────────────────────
+# Mirror of CI's "P1 · Version Consistency" job. ci.yml promises `make pre-push`
+# ≡ CI, so the gate must be reachable from the documented local target or drift
+# is undetectable until the release job dies (bonnyr-f5 #180 r5, F3). Pulled in
+# by quick-check (a pre-push prerequisite).
+version-check:
+ @echo ""
+ @echo "=== Version Artifact Consistency (Helm tag/appVersion, frontend, operator) ==="
+ @bash scripts/sync-version-artifacts.sh --check
+
# ── Quick check (~15s): lint + types + contracts ────────────────────────────
# Run before every commit. Catches most CI failures instantly.
-quick-check: lint typecheck-backend openapi-types-check check-migrations
+quick-check: lint typecheck-backend openapi-types-check check-migrations version-check
@echo ""
@echo "========================================="
@echo " Quick check passed (~15s)"
@@ -694,8 +779,10 @@ quick-check: lint typecheck-backend openapi-types-check check-migrations
# ── Pre-push (~90s parallel): mirrors ALL CI jobs ───────────────────────────
# Run once before git push. Runs test suites in parallel for speed.
-# Prerequisite: quick-check runs first (sequential), then tests fan out.
-pre-push: quick-check
+# Prerequisite: quick-check runs first (sequential), then the CI-parity gates
+# (shellcheck / version-check / commit-lint / script-selftests / secret-scan --
+# bonnyr-f5 #182 r3, so `make pre-push` genuinely == CI), then tests fan out.
+pre-push: quick-check ci-gates
@echo ""
@echo "=== Running all test suites in parallel... ==="
@failed=""; \
@@ -1160,6 +1247,46 @@ push-images:
echo " Platforms: $(PLATFORMS)"; \
echo " Builder: $(BUILDX_BUILDER)"; \
echo ""; \
+ HIGHEST_TAG=$$(git tag -l 'v*' 2>/dev/null | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | sort -V | tail -1); \
+ if [ -n "$$HIGHEST_TAG" ] && [ "$${FORCE_LATEST:-}" != "1" ]; then \
+ HIGHEST=$$(printf '%s\n%s\n' "$${HIGHEST_TAG#v}" "$$VERSION" | sort -V | tail -1); \
+ if [ "$$VERSION" != "$$HIGHEST" ]; then \
+ echo "ERROR: local VERSION $$VERSION is older than the highest released tag $$HIGHEST_TAG."; \
+ echo " bake pushes the rolling ':latest' tag, so this stale tree would move :latest backward"; \
+ echo " (release.yml's recency guard covers the CI path; this covers the operator path)."; \
+ echo " Check out the latest release first, or re-run with FORCE_LATEST=1 to override deliberately."; \
+ exit 1; \
+ fi; \
+ fi; \
+ if [ "$${FORCE_LATEST:-}" != "1" ]; then \
+ echo " Probing the registry so this push can't silently overwrite an already-published :$$VERSION tag..."; \
+ PROBE_OUT=$$(REGISTRY=$$REGISTRY VERSION=$$VERSION bash scripts/registry-tag-probe.sh 2>/dev/null) || { \
+ echo "ERROR: could not run the registry existence probe (scripts/registry-tag-probe.sh)."; \
+ echo " Re-run with FORCE_LATEST=1 only if you intend to overwrite the immutable :$$VERSION tag."; \
+ exit 1; \
+ }; \
+ EXISTING=$$(printf '%s\n' "$$PROBE_OUT" | awk -F'\t' '$$1=="exists"{print " "$$2}'); \
+ UNKNOWN=$$(printf '%s\n' "$$PROBE_OUT" | awk -F'\t' '$$1=="unknown"{print " "$$2": "$$3}'); \
+ if [ -n "$$EXISTING" ]; then \
+ echo "ERROR: images for :$$VERSION already exist in $$REGISTRY. bake would move the IMMUTABLE :$$VERSION tag"; \
+ echo " and orphan the cosign/SBOM/SLSA attestations bound to the old digests:"; \
+ printf '%s\n' "$$EXISTING"; \
+ echo " (VERSION == the highest tag is exactly the state of a fresh 'main' right after a release —"; \
+ echo " the recency guard above lets that through, so this existence probe is what protects the tag,"; \
+ echo " mirroring release.yml's 'Refuse to overwrite an already-published tag' step.)"; \
+ echo " Re-run with FORCE_LATEST=1 only if you intend to overwrite them."; \
+ exit 1; \
+ fi; \
+ if [ -n "$$UNKNOWN" ]; then \
+ echo "ERROR: could not confirm the :$$VERSION tag is free (auth / network / rate-limit). Refusing to"; \
+ echo " publish rather than risk overwriting an immutable tag that a probe simply could not see:"; \
+ printf '%s\n' "$$UNKNOWN"; \
+ echo " Export REGISTRY_USERNAME/REGISTRY_PASSWORD for an authenticated probe, or re-run with"; \
+ echo " FORCE_LATEST=1 to override deliberately."; \
+ exit 1; \
+ fi; \
+ echo " No existing :$$VERSION manifests found — safe to publish."; \
+ fi; \
echo "=== Building + pushing all images in parallel (docker buildx bake) ==="; \
GIT_REVISION=$$(git rev-parse HEAD 2>/dev/null || echo unknown); \
REGISTRY=$$REGISTRY VERSION=$$VERSION PLATFORMS=$(PLATFORMS) GIT_REVISION=$$GIT_REVISION \
diff --git a/README.md b/README.md
index 230be971..4b4b51d9 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,11 @@ make deploy
Open **https://localhost** and accept the self-signed certificate warning.
+> **Enabling MCP:** the MCP server and backend share a service credential you must
+> set — put `MCP_SERVICE_PASSWORD` in `.env` before starting. Without it the stack
+> still comes up, but the MCP server can't authenticate and MCP tools return auth
+> errors until you set the variable and restart. See [.env.example](.env.example).
+
`make deploy` detects macOS/WSL and switches to bridge networking with published
ports (`docker-compose.local.yml`); on a Linux server it uses host networking. You
do not pick — it picks.
@@ -140,9 +145,17 @@ For first-time destructive bootstrap only (wipes existing BNK Forge volumes), us
| Field | Value |
|-------|-------|
| **Username** | `admin` |
-| **Password** | `changeme` |
+| **Password** | _generated on first startup — see below_ |
+
+The admin password is generated randomly on first startup (there is no shipped
+default). Retrieve it once from the backend logs:
+
+```bash
+docker exec bnk-forge-backend cat /app/keys/initial_admin_password
+```
-You'll be prompted to change the password on first login.
+Or choose your own beforehand by setting `DEFAULT_ADMIN_PASSWORD` in `.env`. You
+will be **required** to change it on first login (enforced server-side).
---
diff --git a/The_BNK_Forge_Developers_Guide.md b/The_BNK_Forge_Developers_Guide.md
index 4e10f2e4..73fdd463 100644
--- a/The_BNK_Forge_Developers_Guide.md
+++ b/The_BNK_Forge_Developers_Guide.md
@@ -198,7 +198,7 @@ make install
# 4. Open the UI
# macOS / WSL / Linux desktop: https://localhost/
-# Default login: admin / changeme (change it on first login)
+# Default login: admin (password: set DEFAULT_ADMIN_PASSWORD, else the generated one at /app/keys/initial_admin_password; change on first login)
```
That is it. The platform detection in the Makefile does the right thing on Darwin (macOS), WSL2, and native Linux without further configuration.
@@ -276,7 +276,7 @@ When the install finishes you will see something like:
=========================================
URL: https://localhost/
- Login: admin / changeme
+ Login: admin (password: DEFAULT_ADMIN_PASSWORD if set, else generated — see /app/keys/initial_admin_password)
```
Open the URL in your browser. Self-signed cert warnings are expected; accept once.
diff --git a/backend/alembic/versions/v2_154_user_is_service_account.py b/backend/alembic/versions/v2_154_user_is_service_account.py
new file mode 100644
index 00000000..4057040a
--- /dev/null
+++ b/backend/alembic/versions/v2_154_user_is_service_account.py
@@ -0,0 +1,32 @@
+"""Add users.is_service_account for service-account provenance.
+
+Revision ID: v2_154
+Revises: v2_153
+
+bonnyr-f5 #188: ensure_service_user identified service accounts by NAME
+(a one-entry denylist of "admin"), so pointing MCP_SERVICE_USERNAME at any other
+human row (operator, a named user) let the boot-time reconcile overwrite its
+password, promote it to admin, clear its must-change gate and re-activate it.
+Provenance recorded at creation lets the seeder refuse any pre-existing row it
+did not create, independent of the name.
+"""
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "v2_154"
+down_revision = "v2_153"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("users") as batch:
+ batch.add_column(
+ sa.Column("is_service_account", sa.Boolean(), nullable=False, server_default=sa.false())
+ )
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("users") as batch:
+ batch.drop_column("is_service_account")
diff --git a/backend/alembic/versions/v2_155_backfill_is_service_account.py b/backend/alembic/versions/v2_155_backfill_is_service_account.py
new file mode 100644
index 00000000..1e1f46aa
--- /dev/null
+++ b/backend/alembic/versions/v2_155_backfill_is_service_account.py
@@ -0,0 +1,92 @@
+"""Backfill users.is_service_account for the legacy mcp service account.
+
+Revision ID: v2_155
+Revises: v2_154
+
+bonnyr-f5 #188 round 4 (INV-7): the backfill MUST live in its own revision, not
+inside ``v2_154``. ``v2_154`` only adds the column (``server_default false``) and
+already shipped in earlier RCs of this branch, so any install that ran it at the
+earlier commit has ``alembic_version = v2_154`` and will NEVER re-run it — an
+applied revision is immutable. Appending the backfill to ``v2_154`` therefore
+skips exactly the existing installs it was meant to fix. Cutting a NEW revision
+that chains from ``v2_154`` guarantees every such install applies the backfill on
+its next ``alembic upgrade``.
+
+Why the backfill is needed at all: the column ships ``server_default false``, so
+without it EVERY pre-existing row — including the ``mcp`` service account that
+every already-deployed install carries — is classified ``is_service_account =
+False``. Both consumers gate on that column, so the mis-classification is a trap
+on an upgraded install:
+
+ * ``disable_stale_service_user`` filters ``is_service_account IS TRUE``, so the
+ stale ``mcp`` row is never disabled and the shipped ``mcp-service-changeme``
+ default keeps authenticating as role=admin -> issue #187 stays open for every
+ existing install.
+ * ``ensure_service_user`` refuses any row where ``not is_service_account``, so
+ setting a real ``MCP_SERVICE_PASSWORD`` raises forever and MCP is dead.
+
+Scope of the backfill is deliberately narrow — we reclassify a row as a service
+account ONLY when it carries the exact fingerprint that the legacy
+``ensure_service_user`` + ``create_user`` seed produced, never an arbitrary row:
+
+ * ``username = 'mcp'`` — the ONLY value the legacy service account was ever
+ created under. ``MCP_SERVICE_USERNAME`` defaults to ``'mcp'`` (core/config.py)
+ and the migration cannot know an operator's overridden value at apply time;
+ reading app settings into a migration is non-deterministic and, worse, some
+ legacy ``.env`` files point that var at ``admin`` — backfilling the configured
+ name would then reclassify the HUMAN admin as a service account, the exact
+ takeover #188 set out to prevent. We backfill the known legacy default only.
+ * ``email = 'mcp@bnk-forge.local'`` — ``create_user`` synthesised the service
+ account's email as ``f"{username}@bnk-forge.local"``, so the legacy ``mcp``
+ row provably has this address. Requiring it as a second signal means a real
+ human who merely happens to be named ``mcp`` (with any real email) is left
+ untouched.
+
+The pair (username + synthesised email) is the creation fingerprint of the
+service account and cannot collide with a human provisioned through normal
+signup, which always carries a real email. Rows that don't match keep the
+``server_default false`` — correct, they are human accounts.
+
+Documented edge: an operator who set a CUSTOM ``MCP_SERVICE_USERNAME`` (not the
+default ``mcp``) before upgrading will not have that row backfilled here; the
+remedy is to point ``MCP_SERVICE_USERNAME`` at a dedicated name (the default
+``mcp`` is now backfilled and reconcilable). Silently reclassifying an
+operator-named row we cannot prove we created risks taking over a human account,
+which is strictly worse than a one-line rename for the rare custom-username install.
+"""
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "v2_155"
+down_revision = "v2_154"
+branch_labels = None
+depends_on = None
+
+# The legacy default service username and the email create_user synthesised for
+# it. Kept as constants so the backfill scope is explicit and auditable.
+_LEGACY_SERVICE_USERNAME = "mcp"
+_LEGACY_SERVICE_EMAIL = "mcp@bnk-forge.local"
+
+
+def upgrade() -> None:
+ # Backfill: classify ONLY the provably-seeded legacy mcp service account.
+ # Parameterised so the literals are quoted safely on every backend.
+ op.execute(
+ sa.text(
+ "UPDATE users SET is_service_account = :t "
+ "WHERE username = :u AND email = :e"
+ ).bindparams(t=True, u=_LEGACY_SERVICE_USERNAME, e=_LEGACY_SERVICE_EMAIL)
+ )
+
+
+def downgrade() -> None:
+ # Reversing the classification is safe and precise: only rows carrying the
+ # exact legacy fingerprint were flipped, so we clear the flag for exactly
+ # those rows. The column itself is owned by v2_154 and is left in place.
+ op.execute(
+ sa.text(
+ "UPDATE users SET is_service_account = :f "
+ "WHERE username = :u AND email = :e"
+ ).bindparams(f=False, u=_LEGACY_SERVICE_USERNAME, e=_LEGACY_SERVICE_EMAIL)
+ )
diff --git a/backend/core/auth_middleware.py b/backend/core/auth_middleware.py
index ad10b727..7d64fb2f 100644
--- a/backend/core/auth_middleware.py
+++ b/backend/core/auth_middleware.py
@@ -4,16 +4,21 @@
Can be disabled via REQUIRE_AUTH=false for backward compatibility.
"""
import logging
+from typing import TYPE_CHECKING
from fastapi import Request
from fastapi.responses import JSONResponse
from jose import JWTError
+from starlette.concurrency import run_in_threadpool
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
from core.config import settings
from core.errors import UnauthorizedError
+if TYPE_CHECKING:
+ from models import User
+
logger = logging.getLogger(__name__)
# Long-lived API tokens (CLI / CI-CD) carry this prefix; they are verified
@@ -21,19 +26,23 @@
API_TOKEN_PREFIX = "bnk_"
-def _verify_api_token(token: str) -> None:
- """Raise UnauthorizedError unless ``token`` is a live API token.
+def _verify_api_token(token: str) -> "User":
+ """Return the owning User for a live API token, or raise UnauthorizedError.
Opens its own session: middleware runs outside FastAPI's dependency
- injection, so ``get_db`` is not available here.
+ injection, so ``get_db`` is not available here. The User is refreshed before
+ the session closes so the caller can read ``must_change_password`` off the
+ detached instance (same pattern as token_user_state).
"""
from database import SessionLocal
from services.api_token_service import ApiTokenService
db = SessionLocal()
try:
- ApiTokenService(db).verify(token) # raises UnauthorizedError if invalid
+ user, _api_token = ApiTokenService(db).verify(token) # raises if invalid
db.commit() # verify() stamps last_used_at
+ db.refresh(user)
+ return user
except Exception:
db.rollback()
raise
@@ -146,15 +155,53 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
# Validate the token
token = auth_header.split(" ", 1)[1]
try:
+ from core.errors import ForbiddenError
+ from services.auth_service import enforce_password_change
if token.startswith(API_TOKEN_PREFIX):
# Long-lived CLI / CI-CD token — a DB-backed hash, not a JWT, so
- # decode_token() would reject it. 21 /api routes have no auth
+ # decode_token() would reject it. ~32 /api routes have no auth
# dependency of their own and rely on this middleware alone, so
# it must fully verify the token here, not defer to the route.
- _verify_api_token(token)
+ #
+ # bonnyr-f5 #186 r5 (Major): _verify_api_token opens a SYNC DB
+ # session; awaiting it directly on the event-loop thread would
+ # block every concurrent request for the duration of the query.
+ # Off-load the blocking I/O to a worker thread so dispatch stays
+ # non-blocking (same treatment as token_user_state below).
+ user = await run_in_threadpool(_verify_api_token, token)
+ enforce_password_change(path, user)
else:
- from services.auth_service import decode_token
+ from services.auth_service import decode_token, token_user_state
decode_token(token) # Will raise UnauthorizedError if invalid
+ # #184/#186: the dependency-only gate is bypassed on routes that
+ # declare no get_current_user; enforce must_change here too, where
+ # auth is actually resolved. decode_token already succeeded, so a
+ # None from token_user_state is a VALID JWT whose user can't be
+ # resolved (deleted/disabled/DB error) -- the fail-CLOSED case, per
+ # its own contract and the WS validators. Refuse it, don't skip the
+ # gate (bonnyr-f5 #186 r2: skipping was a fail-open bypass on the
+ # ~32 dependency-less routes).
+ # bonnyr-f5 #186 r5 (Major): token_user_state opens a SYNC DB
+ # session on every authenticated request. Run it in a worker
+ # thread so the blocking DB round-trip never stalls the event
+ # loop (the gate previously only paid this cost for rare bnk_
+ # tokens; it now runs for every JWT request).
+ jwt_user = await run_in_threadpool(token_user_state, token)
+ if jwt_user is None:
+ raise UnauthorizedError("Token subject could not be resolved")
+ enforce_password_change(path, jwt_user)
+ except ForbiddenError as exc:
+ return JSONResponse(
+ status_code=403,
+ content={
+ "error": {
+ "code": "PASSWORD_CHANGE_REQUIRED",
+ "message": str(exc),
+ "details": {},
+ "path": path,
+ }
+ },
+ )
except (JWTError, UnauthorizedError):
return JSONResponse(
status_code=401,
diff --git a/backend/core/config.py b/backend/core/config.py
index 78d44c58..4b2e5099 100644
--- a/backend/core/config.py
+++ b/backend/core/config.py
@@ -16,6 +16,10 @@
logger = logging.getLogger(__name__)
+# Passwords ever shipped as the MCP service default. Treated as "not set" on
+# both the fail-fast (validate_production) and the boot-rotation path (bonnyr-f5 #188).
+MCP_KNOWN_DEFAULT_PASSWORDS = ("mcp-service-changeme", "changeme")
+
# BE-007: Directory for persisting auto-generated keys across restarts
_KEYS_DIR = os.environ.get("KEYS_DIR", "/app/keys")
@@ -97,10 +101,39 @@ def cors_origins(self) -> list[str]:
JWT_SECRET_KEY: str | None = None
ENCRYPTION_KEY: str | None = None
- # Seed credentials — distinct vars so admin rotation never affects MCP
- DEFAULT_ADMIN_PASSWORD: str = "changeme"
+ # DEFAULT_ADMIN_PASSWORD defaults to None, never a hardcoded value: a
+ # shipped default like "changeme" is a live, publicly-known admin credential
+ # on every fresh deployment (#184). When unset, seed_admin_user generates a
+ # random one and logs it once (the account is must_change_password anyway).
+ DEFAULT_ADMIN_PASSWORD: str | None = None
+ # Test/ephemeral environments (e2e) seed a KNOWN admin and skip the
+ # must-change gate so the suite can reach protected routes. Defaults True;
+ # never set false on a real deployment.
+ DEFAULT_ADMIN_MUST_CHANGE: bool = True
+ # #186 BLOCKER 1: MCP_SERVICE_PASSWORD is the same class of shipped default as
+ # DEFAULT_ADMIN_PASSWORD above (the seeded 'mcp' account is role=admin and
+ # exempt from the must-change gate), so it must NEVER carry a published value.
+ # Defaults to None; when unset ensure_service_user generates a random secret
+ # and surfaces it once, and a published default (mcp-service-changeme) is
+ # refused as a seed value and rotated out of any existing row.
+ #
+ # #186 BLOCKER 1 (bonnyr-f5 r5): the BACKEND now receives MCP_SERVICE_PASSWORD
+ # on every deploy mode (the backend-env anchors in every compose file, the
+ # ibm installer, and the Helm shared-env in _helpers.tpl sourced from the
+ # release Secret's mcp-password key), so it reconciles the mcp account to the
+ # same per-install secret the mcp client uses instead of generating a private
+ # one the client can never match. The reserved-name guard in
+ # ensure_service_user and #188's Helm mcp-secret work share this credential
+ # surface; see the PR discussion for the #186/#188 integration split.
MCP_SERVICE_USERNAME: str = "mcp"
- MCP_SERVICE_PASSWORD: str = "mcp-service-changeme"
+ # #187: shared secret between the backend (which seeds the `mcp` service
+ # account) and the MCP server (which authenticates with it). Defaults to
+ # None -- never a shipped value like "mcp-service-changeme", which is a live
+ # admin credential. It can't be auto-generated (both sides must receive the
+ # same value), so it must be set explicitly; validate_production fails fast
+ # in staging/prod, and when unset the backend simply doesn't seed the account
+ # (MCP is unavailable until it's configured).
+ MCP_SERVICE_PASSWORD: str | None = None
# Benchmark agent auth flag.
# When False (default): register/ingest/WS are open (preserves the documented curl flow).
@@ -199,6 +232,17 @@ def validate_production(self) -> None:
"ENCRYPTION_KEY was not explicitly set — set it as an environment variable"
)
+ # #187: the MCP service password is a shared secret and cannot be
+ # auto-generated -- it must be set explicitly and identically on the
+ # backend and the MCP server. Refuse an unset or known-default value.
+ # bonnyr-f5: the actually-shipped default across dist/helm/scripts was
+ # "changeme", not just "mcp-service-changeme" — reject both.
+ if not self.MCP_SERVICE_PASSWORD or self.MCP_SERVICE_PASSWORD in MCP_KNOWN_DEFAULT_PASSWORDS:
+ issues.append(
+ "MCP_SERVICE_PASSWORD was not set to a real value — set it (the same "
+ "value the MCP server gets as BNK_FORGE_PASSWORD) as an environment variable"
+ )
+
if "*" in self.ALLOWED_ORIGINS:
issues.append(
"ALLOWED_ORIGINS contains '*' (wildcard) — set specific origins"
@@ -219,6 +263,7 @@ def validate_production(self) -> None:
logger.error("To fix: set these as environment variables in docker-compose.yml.")
logger.error(" JWT_SECRET_KEY=$(python3 -c \"import secrets; print(secrets.token_hex(32))\")")
logger.error(" ENCRYPTION_KEY=$(python3 -c \"import secrets; print(secrets.token_hex(16))\")")
+ logger.error(" MCP_SERVICE_PASSWORD=")
logger.error("See: docs/DEPLOYMENT.md")
logger.error("=" * 60)
raise SystemExit(1)
diff --git a/backend/models/system.py b/backend/models/system.py
index 9ef1733c..c8e18578 100644
--- a/backend/models/system.py
+++ b/backend/models/system.py
@@ -1,6 +1,6 @@
"""System models: ApplicationSetting, SyncJob, User, AuditLog, Notification, HelmChart, CloudCredentialTemplate."""
-from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, String, Text
+from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, String, Text, false
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -152,6 +152,12 @@ class User(Base):
role = Column(String(50), nullable=False, default="operator")
is_active = Column(Boolean, default=True, nullable=False)
must_change_password = Column(Boolean, default=False, nullable=False)
+ # bonnyr-f5 #188: provenance for service accounts (mcp). ensure_service_user
+ # refuses to reconcile a row it did NOT create as a service account, so
+ # pointing MCP_SERVICE_USERNAME at a human row can't take it over.
+ # server_default mirrors migration v2_154 so fresh (create_all) and migrated
+ # installs agree on the DB-level default (bonnyr-f5 #188 nit).
+ is_service_account = Column(Boolean, default=False, server_default=false(), nullable=False)
last_login_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
diff --git a/backend/openapi.json b/backend/openapi.json
index 15b8a979..8ebd6722 100644
--- a/backend/openapi.json
+++ b/backend/openapi.json
@@ -61016,6 +61016,11 @@
"type": "boolean",
"title": "Is Active"
},
+ "is_service_account": {
+ "type": "boolean",
+ "title": "Is Service Account",
+ "default": false
+ },
"must_change_password": {
"type": "boolean",
"title": "Must Change Password"
@@ -61160,6 +61165,11 @@
"type": "boolean",
"title": "Is Active"
},
+ "is_service_account": {
+ "type": "boolean",
+ "title": "Is Service Account",
+ "default": false
+ },
"must_change_password": {
"type": "boolean",
"title": "Must Change Password"
@@ -61283,6 +61293,11 @@
"type": "boolean",
"title": "Is Active"
},
+ "is_service_account": {
+ "type": "boolean",
+ "title": "Is Service Account",
+ "default": false
+ },
"must_change_password": {
"type": "boolean",
"title": "Must Change Password"
diff --git a/backend/routes/auth.py b/backend/routes/auth.py
index b72722e1..ecf526d3 100644
--- a/backend/routes/auth.py
+++ b/backend/routes/auth.py
@@ -32,6 +32,7 @@
create_access_token,
create_user,
get_user_from_token,
+ holds_known_default_password,
)
logger = logging.getLogger(__name__)
@@ -106,9 +107,23 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
# Re-clamp to the owner's *current* role too, so demoting a user
# immediately narrows every token they already issued.
user.request_role = clamp_role(api_token.role, user.role)
+ _enforce_password_change(request, user)
return user
- return get_user_from_token(db, token)
+ user = get_user_from_token(db, token)
+ _enforce_password_change(request, user)
+ return user
+
+
+# The gate now lives in services.auth_service so AuthMiddleware and this
+# dependency enforce it from ONE place -- a dependency-less route was bypassing
+# the dependency-only version (see enforce_password_change).
+from services.auth_service import enforce_password_change # noqa: E402
+
+
+def _enforce_password_change(request: Request, user: User) -> None:
+ """#184: gate a must-change user at the get_current_user dependency."""
+ enforce_password_change(request.url.path, user)
def require_role(*allowed_roles: str):
@@ -229,6 +244,9 @@ def _user_to_dict(user: User) -> dict:
# carries a request_role, so admin user listings are unaffected.
"role": effective_role(user),
"is_active": user.is_active,
+ # bonnyr-f5 #188: surface provenance so the UI can distinguish a service
+ # account (whose re-enable is guarded) from a human account.
+ "is_service_account": bool(user.is_service_account),
"must_change_password": user.must_change_password,
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
"created_at": user.created_at.isoformat() if user.created_at else None,
@@ -355,6 +373,25 @@ def update_user(
target.email = request.email
if request.is_active is not None:
+ # bonnyr-f5 #188: re-enabling a service account that still holds a shipped
+ # default password would resurrect the published default credential (disable
+ # only flips is_active — the bcrypt("mcp-service-changeme") hash is left in
+ # place). Refuse the re-enable until the secret is rotated; the operator sets
+ # MCP_SERVICE_PASSWORD (startup re-seeds and re-activates the row with a real
+ # hash) or changes the password explicitly. Never restore a known default.
+ if (
+ request.is_active
+ and not target.is_active
+ and target.is_service_account
+ and holds_known_default_password(target)
+ ):
+ from core.errors import BadRequestError
+ raise BadRequestError(
+ f"Cannot re-enable service account '{target.username}': it still "
+ "holds a known default password. Rotate the credential first — set "
+ "MCP_SERVICE_PASSWORD to a strong secret and restart the backend "
+ "(it re-seeds and re-activates the account with a real hash)."
+ )
target.is_active = request.is_active
db.commit()
diff --git a/backend/routes/benchmarks.py b/backend/routes/benchmarks.py
index b47f64ce..11788c37 100644
--- a/backend/routes/benchmarks.py
+++ b/backend/routes/benchmarks.py
@@ -122,6 +122,29 @@ def _require_agent_bearer(request: Request) -> dict:
f"Token role '{role or 'none'}' may not write to agent endpoints",
code="AGENT_AUTH_FORBIDDEN",
)
+ # bonnyr-f5 #186 r2: a human (operator/admin) token owed a password change
+ # must not write here either -- this path skipped the gate entirely (a
+ # must-change admin could create an agent). Agent tokens carry no User row,
+ # and this endpoint is role-based by design, so only gate a token that
+ # resolves to a REAL user: if that user owes a password change, refuse.
+ if role != "agent":
+ from core.errors import ForbiddenError
+ from services.auth_service import enforce_password_change, token_user_state
+ agent_user = token_user_state(token)
+ # token_user_state's contract: the caller refuses on None -- fail CLOSED.
+ # A non-agent role that resolves to no live User (deleted/disabled row, or
+ # a signed token whose subject never existed) must be rejected here, not
+ # waved through. Without this else the gate is skipped for exactly that
+ # case (proven fail-open: a nonexistent user's admin JWT -> 201).
+ if agent_user is None:
+ raise BadRequestError(
+ "Token does not resolve to an active user",
+ code="AGENT_AUTH_INVALID",
+ )
+ try:
+ enforce_password_change(request.url.path, agent_user)
+ except ForbiddenError as exc:
+ raise BadRequestError(str(exc), code="AGENT_AUTH_PASSWORD_CHANGE_REQUIRED")
return payload
@@ -1483,10 +1506,27 @@ def _agent_ws_authorized(websocket: WebSocket, agent_id: int) -> int | None:
try:
from services.auth_service import decode_token
- decode_token(token)
- return None
+ payload = decode_token(token)
except Exception:
return 4001
+ # #186 (bonnyr-f5 r4, INV-10): decode_token validates the signature/expiry
+ # only, so this path waved a must-change human admin straight through — the
+ # one JWT-resolving entry point that skipped the gate the other five enforce.
+ # Agent tokens (role=agent) carry no User row and legitimately reach this
+ # branch when agent auth is off, so gate ONLY a token that resolves to a real
+ # user: refuse if that user owes a password change or no longer resolves
+ # (deleted/disabled). Mirrors the POST /api/benchmarks/agents gate above.
+ if payload.get("role") != "agent":
+ from services.auth_service import token_user_state
+
+ ws_user = token_user_state(token)
+ if ws_user is None or ws_user.must_change_password:
+ logger.warning(
+ "Agent %d WS rejected: token owes a password change or does not resolve to an active user",
+ agent_id,
+ )
+ return 4001
+ return None
@ws_router.websocket("/ws/benchmarks/agents/{agent_id}")
diff --git a/backend/routes/dpus_websocket.py b/backend/routes/dpus_websocket.py
index 32b0f27f..58b5bef7 100644
--- a/backend/routes/dpus_websocket.py
+++ b/backend/routes/dpus_websocket.py
@@ -58,6 +58,14 @@ async def _validate_ws_token(websocket: WebSocket, token: str | None) -> bool:
if role not in ("admin", "operator"):
await websocket.close(code=4401, reason="Unauthorized — operator role required")
return False
+ # #184: fail closed on the actual User row (see k8s_websocket) -- refuse
+ # if it can't be resolved or still owes a password change, so a
+ # seed-credential admin never reaches the DPU console / BMC SSH.
+ from services.auth_service import token_user_state
+ ws_user = token_user_state(token)
+ if ws_user is None or ws_user.must_change_password:
+ await websocket.close(code=4401, reason="Unauthorized")
+ return False
return True
except Exception:
await websocket.close(code=4401, reason="Unauthorized — invalid token")
diff --git a/backend/routes/k8s_websocket.py b/backend/routes/k8s_websocket.py
index e5b3e904..df692ee2 100644
--- a/backend/routes/k8s_websocket.py
+++ b/backend/routes/k8s_websocket.py
@@ -43,6 +43,14 @@ async def _validate_ws_token(websocket: WebSocket, token: str | None) -> bool:
if role not in ("admin", "operator", "viewer"):
await websocket.close(code=4401, reason="Unauthorized — insufficient role")
return False
+ # #184: fail closed on the actual User row -- refuse if it can't be
+ # resolved (deleted/disabled account, DB error) OR still owes a password
+ # change, so a seed-credential admin never reaches pod exec / DPU console.
+ from services.auth_service import token_user_state
+ ws_user = token_user_state(token)
+ if ws_user is None or ws_user.must_change_password:
+ await websocket.close(code=4401, reason="Unauthorized")
+ return False
return True
except Exception:
await websocket.close(code=4401, reason="Unauthorized — invalid or expired token")
diff --git a/backend/schemas/auth.py b/backend/schemas/auth.py
index 3cdab48b..e2d5f16b 100644
--- a/backend/schemas/auth.py
+++ b/backend/schemas/auth.py
@@ -22,6 +22,7 @@ class UserInfo(BaseModel):
email: str
role: str
is_active: bool
+ is_service_account: bool = False # bonnyr-f5 #188: service-account provenance
must_change_password: bool
last_login_at: str | None = None
created_at: str | None = None
@@ -59,6 +60,11 @@ class UserResponse(BaseModel):
email: str
role: str
is_active: bool
+ # bonnyr-f5 #188: expose provenance so the UI can tell a service account from a
+ # human one. Re-enabling a service account that still holds a shipped default is
+ # refused (PUT /api/auth/users/{id} -> 400), so the toggle must be able to
+ # render it disabled/annotated instead of 400ing blind.
+ is_service_account: bool = False
must_change_password: bool
last_login_at: str | None = None
created_at: str | None = None
@@ -100,6 +106,7 @@ class UserWithProjectCount(BaseModel):
email: str
role: str
is_active: bool
+ is_service_account: bool = False # bonnyr-f5 #188: provenance for the users listing (UI toggle guard)
must_change_password: bool
last_login_at: str | None = None
created_at: str | None = None
diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py
index e364ea04..155a17ba 100644
--- a/backend/services/auth_service.py
+++ b/backend/services/auth_service.py
@@ -3,6 +3,7 @@
Handles user management, password hashing, and JWT token generation.
"""
import logging
+import secrets
from datetime import UTC, datetime, timedelta
from typing import Any, cast
@@ -10,7 +11,7 @@
from passlib.context import CryptContext
from sqlalchemy.orm import Session
-from core.config import settings
+from core.config import MCP_KNOWN_DEFAULT_PASSWORDS, settings
from core.errors import BadRequestError, ConflictError, UnauthorizedError
from models import User
@@ -34,6 +35,20 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return cast(bool, pwd_context.verify(plain_password, hashed_password))
+def holds_known_default_password(user: User) -> bool:
+ """True if the user's stored hash still matches a shipped default password.
+
+ bonnyr-f5 #188 (round 4): disable_stale_service_user only flips is_active — it
+ never touches the hash. So a disabled service account still carries
+ bcrypt("mcp-service-changeme"), and simply re-activating the row (e.g. via
+ PUT /api/auth/users/{id}) would bring the published default credential back to
+ life. The re-enable path checks this so a known default can never be revived
+ without first rotating to a real secret.
+ """
+ stored = str(user.hashed_password)
+ return any(verify_password(candidate, stored) for candidate in MCP_KNOWN_DEFAULT_PASSWORDS)
+
+
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
"""Create a JWT access token."""
to_encode = data.copy()
@@ -74,6 +89,66 @@ def authenticate_user(db: Session, username: str, password: str) -> User:
return user
+def token_user_state(token: str) -> User | None:
+ """#184: resolve the JWT's user for the WebSocket auth gate, or None.
+
+ WebSocket validators (k8s/dpus) authenticate off JWT claims alone and never
+ load the User, so must_change_password -- and account existence/active state
+ -- are invisible there. This loads the row so the WS paths enforce the same
+ gate as get_current_user, from one place.
+
+ Returns the User on success, or None if it cannot be resolved for ANY reason
+ (invalid/expired token, deleted or disabled account, a transient DB error).
+ The caller refuses on None: fail CLOSED, so a resolution failure never
+ re-opens pod exec / BMC SSH the way returning "no change owed" would.
+
+ NOTE: the returned instance is read (``must_change_password``) by the caller
+ AFTER this session has closed. That is only safe because get_db_context()
+ closes WITHOUT committing, so the loaded column stays readable on the
+ detached instance. If get_db_context ever gains a db.commit(),
+ expire_on_commit=True would expire that attribute and every WebSocket would
+ then fail closed with no obvious cause -- read must_change_password here, or
+ disable expire_on_commit, if that changes.
+ """
+ from database import get_db_context
+ try:
+ with get_db_context() as db:
+ return get_user_from_token(db, token)
+ except Exception:
+ return None
+
+
+# The only endpoints a must-change user needs before rotating: submit the new
+# password, and read their own state so the UI can show the change screen.
+# Exact full paths, not suffixes: this is a security gate, so it must not accept
+# an unrelated route that merely ends in "/auth/me".
+PASSWORD_CHANGE_EXEMPT_PATHS = frozenset({
+ "/api/auth/change-password",
+ "/api/auth/me",
+})
+
+
+def enforce_password_change(path: str, user: User) -> None:
+ """#184/#186: refuse a must-change user everything but the exempt endpoints.
+
+ Enforced at BOTH auth-resolution points -- the get_current_user dependency
+ AND AuthMiddleware -- so a route that declares no dependency of its own (there
+ are ~32 such /api routes) still inherits the gate. Without the middleware half
+ the seed credential can skip the change-password screen and call those routes
+ directly (proven: DELETE /api/benchmarks/configs/{id} -> 204 with the seed
+ token). Raises ForbiddenError; callers translate it to 403.
+ """
+ if not getattr(user, "must_change_password", False):
+ return
+ if path.rstrip("/") in PASSWORD_CHANGE_EXEMPT_PATHS:
+ return
+ from core.errors import ForbiddenError
+ raise ForbiddenError(
+ "Password change required before using the API. "
+ "POST /api/auth/change-password with your current and new password."
+ )
+
+
def get_user_from_token(db: Session, token: str) -> User:
"""Get the user associated with a JWT token. Raises UnauthorizedError on failure."""
payload = decode_token(token)
@@ -134,52 +209,412 @@ def change_password(db: Session, user: User, current_password: str, new_password
logger.info(f"Password changed for user: {user.username}")
+# Passwords this project has shipped as an admin default at some point. An
+# existing account still authenticating with one of these is an upgrade left
+# holding a publicly-known credential.
+_KNOWN_DEFAULT_ADMIN_PASSWORDS = ("changeme",)
+
+# Passwords this project has shipped as a default for the mcp SERVICE account
+# (role=admin, must_change bypassed) at some point -- the exact same #184 hazard
+# class as the admin defaults above, just a second account. Published in
+# config.py, the compose files, and .env.example, so any account still
+# authenticating with one of these holds a publicly-known admin credential.
+# ``changeme`` is here too because the old shipped compose pointed the MCP client
+# at admin/changeme. Refused as a seed value and rotated out of any existing row.
+_KNOWN_DEFAULT_SERVICE_PASSWORDS = ("mcp-service-changeme", "changeme")
+
+# #186 BLOCKER 3 (bonnyr-f5 r5): usernames that belong to a HUMAN identity and
+# must never be resolved by ensure_service_user. That function locates its target
+# purely by ``User.username`` and then force-sets role=admin / is_active=True /
+# must_change_password=False. If MCP_SERVICE_USERNAME (or the Helm chart's
+# mcpUsername) is pointed at "admin", it would REWRITE the human admin row --
+# clearing the #184 must-change gate and handing the mcp secret full admin access
+# (probe: "mcp secret now authenticates as admin? YES role=admin must_change=False").
+# Refuse the co-option: a service account may not adopt a reserved human identity.
+# (Name kept identical to #188's guard so the two land cleanly on the integration
+# branch.)
+_RESERVED_HUMAN_USERNAMES = frozenset({"admin"})
+
+
+class GeneratedCredentialPersistError(RuntimeError):
+ """A generated credential could not be written to the keys dir.
+
+ #186 (bonnyr-f5): the docs promise "the plaintext is never logged". The old
+ code broke that promise — on an unwritable ``/app/keys`` it fell back to
+ logging the generated plaintext, leaking a live secret into the logs (a real
+ aggregation-exposure risk). We now fail closed instead: raise this
+ (WITHOUT the plaintext in the message) so startup refuses to proceed and the
+ operator remediates. Because the credential is persisted BEFORE the DB row is
+ created/rotated, a failure leaves nothing committed and the next boot retries
+ cleanly once the keys volume is writable (or an explicit password env var is
+ set, which skips generation entirely).
+ """
+
+
+def _persist_generated_password(password: str, filename: str = "initial_admin_password") -> str:
+ """Write a generated credential to a mode-0600 file in the keys dir.
+
+ Returns the path on success. Raises :class:`GeneratedCredentialPersistError`
+ if the keys dir is unwritable — the plaintext is NEVER logged or included in
+ the exception, so an unwritable ``/app/keys`` can never leak the secret. The
+ caller logs a POINTER to the returned path; there is deliberately no
+ "log the secret instead" fallback.
+
+ The file is created with 0o600 at open() time so the plaintext credential is
+ never momentarily group/world-readable (open()+chmod would create it 0644
+ under the usual umask, then narrow it). O_TRUNC handles a stale file from a
+ prior seed/rotation without failing.
+
+ Shared by the fresh-install admin seed (#184), the upgrade remediation
+ (#186), and the mcp service-account seed/rotation (#186 BLOCKER 1) so a
+ generated credential is surfaced identically on every path — ``filename``
+ keeps the admin and mcp secrets in distinct files so neither clobbers the
+ other.
+ """
+ import os
+ keys_dir = os.environ.get("KEYS_DIR", "/app/keys")
+ pw_path = os.path.join(keys_dir, filename)
+ try:
+ os.makedirs(keys_dir, exist_ok=True)
+ fd = os.open(pw_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ with os.fdopen(fd, "w") as fh:
+ fh.write(password + "\n")
+ except OSError as exc: # PermissionError/NotADirectoryError are OSError subclasses
+ # Fail closed. NEVER put `password` in this message: it propagates into
+ # logs, which is exactly the leak we are closing (#186).
+ raise GeneratedCredentialPersistError(
+ f"could not persist generated credential to {pw_path}: {exc}"
+ ) from exc
+ return pw_path
+
+
+def _rotate_known_default_admin(db: Session) -> None:
+ """#186 (bonnyr-f5): make a published default credential UNUSABLE on upgrade.
+
+ A deployment seeded before #184 holds admin/'changeme' with
+ must_change_password=False. The new seed logic never runs for it (users
+ already exist), so it keeps the published default.
+
+ Merely flagging must_change_password does NOT remove the capability:
+ /api/auth/change-password is exempt from the gate and verifies
+ current_password against the stored hash, so anyone holding the published
+ 'changeme' (it's in dist/README.md, user-pack/install-guide.html and
+ scripts/ibm_cloud_bnk_forge.sh) could rotate the password before the operator
+ does and take over the account. A mitigation must remove the capability, not
+ request its removal.
+
+ So we OVERWRITE the hash -- the published default stops working the moment
+ this runs -- and leave the account must_change_password so the replacement
+ only survives until first login.
+
+ Provenance (bonnyr-f5 r4): the replacement follows the SAME source-of-truth
+ rule as a fresh seed, so the documented retrieval instructions stay correct
+ on upgrade too:
+ * DEFAULT_ADMIN_PASSWORD set to a non-published value (Helm wires it from
+ the ``admin-password`` Secret) -> rotate TO that value, so the Secret /
+ env the docs tell operators to read is what now authenticates. No
+ keys-file is written (nothing was generated).
+ * otherwise -> generate a fresh random secret and surface it exactly like a
+ fresh install (mode-0600 keys-file, pointer logged once).
+ Rotating to a *published* default (e.g. DEFAULT_ADMIN_PASSWORD=changeme) is
+ refused -- that would just re-publish the hole -- so such a value falls
+ through to generation.
+ """
+ # #186 (bonnyr-f5 r4, INV-8): lock the row for the read-then-write. Two `api`
+ # replicas booting together would otherwise both read admin/'changeme', each
+ # generate a DIFFERENT secret, and interleave file-write vs DB-commit so the
+ # keys-file and the stored hash end up from different runs -> permanent admin
+ # lockout. FOR UPDATE serializes them: the loser blocks, then re-reads the
+ # already-rotated hash (no longer a known default) and no-ops. (Silently
+ # ignored on SQLite, which the tests use and which has no concurrent writers.)
+ admin = db.query(User).filter(User.username == "admin").with_for_update().first()
+ if admin is None:
+ return
+ if not any(verify_password(p, admin.hashed_password) for p in _KNOWN_DEFAULT_ADMIN_PASSWORDS):
+ return
+
+ configured = settings.DEFAULT_ADMIN_PASSWORD
+ if configured and configured not in _KNOWN_DEFAULT_ADMIN_PASSWORDS:
+ # Rotate to the operator/chart-supplied secret so the documented source
+ # (Helm admin-password Secret / DEFAULT_ADMIN_PASSWORD env) is authoritative.
+ admin.hashed_password = hash_password(configured) # type: ignore[assignment]
+ admin.must_change_password = True # type: ignore[assignment]
+ db.commit()
+ logger.warning(
+ "Existing 'admin' still held a known shipped default password; "
+ "OVERWROTE it with DEFAULT_ADMIN_PASSWORD (the published default no "
+ "longer works) -- retrieve it from the same source you configured "
+ "(Helm: the admin-password Secret) and change it on first login (#186).",
+ )
+ return
+
+ new_password = secrets.token_urlsafe(18)
+ # Persist the new secret BEFORE overwriting the hash: if the keys dir is
+ # unwritable this raises (fail closed, no plaintext logged) with the row's
+ # published-default hash untouched, so the next boot retries the whole
+ # remediation cleanly. Never fall back to logging the plaintext (#186).
+ pw_path = _persist_generated_password(new_password)
+ admin.hashed_password = hash_password(new_password) # type: ignore[assignment]
+ admin.must_change_password = True # type: ignore[assignment]
+ db.commit()
+ logger.warning(
+ "Existing 'admin' still held a known shipped default password; "
+ "OVERWROTE it with a generated secret (the published default no longer "
+ "works) and wrote the new one to %s -- retrieve it, then change it on "
+ "first login (#186).",
+ pw_path,
+ )
+
+
def seed_admin_user(db: Session) -> User | None:
"""Create default admin user if no users exist. Returns the user or None if already exists."""
existing_users = db.query(User).count()
if existing_users > 0:
+ _rotate_known_default_admin(db) # #186: upgrade safety for pre-#184 installs
return None
+ # #184: never seed a known/published default. If DEFAULT_ADMIN_PASSWORD is
+ # unset, generate a strong random one and persist it to the (mode-600,
+ # volume-backed) keys dir so the operator can retrieve it. The account is
+ # must_change_password, so it only survives until first login regardless.
+ seed_password = settings.DEFAULT_ADMIN_PASSWORD
+ generated = False
+ if not seed_password:
+ seed_password = secrets.token_urlsafe(18)
+ generated = True
+
+ pw_path = None
+ if generated:
+ # Persist the one-time password BEFORE creating the row: a single boot-log
+ # line is easy to miss (log rotation, JSON formatting) and logging the
+ # plaintext is a known aggregation-exposure risk, so we surface a POINTER,
+ # never the secret. Persisting first means an unwritable keys dir raises
+ # here (fail closed, no plaintext logged) with NO admin row committed, so
+ # the next boot retries the seed cleanly instead of stranding an admin
+ # account whose generated password nobody can read (#186).
+ pw_path = _persist_generated_password(seed_password)
+
admin = create_user(
db=db,
username="admin",
email="admin@bnk-forge.local",
- password=settings.DEFAULT_ADMIN_PASSWORD,
+ password=seed_password,
role="admin",
- must_change_password=True,
+ must_change_password=settings.DEFAULT_ADMIN_MUST_CHANGE,
)
# ENG-006: Startup seed manages its own transaction
db.commit()
- logger.info("Seeded default admin user — password change required on first login")
+ if generated:
+ logger.warning(
+ "Seeded admin user 'admin' with a GENERATED password, written to "
+ "%s (retrieve it, then delete it — you must change it on first login). "
+ "Set DEFAULT_ADMIN_PASSWORD to choose your own instead.",
+ pw_path,
+ )
+ else:
+ logger.info("Seeded admin user 'admin' from DEFAULT_ADMIN_PASSWORD — change required on first login")
return admin
-def ensure_service_user(db: Session, username: str, password: str, role: str = "admin") -> None:
+# NOTE: _RESERVED_HUMAN_USERNAMES is defined once, above (near the config
+# constants), and shared by ensure_service_user below — the #188 and #186 guards
+# use the identical frozenset, so the integration keeps a single definition.
+def _log_generated_service_password(username: str, pw_path: str, action: str) -> None:
+ """Log a POINTER to a persisted generated service-account secret (#186).
+
+ The plaintext is never logged — the caller persisted it via
+ :func:`_persist_generated_password` (which fails closed) and passes only the
+ resulting file path here.
+ """
+ logger.warning(
+ "%s service account '%s' with a GENERATED password, written to %s "
+ "(retrieve it and point the MCP client at it via MCP_SERVICE_PASSWORD). "
+ "Set MCP_SERVICE_PASSWORD to choose your own instead (#186).",
+ action, username, pw_path,
+ )
+
+
+def ensure_service_user(
+ db: Session, username: str, password: str | None, role: str = "admin"
+) -> None:
"""Idempotent create-or-reconcile a non-human service account.
- Called unconditionally on every startup so the stored password hash always
- matches the current MCP_SERVICE_PASSWORD env var — prevents auth drift when
- the env var is rotated without the DB being updated.
+ Called unconditionally on every startup. A genuine operator-supplied password
+ is reconciled onto the row so the stored hash always matches the current
+ MCP_SERVICE_PASSWORD env var — prevents auth drift when the env var is rotated
+ without the DB being updated.
+
+ Combined credential model (#186 + bonnyr-f5 #188):
+ * provenance (#188): a freshly-created row is flagged is_service_account so
+ disable_stale_service_user can find it and a later reconcile can prove it
+ is ours. A pre-existing row that is NOT a service account is refused —
+ UNLESS it still authenticates with a shipped published default, which is
+ by definition a stale service credential from a pre-provenance install
+ (the v2_155 backfill flags the known legacy 'mcp' row, but a row seeded
+ under another path may still lack the flag); neutralising it is exactly
+ the upgrade remediation, so we adopt and rotate/reconcile it.
+ * published-default handling (#186): the mcp service account is role=admin
+ and must_change_password=False, so it is EXEMPT from the #184 must-change
+ gate. Seeding/reconciling it to a shipped published default
+ (mcp-service-changeme / changeme) would republish a live, publicly-known
+ admin credential. So a published default (or an absent password) is
+ treated as "no usable secret":
+ - fresh row -> a strong random secret is generated and surfaced like
+ the admin seed (mode-0600 file + one-time pointer log).
+ - existing row still holding a known published default -> OVERWRITTEN
+ with a fresh random secret (upgrade remediation).
+ - existing row already holding a generated/operator secret -> left
+ intact, so the reconcile is idempotent and does not churn the secret.
+
+ #186 BLOCKER 1 (bonnyr-f5 r5): the backend now receives MCP_SERVICE_PASSWORD on
+ every deploy mode (compose backend-env anchors, the ibm installer, and the Helm
+ shared-env sourced from the release Secret's mcp-password key), so this
+ reconcile binds the mcp account to the SAME per-install secret the mcp client
+ uses. A reserved-name guard refuses to run against a human username such as
+ ``admin``.
"""
- user = db.query(User).filter(User.username == username).first()
+ # #186 BLOCKER 3 / #188 (bonnyr-f5): fail closed BEFORE any lookup if the
+ # caller points a service account at a reserved human username. Without this,
+ # a deployment that sets MCP_SERVICE_USERNAME=admin (or ships the chart's old
+ # mcpUsername: admin) silently rewrites the human admin row and grants the mcp
+ # secret admin access. A service account may never co-opt a human identity.
+ if username in _RESERVED_HUMAN_USERNAMES:
+ raise ValueError(
+ f"refusing to reconcile reserved human username '{username}' as a "
+ f"service account — set MCP_SERVICE_USERNAME to a dedicated name like "
+ f"'mcp' (a service account must not co-opt the human admin identity)"
+ )
+
+ published_default = bool(password) and password in _KNOWN_DEFAULT_SERVICE_PASSWORDS
+ # An operator secret we may actually store, or None if there is nothing usable.
+ usable_password = None if (not password or published_default) else password
+
+ # #186 (bonnyr-f5 r4/r5, INV-8): lock the row for the read-then-write on the
+ # RECONCILE/ROTATE (existing-row) paths, so two `api` replicas cannot desync
+ # the stored hash from the keys-file. No-op on SQLite (tests). with_for_update()
+ # cannot lock a not-yet-existing row, so the FIRST-CREATE path is serialised by
+ # the username UNIQUE constraint instead (a losing racer's INSERT raises and
+ # that boot's seed retries).
+ user = db.query(User).filter(User.username == username).with_for_update().first()
+
if user is None:
- create_user(
+ generated = usable_password is None
+ seed_password = usable_password if usable_password is not None else secrets.token_urlsafe(18)
+ # Persist the generated secret BEFORE creating the row (fail closed on an
+ # unwritable keys dir, never logging the plaintext) so a failure leaves
+ # nothing committed and the next boot retries cleanly (#186).
+ pw_path = (
+ _persist_generated_password(seed_password, filename=f"initial_{username}_password")
+ if generated
+ else None
+ )
+ svc = create_user(
db=db,
username=username,
email=f"{username}@bnk-forge.local",
- password=password,
+ password=seed_password,
role=role,
must_change_password=False,
)
+ svc.is_service_account = True # type: ignore[assignment] # provenance (#188)
# ENG-006: Startup seed manages its own transaction
db.commit()
- logger.info(f"Created service account: {username} (role={role})")
- else:
- # Reconcile: update hash to match current env var; never requires current password
- user.hashed_password = hash_password(password) # type: ignore[assignment]
+ if generated:
+ _log_generated_service_password(username, pw_path, "Seeded")
+ else:
+ logger.info(f"Created service account: {username} (role={role})")
+ return
+
+ # Existing row. bonnyr-f5 #188: refuse to reconcile a row this seeder did NOT
+ # provision as a service account — a name collision must never take over a
+ # human account. Gate on provenance, not the username. EXCEPTION (#186
+ # integration): a row still authenticating with a shipped published default is
+ # a stale service credential from a pre-provenance install; neutralising it is
+ # the upgrade remediation, so we adopt it (and re-flag it below) rather than
+ # leave the published default live.
+ if not user.is_service_account:
+ holds_published_default = any(
+ verify_password(p, str(user.hashed_password))
+ for p in _KNOWN_DEFAULT_SERVICE_PASSWORDS
+ )
+ if not holds_published_default:
+ raise ValueError(
+ f"refusing to reconcile '{username}': it is not a service account. "
+ f"Point MCP_SERVICE_USERNAME at a dedicated name that isn't an "
+ f"existing user."
+ )
+
+ if usable_password is not None:
+ # Operator supplied a genuine (non-default) password: reconcile to it.
+ # Re-activation is required and safe (disable_stale_service_user may have
+ # deactivated this row when no real password was configured; its docstring
+ # promises that configuring one "re-seeds and re-activates it"). Role is
+ # left untouched — we do NOT widen privilege on reconcile (bonnyr-f5 #188).
+ user.hashed_password = hash_password(usable_password) # type: ignore[assignment]
user.must_change_password = False # type: ignore[assignment]
- user.role = role # type: ignore[assignment]
- user.is_active = True # type: ignore[assignment]
- # ENG-006: Startup seed manages its own transaction
- db.commit()
+ user.is_active = True # type: ignore[assignment] # revive a disabled-stale row
+ user.is_service_account = True # type: ignore[assignment] # adopt a backfilled/legacy row
+ db.commit() # ENG-006: Startup seed manages its own transaction
logger.info(f"Reconciled service account: {username}")
+ return
+
+ # No usable secret configured. Only touch the row if it still holds a known
+ # published default (an upgrade left holding the shipped credential); rotate it
+ # to a fresh random secret so the published value stops working. A row that
+ # already holds a generated/operator secret is left untouched (idempotent).
+ if any(verify_password(p, str(user.hashed_password)) for p in _KNOWN_DEFAULT_SERVICE_PASSWORDS):
+ new_password = secrets.token_urlsafe(18)
+ # Persist BEFORE overwriting the hash: fail closed on an unwritable keys
+ # dir (no plaintext logged), leaving the published-default hash untouched
+ # so the next boot retries the rotation cleanly (#186).
+ pw_path = _persist_generated_password(new_password, filename=f"initial_{username}_password")
+ user.hashed_password = hash_password(new_password) # type: ignore[assignment]
+ user.must_change_password = False # type: ignore[assignment]
+ user.is_active = True # type: ignore[assignment]
+ user.is_service_account = True # type: ignore[assignment] # adopt the stale row
+ db.commit() # ENG-006: Startup seed manages its own transaction
+ _log_generated_service_password(username, pw_path, "Rotated")
+
+
+def disable_stale_service_user(db: Session) -> None:
+ """#188 (bonnyr-f5): a service account seeded by a prior release still holds
+ the shipped 'mcp-service-changeme' default and keeps authenticating on upgrade.
+ Deactivate EVERY active service-account row so no known default can be used
+ until the operator configures a real password (which re-seeds and re-activates
+ the account).
+
+ Round 5 (BLOCKER-1): seed_auth_step now calls this UNCONDITIONALLY, before the
+ reconcile — not only on the no-password path. The reconcile is name-keyed, so
+ on the diligent-operator path (strong MCP_SERVICE_PASSWORD but MCP_USERNAME
+ left at the legacy 'admin') it raises a reserved-name ValueError and never
+ reaches a disable; running this first is what closes that hole. When a usable
+ password IS set for a dedicated username, the reconcile re-activates that one
+ row immediately after, so the net effect is: exactly the configured service
+ account stays active, every stale default is revoked.
+
+ Keyed on provenance (is_service_account), NOT on the configured username
+ (bonnyr-f5 #188 round 4, INV-11): on the dist/IBM upgrade path
+ MCP_SERVICE_USERNAME resolves from a legacy .env to 'admin', so matching the
+ configured name would early-return and leave the stale 'mcp' row still
+ authenticating with the shipped default. The provenance flag is set only on
+ rows this seeder created, never on a human account, so disabling all service
+ accounts can never touch a human login — which is also why no reserved-username
+ guard is needed (or wanted: that guard is exactly what made this a no-op).
+ """
+ rows = db.query(User).filter(
+ User.is_active.is_(True),
+ User.is_service_account.is_(True), # bonnyr-f5 #188: never a human row
+ ).all()
+ if not rows:
+ return
+ for user in rows:
+ user.is_active = False # type: ignore[assignment]
+ db.commit()
+ for user in rows:
+ logger.warning(
+ "Disabled stale MCP service account '%s' — no usable "
+ "MCP_SERVICE_PASSWORD is set, so its pre-existing (possibly default) "
+ "credential must not keep authenticating. Set MCP_SERVICE_PASSWORD to "
+ "re-enable MCP.",
+ user.username,
+ )
diff --git a/backend/services/execution/container_runner.py b/backend/services/execution/container_runner.py
index 79c2f615..ef1434ed 100644
--- a/backend/services/execution/container_runner.py
+++ b/backend/services/execution/container_runner.py
@@ -550,7 +550,9 @@ def is_root_user(image_user: str | None) -> bool:
An image that never declares USER reports an empty string and runs as
root — that is the common case and must be caught.
- Closes the numeric bypass only — see the KNOWN GAP note in the body.
+ Fails closed on anything that is not a bare non-zero decimal uid,
+ which also subsumes the named-alias case (see the body) — there is no
+ remaining KNOWN GAP.
Only the uid half decides this. Docker's USER is ``[:]``,
so an image declaring ``USER 0:100`` or ``USER root:wheel`` runs as uid 0
@@ -647,7 +649,8 @@ def _fail(message: str, stdout: str = "") -> StepResult:
f"Artifact image {spec.image_digest} runs as root "
f"(USER={image_user or ''}). Refusing to start it: the workspace is "
f"mounted from the host, so a root container is a host-root write primitive. "
- f"Rebuild the image with a NUMERIC non-root USER (e.g. `USER 65532`). "
+ f"Rebuild the image with a NUMERIC non-root USER — `USER 1000` matches "
+ f"the workspace owner (chowned 1000:1000), so the step can write it. "
f"A named user is refused because it cannot be resolved to a uid "
f"without the image's own /etc/passwd — `USER toor` may well be uid 0. "
f"The Kubernetes substrate already enforces this: runAsNonRoot is "
diff --git a/backend/startup_steps.py b/backend/startup_steps.py
index 29afd405..0491a163 100644
--- a/backend/startup_steps.py
+++ b/backend/startup_steps.py
@@ -215,23 +215,82 @@ def seed_deployable_releases_step():
def seed_auth_step():
"""Seed default admin user if no users exist; always reconcile MCP service account."""
from database import get_db_context
- from services.auth_service import ensure_service_user, seed_admin_user
- with get_db_context() as db:
- admin = seed_admin_user(db)
- if admin:
- logger.info(" Created default admin user — change password on first login")
- logger.info(" See docs/INSTALLATION.md for first-login instructions")
- else:
- logger.info(" Users already exist")
-
- # Unconditional: ensure MCP service account exists and its password hash matches
- # current MCP_SERVICE_PASSWORD — prevents auth drift when the env var is rotated.
- with get_db_context() as db:
- ensure_service_user(
- db,
- username=settings.MCP_SERVICE_USERNAME,
- password=settings.MCP_SERVICE_PASSWORD,
- )
+ from services.auth_service import (
+ GeneratedCredentialPersistError,
+ ensure_service_user,
+ seed_admin_user,
+ )
+ try:
+ with get_db_context() as db:
+ admin = seed_admin_user(db)
+ if admin:
+ logger.info(" Created default admin user — change password on first login")
+ logger.info(" See docs/INSTALLATION.md for first-login instructions")
+ else:
+ logger.info(" Users already exist")
+
+
+ # bonnyr-f5 #188: treat a shipped known default (changeme) as "unset" so a
+ # dist/IBM upgrade doesn't re-seed the mcp account to a known password.
+ from core.config import MCP_KNOWN_DEFAULT_PASSWORDS
+ _mcp_pw_usable = bool(settings.MCP_SERVICE_PASSWORD) and settings.MCP_SERVICE_PASSWORD not in MCP_KNOWN_DEFAULT_PASSWORDS
+
+ # bonnyr-f5 #188 round 5 (BLOCKER-1): disable stale service accounts
+ # UNCONDITIONALLY, before any reconcile — never only on the no-password
+ # path. The reconcile below touches ONLY the row whose name matches
+ # MCP_SERVICE_USERNAME; on the diligent-operator upgrade path that name
+ # resolves from a legacy .env to 'admin', so ensure_service_user raises a
+ # reserved-name ValueError and returns WITHOUT disabling the legacy 'mcp'
+ # row — leaving it active with the shipped default even though the operator
+ # did the right thing. Running the provenance-keyed disable first (round 4,
+ # INV-11: keyed on is_service_account, not the configured username) neutralises
+ # every stale default; the reconcile then re-activates the one account whose
+ # credentials we actually manage.
+ from services.auth_service import disable_stale_service_user
+ with get_db_context() as db:
+ disable_stale_service_user(db)
+
+ # #187/#188: only reconcile when a real password is configured; never seed
+ # the account with a shipped default. When unset, MCP is simply unavailable
+ # (the stale default row was already disabled above) until an operator sets
+ # MCP_SERVICE_PASSWORD (and gives the MCP server the same value). When it IS
+ # set, ensure_service_user reconciles the stored hash to it — preventing auth
+ # drift when the env var is rotated. (#186's generate-a-secret fallback for
+ # the unset case is deliberately NOT taken here: #188's disable-stale is the
+ # chosen behaviour for an unset MCP password. See the integration notes.)
+ if _mcp_pw_usable:
+ try:
+ with get_db_context() as db:
+ ensure_service_user(
+ db,
+ username=settings.MCP_SERVICE_USERNAME,
+ password=settings.MCP_SERVICE_PASSWORD,
+ )
+ except ValueError as exc:
+ # Reserved-username refusal (e.g. MCP_USERNAME still 'admin'): loud,
+ # not fatal — MCP stays down but the human admin is not taken over,
+ # and the stale default row was already disabled above.
+ logger.error(" MCP service account NOT seeded: %s", exc)
+ else:
+ logger.warning(
+ " MCP_SERVICE_PASSWORD is not set — MCP service account not seeded; "
+ "the MCP server will be unable to authenticate until you set it"
+ )
+ except GeneratedCredentialPersistError as exc:
+ # #186 (bonnyr-f5): a generated admin/service credential could not be
+ # written to the keys dir. We refuse to fall back to LOGGING the plaintext
+ # (a real secret-into-logs leak). Fail closed instead: SystemExit escapes
+ # the best-effort step handler in main.py (which only catches Exception),
+ # so the process refuses to start rather than run with an unretrievable
+ # generated credential — no plaintext ever reaches the logs. The operator
+ # makes the keys volume (KEYS_DIR, default /app/keys) writable, or sets an
+ # explicit DEFAULT_ADMIN_PASSWORD / MCP_SERVICE_PASSWORD (which skips
+ # generation entirely), then restarts.
+ raise SystemExit(
+ f"Cannot start: {exc}. Refusing to log the generated plaintext secret. "
+ "Make the keys volume (KEYS_DIR, default /app/keys) writable, or set "
+ "DEFAULT_ADMIN_PASSWORD / MCP_SERVICE_PASSWORD, then restart."
+ ) from exc
if settings.REQUIRE_AUTH:
logger.info(" Authentication ENABLED (REQUIRE_AUTH=true)")
diff --git a/backend/tests/component/test_auth_service.py b/backend/tests/component/test_auth_service.py
index 33a194bf..431cc8be 100644
--- a/backend/tests/component/test_auth_service.py
+++ b/backend/tests/component/test_auth_service.py
@@ -179,6 +179,12 @@ def test_disabled_user_raises(self, db):
class TestSeedAdminUser:
+ @pytest.fixture(autouse=True)
+ def _isolate_keys_dir(self, monkeypatch, tmp_path):
+ # seed_admin_user may generate + persist a password to KEYS_DIR; keep it
+ # out of the working tree (default is /app/keys) for every test here.
+ monkeypatch.setenv("KEYS_DIR", str(tmp_path))
+
def test_seeds_when_no_users(self, db):
admin = seed_admin_user(db)
assert admin is not None
@@ -192,16 +198,139 @@ def test_returns_none_when_users_exist(self, db):
result = seed_admin_user(db)
assert result is None
- def test_seeded_admin_can_login(self, db):
+ def test_seeded_admin_can_login_with_explicit_password(self, db, monkeypatch):
+ # When DEFAULT_ADMIN_PASSWORD is set, the seed uses it.
+ monkeypatch.setattr(settings, "DEFAULT_ADMIN_PASSWORD", "explicit-admin-pw")
seed_admin_user(db)
- user = authenticate_user(db, "admin", settings.DEFAULT_ADMIN_PASSWORD)
+ user = authenticate_user(db, "admin", "explicit-admin-pw")
assert user.username == "admin"
+ def test_seeded_admin_generates_random_password_when_unset(self, db, monkeypatch, tmp_path):
+ # #184: with DEFAULT_ADMIN_PASSWORD unset, the seed must NOT use a known
+ # default -- it generates a random one, so the published "changeme"
+ # never authenticates. KEYS_DIR -> tmp so the generated-password file
+ # doesn't land in the working tree.
+ monkeypatch.setattr(settings, "DEFAULT_ADMIN_PASSWORD", None)
+ admin = seed_admin_user(db)
+ assert (tmp_path / "initial_admin_password").exists()
+ assert admin is not None
+ assert admin.must_change_password is True
+ with pytest.raises(UnauthorizedError):
+ authenticate_user(db, "admin", "changeme")
+
+ def test_generated_password_file_is_mode_0600(self, db, monkeypatch, tmp_path):
+ # #186 (bonnyr-f5): the commit is titled "harden the password-file mode"
+ # but only .exists() was asserted. The plaintext credential must be 0600,
+ # never group/world-readable.
+ import os
+ import stat
+ monkeypatch.setattr(settings, "DEFAULT_ADMIN_PASSWORD", None)
+ seed_admin_user(db)
+ pw = tmp_path / "initial_admin_password"
+ mode = stat.S_IMODE(os.stat(pw).st_mode)
+ assert mode == 0o600, f"expected 0o600, got {oct(mode)}"
+
+ def test_rotates_existing_admin_still_on_a_known_default(self, db, tmp_path):
+ # #186 (bonnyr-f5): an upgrade left admin/'changeme' with
+ # must_change_password=False -- the seed logic never re-runs for it. On
+ # boot, seed_admin_user (users exist -> None) must INVALIDATE the
+ # published default, not merely flag it: /api/auth/change-password is
+ # exempt from the gate and verifies against the stored hash, so a flag
+ # alone leaves 'changeme' usable to rotate the account. The hash must be
+ # overwritten and a fresh secret surfaced like a fresh install.
+ from models.system import User
+ create_user(db, "admin", "admin@bnk-forge.local", "changeme",
+ role="admin", must_change_password=False)
+ db.commit()
+ assert seed_admin_user(db) is None
+ admin = db.query(User).filter(User.username == "admin").first()
+ assert admin.must_change_password is True
+ # The published default no longer authenticates -- capability removed.
+ with pytest.raises(UnauthorizedError):
+ authenticate_user(db, "admin", "changeme")
+ # A fresh generated secret was surfaced exactly like a fresh install.
+ pw_file = tmp_path / "initial_admin_password"
+ assert pw_file.exists()
+ new_pw = pw_file.read_text().strip()
+ assert new_pw and new_pw != "changeme"
+ assert authenticate_user(db, "admin", new_pw).username == "admin"
+
+ def test_rotation_is_idempotent_across_boots(self, db, tmp_path):
+ # #186: after the one-time overwrite the stored password is the generated
+ # secret, so a second boot's verify("changeme", ...) is False and the
+ # account is left untouched (no re-rotation, no new file churn).
+ from models.system import User
+ create_user(db, "admin", "admin@bnk-forge.local", "changeme",
+ role="admin", must_change_password=False)
+ db.commit()
+ seed_admin_user(db)
+ first_pw = (tmp_path / "initial_admin_password").read_text().strip()
+ seed_admin_user(db) # second boot
+ admin = db.query(User).filter(User.username == "admin").first()
+ # Still the same generated secret from the first rotation.
+ assert authenticate_user(db, "admin", first_pw).username == "admin"
+ assert admin.must_change_password is True
+
+ def test_does_not_touch_an_admin_with_a_real_password(self, db):
+ from models.system import User
+ create_user(db, "admin", "admin@bnk-forge.local", "a-Strong-Real-Pw-1",
+ role="admin", must_change_password=False)
+ db.commit()
+ seed_admin_user(db)
+ admin = db.query(User).filter(User.username == "admin").first()
+ assert admin.must_change_password is False # not a known default → untouched
+
+ def test_rotation_honors_default_admin_password_when_set(self, db, monkeypatch, tmp_path):
+ # #186 (bonnyr-f5 r4) provenance: when DEFAULT_ADMIN_PASSWORD is set
+ # (Helm wires it from the admin-password Secret), the upgrade rotation
+ # must rotate TO that value so the documented source-of-truth (the
+ # Secret / env) authenticates -- and it must NOT write a keys-file
+ # (nothing was generated), so the docs' Helm "read the Secret"
+ # instruction stays correct on upgrade.
+ from models.system import User
+ monkeypatch.setattr(settings, "DEFAULT_ADMIN_PASSWORD", "chart-supplied-secret-x")
+ create_user(db, "admin", "admin@bnk-forge.local", "changeme",
+ role="admin", must_change_password=False)
+ db.commit()
+ assert seed_admin_user(db) is None
+ admin = db.query(User).filter(User.username == "admin").first()
+ assert admin.must_change_password is True
+ with pytest.raises(UnauthorizedError):
+ authenticate_user(db, "admin", "changeme") # published default gone
+ # The configured value now authenticates (Secret == source of truth).
+ assert authenticate_user(db, "admin", "chart-supplied-secret-x").username == "admin"
+ # No keys-file written: nothing was generated.
+ assert not (tmp_path / "initial_admin_password").exists()
+
+ def test_rotation_refuses_to_rotate_to_a_published_default(self, db, monkeypatch, tmp_path):
+ # #186: DEFAULT_ADMIN_PASSWORD=changeme must NOT be used as the rotation
+ # target (that would re-publish the hole) -- fall through to a generated
+ # keys-file secret instead.
+ from models.system import User
+ monkeypatch.setattr(settings, "DEFAULT_ADMIN_PASSWORD", "changeme")
+ create_user(db, "admin", "admin@bnk-forge.local", "changeme",
+ role="admin", must_change_password=False)
+ db.commit()
+ seed_admin_user(db)
+ admin = db.query(User).filter(User.username == "admin").first()
+ with pytest.raises(UnauthorizedError):
+ authenticate_user(db, "admin", "changeme")
+ pw_file = tmp_path / "initial_admin_password"
+ assert pw_file.exists()
+ assert authenticate_user(db, "admin", pw_file.read_text().strip()).username == "admin"
+
# ── ensure_service_user ──────────────────────────────────────────────
class TestEnsureServiceUser:
+ @pytest.fixture(autouse=True)
+ def _isolate_keys_dir(self, monkeypatch, tmp_path):
+ # ensure_service_user may generate + persist a secret to KEYS_DIR; keep it
+ # out of the working tree (default /app/keys). Persist now fails closed on
+ # an unwritable dir, so a writable KEYS_DIR is required for these tests.
+ monkeypatch.setenv("KEYS_DIR", str(tmp_path))
+
def test_creates_service_user_when_absent(self, db):
ensure_service_user(db, username="mcp", password="secret")
user = authenticate_user(db, "mcp", "secret")
@@ -227,3 +356,333 @@ def test_idempotent_create(self, db):
from models import User
count = db.query(User).filter(User.username == "mcp").count()
assert count == 1
+
+ def test_refuses_to_reconcile_the_human_admin(self, db):
+ # #188 (bonnyr-f5): MCP_USERNAME still 'admin' on an old .env would point
+ # ensure_service_user at the human admin row and take it over (rewrite
+ # hash, clear must_change). Refuse, and leave the admin row untouched.
+ from services.auth_service import create_user, verify_password
+ create_user(db, "admin", "admin@bnk-forge.local", "human-admin-pw",
+ role="admin", must_change_password=True)
+ db.commit()
+ with pytest.raises(ValueError, match="reserved human username"):
+ ensure_service_user(db, username="admin", password="mcp-secret")
+ from models import User
+ admin = db.query(User).filter(User.username == "admin").first()
+ assert admin.must_change_password is True # gate not cleared
+ assert verify_password("human-admin-pw", admin.hashed_password) # hash intact
+
+ def test_disable_stale_service_user_deactivates_mcp(self, db):
+ # #188: upgrade with MCP_SERVICE_PASSWORD unset must not leave the old
+ # mcp/'mcp-service-changeme' account authenticating.
+ from services.auth_service import disable_stale_service_user, ensure_service_user
+ ensure_service_user(db, username="mcp", password="mcp-service-changeme") # sets is_service_account
+ disable_stale_service_user(db)
+ from models import User
+ assert db.query(User).filter(User.username == "mcp").first().is_active is False
+
+ def test_disable_stale_is_keyed_on_provenance_not_configured_username(self, db):
+ # bonnyr-f5 #188 round 4 (INV-11): on the dist/IBM upgrade path
+ # MCP_SERVICE_USERNAME resolves from a legacy .env to 'admin', so a
+ # name-keyed disable early-returned and left the legacy 'mcp' service row
+ # (mcp-service-changeme, role=admin) still authenticating. The disable must
+ # deactivate the service account by provenance regardless of the configured
+ # name, while never touching the human admin.
+ from models import User
+ from services.auth_service import (
+ authenticate_user,
+ create_user,
+ disable_stale_service_user,
+ ensure_service_user,
+ )
+ ensure_service_user(db, username="mcp", password="mcp-service-changeme") # legacy service row
+ create_user(db, "admin", "admin@bnk-forge.local", "human-admin-pw", role="admin")
+ db.commit()
+ disable_stale_service_user(db) # startup no longer passes a username at all
+ assert db.query(User).filter(User.username == "mcp").first().is_active is False
+ assert db.query(User).filter(User.username == "admin").first().is_active is True
+ with pytest.raises(UnauthorizedError):
+ authenticate_user(db, "mcp", "mcp-service-changeme") # default no longer works
+ # Human admin login is untouched.
+ assert authenticate_user(db, "admin", "human-admin-pw").username == "admin"
+
+ def test_refuses_to_reconcile_a_non_reserved_human(self, db):
+ # bonnyr-f5 #188 r2: the guard was a one-name denylist. Point the service
+ # username at ANY existing human row (here 'operator') and the reconcile
+ # would take it over. Provenance (is_service_account) refuses it.
+ from services.auth_service import create_user, verify_password
+ create_user(db, "operator", "operator@bnk-forge.local", "human-op-pw",
+ role="operator", must_change_password=False)
+ db.commit()
+ with pytest.raises(ValueError, match="not a service account"):
+ ensure_service_user(db, username="operator", password="mcp-secret")
+ from models import User
+ op = db.query(User).filter(User.username == "operator").first()
+ assert op.role == "operator" # NOT promoted to admin
+ assert verify_password("human-op-pw", op.hashed_password) # password intact
+
+ def test_disable_stale_never_touches_admin(self, db):
+ # A human admin carries is_service_account=False, so provenance-keyed
+ # disable leaves it active even though its name is 'admin'.
+ from services.auth_service import create_user, disable_stale_service_user
+ create_user(db, "admin", "admin@bnk-forge.local", "pw", role="admin")
+ db.commit()
+ disable_stale_service_user(db) # provenance-keyed -> human admin untouched
+ from models import User
+ assert db.query(User).filter(User.username == "admin").first().is_active is True
+
+ def test_reconcile_reactivates_a_disabled_stale_service_account(self, db):
+ # bonnyr-f5 #188 BLOCKER 3: disable_stale_service_user deactivates the mcp
+ # row when no real password is set; setting a real MCP_SERVICE_PASSWORD must
+ # then re-seed AND re-activate it (as its docstring promises). Without the
+ # reactivation the account stays is_active=False and every MCP login fails
+ # with "Account is disabled" despite a correct password.
+ from models import User
+ from services.auth_service import disable_stale_service_user, ensure_service_user
+ ensure_service_user(db, username="mcp", password="mcp-service-changeme")
+ disable_stale_service_user(db)
+ assert db.query(User).filter(User.username == "mcp").first().is_active is False
+ # Operator now configures a real secret -> reconcile must revive the account.
+ ensure_service_user(db, username="mcp", password="a-real-strong-secret")
+ mcp = db.query(User).filter(User.username == "mcp").first()
+ assert mcp.is_active is True # re-activated
+ assert mcp.is_service_account is True # provenance preserved
+ # And the new secret authenticates while the old default does not.
+ assert authenticate_user(db, "mcp", "a-real-strong-secret").username == "mcp"
+ with pytest.raises(UnauthorizedError):
+ authenticate_user(db, "mcp", "mcp-service-changeme")
+ # ── #186 BLOCKER 1: the published mcp default must never authenticate ──
+
+ @pytest.mark.parametrize("published_default", ["mcp-service-changeme", "changeme"])
+ def test_published_default_seed_cannot_authenticate(self, db, published_default):
+ """Seeding the mcp account with a shipped default must NOT store that
+ value — the published credential can never authenticate."""
+ from core.errors import UnauthorizedError as UnauthError
+ ensure_service_user(db, username="mcp", password=published_default)
+ from models import User
+ assert db.query(User).filter(User.username == "mcp").count() == 1
+ with pytest.raises(UnauthError):
+ authenticate_user(db, "mcp", published_default)
+
+ def test_none_password_seeds_generated_secret(self, db):
+ """MCP_SERVICE_PASSWORD unset (None) still creates the row, but with a
+ generated secret — neither None nor the published default authenticates."""
+ from core.errors import UnauthorizedError as UnauthError
+ ensure_service_user(db, username="mcp", password=None)
+ from models import User
+ assert db.query(User).filter(User.username == "mcp").count() == 1
+ with pytest.raises(UnauthError):
+ authenticate_user(db, "mcp", "mcp-service-changeme")
+
+ def test_upgrade_rotates_existing_published_default(self, db):
+ """An account carried over from a pre-fix install still holding the
+ published default is OVERWRITTEN with a random secret on next boot."""
+ from core.errors import UnauthorizedError as UnauthError
+ from services.auth_service import create_user, hash_password
+ # Simulate the pre-fix seeded row: hash of the published default.
+ user = create_user(db, username="mcp", email="mcp@bnk-forge.local",
+ password="mcp-service-changeme", role="admin")
+ db.commit()
+ assert authenticate_user(db, "mcp", "mcp-service-changeme") # live before fix
+ ensure_service_user(db, username="mcp", password=None) # unset env on upgrade boot
+ with pytest.raises(UnauthError):
+ authenticate_user(db, "mcp", "mcp-service-changeme") # dead after fix
+
+ def test_generated_secret_not_churned_on_reboot(self, db):
+ """With no operator password, a row already holding a generated (non-
+ default) secret is left untouched — reboots don't rotate it, so the MCP
+ client's retrieved secret keeps working."""
+ from models import User
+ ensure_service_user(db, username="mcp", password=None)
+ first_hash = db.query(User).filter(User.username == "mcp").first().hashed_password
+ ensure_service_user(db, username="mcp", password=None)
+ second_hash = db.query(User).filter(User.username == "mcp").first().hashed_password
+ assert first_hash == second_hash
+
+ def test_operator_password_still_reconciles(self, db):
+ """A genuine operator-set password is honored (MCP stays usable when the
+ operator configures MCP_SERVICE_PASSWORD)."""
+ ensure_service_user(db, username="mcp", password="a-real-operator-secret")
+ user = authenticate_user(db, "mcp", "a-real-operator-secret")
+ assert user.username == "mcp"
+ assert user.must_change_password is False
+
+ def test_reserved_human_username_is_refused(self, db):
+ """#186 BLOCKER 3 (bonnyr-f5): a service account may not adopt a reserved
+ human identity such as `admin`. The call raises and never touches the row."""
+ import pytest
+ with pytest.raises(ValueError, match="reserved human username"):
+ ensure_service_user(db, username="admin", password="mcp-service-secret")
+
+ def test_reserved_username_does_not_rewrite_human_admin(self, db):
+ """The attack bonnyr reproduced: pointing the mcp reconcile at `admin`
+ would clear must_change and grant the mcp secret admin access. The guard
+ must leave the real admin row (its hash + must_change gate) intact."""
+ import pytest
+
+ from models import User
+ create_user(db, "admin", "admin@test.com", "human-admin-pw",
+ role="admin", must_change_password=True)
+ db.commit()
+ with pytest.raises(ValueError):
+ ensure_service_user(db, username="admin", password="mcp-secret")
+ db.rollback()
+ admin = db.query(User).filter(User.username == "admin").first()
+ # Human admin credential + gate survive; the mcp secret never authenticates as admin.
+ assert admin.must_change_password is True
+ authenticate_user(db, "admin", "human-admin-pw") # still the human's password
+ with pytest.raises(UnauthorizedError):
+ authenticate_user(db, "admin", "mcp-secret")
+
+
+class TestTokenUserState:
+ """#184: the WS gate helper -- resolve the User row and fail CLOSED.
+
+ Returns the User on success, None on any resolution failure; the WS
+ validators refuse on None OR must_change_password.
+ """
+
+ def test_resolves_must_change_user(self, db):
+ from services.auth_service import token_user_state
+ create_user(db, "wsmust", "wsmust@test.com", "pw", role="admin", must_change_password=True)
+ db.commit()
+ token = create_access_token(data={"sub": "wsmust", "role": "admin"})
+ user = token_user_state(token)
+ assert user is not None and user.must_change_password is True
+
+ def test_resolves_normal_user(self, db):
+ from services.auth_service import token_user_state
+ create_user(db, "wsok", "wsok@test.com", "pw", role="admin", must_change_password=False)
+ db.commit()
+ token = create_access_token(data={"sub": "wsok", "role": "admin"})
+ user = token_user_state(token)
+ assert user is not None and user.must_change_password is False
+
+ def test_none_on_garbage_token(self):
+ from services.auth_service import token_user_state
+ assert token_user_state("not-a-token") is None
+
+ def test_none_for_deactivated_account(self, db):
+ # #184 review: a disabled account must fail closed on WS, matching
+ # get_current_user. get_user_from_token raises "Account is disabled",
+ # which token_user_state turns into None (-> WS refuses).
+ u = create_user(db, "wsdisabled", "wsdisabled@test.com", "pw", role="admin")
+ u.is_active = False
+ db.commit()
+ from services.auth_service import token_user_state
+ token = create_access_token(data={"sub": "wsdisabled", "role": "admin"})
+ assert token_user_state(token) is None
+
+ def test_none_for_deleted_account(self, db):
+ from services.auth_service import token_user_state
+ token = create_access_token(data={"sub": "ghost", "role": "admin"})
+ assert token_user_state(token) is None
+
+
+class TestUnwritableKeysDirNeverLeaksPlaintext:
+ """#186 (bonnyr-f5): the docs promise "the plaintext is never logged".
+
+ On an UNWRITABLE /app/keys the old code fell back to LOGGING the generated
+ plaintext (a real secret-into-logs leak). Every generated-credential path
+ must now fail closed (raise GeneratedCredentialPersistError) WITHOUT the
+ plaintext ever reaching a log record or the exception message.
+
+ Each test patches secrets.token_urlsafe to a sentinel so the assertion is
+ exact: the sentinel must appear in NO log message and NOT in str(exc).
+ """
+
+ SENTINEL = "SENTINEL-do-not-log-this-secret-42"
+
+ @pytest.fixture(autouse=True)
+ def _sentinel_secret(self, monkeypatch):
+ # Make every generated secret a known sentinel we can search for.
+ monkeypatch.setattr(
+ "services.auth_service.secrets.token_urlsafe", lambda *_a, **_k: self.SENTINEL
+ )
+
+ def _unwritable_keys(self, monkeypatch, tmp_path):
+ # A FILE used as a directory -> os.makedirs raises NotADirectoryError
+ # (an OSError), simulating an unwritable /app/keys mount.
+ blocker = tmp_path / "blocker"
+ blocker.write_text("x")
+ monkeypatch.setenv("KEYS_DIR", str(blocker / "keys"))
+
+ def _assert_no_leak(self, caplog):
+ for rec in caplog.records:
+ assert self.SENTINEL not in rec.getMessage(), (
+ f"plaintext leaked into logs: {rec.getMessage()!r}"
+ )
+
+ def test_seed_generated_fails_closed_no_leak(self, db, monkeypatch, tmp_path, caplog):
+ import logging
+
+ from services.auth_service import GeneratedCredentialPersistError
+ monkeypatch.setattr(settings, "DEFAULT_ADMIN_PASSWORD", None)
+ self._unwritable_keys(monkeypatch, tmp_path)
+ caplog.set_level(logging.DEBUG)
+ with pytest.raises(GeneratedCredentialPersistError) as ei:
+ seed_admin_user(db)
+ assert self.SENTINEL not in str(ei.value)
+ self._assert_no_leak(caplog)
+ # Fail closed: no admin row was committed, so the next boot retries.
+ from models.system import User
+ assert db.query(User).filter(User.username == "admin").first() is None
+
+ def test_rotate_admin_fails_closed_no_leak(self, db, monkeypatch, tmp_path, caplog):
+ import logging
+
+ from models.system import User
+ from services.auth_service import GeneratedCredentialPersistError
+ create_user(db, "admin", "admin@bnk-forge.local", "changeme",
+ role="admin", must_change_password=False)
+ db.commit()
+ self._unwritable_keys(monkeypatch, tmp_path)
+ caplog.set_level(logging.DEBUG)
+ with pytest.raises(GeneratedCredentialPersistError) as ei:
+ seed_admin_user(db)
+ assert self.SENTINEL not in str(ei.value)
+ self._assert_no_leak(caplog)
+ # Fail closed: the published-default hash is left untouched (still
+ # 'changeme') so the next boot retries the rotation cleanly rather than
+ # stranding an admin whose generated password nobody can read. (The
+ # rotate path raises BEFORE mutating the row, so nothing to roll back.)
+ admin = db.query(User).filter(User.username == "admin").first()
+ assert verify_password("changeme", str(admin.hashed_password))
+
+ def test_service_seed_fails_closed_no_leak(self, db, monkeypatch, tmp_path, caplog):
+ import logging
+
+ from models import User
+ from services.auth_service import GeneratedCredentialPersistError
+ self._unwritable_keys(monkeypatch, tmp_path)
+ caplog.set_level(logging.DEBUG)
+ with pytest.raises(GeneratedCredentialPersistError) as ei:
+ ensure_service_user(db, username="mcp", password=None)
+ assert self.SENTINEL not in str(ei.value)
+ self._assert_no_leak(caplog)
+ assert db.query(User).filter(User.username == "mcp").first() is None
+
+ def test_service_rotate_fails_closed_no_leak(self, db, monkeypatch, tmp_path, caplog):
+ import logging
+
+ from models import User
+ from services.auth_service import (
+ GeneratedCredentialPersistError,
+ hash_password,
+ )
+ from services.auth_service import (
+ create_user as _cu,
+ )
+ u = _cu(db, "mcp", "mcp@bnk-forge.local", "mcp-service-changeme",
+ role="admin", must_change_password=False)
+ u.hashed_password = hash_password("mcp-service-changeme")
+ db.commit()
+ self._unwritable_keys(monkeypatch, tmp_path)
+ caplog.set_level(logging.DEBUG)
+ with pytest.raises(GeneratedCredentialPersistError) as ei:
+ ensure_service_user(db, username="mcp", password=None)
+ assert self.SENTINEL not in str(ei.value)
+ self._assert_no_leak(caplog)
+ # Published default hash untouched -> next boot retries the rotation.
+ mcp = db.query(User).filter(User.username == "mcp").first()
+ assert verify_password("mcp-service-changeme", str(mcp.hashed_password))
diff --git a/backend/tests/component/test_k8s_websocket.py b/backend/tests/component/test_k8s_websocket.py
index fc8c9a04..0662877b 100644
--- a/backend/tests/component/test_k8s_websocket.py
+++ b/backend/tests/component/test_k8s_websocket.py
@@ -57,11 +57,12 @@ async def test_empty_string_token_closes_4401(self):
ws.close.assert_awaited_once()
@pytest.mark.asyncio
- async def test_valid_admin_token_returns_true(self):
+ async def test_valid_admin_token_returns_true(self, db):
"""Should return True for a valid admin JWT token."""
from routes.k8s_websocket import _validate_ws_token
- from services.auth_service import create_access_token
-
+ from services.auth_service import create_access_token, create_user
+ create_user(db, "testadmin", "testadmin@t.com", "pw", role="admin", must_change_password=False)
+ db.commit()
token = create_access_token(data={"sub": "testadmin", "role": "admin"})
ws = AsyncMock()
result = await _validate_ws_token(ws, token)
@@ -69,27 +70,55 @@ async def test_valid_admin_token_returns_true(self):
ws.close.assert_not_awaited()
@pytest.mark.asyncio
- async def test_valid_operator_token_returns_true(self):
+ async def test_valid_operator_token_returns_true(self, db):
"""Should return True for a valid operator JWT token."""
from routes.k8s_websocket import _validate_ws_token
- from services.auth_service import create_access_token
-
+ from services.auth_service import create_access_token, create_user
+ create_user(db, "testop", "testop@t.com", "pw", role="operator", must_change_password=False)
+ db.commit()
token = create_access_token(data={"sub": "testop", "role": "operator"})
ws = AsyncMock()
result = await _validate_ws_token(ws, token)
assert result is True
@pytest.mark.asyncio
- async def test_valid_viewer_token_returns_true(self):
+ async def test_valid_viewer_token_returns_true(self, db):
"""Should return True for a valid viewer JWT token."""
from routes.k8s_websocket import _validate_ws_token
- from services.auth_service import create_access_token
-
+ from services.auth_service import create_access_token, create_user
+ create_user(db, "testviewer", "testviewer@t.com", "pw", role="viewer", must_change_password=False)
+ db.commit()
token = create_access_token(data={"sub": "testviewer", "role": "viewer"})
ws = AsyncMock()
result = await _validate_ws_token(ws, token)
assert result is True
+ @pytest.mark.asyncio
+ async def test_must_change_user_refused(self, db):
+ """#184: a valid token whose user still owes a password change must be
+ refused at the WS boundary -- otherwise a seed-credential admin gets a
+ pod shell while REST refuses /api/auth/users."""
+ from routes.k8s_websocket import _validate_ws_token
+ from services.auth_service import create_access_token, create_user
+ create_user(db, "wsmustchange", "wsmc@t.com", "pw", role="admin", must_change_password=True)
+ db.commit()
+ token = create_access_token(data={"sub": "wsmustchange", "role": "admin"})
+ ws = AsyncMock()
+ result = await _validate_ws_token(ws, token)
+ assert result is False
+ ws.close.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_token_for_missing_user_refused(self, db):
+ """#184 fail-closed: a token whose user doesn't exist is refused, not
+ allowed through on a resolution failure."""
+ from routes.k8s_websocket import _validate_ws_token
+ from services.auth_service import create_access_token
+ token = create_access_token(data={"sub": "nobody", "role": "admin"})
+ ws = AsyncMock()
+ result = await _validate_ws_token(ws, token)
+ assert result is False
+
@pytest.mark.asyncio
async def test_invalid_role_closes_4401(self):
"""Should close WebSocket for token with unrecognized role."""
diff --git a/backend/tests/component/test_startup_steps.py b/backend/tests/component/test_startup_steps.py
index 7493a2c3..5a346842 100644
--- a/backend/tests/component/test_startup_steps.py
+++ b/backend/tests/component/test_startup_steps.py
@@ -2,7 +2,42 @@
from unittest.mock import MagicMock, call, patch
-from startup_steps import seed_defaults_step, sync_module_catalog_step
+import pytest
+
+from startup_steps import seed_auth_step, seed_defaults_step, sync_module_catalog_step
+
+
+@patch("database.get_db_context")
+@patch("services.auth_service.seed_admin_user")
+def test_seed_auth_step_fails_closed_on_unpersistable_credential(
+ mock_seed_admin, mock_get_db_context
+):
+ """#186 (bonnyr-f5 r4): when a generated credential can't be written to the
+ keys dir, seed_auth_step must REFUSE TO START (SystemExit) rather than fall
+ back to logging the plaintext. SystemExit (BaseException) escapes main.py's
+ best-effort `except Exception`, so the process actually halts — and the
+ error message must NOT contain the secret.
+ """
+ from services.auth_service import GeneratedCredentialPersistError
+
+ db = MagicMock()
+ ctx = MagicMock()
+ ctx.__enter__.return_value = db
+ ctx.__exit__.return_value = False
+ mock_get_db_context.return_value = ctx
+ # The seed path raised because /app/keys was unwritable. The exception
+ # carries NO plaintext (that is the whole point of the fail-closed design).
+ mock_seed_admin.side_effect = GeneratedCredentialPersistError(
+ "could not persist generated credential to /app/keys/initial_admin_password: "
+ "[Errno 13] Permission denied"
+ )
+
+ with pytest.raises(SystemExit) as ei:
+ seed_auth_step()
+
+ # SystemExit, not swallowed; and the message never carries a secret.
+ assert "Refusing to log" in str(ei.value)
+ assert "Permission denied" in str(ei.value)
@patch("database.get_db_context")
diff --git a/backend/tests/integration/test_routes_auth.py b/backend/tests/integration/test_routes_auth.py
index 21e88f29..2ece452d 100644
--- a/backend/tests/integration/test_routes_auth.py
+++ b/backend/tests/integration/test_routes_auth.py
@@ -209,6 +209,21 @@ def test_list_users_admin(self, client, admin_headers, all_test_users, db):
usernames = [u["username"] for u in users]
assert "testadmin" in usernames
+ def test_list_users_exposes_service_account_flag(self, client, admin_headers, all_test_users, db):
+ """bonnyr-f5 #188: the user listing surfaces is_service_account so the UI can
+ tell a service account (whose re-enable is guarded) from a human account
+ instead of blindly 400ing on the toggle."""
+ from services.auth_service import ensure_service_user
+ ensure_service_user(db, username="mcp", password="a-strong-real-secret")
+ db.commit()
+
+ response = client.get("/api/auth/users", headers=admin_headers)
+ assert response.status_code == 200
+ by_name = {u["username"]: u for u in response.json()["users"]}
+ assert "is_service_account" in by_name["testadmin"]
+ assert by_name["testadmin"]["is_service_account"] is False
+ assert by_name["mcp"]["is_service_account"] is True
+
def test_list_users_viewer_denied(self, client, viewer_headers, all_test_users):
"""Viewer cannot list users — returns 403."""
response = client.get("/api/auth/users", headers=viewer_headers)
@@ -240,3 +255,172 @@ def test_delete_nonexistent_user(self, client, admin_headers, sample_user):
"""Deleting nonexistent user returns 404."""
response = client.delete("/api/auth/users/99999", headers=admin_headers)
assert response.status_code == 404
+
+
+class TestServiceAccountReEnableGuard:
+ """bonnyr-f5 #188 (round 4): re-enabling a disabled service account via
+ PUT /api/auth/users/{id} must not resurrect a shipped default credential.
+ disable_stale_service_user only flips is_active; the bcrypt hash of
+ 'mcp-service-changeme' stays, so a naive re-enable brought the default back.
+ """
+
+ def _seed_disabled_default_mcp(self, db):
+ # Simulate a pre-#186 upgrade row that GENUINELY holds bcrypt("mcp-service
+ # -changeme"). The merged ensure_service_user (integration: #186 + #188)
+ # refuses to STORE a published default — it generates a random secret
+ # instead — so build the legacy row directly, exactly as an already-deployed
+ # DB carries it: the v2_155 migration flags it is_service_account=True, and
+ # disable_stale_service_user then deactivates it. This is precisely the state
+ # the PUT-route guard defends against (re-enabling would resurrect the
+ # publicly-known default).
+ from services.auth_service import create_user, disable_stale_service_user
+ mcp = create_user(
+ db,
+ username="mcp",
+ email="mcp@bnk-forge.local",
+ password="mcp-service-changeme",
+ role="admin",
+ must_change_password=False,
+ )
+ mcp.is_service_account = True # v2_155 backfill marks the legacy mcp row
+ db.commit()
+ disable_stale_service_user(db)
+ mcp = db.query(User).filter(User.username == "mcp").first()
+ assert mcp.is_active is False
+ assert mcp.is_service_account is True
+ return mcp
+
+ def test_reenable_refused_while_default_hash_present(
+ self, client, admin_headers, sample_user, db
+ ):
+ from core.errors import UnauthorizedError
+ from services.auth_service import authenticate_user
+
+ mcp = self._seed_disabled_default_mcp(db)
+ resp = client.put(
+ f"/api/auth/users/{mcp.id}",
+ json={"is_active": True},
+ headers=admin_headers,
+ )
+ assert resp.status_code == 400
+ assert "known default password" in resp.json()["error"]["message"]
+
+ db.refresh(mcp)
+ assert mcp.is_active is False # re-enable refused
+
+ # Mutation test: the shipped default must NOT authenticate.
+ with pytest.raises(UnauthorizedError):
+ authenticate_user(db, "mcp", "mcp-service-changeme")
+
+ def test_reenable_allowed_after_real_password_rotation(
+ self, client, admin_headers, sample_user, db
+ ):
+ """A service account carrying a real (non-default) hash re-enables fine —
+ the guard is scoped to known-default hashes only."""
+ from core.errors import UnauthorizedError
+ from services.auth_service import authenticate_user, ensure_service_user
+
+ self._seed_disabled_default_mcp(db)
+ # Operator rotates to a strong secret (startup re-seeds + re-activates).
+ ensure_service_user(db, username="mcp", password="a-real-strong-secret")
+ mcp = db.query(User).filter(User.username == "mcp").first()
+ assert mcp.is_active is True
+
+ # Admin may still toggle it via the route now that no default hash remains.
+ resp = client.put(
+ f"/api/auth/users/{mcp.id}", json={"is_active": True}, headers=admin_headers
+ )
+ assert resp.status_code == 200
+ db.refresh(mcp)
+ assert mcp.is_active is True
+ assert authenticate_user(db, "mcp", "a-real-strong-secret").is_active is True
+ # And the old default is gone for good.
+ with pytest.raises(UnauthorizedError):
+ authenticate_user(db, "mcp", "mcp-service-changeme")
+
+
+class TestMustChangePasswordEnforcement:
+ """#184: must_change_password must gate the API server-side, not just the UI.
+
+ A seeded/admin-created must-change user gets a valid token, so without a
+ server gate a client could skip the change-password screen and call every
+ endpoint directly with the seed credential.
+ """
+
+ def _make_must_change_admin(self, db):
+ from services.auth_service import create_user
+ u = create_user(
+ db, "mustchange", "mustchange@test.com", "startpw",
+ role="admin", must_change_password=True,
+ )
+ db.commit()
+ return u
+
+ def _login(self, client, username, password):
+ r = client.post("/api/auth/login", json={"username": username, "password": password})
+ assert r.status_code == 200, r.text
+ assert r.json()["must_change_password"] is True
+ return r.json()["token"]
+
+ def test_protected_endpoint_refused_until_password_changed(self, client, db):
+ self._make_must_change_admin(db)
+ token = self._login(client, "mustchange", "startpw")
+ hdr = {"Authorization": f"Bearer {token}"}
+
+ # A normal protected endpoint is refused with 403 while must-change.
+ blocked = client.get("/api/auth/users", headers=hdr)
+ assert blocked.status_code == 403, blocked.text
+
+ # The exempt endpoints still work: read own state and change password.
+ assert client.get("/api/auth/me", headers=hdr).status_code == 200
+
+ changed = client.post(
+ "/api/auth/change-password",
+ headers=hdr,
+ json={"current_password": "startpw", "new_password": "BrandNewPw123!"},
+ )
+ assert changed.status_code == 200, changed.text
+
+ # After the change the flag clears, so the same endpoint now works.
+ after = client.get("/api/auth/users", headers=hdr)
+ assert after.status_code == 200, after.text
+
+ def test_dependency_less_route_is_gated_by_the_middleware(self, client, db):
+ """#186 (bonnyr-f5): the gate lived only in get_current_user, so a route
+ that declares NO auth dependency and relies on AuthMiddleware alone was
+ bypassable with the seed credential. /api/system/process-metrics is such
+ a route (public_router, no get_current_user, not in PUBLIC_PATHS). A
+ must-change token must be refused there, at the middleware, not served.
+ """
+ self._make_must_change_admin(db)
+ token = self._login(client, "mustchange", "startpw")
+ hdr = {"Authorization": f"Bearer {token}"}
+
+ # Middleware-only route: must be 403 while must-change (was 200 = bypass).
+ blocked = client.get("/api/system/process-metrics", headers=hdr)
+ assert blocked.status_code == 403, blocked.text
+
+ # Exempt read still works so the UI can drive the change screen.
+ assert client.get("/api/auth/me", headers=hdr).status_code == 200
+
+ # After rotating, the same middleware-only route is reachable.
+ assert client.post(
+ "/api/auth/change-password", headers=hdr,
+ json={"current_password": "startpw", "new_password": "BrandNewPw123!"},
+ ).status_code == 200
+ assert client.get("/api/system/process-metrics", headers=hdr).status_code == 200
+
+ def test_non_must_change_user_is_not_gated(self, client, admin_headers, sample_user):
+ # Regression guard: an ordinary user (must_change False) reaches the API.
+ assert client.get("/api/auth/users", headers=admin_headers).status_code == 200
+
+ def test_path_route_with_auth_me_suffix_is_not_exempted(self, client, db):
+ # #184 review: a ':path' route (e.g. /api/state/.../resource/{addr:path})
+ # takes an attacker-chosen tail. Exact-path matching on request.url.path
+ # must NOT exempt "/api/state/module/1/resource/x/auth/me" just because it
+ # ends in /auth/me -- the gate refuses it (403) before the handler runs.
+ self._make_must_change_admin(db)
+ token = self._login(client, "mustchange", "startpw")
+ hdr = {"Authorization": f"Bearer {token}"}
+ r = client.get("/api/state/module/1/resource/x/auth/me", headers=hdr)
+ assert r.status_code == 403, r.text
diff --git a/backend/tests/integration/test_routes_k8s_websocket.py b/backend/tests/integration/test_routes_k8s_websocket.py
index 8e849487..a0d9f76b 100644
--- a/backend/tests/integration/test_routes_k8s_websocket.py
+++ b/backend/tests/integration/test_routes_k8s_websocket.py
@@ -56,7 +56,7 @@ def test_exec_valid_admin_token_accepted(self, client, sample_user):
msg = ws.receive_json()
assert msg["type"] == "error"
- def test_exec_valid_viewer_token_accepted(self, client, sample_user):
+ def test_exec_valid_viewer_token_accepted(self, client, sample_viewer_user):
"""Valid viewer JWT passes auth — connection accepted."""
token = _make_token("viewer")
with client.websocket_connect(
@@ -65,7 +65,7 @@ def test_exec_valid_viewer_token_accepted(self, client, sample_user):
msg = ws.receive_json()
assert msg["type"] == "error"
- def test_exec_valid_operator_token_accepted(self, client, sample_user):
+ def test_exec_valid_operator_token_accepted(self, client, sample_operator_user):
"""Valid operator JWT passes auth — connection accepted."""
token = _make_token("operator")
with client.websocket_connect(
diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py
index 6cbdea2f..b073e2db 100644
--- a/backend/tests/test_migrations.py
+++ b/backend/tests/test_migrations.py
@@ -534,3 +534,112 @@ def test_drift_gate_ignores_out_of_band_tables(self):
finally:
engine.dispose()
os.unlink(db_path)
+
+ def test_v2_155_backfills_only_the_legacy_mcp_service_row(self):
+ """bonnyr-f5 #188 r4 (INV-7): the backfill lives in a NEW revision v2_155,
+ NOT appended to the already-shipped v2_154.
+
+ v2_154 (shipped in earlier RCs) only adds the column with server_default
+ false — appending a backfill there would never run for an install already
+ stamped v2_154 (an applied revision is immutable), i.e. exactly the existing
+ installs the backfill must fix. v2_155 chains from v2_154 and does the
+ backfill, so any install at v2_154 applies it on the next upgrade.
+
+ This test drives the two revisions in sequence and asserts v2_155 flips
+ ONLY the row with the legacy creation fingerprint (username 'mcp' + the
+ synthesised email 'mcp@bnk-forge.local'), never a human — including a human
+ named 'admin', a normal human, or a human who merely happens to be named
+ 'mcp' with a real email. It also proves the exact BLOCKER-2 scenario: a DB
+ already at v2_154 with the mcp row still False gets it backfilled by v2_155.
+ """
+ import importlib.util
+
+ import sqlalchemy as sa
+ from alembic.operations import Operations
+ from alembic.runtime.migration import MigrationContext
+ from sqlalchemy import create_engine, inspect, text
+ from sqlalchemy.pool import StaticPool
+
+ def _load(basename, modname):
+ path = os.path.join(backend_path, "alembic", "versions", basename)
+ spec = importlib.util.spec_from_file_location(modname, path)
+ assert spec is not None and spec.loader is not None
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+ v2_154 = _load("v2_154_user_is_service_account.py", "migration_v2_154")
+ v2_155 = _load("v2_155_backfill_is_service_account.py", "migration_v2_155")
+
+ # v2_155 must chain directly from v2_154 (single linear head).
+ assert v2_155.down_revision == "v2_154"
+ assert v2_154.down_revision == "v2_153"
+
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ # users table in the pre-v2_154 shape (no is_service_account column).
+ metadata = sa.MetaData()
+ sa.Table(
+ "users", metadata,
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column("username", sa.String(255), unique=True, nullable=False),
+ sa.Column("email", sa.String(255), unique=True, nullable=False),
+ sa.Column("hashed_password", sa.String(255), nullable=False),
+ sa.Column("role", sa.String(50), nullable=False, server_default="operator"),
+ sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.true()),
+ sa.Column("must_change_password", sa.Boolean, nullable=False, server_default=sa.false()),
+ )
+ metadata.create_all(engine)
+ with engine.begin() as conn:
+ conn.execute(text(
+ "INSERT INTO users (username,email,hashed_password,role,is_active,must_change_password) VALUES "
+ "('mcp','mcp@bnk-forge.local','x','admin',1,0)," # legacy service acct
+ "('admin','admin@corp.com','y','admin',1,0)," # human admin
+ "('alice','alice@corp.com','z','operator',1,0)," # ordinary human
+ "('mcp2','mcp@real-human.com','w','operator',1,0)" # human named 'mcp' w/ real email
+ ))
+
+ def flags(conn):
+ return {
+ r._mapping["username"]: r._mapping["is_service_account"]
+ for r in conn.execute(text("SELECT username, is_service_account FROM users"))
+ }
+
+ with engine.begin() as connection:
+ migration_context = MigrationContext.configure(connection)
+ ops = Operations(migration_context)
+
+ # v2_154 adds the column only — every pre-existing row is False. This is
+ # the exact state of an install stamped v2_154 at the earlier commit.
+ v2_154.op = ops
+ v2_154.upgrade()
+ f0 = flags(connection)
+ assert all(v in (0, False) for v in f0.values()), (
+ "v2_154 must NOT backfill — that would resurrect the immutable-migration hole"
+ )
+
+ # v2_155 backfills exactly the legacy mcp row (the BLOCKER-2 fix path).
+ v2_155.op = ops
+ v2_155.upgrade()
+ f = flags(connection)
+ assert f["mcp"] in (1, True), "legacy mcp service row must be backfilled True by v2_155"
+ assert f["admin"] in (0, False), "human admin must NOT be reclassified"
+ assert f["alice"] in (0, False), "ordinary human must NOT be reclassified"
+ assert f["mcp2"] in (0, False), "human named 'mcp' with a real email must NOT be reclassified"
+
+ # Round-trip: v2_155 down clears only the flag; v2_154 down drops the column.
+ v2_155.downgrade()
+ assert flags(connection)["mcp"] in (0, False), "v2_155 downgrade must clear the flag"
+ v2_154.downgrade()
+ cols = {c["name"] for c in inspect(connection).get_columns("users")}
+ assert "is_service_account" not in cols
+
+ # Idempotent re-apply (CI round-trip gate).
+ v2_154.upgrade()
+ v2_155.upgrade()
+ assert flags(connection)["mcp"] in (1, True)
+
+ engine.dispose()
diff --git a/backend/tests/test_startup_seed_auth.py b/backend/tests/test_startup_seed_auth.py
new file mode 100644
index 00000000..8837359a
--- /dev/null
+++ b/backend/tests/test_startup_seed_auth.py
@@ -0,0 +1,156 @@
+"""Startup seed_auth_step coverage — bonnyr-f5 #188 round 5 (BLOCKER-1).
+
+These tests drive the real ``seed_auth_step`` against a legacy-install fixture,
+covering the previously-untested reconcile / disable branching. The central case
+reproduces the *diligent operator* path: a legacy install whose ``.env`` still
+carries ``MCP_USERNAME=admin`` but who follows the new docs and sets a strong
+``MCP_PASSWORD``. Before the fix the stale ``mcp`` row kept authenticating with
+the shipped default; the fix makes the provenance-keyed disable unconditional so
+that path is closed too.
+"""
+
+
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+import database
+import models # noqa: F401 — register all tables on Base.metadata
+import startup_steps
+from core.errors import UnauthorizedError
+from database import Base
+from models import User
+from services.auth_service import authenticate_user, hash_password
+
+LEGACY_DEFAULT = "mcp-service-changeme"
+
+
+@pytest.fixture()
+def legacy_db(monkeypatch):
+ """A DB whose only service account is the legacy ``mcp`` row holding the
+ shipped default, is_active=True, is_service_account=True — i.e. exactly what
+ v2_155 leaves behind on a pre-#188 upgrade."""
+ engine = create_engine(
+ "sqlite://",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(bind=engine)
+ session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+ # seed_auth_step calls get_db_context(), which builds sessions from the
+ # module-global SessionLocal — repoint it at our throwaway engine.
+ monkeypatch.setattr(database, "SessionLocal", session_factory)
+
+ db = session_factory()
+ legacy = User(
+ username="mcp",
+ email="mcp@bnk-forge.local",
+ hashed_password=hash_password(LEGACY_DEFAULT),
+ role="admin",
+ is_active=True,
+ is_service_account=True,
+ must_change_password=False,
+ )
+ # A human admin, to prove the disable never touches it.
+ human = User(
+ username="admin",
+ email="admin@bnk-forge.local",
+ hashed_password=hash_password("real-admin-secret"),
+ role="admin",
+ is_active=True,
+ is_service_account=False,
+ must_change_password=False,
+ )
+ db.add_all([legacy, human])
+ db.commit()
+ db.close()
+
+ yield session_factory
+ engine.dispose()
+
+
+def _legacy_default_still_works(session_factory) -> bool:
+ db = session_factory()
+ try:
+ authenticate_user(db, "mcp", LEGACY_DEFAULT)
+ return True
+ except UnauthorizedError:
+ return False
+ finally:
+ db.close()
+
+
+def _set_mcp_env(monkeypatch, username, password):
+ monkeypatch.setattr(startup_steps.settings, "MCP_SERVICE_USERNAME", username)
+ monkeypatch.setattr(startup_steps.settings, "MCP_SERVICE_PASSWORD", password)
+ monkeypatch.setattr(startup_steps.settings, "REQUIRE_AUTH", True)
+
+
+def test_diligent_operator_admin_username_disables_stale_default(legacy_db, monkeypatch):
+ """BLOCKER-1: strong password set, but MCP_USERNAME left at legacy 'admin'.
+
+ ensure_service_user raises a reserved-name ValueError (swallowed), so the
+ ONLY thing that can neutralise the legacy row is the unconditional disable.
+ """
+ _set_mcp_env(monkeypatch, "admin", "strong-new-secret-xyz")
+ assert _legacy_default_still_works(legacy_db) is True # vulnerable pre-run
+
+ startup_steps.seed_auth_step()
+
+ assert _legacy_default_still_works(legacy_db) is False, (
+ "legacy mcp/mcp-service-changeme still authenticates — remediation did "
+ "not fire on the diligent-operator path"
+ )
+ # Human admin must be untouched.
+ db = legacy_db()
+ human = db.query(User).filter(User.username == "admin", User.is_service_account.is_(False)).one()
+ assert human.is_active is True
+ assert authenticate_user(db, "admin", "real-admin-secret")
+ db.close()
+
+
+def test_usable_password_dedicated_name_reconciles_and_disables_legacy(legacy_db, monkeypatch):
+ """Strong password + a dedicated MCP username: the new account authenticates
+ with the new secret AND the stale default row is disabled."""
+ _set_mcp_env(monkeypatch, "mcp-svc", "strong-new-secret-xyz")
+
+ startup_steps.seed_auth_step()
+
+ assert _legacy_default_still_works(legacy_db) is False
+ db = legacy_db()
+ assert authenticate_user(db, "mcp-svc", "strong-new-secret-xyz")
+ db.close()
+
+
+def test_unset_password_disables_stale_default(legacy_db, monkeypatch):
+ """No usable password at all: the stale default must be disabled."""
+ _set_mcp_env(monkeypatch, "mcp", None)
+
+ startup_steps.seed_auth_step()
+
+ assert _legacy_default_still_works(legacy_db) is False
+
+
+def test_known_default_password_treated_as_unset(legacy_db, monkeypatch):
+ """A shipped default supplied as MCP_PASSWORD must not re-seed the account —
+ it is treated as unset and the stale row is disabled."""
+ _set_mcp_env(monkeypatch, "mcp", "changeme")
+
+ startup_steps.seed_auth_step()
+
+ assert _legacy_default_still_works(legacy_db) is False
+
+
+def test_configured_mcp_row_is_reconciled_when_matching_username(legacy_db, monkeypatch):
+ """When MCP_USERNAME matches the legacy row's name and a real password is set,
+ the account is reconciled (hash rotated to the new secret, re-activated)."""
+ _set_mcp_env(monkeypatch, "mcp", "brand-new-strong-secret")
+
+ startup_steps.seed_auth_step()
+
+ # Old default no longer works; new secret does.
+ assert _legacy_default_still_works(legacy_db) is False
+ db = legacy_db()
+ assert authenticate_user(db, "mcp", "brand-new-strong-secret")
+ db.close()
diff --git a/backend/tests/unit/test_auth_middleware.py b/backend/tests/unit/test_auth_middleware.py
index 66e3afdf..e190ccd3 100644
--- a/backend/tests/unit/test_auth_middleware.py
+++ b/backend/tests/unit/test_auth_middleware.py
@@ -101,3 +101,45 @@ def test_get_benchmarks_agents_no_auth_returns_401(self, client):
def test_get_benchmark_agent_by_id_no_auth_returns_401(self, client):
resp = client.get("/api/benchmarks/agents/1")
assert resp.status_code == 401
+
+
+# ── API-token (bnk_) branch: verified + gated via the middleware ─────────────
+
+
+class TestApiTokenBranchGatedByMiddleware:
+ """#186 r5 (bonnyr-f5, Minor): the bnk_ API-token branch had no coverage.
+
+ It is verified in the middleware (not deferred to the route) and runs the
+ must-change gate. Both the verify and the gate now run through
+ run_in_threadpool so the sync DB session never blocks the event loop; these
+ tests exercise that path end to end with a stubbed verifier.
+ """
+
+ def _client_with_verifier(self, monkeypatch, user):
+ from core import auth_middleware as mw_module
+ app = _make_app(require_auth=True)
+
+ @app.get("/api/projects")
+ async def _projects(request: Request): # a dependency-less /api route
+ return JSONResponse({"ok": True})
+
+ # monkeypatch auto-restores the real verifier after the test, so this stub
+ # never leaks into the other TestMiddlewareVerifiesApiTokens cases.
+ monkeypatch.setattr(mw_module, "_verify_api_token", lambda token: user)
+ return TestClient(app, raise_server_exceptions=True)
+
+ def test_api_token_must_change_user_is_403(self, monkeypatch):
+ class _U:
+ must_change_password = True
+ client = self._client_with_verifier(monkeypatch, _U())
+ resp = client.get("/api/projects", headers={"Authorization": "Bearer bnk_deadbeef"})
+ assert resp.status_code == 403
+ assert resp.json()["error"]["code"] == "PASSWORD_CHANGE_REQUIRED"
+
+ def test_api_token_settled_user_passes(self, monkeypatch):
+ class _U:
+ must_change_password = False
+ client = self._client_with_verifier(monkeypatch, _U())
+ resp = client.get("/api/projects", headers={"Authorization": "Bearer bnk_deadbeef"})
+ assert resp.status_code == 200
+ assert resp.json() == {"ok": True}
diff --git a/backend/tests/unit/test_benchmark_agent_auth.py b/backend/tests/unit/test_benchmark_agent_auth.py
index c69e2d4a..e4a993b6 100644
--- a/backend/tests/unit/test_benchmark_agent_auth.py
+++ b/backend/tests/unit/test_benchmark_agent_auth.py
@@ -118,8 +118,13 @@ def test_register_rejects_invalid_token(self, client):
assert resp.status_code == 400
assert "AGENT_AUTH_INVALID" in resp.text
- def test_register_accepts_valid_token(self, client, admin_headers):
- """Flag on + valid JWT → accepted (not a 400/401)."""
+ def test_register_accepts_valid_token(self, client, sample_user, admin_headers):
+ """Flag on + valid JWT for a real, live admin → accepted (not a 400/401).
+
+ #186 (bonnyr-f5): the gate fails CLOSED on a token that resolves to no
+ live User, so the token must correspond to a real row (sample_user is
+ 'testadmin', which admin_headers is issued for) -- as any human token
+ does, since a token is only minted after that user logs in."""
with patch("routes.benchmarks.settings") as mock_settings:
mock_settings.BENCHMARK_AGENT_AUTH_REQUIRED = True
resp = client.post(
@@ -161,8 +166,17 @@ def test_register_accepts_agent_role_token(self, client):
)
assert resp.status_code in (200, 201), resp.text
- def test_register_accepts_operator_token(self, client):
- """The documented human curl flow keeps working with an operator token."""
+ def test_register_accepts_operator_token(self, client, db):
+ """The documented human curl flow keeps working with an operator token.
+
+ #186 (bonnyr-f5): the token must resolve to a real, live operator -- the
+ gate fails CLOSED on a token for a user that does not exist or is
+ disabled. A real curl operator always has a row (that is how they got the
+ token), so create one, unlike a forged token for a phantom user."""
+ from services.auth_service import create_user
+ create_user(db, "op", "op@t.com", "pw-op-123",
+ role="operator", must_change_password=False)
+ db.commit()
with patch("routes.benchmarks.settings") as mock_settings:
mock_settings.BENCHMARK_AGENT_AUTH_REQUIRED = True
with patch("core.auth_middleware.settings") as mw_settings:
@@ -174,6 +188,45 @@ def test_register_accepts_operator_token(self, client):
)
assert resp.status_code in (200, 201), resp.text
+ def test_register_refuses_a_must_change_user(self, client, db):
+ """#186 r2 (bonnyr-f5): a real must-change admin/operator was able to
+ create an agent (201) because this path never gated must_change. A token
+ that resolves to a real user owing a password change must be refused."""
+ from services.auth_service import create_access_token, create_user
+ create_user(db, "mc-admin", "mc-admin@t.com", "pw",
+ role="admin", must_change_password=True)
+ db.commit()
+ token = create_access_token({"sub": "mc-admin", "role": "admin"})
+ with patch("routes.benchmarks.settings") as mock_settings:
+ mock_settings.BENCHMARK_AGENT_AUTH_REQUIRED = True
+ with patch("core.auth_middleware.settings") as mw_settings:
+ mw_settings.REQUIRE_AUTH = False
+ resp = client.post(
+ "/api/benchmarks/agents",
+ json=_register_payload(),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert resp.status_code == 400, resp.text
+ assert resp.json()["error"]["code"] == "AGENT_AUTH_PASSWORD_CHANGE_REQUIRED"
+
+ def test_register_rejects_nonexistent_user_token(self, client):
+ """#186 (bonnyr-f5): INV-20 fail-open. A validly-signed admin/operator
+ token that resolves to NO live User (deleted/disabled row, or a phantom
+ subject) must be refused -- token_user_state's contract is fail CLOSED.
+ Previously the `if agent_user is not None:` had no else, so None skipped
+ the gate and this returned 201."""
+ with patch("routes.benchmarks.settings") as mock_settings:
+ mock_settings.BENCHMARK_AGENT_AUTH_REQUIRED = True
+ with patch("core.auth_middleware.settings") as mw_settings:
+ mw_settings.REQUIRE_AUTH = False
+ resp = client.post(
+ "/api/benchmarks/agents",
+ json=_register_payload(),
+ headers=self._headers_for(sub="phantom-admin", role="admin"),
+ )
+ assert resp.status_code == 400, resp.text
+ assert "AGENT_AUTH_INVALID" in resp.text
+
def test_register_rejects_token_with_no_role(self, client):
"""A token with no role claim must fail closed, not fall through."""
with patch("routes.benchmarks.settings") as mock_settings:
@@ -371,3 +424,87 @@ def test_matching_agent_id_passes(self):
token_agent_id = payload.get("agent_id")
path_agent_id = 7
assert int(token_agent_id) == path_agent_id
+
+
+class TestAgentWSLayer2MustChangeGate:
+ """#186 (bonnyr-f5 r4, INV-10): the agent WS Layer-2 (global JWT) branch
+ must enforce the must-change gate the other five JWT entry points enforce.
+
+ token_user_state opens its own DB session, so these unit tests patch it to
+ isolate the gate LOGIC in _agent_ws_authorized (DB-backed behavior is
+ covered by token_user_state's own component tests).
+ """
+
+ def _ws(self, token):
+ from unittest.mock import MagicMock
+ ws = MagicMock()
+ ws.query_params = {"token": token}
+ return ws
+
+ def test_rejects_must_change_human_admin(self):
+ # The reproduced bug: a valid admin token owing a password change was
+ # admitted (returned None). It must now close 4001.
+ from unittest.mock import MagicMock, patch
+
+ from routes.benchmarks import _agent_ws_authorized
+ from services.auth_service import create_access_token
+
+ token = create_access_token(data={"sub": "admin", "role": "admin"})
+ must_change_user = MagicMock(must_change_password=True)
+ with (
+ patch("core.config.settings.BENCHMARK_AGENT_AUTH_REQUIRED", False),
+ patch("core.config.settings.REQUIRE_AUTH", True),
+ patch("services.auth_service.token_user_state", return_value=must_change_user),
+ ):
+ assert _agent_ws_authorized(self._ws(token), 5) == 4001
+
+ def test_admits_settled_human_admin(self):
+ from unittest.mock import MagicMock, patch
+
+ from routes.benchmarks import _agent_ws_authorized
+ from services.auth_service import create_access_token
+
+ token = create_access_token(data={"sub": "admin", "role": "admin"})
+ settled_user = MagicMock(must_change_password=False)
+ with (
+ patch("core.config.settings.BENCHMARK_AGENT_AUTH_REQUIRED", False),
+ patch("core.config.settings.REQUIRE_AUTH", True),
+ patch("services.auth_service.token_user_state", return_value=settled_user),
+ ):
+ assert _agent_ws_authorized(self._ws(token), 5) is None
+
+ def test_rejects_human_token_resolving_to_no_user(self):
+ # Fail closed: a signed token whose subject no longer resolves (deleted/
+ # disabled) must be refused, not waved through.
+ from unittest.mock import patch
+
+ from routes.benchmarks import _agent_ws_authorized
+ from services.auth_service import create_access_token
+
+ token = create_access_token(data={"sub": "ghost", "role": "admin"})
+ with (
+ patch("core.config.settings.BENCHMARK_AGENT_AUTH_REQUIRED", False),
+ patch("core.config.settings.REQUIRE_AUTH", True),
+ patch("services.auth_service.token_user_state", return_value=None),
+ ):
+ assert _agent_ws_authorized(self._ws(token), 5) == 4001
+
+ def test_agent_role_token_admitted_without_user_lookup(self):
+ # Agent tokens carry no User row; they must still connect on this path
+ # (agent auth off, global JWT on) and must NOT be gated via token_user_state.
+ from unittest.mock import patch
+
+ from routes.benchmarks import _agent_ws_authorized
+ from services.auth_service import create_access_token
+
+ token = create_access_token(data={"sub": "forge-agent", "role": "agent"})
+
+ def _boom(*_a, **_k): # token_user_state must not be consulted for agents
+ raise AssertionError("token_user_state should not be called for an agent token")
+
+ with (
+ patch("core.config.settings.BENCHMARK_AGENT_AUTH_REQUIRED", False),
+ patch("core.config.settings.REQUIRE_AUTH", True),
+ patch("services.auth_service.token_user_state", _boom),
+ ):
+ assert _agent_ws_authorized(self._ws(token), 5) is None
diff --git a/backend/tests/unit/test_core_config.py b/backend/tests/unit/test_core_config.py
index 1dfd577b..a75d6884 100644
--- a/backend/tests/unit/test_core_config.py
+++ b/backend/tests/unit/test_core_config.py
@@ -116,6 +116,7 @@ def test_production_with_explicit_keys_passes(self):
ENVIRONMENT="production",
JWT_SECRET_KEY="explicit-jwt-key-for-production-use",
ENCRYPTION_KEY="explicit-encryption-key-for-production",
+ MCP_SERVICE_PASSWORD="explicit-mcp-service-secret", # #187: required
ALLOWED_ORIGINS="https://my-app.example.com",
)
# Explicit keys set _auto_generated to False
@@ -124,6 +125,30 @@ def test_production_with_explicit_keys_passes(self):
# Should not raise
s.validate_production()
+ def test_production_without_mcp_service_password_fails(self):
+ """#187: MCP_SERVICE_PASSWORD unset in prod must fail fast."""
+ s = Settings(
+ ENVIRONMENT="production",
+ JWT_SECRET_KEY="explicit-jwt-key-for-production-use",
+ ENCRYPTION_KEY="explicit-encryption-key-for-production",
+ ALLOWED_ORIGINS="https://my-app.example.com",
+ MCP_SERVICE_PASSWORD=None,
+ )
+ with pytest.raises(SystemExit):
+ s.validate_production()
+
+ def test_production_with_default_mcp_password_fails(self):
+ """#187: the known shipped default must also fail, not just unset."""
+ s = Settings(
+ ENVIRONMENT="production",
+ JWT_SECRET_KEY="explicit-jwt-key-for-production-use",
+ ENCRYPTION_KEY="explicit-encryption-key-for-production",
+ ALLOWED_ORIGINS="https://my-app.example.com",
+ MCP_SERVICE_PASSWORD="mcp-service-changeme",
+ )
+ with pytest.raises(SystemExit):
+ s.validate_production()
+
def test_production_wildcard_cors_fails(self):
"""Production with wildcard CORS should fail."""
s = Settings(
@@ -152,6 +177,7 @@ def test_staging_skips_localhost_check(self):
ENVIRONMENT="staging",
JWT_SECRET_KEY="explicit-key",
ENCRYPTION_KEY="explicit-key",
+ MCP_SERVICE_PASSWORD="explicit-mcp-service-secret", # #187: required
ALLOWED_ORIGINS="http://localhost:3000",
)
# Should not raise — staging allows localhost
diff --git a/bin/roadmap-add.py b/bin/roadmap-add.py
index eb65507e..c4ddc832 100755
--- a/bin/roadmap-add.py
+++ b/bin/roadmap-add.py
@@ -75,7 +75,7 @@ def main():
ap = argparse.ArgumentParser(description="Append an item to docs/roadmap.yaml")
ap.add_argument("--section", help="section id (see --list-sections)")
ap.add_argument("--title")
- ap.add_argument("--status", help="status key (shipped/in_progress/blocked/deferred/planned)")
+ ap.add_argument("--status", help="status key (shipped/merged/in_progress/blocked/deferred/planned)")
ap.add_argument("--refs", default="", help='comma-separated, e.g. "#216,PR #188"')
ap.add_argument("--note", default="")
ap.add_argument("--group", default="")
diff --git a/bin/roadmap-gen.py b/bin/roadmap-gen.py
index 5dd30b08..77a3aae8 100755
--- a/bin/roadmap-gen.py
+++ b/bin/roadmap-gen.py
@@ -395,11 +395,12 @@ def main():
print("Wrote %s" % MD_PATH)
print("Wrote %s" % HTML_PATH)
print(
- "Stats: in_progress=%d planned=%d shipped=%d blocked=%d deferred=%d"
+ "Stats: in_progress=%d planned=%d shipped=%d merged=%d blocked=%d deferred=%d"
% (
count_status(data["sections"], "in_progress"),
count_status(data["sections"], "planned"),
count_status(data["sections"], "shipped"),
+ count_status(data["sections"], "merged"),
count_status(data["sections"], "blocked"),
count_status(data["sections"], "deferred"),
)
diff --git a/bnk-operator/charts/bnk-operator/Chart.yaml b/bnk-operator/charts/bnk-operator/Chart.yaml
index eefd4535..285cb08c 100644
--- a/bnk-operator/charts/bnk-operator/Chart.yaml
+++ b/bnk-operator/charts/bnk-operator/Chart.yaml
@@ -3,7 +3,7 @@ name: bnk-operator
description: BNK Operator — lightweight agent that connects K8s clusters to BNK-Forge
type: application
version: 1.1.0
-appVersion: "1.1.0"
+appVersion: "3.1.6"
keywords:
- f5
- bnk
diff --git a/bnk-operator/charts/bnk-operator/values.yaml b/bnk-operator/charts/bnk-operator/values.yaml
index 80603000..9d26b3ac 100644
--- a/bnk-operator/charts/bnk-operator/values.yaml
+++ b/bnk-operator/charts/bnk-operator/values.yaml
@@ -45,8 +45,8 @@ cwc:
# Operator image
image:
- repository: f5/bnk-operator
- tag: "1.2.0"
+ repository: ghcr.io/f5devcentral/bnk-forge-operator
+ tag: "3.1.6"
pullPolicy: IfNotPresent
# Image pull secrets (if using private registry)
diff --git a/dist/.env.example b/dist/.env.example
index 0eaa12b2..7bd3a88e 100644
--- a/dist/.env.example
+++ b/dist/.env.example
@@ -15,8 +15,8 @@ COMPOSE_PROJECT_NAME=bnk-forge
# ── Container Registry ──────────────────────────────────────────────────────
# Where to pull BNK Forge images from (no trailing slash)
-BNK_FORGE_REGISTRY=ghcr.io/your-org
-BNK_FORGE_VERSION=3.0.1
+BNK_FORGE_REGISTRY=ghcr.io/f5devcentral
+BNK_FORGE_VERSION=latest
# ── Database ────────────────────────────────────────────────────────────────
POSTGRES_PASSWORD=bnkforge_dev_password
@@ -25,9 +25,19 @@ POSTGRES_PASSWORD=bnkforge_dev_password
REDIS_PASSWORD=bnkforge_redis_dev
# ── MCP Server (AI assistant integration) ───────────────────────────────────
-# Must match a valid BNK Forge user. Default: admin/changeme
-MCP_USERNAME=admin
-MCP_PASSWORD=changeme
+# #186/#187: MCP authenticates as the dedicated 'mcp' service account (role=admin,
+# no must-change gate), NOT the human admin. Do NOT point it at admin/changeme:
+# #184 generates the admin password and gates it, so that wiring 403s every call.
+# Set MCP_SERVICE_PASSWORD to a value of your choosing (no shipped default — a
+# published one can no longer authenticate); the backend reconciles the 'mcp'
+# account to it on every startup. Until you set it, the backend leaves the account
+# unseeded (and disables any stale one carried over from an upgrade), so MCP
+# integration is simply unavailable — the MCP server cannot authenticate. The hard
+# fail-fast (the backend REFUSES TO BOOT without it) only fires when
+# ENVIRONMENT=staging or production; this package does not set ENVIRONMENT, so it
+# runs in the default development mode where that check is skipped.
+MCP_SERVICE_USERNAME=mcp
+MCP_SERVICE_PASSWORD=
# ── Container (artifact) engine — Docker socket proxy ───────────────────────
# The container-image deployment engine runs each artifact step as a sibling
diff --git a/dist/README.md b/dist/README.md
index 7c0c0118..20d8fbc8 100644
--- a/dist/README.md
+++ b/dist/README.md
@@ -3,7 +3,7 @@
## Prerequisites
- **Docker Engine 24+** with **Docker Compose v2.24+**
-- Access to the BNK Forge container registry (if private)
+- Network access to `ghcr.io` (images are public — no registry login required)
- 4 GB RAM minimum (8 GB recommended)
- 10 GB disk space
@@ -12,8 +12,8 @@
### 1. Download and extract
```bash
-tar xzf bnk-forge-3.0.1.tar.gz
-cd bnk-forge-3.0.1
+tar xzf bnk-forge-3.1.6.tar.gz
+cd bnk-forge-3.1.6
```
### 2. Configure
@@ -27,25 +27,13 @@ nano .env # Set BNK_FORGE_REGISTRY and passwords
| Variable | Description | Example |
|---|---|---|
-| `BNK_FORGE_REGISTRY` | Container registry URL (no trailing slash) | `ghcr.io/your-org` |
-| `BNK_FORGE_VERSION` | Image version tag | `3.0.1` |
+| `BNK_FORGE_REGISTRY` | Container registry URL (no trailing slash) | `ghcr.io/f5devcentral` (public) |
+| `BNK_FORGE_VERSION` | Image version tag | `3.1.6` |
| `POSTGRES_PASSWORD` | PostgreSQL password | *(change for production)* |
| `REDIS_PASSWORD` | Redis password | *(change for production)* |
+| `MCP_PASSWORD` | Password for the dedicated `mcp` service account (backend `MCP_SERVICE_PASSWORD`). Ships **empty** — MCP stays disabled until you set a strong secret. Never `admin`. | *(required to enable MCP)* |
-### 3. Authenticate to registry (if private)
-
-```bash
-# GitHub Container Registry
-echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
-
-# Docker Hub
-docker login
-
-# AWS ECR
-aws ecr get-login-password | docker login --username AWS --password-stdin ACCOUNT.dkr.ecr.REGION.amazonaws.com
-```
-
-### 4. Install
+### 3. Install
**Linux server** (host networking — production):
```bash
@@ -59,12 +47,12 @@ chmod +x install.sh
./install.sh --local
```
-### 5. Access
+### 4. Access
- **Mac/Windows (`--local`):** open **https://localhost**
- **Linux server:** open **https://\** — the installer prints the exact URL at the end
-Accept the self-signed certificate warning. Login: **admin** / **changeme**
+Accept the self-signed certificate warning. Login as **admin** — retrieve the generated password with `docker compose exec backend cat /app/keys/initial_admin_password`, or set `DEFAULT_ADMIN_PASSWORD` before install. You'll change it on first login.
---
@@ -183,7 +171,7 @@ gunzip -c backup_20260417.sql.gz | docker exec -i bnk-forge-postgres psql -U bnk
## File Structure
```
-bnk-forge-3.0.1/
+bnk-forge-3.1.6/
├── docker-compose.yml # Main compose (Linux server — host networking)
├── docker-compose.local.yml # Overlay for macOS/Windows (bridge networking)
├── .env.example # Configuration template
@@ -235,17 +223,17 @@ This creates `dist/bnk-forge-VERSION.tar.gz` containing all files needed for ins
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
# Build + push all images for amd64 + arm64 (default)
-make push-images BNK_FORGE_REGISTRY=ghcr.io/your-org
+make push-images BNK_FORGE_REGISTRY=ghcr.io/f5devcentral
# Or push only amd64 (faster, if you don't need ARM)
-make push-images BNK_FORGE_REGISTRY=ghcr.io/your-org PLATFORMS=linux/amd64
+make push-images BNK_FORGE_REGISTRY=ghcr.io/f5devcentral PLATFORMS=linux/amd64
```
-This uses `docker buildx build --push` to build all 6 images (api, worker, beat, frontend, proxy, mcp) for both architectures and push **multi-arch manifest lists** to the registry. Each tag (e.g., `bnk-forge-api:3.0.1`) is a manifest that Docker automatically resolves to the correct platform on `docker pull`.
+This uses `docker buildx build --push` to build all 7 images (api, worker, beat, frontend, proxy, mcp, operator) for both architectures and push **multi-arch manifest lists** to the registry. Each tag (e.g., `bnk-forge-api:3.1.6`) is a manifest that Docker automatically resolves to the correct platform on `docker pull`.
**Verify the manifest:**
```bash
-docker manifest inspect ghcr.io/your-org/bnk-forge-api:3.0.1
+docker manifest inspect ghcr.io/f5devcentral/bnk-forge-api:3.1.6
```
You should see entries for both `linux/amd64` and `linux/arm64`.
@@ -266,18 +254,21 @@ gh release create v${VERSION} dist/bnk-forge-${VERSION}.tar.gz \
### What `gh release create` does
-1. Creates a Git tag (`v3.0.1`) on the current commit
-2. Creates a GitHub Release page at `https://github.com/your-org/bnk-forge/releases/tag/v3.0.1`
+1. Creates a Git tag (`v3.1.6`) on the current commit
+2. Creates a GitHub Release page at `https://github.com/f5devcentral/bnk-forge/releases/tag/v3.1.6`
3. Uploads the tarball as a downloadable release asset
### End-user download URL
-After publishing, users can download and install with:
+Once a full (non-prerelease) `vX.Y.Z` release with an attached tarball exists, users
+download and install with the URL below — substitute the version you actually published
+(the example `3.1.6` is illustrative; no release asset exists until you cut one):
```bash
-# Download from GitHub Releases
-curl -L https://github.com/your-org/bnk-forge/releases/download/v3.0.1/bnk-forge-3.0.1.tar.gz | tar xz
-cd bnk-forge-3.0.1
+# Download from GitHub Releases — replace 3.1.6 with your published version
+VERSION=3.1.6
+curl -L https://github.com/f5devcentral/bnk-forge/releases/download/v${VERSION}/bnk-forge-${VERSION}.tar.gz | tar xz
+cd bnk-forge-${VERSION}
./install.sh
```
diff --git a/dist/docker-compose.local.yml b/dist/docker-compose.local.yml
index 90793d53..73293ad5 100644
--- a/dist/docker-compose.local.yml
+++ b/dist/docker-compose.local.yml
@@ -24,6 +24,9 @@ x-local-backend-env: &local-backend-env
# Bridge mode: reach the socket proxy by service DNS (the base compose's
# 127.0.0.1:2375 is the container's own loopback here, not the host).
DOCKER_HOST: tcp://docker-socket-proxy:2375
+ # MCP service account (see base compose): no shipped default (#186/#187).
+ MCP_SERVICE_USERNAME: ${MCP_SERVICE_USERNAME:-mcp}
+ MCP_SERVICE_PASSWORD: ${MCP_SERVICE_PASSWORD:-}
networks:
bnk-local:
@@ -124,8 +127,12 @@ services:
- "8081:8081"
environment:
BNK_FORGE_API_URL: http://backend:8000
- BNK_FORGE_USERNAME: ${MCP_USERNAME:-admin}
- BNK_FORGE_PASSWORD: ${MCP_PASSWORD:-changeme}
+ # #186: MCP authenticates as the dedicated 'mcp' service account, NOT the
+ # human admin. admin/changeme is gone (#184 generates the admin password
+ # and gates it). Set MCP_SERVICE_PASSWORD in .env (no shipped default — the
+ # published one can no longer authenticate); the backend reconciles to it.
+ BNK_FORGE_USERNAME: ${MCP_SERVICE_USERNAME:-mcp}
+ BNK_FORGE_PASSWORD: ${MCP_SERVICE_PASSWORD:-}
MCP_PORT: "8081"
postgres-backup:
diff --git a/dist/docker-compose.yml b/dist/docker-compose.yml
index 286b7de1..56e007cb 100644
--- a/dist/docker-compose.yml
+++ b/dist/docker-compose.yml
@@ -11,7 +11,7 @@
#
# Prerequisites:
# - Docker Engine 24+ with Compose v2.24+
-# - Authenticated to the container registry (if private)
+# - Network access to ghcr.io (images are public — no registry login required)
#
# Configuration:
# Copy .env.example to .env and set your passwords before first start.
@@ -19,8 +19,8 @@
# ── Registry configuration ──────────────────────────────────────────────────
# Set BNK_FORGE_REGISTRY and BNK_FORGE_VERSION in .env or environment:
-# BNK_FORGE_REGISTRY=ghcr.io/your-org (no trailing slash)
-# BNK_FORGE_VERSION=3.0.1 (or "latest")
+# BNK_FORGE_REGISTRY=ghcr.io/f5devcentral (no trailing slash)
+# BNK_FORGE_VERSION=3.1.6 (or "latest")
x-backend-env: &backend-env
DATABASE_URL: postgresql://bnkforge:${POSTGRES_PASSWORD:-bnkforge_dev_password}@localhost:5432/bnkforge
@@ -32,6 +32,21 @@ x-backend-env: &backend-env
# networking the proxy publishes to the host loopback, so services reach it
# at 127.0.0.1:2375. Overridable via DOCKER_HOST in .env.
DOCKER_HOST: ${DOCKER_HOST:-tcp://127.0.0.1:2375}
+ # #186: plumb the operator-chosen initial admin password to the backend
+ # (config.py has no env_file, so an unpassed var never reaches the container).
+ DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-}
+ # #186: plumb the must-change gate too, or the seeded admin owes a password
+ # change no route accepts (login is exempt; every other /api route 403s).
+ # Defaults to "true" (secure); ephemeral CI overrides to "false" to let the
+ # e2e suite reach protected routes.
+ DEFAULT_ADMIN_MUST_CHANGE: ${DEFAULT_ADMIN_MUST_CHANGE:-true}
+ # #186/#187 (bonnyr-f5 r5): the backend reconciles the mcp account to
+ # MCP_SERVICE_PASSWORD on every boot and seeds it from the same value the MCP
+ # server authenticates with; plumb it (and the username) here or the backend
+ # generates a random secret the mcp client can never match. No shipped default —
+ # the operator sets MCP_SERVICE_PASSWORD in .env (see .env.example).
+ MCP_SERVICE_USERNAME: ${MCP_SERVICE_USERNAME:-mcp}
+ MCP_SERVICE_PASSWORD: ${MCP_SERVICE_PASSWORD:-}
x-worker-volumes: &worker-volumes
- module_catalog:/tmp/bnk-forge-modules
@@ -160,7 +175,7 @@ services:
memory: 32M
backend:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-api:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-api:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-backend
network_mode: host
logging: *default-logging
@@ -201,7 +216,7 @@ services:
memory: 256M
celery-worker:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-worker:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-worker:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-celery-worker
network_mode: host
logging: *default-logging
@@ -233,7 +248,7 @@ services:
memory: 512M
celery-worker-2:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-worker:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-worker:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-celery-worker-2
network_mode: host
logging: *default-logging
@@ -265,7 +280,7 @@ services:
memory: 512M
celery-beat:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-beat:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-beat:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-celery-beat
network_mode: host
logging: *default-logging
@@ -295,7 +310,7 @@ services:
memory: 64M
frontend:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-frontend:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-frontend:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-frontend
network_mode: host
logging: *default-logging
@@ -321,7 +336,7 @@ services:
memory: 32M
proxy:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-proxy:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-proxy:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-proxy
network_mode: host
logging: *default-logging
@@ -347,26 +362,37 @@ services:
memory: 32M
mcp:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-mcp:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-mcp:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-mcp
network_mode: host
logging: *default-logging
environment:
BNK_FORGE_API_URL: http://localhost:8000
- BNK_FORGE_USERNAME: ${MCP_USERNAME:-admin}
- BNK_FORGE_PASSWORD: ${MCP_PASSWORD:-changeme}
+ # #186: MCP authenticates as the dedicated 'mcp' service account, NOT the
+ # human admin. admin/changeme is gone (#184 generates the admin password
+ # and gates it), so the old admin/changeme wiring 403s every tool call.
+ # Set MCP_SERVICE_PASSWORD in .env (no shipped default — the published one
+ # can no longer authenticate); the backend reconciles the account to it.
+ BNK_FORGE_USERNAME: ${MCP_SERVICE_USERNAME:-mcp}
+ BNK_FORGE_PASSWORD: ${MCP_SERVICE_PASSWORD:-}
MCP_PORT: "8081"
MCP_LOG_LEVEL: INFO
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
+ # Auth-probe healthcheck (bonnyr-f5 #188, INV-10): log in to the backend with
+ # the configured MCP credentials and exit non-zero on 401 OR when no credential
+ # is configured. MCP_SERVICE_PASSWORD ships empty (see .env.example), so without this the
+ # container would report green while every tool call 401s. A bare liveness ping
+ # can't see that — it only proves the HTTP port answers. Same probe the dev
+ # compose uses, so the "no creds -> unhealthy" signal fires on the shipped path too.
healthcheck:
- test: ["CMD-SHELL", "python -c \"import urllib.request; req=urllib.request.Request('http://localhost:8081/mcp',headers={'Accept':'application/json,text/event-stream','Content-Type':'application/json'},data=b'{\\\"jsonrpc\\\":\\\"2.0\\\",\\\"method\\\":\\\"ping\\\",\\\"id\\\":1}'); urllib.request.urlopen(req)\" 2>/dev/null || exit 1"]
+ test: ["CMD", "python", "-m", "bnk_forge_mcp.healthcheck"]
interval: 30s
- timeout: 5s
+ timeout: 10s
retries: 3
- start_period: 15s
+ start_period: 30s
deploy:
resources:
limits:
diff --git a/dist/install.sh b/dist/install.sh
index 2c8be224..762633aa 100644
--- a/dist/install.sh
+++ b/dist/install.sh
@@ -10,7 +10,7 @@
#
# Prerequisites:
# - Docker Engine 24+ with Compose v2.24+
-# - Authenticated to the container registry (if private)
+# - Network access to ghcr.io (images are public — no registry login required)
#
set -euo pipefail
@@ -359,7 +359,7 @@ if [ "$URL" != "https://localhost" ]; then
echo " (accept the self-signed certificate warning)"
fi
echo ""
-echo " Login: admin / changeme"
+echo " Login: admin (password: DEFAULT_ADMIN_PASSWORD if set, else run: docker compose exec backend cat /app/keys/initial_admin_password)"
echo ""
echo " Next steps:"
echo " 1. Change your password on first login"
diff --git a/docker-bake.hcl b/docker-bake.hcl
index 52e486d2..8b8c774d 100644
--- a/docker-bake.hcl
+++ b/docker-bake.hcl
@@ -19,6 +19,32 @@ variable "GIT_REVISION" {
variable "SOURCE_URL" {
default = "https://github.com/f5devcentral/bnk-forge"
}
+# Build timestamp for org.opencontainers.image.created (RFC 3339). Empty by
+# default so a plain `docker buildx bake` does not stamp a wall-clock time into
+# the image config.
+# A timestamp() default stamped a FRESH time into every build, guaranteeing a
+# different config digest on every rebuild. CI instead sets CREATED to the
+# release commit's committer date (fixed for a given tag) and also exports
+# SOURCE_DATE_EPOCH. That removes the two most obvious sources of variance.
+#
+# It does NOT make a rebuild byte-reproducible, and a republish CAN move the
+# digest. The Dockerfiles run `apt-get update` / `apk upgrade` / `pip` / `npm`
+# against live package indexes, and there is no buildkit `rewrite-timestamp`
+# pass normalizing layer mtimes to SOURCE_DATE_EPOCH — so two builds of the
+# same tag can produce different layer diff_ids and a different image digest
+# (bonnyr-f5 #181 round 4, verified: two SOURCE_DATE_EPOCH-pinned builds gave
+# divergent digests). Because a republish may not resolve to the original
+# digest, the immutable :VERSION tag is protected the honest way — an existence
+# probe REFUSES a republish by default and requires an explicit force to
+# overwrite — rather than by relying on determinism we do not have. That probe
+# guards BOTH paths that bake --push this file: the release workflow (see
+# release.yml "Refuse to overwrite an already-published tag") and the operator
+# `make push-images` command (Makefile, FORCE_LATEST=1 to override). Both call
+# scripts/registry-tag-probe.sh, so the protection is not fixed at one call
+# site only (bonnyr-f5 #181 round 5, F3).
+variable "CREATED" {
+ default = ""
+}
group "default" {
targets = ["api", "worker", "beat", "frontend", "proxy", "mcp", "operator"]
@@ -26,12 +52,21 @@ group "default" {
target "_common" {
platforms = split(",", PLATFORMS)
- labels = {
- "org.opencontainers.image.source" = SOURCE_URL
- "org.opencontainers.image.revision" = GIT_REVISION
- "org.opencontainers.image.version" = VERSION
- "org.opencontainers.image.created" = timestamp()
- }
+ # Omit org.opencontainers.image.created entirely when CREATED is empty instead
+ # of stamping an empty-string label: an empty value is spec-invalid and
+ # falsifies the label table in docs/DOCKER.md. A plain `docker buildx bake`
+ # (e.g. `make push-images`, which does not set CREATED) must not emit the key
+ # at all; CI sets CREATED to the release commit's committer date. This is the
+ # same conditional shape the ROLLING_TAG tags use below (bonnyr-f5 #181 round
+ # 5, F7).
+ labels = merge(
+ {
+ "org.opencontainers.image.source" = SOURCE_URL
+ "org.opencontainers.image.revision" = GIT_REVISION
+ "org.opencontainers.image.version" = VERSION
+ },
+ CREATED != "" ? { "org.opencontainers.image.created" = CREATED } : {},
+ )
}
target "_backend" {
diff --git a/docker-compose.adr424.yml b/docker-compose.adr424.yml
index f9c34e46..2143aeb6 100644
--- a/docker-compose.adr424.yml
+++ b/docker-compose.adr424.yml
@@ -8,7 +8,7 @@
# ports: !override [...] → replace host port mapping
# image: adr424-* → project-scoped image tags
#
-# Entrypoint: https://localhost:11443 (admin / changeme)
+# Entrypoint: https://localhost:11443 (admin / password generated on first start — see backend logs)
# Bring up command:
# docker compose -p adr424 \
# -f docker-compose.yml -f docker-compose.local.yml -f docker-compose.adr424.yml \
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index 0fa31a8b..1315795c 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -38,6 +38,20 @@ x-local-backend-env: &local-backend-env
CELERY_BROKER_URL: redis://:${REDIS_PASSWORD:-bnkforge_redis_dev}@redis:6379/0
CELERY_RESULT_BACKEND: redis://:${REDIS_PASSWORD:-bnkforge_redis_dev}@redis:6379/0
TF_PLUGIN_CACHE_DIR: /app/provider-cache
+ # #186: plumb the operator-chosen initial admin password to the backend
+ # (config.py has no env_file, so an unpassed var never reaches the container).
+ DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-}
+ # #186: plumb the must-change gate too, or the seeded admin owes a password
+ # change no route accepts (login is exempt; every other /api route 403s).
+ # Defaults to "true" (secure); ephemeral CI overrides to "false" to let the
+ # e2e suite reach protected routes.
+ DEFAULT_ADMIN_MUST_CHANGE: ${DEFAULT_ADMIN_MUST_CHANGE:-true}
+ # #186/#187 (bonnyr-f5 r5): the backend reconciles the mcp account to
+ # MCP_SERVICE_PASSWORD on every boot and seeds it from the same .env value the
+ # MCP server authenticates with; plumb it (and the username) here or the backend
+ # generates a random secret the mcp client can never match. Unset -> not seeded.
+ MCP_SERVICE_USERNAME: ${MCP_SERVICE_USERNAME:-mcp}
+ MCP_SERVICE_PASSWORD: ${MCP_SERVICE_PASSWORD:-}
# Shared bridge network — all services can resolve each other by service name
networks:
@@ -143,8 +157,9 @@ services:
BNK_FORGE_USERNAME: ${MCP_SERVICE_USERNAME:-mcp}
# MCP authenticates as the dedicated service account seeded by the backend.
# Set MCP_SERVICE_PASSWORD in .env; backend reconciles the stored hash on
- # every startup so backend and MCP always stay in sync.
- BNK_FORGE_PASSWORD: ${MCP_SERVICE_PASSWORD:-mcp-service-changeme}
+ # every startup so backend and MCP always stay in sync. No shipped default
+ # (#186): the old mcp-service-changeme can no longer authenticate.
+ BNK_FORGE_PASSWORD: ${MCP_SERVICE_PASSWORD:-}
MCP_PORT: "8081"
postgres-backup:
diff --git a/docker-compose.yml b/docker-compose.yml
index c74594a9..4d7ccb9d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,6 +18,22 @@ x-backend-env: &backend-env
CELERY_BROKER_URL: redis://:${REDIS_PASSWORD:-bnkforge_redis_dev}@localhost:6379/0
CELERY_RESULT_BACKEND: redis://:${REDIS_PASSWORD:-bnkforge_redis_dev}@localhost:6379/0
TF_PLUGIN_CACHE_DIR: /app/provider-cache
+ # #186: plumb the operator-chosen initial admin password to the backend
+ # (config.py has no env_file, so an unpassed var never reaches the container).
+ DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-}
+ # #186: plumb the must-change gate too, or the seeded admin owes a password
+ # change no route accepts (login is exempt; every other /api route 403s).
+ # Defaults to "true" (secure); ephemeral CI overrides to "false" to let the
+ # e2e suite reach protected routes.
+ DEFAULT_ADMIN_MUST_CHANGE: ${DEFAULT_ADMIN_MUST_CHANGE:-true}
+ # #186/#187 (bonnyr-f5 r5): the BACKEND must receive MCP_SERVICE_* too, not just
+ # the mcp service. ensure_service_user reconciles the stored hash to
+ # MCP_SERVICE_PASSWORD every boot; the backend seeds the `mcp` service account
+ # from these and the MCP server authenticates with the SAME .env values. config.py
+ # has no env_file, so an unpassed var never reaches the container. Unset -> empty
+ # -> the account isn't seeded and the MCP server can't auth (clean, not a weak default).
+ MCP_SERVICE_USERNAME: ${MCP_SERVICE_USERNAME:-mcp}
+ MCP_SERVICE_PASSWORD: ${MCP_SERVICE_PASSWORD:-}
x-worker-volumes: &worker-volumes
# Module catalog persisted in Docker volume
@@ -457,9 +473,11 @@ services:
BNK_FORGE_API_URL: http://localhost:8000
BNK_FORGE_USERNAME: ${MCP_SERVICE_USERNAME:-mcp}
# MCP authenticates as the dedicated service account seeded by the backend.
- # Set MCP_SERVICE_PASSWORD in .env; backend reconciles the stored hash on
- # every startup so backend and MCP always stay in sync.
- BNK_FORGE_PASSWORD: ${MCP_SERVICE_PASSWORD:-mcp-service-changeme}
+ # Set MCP_SERVICE_PASSWORD in .env (no shipped default -- #186/#187: the old
+ # mcp-service-changeme can no longer authenticate). The backend seeds the
+ # 'mcp' service account with the same value and reconciles its hash on every
+ # startup so backend and MCP always stay in sync.
+ BNK_FORGE_PASSWORD: ${MCP_SERVICE_PASSWORD:-}
MCP_PORT: "8081"
MCP_LOG_LEVEL: INFO
depends_on:
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 99599aa3..6535358d 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -56,9 +56,21 @@ Need the host itself provisioned too? [`vm-bnk-forge/`](../vm-bnk-forge/README.m
| Field | Value |
|-------|-------|
| **Username** | `admin` |
-| **Password** | `changeme` |
+| **Password** | _generated on first startup_ |
-**You must change the admin password on first login.** Navigate to Settings → Change Password.
+No default password ships (#184). Where to retrieve it depends on how the account
+was provisioned:
+
+- **Helm** — the chart generates a per-install `admin-password` Secret and wires it to
+ the backend, which seeds `admin` from it (or, on upgrade from a build that shipped a
+ default, rotates `admin` to it). That Secret value is what authenticates:
+ `kubectl get secret -bnk-forge-secrets -o jsonpath='{.data.admin-password}' | base64 -d`
+- **Compose** — if you set `DEFAULT_ADMIN_PASSWORD`, that is the password. Otherwise the
+ backend generates one and writes it to a file (the plaintext is never logged — only a
+ pointer to the file is):
+ `docker exec bnk-forge-backend cat /app/keys/initial_admin_password`
+
+**The API refuses all other calls until you change it on first login** — Settings → Change Password.
---
@@ -247,13 +259,20 @@ MCP has two distinct readiness layers:
A deployment can pass layer 1 and still fail layer 2 if MCP credentials are out
of sync with backend credentials.
-Current compose defaults assume backend seeded admin credentials (`admin/changeme`).
-If you rotate the admin password (recommended), also set MCP credentials in your
-runtime environment before deploy/restart:
+The backend runs MCP as its OWN dedicated **service account** (`mcp`), never the
+human admin login (#186/#187) — MCP no longer authenticates with the admin
+password, so rotating `admin` does not affect it. The backend seeds/reconciles
+that account from `MCP_SERVICE_PASSWORD` on every boot; no default ships, so MCP
+stays disabled until you set a real value. Set it in your runtime environment
+before deploy/restart — the SAME value is read by the backend (which provisions
+the `mcp` account from it) and by the MCP container. Do **not** point
+`MCP_SERVICE_USERNAME` at `admin`: the backend refuses to reconcile a reserved
+human username as a service account (it would otherwise take over the admin row),
+which would leave MCP down. Keep the dedicated default name `mcp`:
```bash
-MCP_USERNAME=admin
-MCP_PASSWORD=
+MCP_SERVICE_USERNAME=mcp # optional; defaults to "mcp"
+MCP_SERVICE_PASSWORD=
```
Then recreate MCP:
@@ -340,7 +359,7 @@ Before deploying to production:
- [ ] Configure `MODULE_LIBRARY_GIT_URL` and `MODULE_LIBRARY_GIT_REF`
- [ ] Set `HOST_REPO_PATH` if you want GUI upgrades
- [ ] Set strong `POSTGRES_PASSWORD` and `REDIS_PASSWORD` in `.env`
-- [ ] If backend admin password changed, set matching `MCP_USERNAME` / `MCP_PASSWORD` for MCP runtime
+- [ ] Set a strong `MCP_SERVICE_PASSWORD` — MCP runs as its own dedicated `mcp` account, never `admin` (reserved; the backend refuses it and MCP stays down) — #186/#187
- [ ] After MCP credential changes, recreate MCP (`make mcp-recreate` or `make local-mcp-recreate`)
- [ ] Run `make mcp-readiness` and confirm runtime tool calls pass
- [ ] Ensure firewall rules allow ports 80/443 only from trusted networks
diff --git a/docs/DOCKER.md b/docs/DOCKER.md
index f25eaeb7..76d52643 100644
--- a/docs/DOCKER.md
+++ b/docs/DOCKER.md
@@ -58,7 +58,7 @@ docker build --target worker --build-arg INSTALL_INFRACOST=true -t bnk-forge-wor
## Keyless Image Signing, SBOM, and Provenance
-BNK Forge images published to the registry are signed with **keyless cosign** (Sigstore Fulcio +
+BNK Forge images published **from v4.0.0 onward** (the first release cut through the signing pipeline) are signed with **keyless cosign** (Sigstore Fulcio +
Rekor transparency log). No long-lived signing key is stored — the signature is bound to the
OIDC identity of whoever ran the publish script at the time of signing.
@@ -83,36 +83,43 @@ The script signs each image by digest (not tag) and attaches two attestations:
### Verifying signatures (consumers)
-Replace `` with the email of the person who signed the images (visible in the
-Rekor transparency log entry), and `` with the image digest.
+Replace `` with the image digest you're verifying. You do **not** fill in a
+signer — official images are signed by the release workflow (`release.yml`), and the
+commands below already pin that identity with `--certificate-identity-regexp … release.yml@…`.
+
+> **Note:** this verifies images published by CI. If a maintainer signed an image
+> locally via the manual path above (`SIGN_EXECUTE=1`), it is bound to *that
+> person's* OIDC identity, not the workflow's, so it will not match the regexp
+> here — verify it with `--certificate-identity ` instead. Official
+> releases always go through `release.yml`.
```bash
# Verify the signature
cosign verify \
- ghcr.io/jlcode-tech/bnk-forge-api@ \
- --certificate-identity \
- --certificate-oidc-issuer https://github.com/login/oauth
+ ghcr.io/f5devcentral/bnk-forge-api@ \
+ --certificate-identity-regexp 'https://github.com/f5devcentral/bnk-forge/\.github/workflows/release\.yml@.*' \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com
# Verify + extract the SBOM attestation
cosign verify-attestation \
--type cyclonedx \
- --certificate-identity \
- --certificate-oidc-issuer https://github.com/login/oauth \
- ghcr.io/jlcode-tech/bnk-forge-api@ \
+ --certificate-identity-regexp 'https://github.com/f5devcentral/bnk-forge/\.github/workflows/release\.yml@.*' \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+ ghcr.io/f5devcentral/bnk-forge-api@ \
| jq -r '.payload' | base64 -d | jq .
# Verify + extract the SLSA provenance attestation
cosign verify-attestation \
--type slsaprovenance \
- --certificate-identity \
- --certificate-oidc-issuer https://github.com/login/oauth \
- ghcr.io/jlcode-tech/bnk-forge-api@ \
+ --certificate-identity-regexp 'https://github.com/f5devcentral/bnk-forge/\.github/workflows/release\.yml@.*' \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+ ghcr.io/f5devcentral/bnk-forge-api@ \
| jq -r '.payload' | base64 -d | jq .
```
Apply the same commands to the other image names:
`bnk-forge-worker`, `bnk-forge-beat`, `bnk-forge-frontend`, `bnk-forge-proxy`,
-`bnk-forge-mcp`.
+`bnk-forge-mcp`, `bnk-forge-operator`.
### OCI Labels
@@ -123,7 +130,7 @@ All images carry standard OCI labels injected at build time via `docker-bake.hcl
| `org.opencontainers.image.source` | `https://github.com/f5devcentral/bnk-forge` |
| `org.opencontainers.image.revision` | git commit SHA (`GIT_REVISION` bake arg) |
| `org.opencontainers.image.version` | `VERSION` file contents |
-| `org.opencontainers.image.created` | RFC 3339 timestamp of the build |
+| `org.opencontainers.image.created` | RFC 3339 build timestamp (`CREATED` bake arg). Emitted only when set — CI release builds set it to the release commit's committer date; a plain `make push-images` leaves it unset and the label is omitted rather than written empty. |
Inject `GIT_REVISION` when calling bake:
diff --git a/docs/E2E-CRITICAL-004_MCP_SANITY.md b/docs/E2E-CRITICAL-004_MCP_SANITY.md
index 42c00a68..e2f19d73 100644
--- a/docs/E2E-CRITICAL-004_MCP_SANITY.md
+++ b/docs/E2E-CRITICAL-004_MCP_SANITY.md
@@ -87,8 +87,8 @@ mcp-server/tests/
|----------|----------|---------|---------|
| `MCP_E2E` | Yes | `false` | Gate for E2E tests (skip in unit runs) |
| `API_BASE_URL` | Yes | `http://localhost:8000` | Backend API target |
-| `MCP_USERNAME` | Yes | `admin` | Auth credentials |
-| `MCP_PASSWORD` | Yes | `changeme` | Auth credentials |
+| `MCP_USERNAME` | Yes | `mcp` | Dedicated MCP service-account username (never `admin`, #187) |
+| `MCP_SERVICE_PASSWORD` | Yes | _(no default, #187)_ | Shared secret: backend seeds the mcp account, MCP server authenticates with it |
---
diff --git a/docs/How to write CI container runner modules and blueprints for BNK Forge.md b/docs/How to write CI container runner modules and blueprints for BNK Forge.md
index 43578306..48a4e79e 100644
--- a/docs/How to write CI container runner modules and blueprints for BNK Forge.md
+++ b/docs/How to write CI container runner modules and blueprints for BNK Forge.md
@@ -654,9 +654,14 @@ The container engine is deliberately constrained:
which is the default for most base images*). The workspace is mounted from the host, so
a root container would be a host-root write primitive. Forge does **not** silently remap
you to another uid with `--user`: that would override your image's `USER` and break your
- own state writes. So: put `USER ` in your Dockerfile. uid **1000** matches the
- workspace owner and is the safe choice. This mirrors Kubernetes `runAsNonRoot`, which the
- Kubernetes runner applies to the same artifacts.
+ own state writes. So: put a **numeric** `USER` in your Dockerfile. The gate requires a bare
+ decimal uid — uid **1000** matches the workspace owner and is the safe choice. A **named**
+ user such as `USER nonroot` (the distroless default) is now **refused**: a name can't be
+ resolved to a uid without the image's own `/etc/passwd`, so it can't be proven non-root.
+ If you were on `USER nonroot`, switch to `USER 1000` — it matches the workspace owner (chowned
+ `1000:1000`), so your state writes under `mount_path` succeed. A higher uid such as `65532` clears
+ the non-root gate but cannot write the host-mounted workspace.
+ This mirrors Kubernetes `runAsNonRoot`, which the Kubernetes runner applies to the same artifacts.
- **A dedicated network** — steps attach to the `bnk-forge-artifacts` bridge network rather
than the daemon's default bridge, so artifact containers don't sit alongside unrelated
containers. Egress still works (you can reach cloud control planes); you just don't share
diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
index c3e1e2fa..4445ccdd 100644
--- a/docs/INSTALLATION.md
+++ b/docs/INSTALLATION.md
@@ -27,7 +27,7 @@ cd bnk-forge
make local-deploy
```
-Open **https://localhost** and accept the self-signed certificate warning. Log in with **admin** / **changeme**.
+Open **https://localhost** and accept the self-signed certificate warning. Log in as **admin** — no default password ships; retrieve the generated one from `/app/keys/initial_admin_password` (the boot log points at this file; the plaintext is never logged), or set `DEFAULT_ADMIN_PASSWORD`. You'll change it on first login.
### Linux Server
@@ -39,7 +39,7 @@ make deploy
For first-time clean-slate bootstrap only (destructive), run `make install`.
-Log in with **admin** / **changeme** (you'll be prompted to change the password).
+Log in as **admin** using the generated password from `/app/keys/initial_admin_password` (or set `DEFAULT_ADMIN_PASSWORD`); you'll be prompted to change it on first login.
---
@@ -124,9 +124,33 @@ A default admin account is created automatically on first startup:
| Field | Value |
|-------|-------|
| **Username** | `admin` |
-| **Password** | `changeme` |
+| **Password** | _generated on first startup_ |
-You will be prompted to change the password on first login.
+No default password ships. Where the password comes from — and where to retrieve
+it — depends on whether `DEFAULT_ADMIN_PASSWORD` is set:
+
+- **`DEFAULT_ADMIN_PASSWORD` set** (Helm always sets it, wiring it from the
+ chart's per-install `admin-password` Secret): the backend seeds `admin` from
+ that value — and, on upgrade from a build that shipped a default password,
+ rotates `admin` to it. No keys-file is written. Retrieve it from that source:
+
+ ```bash
+ # Helm (the admin-password Secret is the source of truth)
+ kubectl get secret -bnk-forge-secrets -o jsonpath='{.data.admin-password}' | base64 -d
+ ```
+
+- **`DEFAULT_ADMIN_PASSWORD` unset** (Docker Compose default): the backend
+ generates a random password and writes it to `/app/keys/initial_admin_password`
+ (mode 600); the boot log points at that file (the plaintext itself is never
+ logged). Retrieve it from the file:
+
+ ```bash
+ # Docker Compose
+ docker exec bnk-forge-backend cat /app/keys/initial_admin_password
+ ```
+
+You will be **required** to change it on first login (the API refuses other calls
+until you do).
### Managing Your Local Deployment
@@ -225,7 +249,7 @@ sudo firewall-cmd --reload
Access from any browser: `https://your-server-ip`
-Log in with **admin** / **changeme** (you'll be prompted to change the password).
+Log in as **admin** using the generated password from `/app/keys/initial_admin_password` (or set `DEFAULT_ADMIN_PASSWORD`); you'll be prompted to change it on first login.
Accept the self-signed certificate warning, or replace the certs with your own (see proxy/Dockerfile).
@@ -264,7 +288,7 @@ server topology). The VM path applies the same hardening this guide describes:
key-only with root login disabled, and the GitHub deploy key is shredded once
the clone completes.
-The default credentials (`admin` / `changeme`) and the Docker-socket mount
+The generated admin credentials (see the setup notes above) and the Docker-socket mount
still apply — read the README's security notes before giving such a VM a
public address.
@@ -348,11 +372,11 @@ After starting BNK Forge for the first time:
### 1. Log In
-Open the application URL and log in with the default credentials:
-- **Username:** `admin`
-- **Password:** `changeme`
-
-You will be prompted to set a new password on first login.
+Open the application URL and log in as **`admin`**. There is no default
+password: retrieve the one generated on first startup —
+`docker exec bnk-forge-backend cat /app/keys/initial_admin_password` (compose),
+or `kubectl get secret -bnk-forge-secrets -o jsonpath='{.data.admin-password}' | base64 -d` (Helm).
+You will be **required** to set a new password before the API accepts other calls.
### 2. Connect a Kubernetes Cluster
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 12a346f2..555a229e 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -7,7 +7,7 @@
Source sweep: memories + ADRs (D-001…D-028) + GitHub issues + open PRs, 2026-06-12.
**Deep doc sweep 2026-06-03:** swept `docs/specs/`, sprint plans, and strategic docs; statuses **code-verified** before assignment (many specs that read as "proposed" are in fact already built — see §10). New gap issues from the sweep: #216/#217/#218.
**2026-06-09 sync:** D-021/D-022 fleet epics shipped (PRs #276/#277); D-027 zero-toast shipped (PRs #260/#261/#278); D-028 unified blueprint catalog shipped (PR #274); D-001 Phase 3 / D-019 E1/E3/E6 / Ops MCP+celery / AWS cred-expiry UX all shipped. D-023 (classic BIG-IP) + D-020 (F5 design-system) + benchmark experience remain in-flight.
-**2026-06-12 sync:** cb-rebuild → staging de-stacking COMPLETE; new `customer-build` integration line live on localhost (staging + all open PRs + customer deltas); multi-arch images published `ghcr.io/jlcode-tech 3.1.6-cb.72b29dbb` + rolling `customer-build`. D-023 P1-P3 shipped (PR #280); alembic dedupe shipped (PR #283). 13 PRs in review: #282 (scenario override guard), #284 (BNK registry-driven GA/ReleaseRegistry), #285 (multiarch publish), #286 (benchmark remote agent-host + Slice-4 auth), #287 (AI-Analyzer rename + Migration tab fix), #288 (dist uninstall-purge fix), #290 (D-023 P4 CIS coverage), #291 (Dashboard Command Center fleet section), #292 (bfb-cache atime/LRU fix), #293 (runtime brand flag #289), #295 (CIS IngressClass kind-aware classify), #296 (top-level Infrastructure section), #297 (release automation — team decision pending). D-020 reskin branch fully rebuilt 2026-06-10/11 (complete reskin + anvil ForgeLogo general rebrand).
+**2026-06-12 sync:** cb-rebuild → staging de-stacking COMPLETE; new `customer-build` integration line live on localhost (staging + all open PRs + customer deltas); multi-arch images published `ghcr.io/f5devcentral 3.1.6-cb.72b29dbb` + rolling `customer-build`. D-023 P1-P3 shipped (PR #280); alembic dedupe shipped (PR #283). 13 PRs in review: #282 (scenario override guard), #284 (BNK registry-driven GA/ReleaseRegistry), #285 (multiarch publish), #286 (benchmark remote agent-host + Slice-4 auth), #287 (AI-Analyzer rename + Migration tab fix), #288 (dist uninstall-purge fix), #290 (D-023 P4 CIS coverage), #291 (Dashboard Command Center fleet section), #292 (bfb-cache atime/LRU fix), #293 (runtime brand flag #289), #295 (CIS IngressClass kind-aware classify), #296 (top-level Infrastructure section), #297 (release automation — team decision pending). D-020 reskin branch fully rebuilt 2026-06-10/11 (complete reskin + anvil ForgeLogo general rebrand).
**Human view (clickable):** `docs/roadmap.html` · **Contribution flow:** `docs/ROADMAP_PROCESS.md` · (local per-clone agent queue: `.agent/backlog/BACKLOG.md`).
---
@@ -28,7 +28,7 @@ Source sweep: memories + ADRs (D-001…D-028) + GitHub issues + open PRs, 2026-0
| **Ops: MCP service account + celery-beat healthcheck** | ✅ Shipped | [PR #214](https://github.com/f5devcentral/bnk-forge/issues/214) | Shipped. PR #214 merged. Dedicated non-human `mcp` account (admin-rotation no longer breaks MCP auth) + mtime-freshness beat healthcheck. |
| **Benchmark experience / security hardening** | 🟡 In progress | [PR #211](https://github.com/f5devcentral/bnk-forge/issues/211) · [PR #251](https://github.com/f5devcentral/bnk-forge/issues/251) · [PR #282](https://github.com/f5devcentral/bnk-forge/issues/282) · [PR #286](https://github.com/f5devcentral/bnk-forge/issues/286) · [#294](https://github.com/f5devcentral/bnk-forge/issues/294) | PR #282 (scenario override guard security fix) + PR #286 (remote agent-host provisioning + built-in agent + Slice-4 auth + ported tests) in review. PR #251 (authz/WS-auth/atomic-claim/TLS+SSRF) folds into #211; gated on a maintainer driving #211→staging. #294 (benchmarks page IA + New Run wizard port) deferred pending user decision. |
| **GHCR customer-build publish target + postgres-backup compose drift fix** | ✅ Shipped | [PR #242](https://github.com/f5devcentral/bnk-forge/issues/242) · [PR #243](https://github.com/f5devcentral/bnk-forge/issues/243) | Shipped. PR #242 (postgres-backup compose drift fix) + PR #243 (GHCR customer-build publish target) merged. |
-| **Multi-arch image publish + dist uninstall-purge fix + install messaging** | 🟡 In progress | [PR #285](https://github.com/f5devcentral/bnk-forge/issues/285) · [PR #288](https://github.com/f5devcentral/bnk-forge/issues/288) | PR #285 (push-customer-build-multiarch target: amd64+arm64 to ghcr.io/jlcode-tech) + PR #288 (uninstall --purge deletes wrong compose volumes fix + stale install messaging) in review. |
+| **Multi-arch image publish + dist uninstall-purge fix + install messaging** | 🟡 In progress | [PR #285](https://github.com/f5devcentral/bnk-forge/issues/285) · [PR #288](https://github.com/f5devcentral/bnk-forge/issues/288) | PR #285 (push-customer-build-multiarch target: amd64+arm64 to ghcr.io/f5devcentral) + PR #288 (uninstall --purge deletes wrong compose volumes fix + stale install messaging) in review. |
| **Alembic migration deduplication (v2_131)** | ✅ Shipped | [PR #283](https://github.com/f5devcentral/bnk-forge/issues/283) | Shipped. PR #283 merged. Deduped v2_130 collision (benchmark_run_groups renumbered → v2_131); broken staging migration head fixed. |
| **Release automation — RC-on-staging / final-on-main (conventional-commit bumps)** | 🟡 In progress | [PR #297](https://github.com/f5devcentral/bnk-forge/issues/297) | PR #297 open; team decision pending — may be closed in favor of manual release flow. |
| **D-021 — existing-proxy discovery & migration to BNK (P1+P2+P3)** | ✅ Shipped | [#233](https://github.com/f5devcentral/bnk-forge/issues/233) · [PR #276](https://github.com/f5devcentral/bnk-forge/issues/276) · ADR D-021 | Shipped. PR #276 consolidated (closes epic #233). Proxy discovery, migration path to BNK, P1/P2/P3 complete. |
@@ -48,7 +48,7 @@ Source sweep: memories + ADRs (D-001…D-028) + GitHub issues + open PRs, 2026-0
| **awsbnkctl review follow-up** | 💤 Deferred | [#155](https://github.com/f5devcentral/bnk-forge/issues/155) | SimpleNamespace shim hardening + configured-shape contract test. (awsbnkctl side: SSO refresh-token bootstrap — sibling repo.) |
| **Review follow-ups (deferred polish)** | 💤 Deferred | [#153](https://github.com/f5devcentral/bnk-forge/issues/153) · [#154](https://github.com/f5devcentral/bnk-forge/issues/154) · [#156](https://github.com/f5devcentral/bnk-forge/issues/156) · [#157](https://github.com/f5devcentral/bnk-forge/issues/157) | #153 (#144 TTFT/ports) · #154 (#146 _kind_to_snake) · #156 (#151 MCP envelope sweep) · #157 (#148 BNK substring/prefix). Non-blocking; created 2026-05-27. |
| **Module catalog auto-sync on boot + wire blueprint wizard 'Sync' CTA** | ⚪ Planned | [#419](https://github.com/f5devcentral/bnk-forge/issues/419) | Fresh install / volume wipe leaves the git module catalog (bnk/app/infra packs) un-synced, so BNK/app blueprints are DOA until an operator runs Catalog→Advanced→Modules→'Sync all'. The wizard's 'requires sync' prompt is wired to no endpoint. Fix: (1) boot-time auto-sync step (non-fatal, ref-aware) after builtin seeders; (2) wire wizard CTA to POST /api/module-library/sync. Separate from the d019/adr-204 execution_engine seeder-guard fix. |
-| **CI container runner engine — security hardening follow-ups** | ⚪ Planned | [#408](https://github.com/f5devcentral/bnk-forge/issues/408) · [PR #340](https://github.com/f5devcentral/bnk-forge/issues/340) | Non-blocking follow-ups from the #340 review (all prior blockers verified fixed pre-merge). Priority: non-root Docker gate bypass via `USER 0:` (host-root primitive), `state.outputs_file` path traversal (arbitrary worker-file read), registry `/test` cross-operator credential exfil + SSRF, install-script PAT/password xtrace leak, K8s deny-all egress netpol breaks the artifact. Plus same-project cluster-name clobber + nits. |
+| **CI container runner engine — security hardening follow-ups** | 🟢 Merged (unreleased) | [#408](https://github.com/f5devcentral/bnk-forge/issues/408) · [PR #340](https://github.com/f5devcentral/bnk-forge/issues/340) | Non-blocking follow-ups from the #340 review (all prior blockers verified fixed pre-merge). Priority: non-root Docker gate bypass via `USER 0:` (host-root primitive), `state.outputs_file` path traversal (arbitrary worker-file read), registry `/test` cross-operator credential exfil + SSRF, install-script PAT/password xtrace leak, K8s deny-all egress netpol breaks the artifact. Plus same-project cluster-name clobber + nits. |
| **Multi-version module catalog — immutable module versions, exact pin resolution (ADR D-033)** | 🟡 In progress | [#433](https://github.com/f5devcentral/bnk-forge/issues/433) · [PR #436](https://github.com/f5devcentral/bnk-forge/issues/436) | Module identity becomes (source, path, version); hashed rows immutable; blueprints resolve pins exactly (BLUEPRINT_MODULE_VERSION_MISSING); ProjectModule FK becomes a true pin with explicit change-version action + UI/MCP. Combined PR #436 (supersedes stacked #434/#435). ADR: docs/adr/D-033-multi-version-module-catalog.md (PR #432). |
| **Module test actions — vendor-CLI e2e/scenario/bench tests via pipeline (ADR D-034)** | ⚪ Planned | [#454](https://github.com/f5devcentral/bnk-forge/issues/454) · [PR #453](https://github.com/f5devcentral/bnk-forge/issues/453) | Container-artifact manifests gain a declarative actions block; container engine gains one generic action dispatcher; UI offers actions on post-apply modules (per-scenario + run-all-green, amber behind warning). v1 results = logs + pass/fail. Scaling = edit vars + re-apply, not an action. Tool-embedded tests → pipeline; external load (aiperf agents) stays in Benchmarks. Slices: PR-1 backend, PR-2 UI, PR-3 packs/docs. ADR: docs/adr/D-034-module-test-actions.md (PR #453). Sibling: #452 cluster auto-registration. |
| **Container-runner contract hardening — min_forge_version + declared capabilities** | ⚪ Planned | [#465](https://github.com/f5devcentral/bnk-forge/issues/465) | Phase 3 of the ctl-runner review. min_forge_version + capability requirements (e.g. wide docker-socket proxy) become machine-checkable artifact-manifest fields enforced at sync/import; Forge injects the proxy endpoint instead of external manifests hardcoding DOCKER_HOST; schema_version evolution policy lands in EXT-003. |
diff --git a/docs/ROADMAP_PROCESS.md b/docs/ROADMAP_PROCESS.md
index 8bdbe7a6..7451532b 100644
--- a/docs/ROADMAP_PROCESS.md
+++ b/docs/ROADMAP_PROCESS.md
@@ -34,7 +34,7 @@ backend/.venv/bin/python bin/roadmap-add.py \
```
- `--list-sections` prints the available section ids + headings.
-- `--status` must be one of: `shipped`, `in_progress`, `blocked`, `deferred`, `planned`.
+- `--status` must be one of: `shipped`, `merged`, `in_progress`, `blocked`, `deferred`, `planned` (the keys in `status_legend`; `merged` = merged to staging but unreleased).
- `--refs` is comma-separated; values like `#216` / `PR #188` become GitHub links.
- `--group` (optional) buckets the item into a named card on the HTML view.
- After adding, also update the **§12 issue index** (`render: raw`, edited by hand in the yaml) and re-run the generator so the index stays in sync.
@@ -49,7 +49,7 @@ backend/.venv/bin/python bin/roadmap-add.py \
## Status vocabulary
-✅ shipped · 🟡 in-progress / partial · ⛔ blocked (state the blocker) · 💤 deferred (state the resume-trigger) · ⚪ not-started / proposed.
+✅ shipped · 🟢 merged (merged to staging, unreleased) · 🟡 in-progress / partial · ⛔ blocked (state the blocker) · 💤 deferred (state the resume-trigger) · ⚪ not-started / proposed.
## Where things live
diff --git a/docs/roadmap.html b/docs/roadmap.html
index eb4ba5c0..126ff539 100644
--- a/docs/roadmap.html
+++ b/docs/roadmap.html
@@ -68,7 +68,7 @@
diff --git a/docs/roadmap.yaml b/docs/roadmap.yaml
index fee97197..cab7ad4a 100644
--- a/docs/roadmap.yaml
+++ b/docs/roadmap.yaml
@@ -17,7 +17,7 @@
# "AUTO:planned" are computed from item counts; any other
# value (e.g. "25+") is a static meta number.
# status_legend: key -> { emoji, dot, label } used for BOTH md + html.
-# keys: shipped | in_progress | blocked | deferred | planned
+# keys: shipped | merged | in_progress | blocked | deferred | planned
# sections: ordered list of
# - id: stable slug (used by roadmap-add.py --section)
# number: section number for the md heading "## N. ..."
@@ -55,7 +55,7 @@ meta:
**2026-06-09 sync:** D-021/D-022 fleet epics shipped (PRs #276/#277); D-027 zero-toast shipped (PRs #260/#261/#278); D-028 unified blueprint catalog shipped (PR #274); D-001 Phase 3 / D-019 E1/E3/E6 / Ops MCP+celery / AWS cred-expiry UX all shipped. D-023 (classic BIG-IP) + D-020 (F5 design-system) + benchmark experience remain in-flight.
- **2026-06-12 sync:** cb-rebuild → staging de-stacking COMPLETE; new `customer-build` integration line live on localhost (staging + all open PRs + customer deltas); multi-arch images published `ghcr.io/jlcode-tech 3.1.6-cb.72b29dbb` + rolling `customer-build`. D-023 P1-P3 shipped (PR #280); alembic dedupe shipped (PR #283). 13 PRs in review: #282 (scenario override guard), #284 (BNK registry-driven GA/ReleaseRegistry), #285 (multiarch publish), #286 (benchmark remote agent-host + Slice-4 auth), #287 (AI-Analyzer rename + Migration tab fix), #288 (dist uninstall-purge fix), #290 (D-023 P4 CIS coverage), #291 (Dashboard Command Center fleet section), #292 (bfb-cache atime/LRU fix), #293 (runtime brand flag #289), #295 (CIS IngressClass kind-aware classify), #296 (top-level Infrastructure section), #297 (release automation — team decision pending). D-020 reskin branch fully rebuilt 2026-06-10/11 (complete reskin + anvil ForgeLogo general rebrand).
+ **2026-06-12 sync:** cb-rebuild → staging de-stacking COMPLETE; new `customer-build` integration line live on localhost (staging + all open PRs + customer deltas); multi-arch images published `ghcr.io/f5devcentral 3.1.6-cb.72b29dbb` + rolling `customer-build`. D-023 P1-P3 shipped (PR #280); alembic dedupe shipped (PR #283). 13 PRs in review: #282 (scenario override guard), #284 (BNK registry-driven GA/ReleaseRegistry), #285 (multiarch publish), #286 (benchmark remote agent-host + Slice-4 auth), #287 (AI-Analyzer rename + Migration tab fix), #288 (dist uninstall-purge fix), #290 (D-023 P4 CIS coverage), #291 (Dashboard Command Center fleet section), #292 (bfb-cache atime/LRU fix), #293 (runtime brand flag #289), #295 (CIS IngressClass kind-aware classify), #296 (top-level Infrastructure section), #297 (release automation — team decision pending). D-020 reskin branch fully rebuilt 2026-06-10/11 (complete reskin + anvil ForgeLogo general rebrand).
**Human view (clickable):** `docs/roadmap.html` · **Contribution flow:** `docs/ROADMAP_PROCESS.md` · (local per-clone agent queue: `.agent/backlog/BACKLOG.md`).
@@ -77,6 +77,10 @@ status_legend:
emoji: ✅
dot: d-ship
label: Shipped
+ merged:
+ emoji: 🟢
+ dot: d-ship
+ label: Merged (unreleased)
in_progress:
emoji: 🟡
dot: d-prog
@@ -212,7 +216,7 @@ sections:
refs:
- 'PR #285'
- 'PR #288'
- note: 'PR #285 (push-customer-build-multiarch target: amd64+arm64 to ghcr.io/jlcode-tech) + PR #288 (uninstall --purge deletes wrong compose volumes fix + stale install messaging) in review.'
+ note: 'PR #285 (push-customer-build-multiarch target: amd64+arm64 to ghcr.io/f5devcentral) + PR #288 (uninstall --purge deletes wrong compose volumes fix + stale install messaging) in review.'
group: Top of queue — PRs open (CI-green)
- title: Alembic migration deduplication (v2_131)
status: shipped
@@ -353,7 +357,7 @@ sections:
- '#419'
note: 'Fresh install / volume wipe leaves the git module catalog (bnk/app/infra packs) un-synced, so BNK/app blueprints are DOA until an operator runs Catalog→Advanced→Modules→''Sync all''. The wizard''s ''requires sync'' prompt is wired to no endpoint. Fix: (1) boot-time auto-sync step (non-fatal, ref-aware) after builtin seeders; (2) wire wizard CTA to POST /api/module-library/sync. Separate from the d019/adr-204 execution_engine seeder-guard fix.'
- title: CI container runner engine — security hardening follow-ups
- status: planned
+ status: merged
refs:
- '#408'
- 'PR #340'
diff --git a/frontend-v2/package.json b/frontend-v2/package.json
index 4e25c0cf..7282abed 100644
--- a/frontend-v2/package.json
+++ b/frontend-v2/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend-v2",
"private": true,
- "version": "2.12.0",
+ "version": "3.1.6",
"type": "module",
"sideEffects": [
"*.css"
diff --git a/frontend-v2/src/types/api-generated.ts b/frontend-v2/src/types/api-generated.ts
index 1e16df98..44a03e85 100644
--- a/frontend-v2/src/types/api-generated.ts
+++ b/frontend-v2/src/types/api-generated.ts
@@ -22745,6 +22745,11 @@ export interface components {
role: string;
/** Is Active */
is_active: boolean;
+ /**
+ * Is Service Account
+ * @default false
+ */
+ is_service_account: boolean;
/** Must Change Password */
must_change_password: boolean;
/** Last Login At */
@@ -22808,6 +22813,11 @@ export interface components {
role: string;
/** Is Active */
is_active: boolean;
+ /**
+ * Is Service Account
+ * @default false
+ */
+ is_service_account: boolean;
/** Must Change Password */
must_change_password: boolean;
/** Last Login At */
@@ -22860,6 +22870,11 @@ export interface components {
role: string;
/** Is Active */
is_active: boolean;
+ /**
+ * Is Service Account
+ * @default false
+ */
+ is_service_account: boolean;
/** Must Change Password */
must_change_password: boolean;
/** Last Login At */
diff --git a/helm/bnk-forge/Chart.yaml b/helm/bnk-forge/Chart.yaml
index 950b44a2..40db248d 100644
--- a/helm/bnk-forge/Chart.yaml
+++ b/helm/bnk-forge/Chart.yaml
@@ -3,7 +3,7 @@ name: bnk-forge
description: BNK-Forge — F5 BNK lifecycle / deployment platform (api, workers, beat, frontend, proxy, mcp)
type: application
version: 0.1.0
-appVersion: "3.0.1"
+appVersion: "3.1.6"
home: https://github.com/f5devcentral/bnk-forge
maintainers:
- name: BNK Forge Maintainers
diff --git a/helm/bnk-forge/templates/NOTES.txt b/helm/bnk-forge/templates/NOTES.txt
index a6f4d8ba..72514875 100644
--- a/helm/bnk-forge/templates/NOTES.txt
+++ b/helm/bnk-forge/templates/NOTES.txt
@@ -18,7 +18,13 @@ To reach the UI:
open https://localhost:8443/
{{- end }}
-Default admin login: admin / changeme (change immediately via UI).
+Admin login: username 'admin'. Retrieve the password with:
+ kubectl get secret {{ include "bnk-forge.fullname" . }}-secrets -o jsonpath='{.data.admin-password}' | base64 -d
+This is the per-install value the backend seeds the 'admin' account with on a fresh
+install — and rotates it to if you upgraded from a build that shipped a default
+password — so it is what authenticates now. You will be required to change it on
+first login (the API refuses other calls until you do). After you change it, use your
+new password — the value above no longer authenticates.
IMPORTANT:
* Shared volumes use ReadWriteMany. Set global.sharedStorageClass to a
diff --git a/helm/bnk-forge/templates/_helpers.tpl b/helm/bnk-forge/templates/_helpers.tpl
index 8b6d01d6..6197d4c2 100644
--- a/helm/bnk-forge/templates/_helpers.tpl
+++ b/helm/bnk-forge/templates/_helpers.tpl
@@ -96,6 +96,42 @@ in-cluster services and pulls secrets from the generated Secret.
secretKeyRef:
name: {{ include "bnk-forge.fullname" . }}-secrets
key: encryption-key
+# #184: seed the admin account from a generated secret, never a shipped
+# default. Retrieve with:
+# kubectl get secret -bnk-forge-secrets -o jsonpath='{.data.admin-password}' | base64 -d
+- name: DEFAULT_ADMIN_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "bnk-forge.fullname" . }}-secrets
+ key: admin-password
+# #186: plumb the must-change gate alongside its sibling, or the seeded admin
+# owes a password change no route accepts (login is exempt; every other /api
+# route 403s). Not a secret -- a plain value. Quote so the bool renders "true"/
+# "false" (do NOT `default` it: a bool false collapses back to the default).
+- name: DEFAULT_ADMIN_MUST_CHANGE
+ # #186 (bonnyr-f5 r4): fall back to the secure "true" only when the value is
+ # nil/unset -- `| default true` cannot be used here because sprig `default`
+ # treats a bool false as empty and would silently flip an intentional false
+ # back to true. kindIs "invalid" is true only for nil, so an explicit false
+ # still renders "false"; nil no longer renders a bare `value:` that makes
+ # pydantic reject an empty string and the backend crashloop.
+ value: {{ if kindIs "invalid" .Values.secrets.adminMustChange }}{{ "true" | quote }}{{ else }}{{ .Values.secrets.adminMustChange | quote }}{{ end }}
+# #186 BLOCKER 1 / #187 (bonnyr-f5 r5): the backend reconciles the mcp service
+# account to MCP_SERVICE_PASSWORD on every boot, so it must read the SAME
+# per-install secret the mcp client (mcp.yaml) reads -- otherwise removing the
+# shipped `changeme` default just makes the backend generate its own secret the
+# client can never match ("removes the default without plumbing the replacement").
+# Source both from the release Secret's mcp-* keys, identical to mcp.yaml.
+- name: MCP_SERVICE_USERNAME
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "bnk-forge.fullname" . }}-secrets
+ key: mcp-username
+- name: MCP_SERVICE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "bnk-forge.fullname" . }}-secrets
+ key: mcp-password
- name: DATABASE_URL
value: "postgresql://bnkforge:$(POSTGRES_PASSWORD)@$(POSTGRES_HOST):5432/bnkforge"
- name: REDIS_URL
diff --git a/helm/bnk-forge/templates/api.yaml b/helm/bnk-forge/templates/api.yaml
index c685eebf..33940721 100644
--- a/helm/bnk-forge/templates/api.yaml
+++ b/helm/bnk-forge/templates/api.yaml
@@ -28,6 +28,12 @@ spec:
{{- include "bnk-forge.componentLabels" (list . "api") | nindent 6 }}
template:
metadata:
+ annotations:
+ # #187: roll the pod when the Secret changes, so the backend (which
+ # re-seeds the mcp account) and the MCP server (which authenticates with
+ # it) pick up a rotated mcp-password together instead of drifting for a
+ # restart cycle.
+ checksum/secret: {{ include (print $.Template.BasePath "/secrets.yaml") . | sha256sum }}
labels:
{{- include "bnk-forge.componentLabels" (list . "api") | nindent 8 }}
spec:
diff --git a/helm/bnk-forge/templates/beat.yaml b/helm/bnk-forge/templates/beat.yaml
index f3fc7613..99a68562 100644
--- a/helm/bnk-forge/templates/beat.yaml
+++ b/helm/bnk-forge/templates/beat.yaml
@@ -15,6 +15,11 @@ spec:
{{- include "bnk-forge.componentLabels" (list . "beat") | nindent 6 }}
template:
metadata:
+ annotations:
+ # #187 (bonnyr-f5 #188): beat consumes the same backendEnv Secret as the
+ # api/mcp pods, so it must roll on the one-shot changeme -> random rotation
+ # too — otherwise it keeps the old credential until something else restarts it.
+ checksum/secret: {{ include (print $.Template.BasePath "/secrets.yaml") . | sha256sum }}
labels:
{{- include "bnk-forge.componentLabels" (list . "beat") | nindent 8 }}
spec:
diff --git a/helm/bnk-forge/templates/mcp.yaml b/helm/bnk-forge/templates/mcp.yaml
index e62a3df7..552e1f24 100644
--- a/helm/bnk-forge/templates/mcp.yaml
+++ b/helm/bnk-forge/templates/mcp.yaml
@@ -29,6 +29,12 @@ spec:
{{- include "bnk-forge.componentLabels" (list . "mcp") | nindent 6 }}
template:
metadata:
+ annotations:
+ # #187: roll the pod when the Secret changes, so the backend (which
+ # re-seeds the mcp account) and the MCP server (which authenticates with
+ # it) pick up a rotated mcp-password together instead of drifting for a
+ # restart cycle.
+ checksum/secret: {{ include (print $.Template.BasePath "/secrets.yaml") . | sha256sum }}
labels:
{{- include "bnk-forge.componentLabels" (list . "mcp") | nindent 8 }}
spec:
diff --git a/helm/bnk-forge/templates/secrets.yaml b/helm/bnk-forge/templates/secrets.yaml
index 39531c55..f4fd6a70 100644
--- a/helm/bnk-forge/templates/secrets.yaml
+++ b/helm/bnk-forge/templates/secrets.yaml
@@ -1,30 +1,81 @@
{{/* Re-use existing secret values across upgrades. */}}
{{- $name := printf "%s-secrets" (include "bnk-forge.fullname" .) -}}
{{- $existing := lookup "v1" "Secret" .Release.Namespace $name -}}
+{{- /* Normalize the existing Secret's .data to a real map up front (bonnyr-f5):
+ a Secret can exist with NO .data map at all (nil) -- e.g. one created with
+ only stringData, or an empty placeholder -- in which case `index $existing.data`
+ and `hasKey $existing.data` both blow up with "index/hasKey of untyped nil"
+ and fail the whole render. Fold nil to an empty dict once, then every
+ per-key lookup below is a safe `hasKey $data ...` guard (which also covers
+ the NEW admin-password key that pre-#184 releases don't have). */ -}}
+{{- $data := dict -}}
+{{- if and $existing $existing.data -}}{{- $data = $existing.data -}}{{- end -}}
{{- $pgPass := .Values.secrets.postgresPassword -}}
-{{- if and (not $pgPass) $existing -}}
-{{- $pgPass = (index $existing.data "postgres-password" | b64dec) -}}
+{{- if and (not $pgPass) (hasKey $data "postgres-password") -}}
+{{- $pgPass = (index $data "postgres-password" | b64dec) -}}
{{- end -}}
{{- if not $pgPass -}}{{- $pgPass = randAlphaNum 24 -}}{{- end -}}
{{- $rdPass := .Values.secrets.redisPassword -}}
-{{- if and (not $rdPass) $existing -}}
-{{- $rdPass = (index $existing.data "redis-password" | b64dec) -}}
+{{- if and (not $rdPass) (hasKey $data "redis-password") -}}
+{{- $rdPass = (index $data "redis-password" | b64dec) -}}
{{- end -}}
{{- if not $rdPass -}}{{- $rdPass = randAlphaNum 24 -}}{{- end -}}
{{- $jwt := .Values.secrets.jwtSecretKey -}}
-{{- if and (not $jwt) $existing -}}
-{{- $jwt = (index $existing.data "jwt-secret-key" | b64dec) -}}
+{{- if and (not $jwt) (hasKey $data "jwt-secret-key") -}}
+{{- $jwt = (index $data "jwt-secret-key" | b64dec) -}}
{{- end -}}
{{- if not $jwt -}}{{- $jwt = randAlphaNum 64 -}}{{- end -}}
{{- $enc := .Values.secrets.encryptionKey -}}
-{{- if and (not $enc) $existing -}}
-{{- $enc = (index $existing.data "encryption-key" | b64dec) -}}
+{{- if and (not $enc) (hasKey $data "encryption-key") -}}
+{{- $enc = (index $data "encryption-key" | b64dec) -}}
{{- end -}}
{{- if not $enc -}}{{- $enc = randAlphaNum 32 -}}{{- end -}}
+{{- $adminPass := .Values.secrets.adminPassword -}}
+{{- if and (not $adminPass) (hasKey $data "admin-password") -}}
+{{- $adminPass = (index $data "admin-password" | b64dec) -}}
+{{- end -}}
+{{- if not $adminPass -}}{{- $adminPass = randAlphaNum 20 -}}{{- end -}}
+
+{{/* #186 BLOCKER 2 / #187 (bonnyr-f5): mcp-password must never be a shipped default
+ ("changeme" / "mcp-service-changeme" were live, publicly-known admin credentials).
+ Honour an operator-set value; else reuse the persisted one from a prior install
+ UNLESS it is a known default, in which case ROTATE it — an existing install carries
+ the default in its Secret and the mcp account is role=admin, must_change_password
+ =False, so #186's gate never rotates it (the chart must). Scope the rotate to the
+ PERSISTED value only, never a values-supplied one: randAlphaNum is non-idempotent,
+ so rotating a values.yaml "changeme" would emit a new password + checksum on EVERY
+ render -> perpetual GitOps drift and rolling pods (bonnyr-f5 #188 round 4). The
+ lookup goes through the normalized $data map (+ hasKey guard) so a Secret that
+ exists with a nil .data map can never blow up the render. This list MUST stay in
+ lockstep with MCP_KNOWN_DEFAULT_PASSWORDS in backend/core/config.py (round 5,
+ Major-2): validate_production treats every one of these as fatal under
+ ENVIRONMENT=production, so the chart must never emit one. */}}
+{{- $mcpDefaults := list "changeme" "mcp-service-changeme" -}}
+{{- $mcpPass := .Values.secrets.mcpPassword -}}
+{{- if and (not $mcpPass) (hasKey $data "mcp-password") -}}
+{{- $mcpFromSecret := (index $data "mcp-password" | b64dec) -}}
+{{- if has $mcpFromSecret $mcpDefaults -}}
+{{- $mcpPass = randAlphaNum 24 -}}
+{{- else -}}
+{{- $mcpPass = $mcpFromSecret -}}
+{{- end -}}
+{{- end -}}
+{{- if not $mcpPass -}}{{- $mcpPass = randAlphaNum 24 -}}{{- end -}}
+{{/* bonnyr-f5 #188 round 5 (Major-2): an operator who PINS a shipped default in
+ values.yaml is not rotated above (that path only fires for a value carried in the
+ persisted Secret; rotating a values-supplied value re-drifts every sync). But
+ config.py makes that same value fatal at import under ENVIRONMENT=production ->
+ api/worker/beat crashloop. Refuse it at render time so the chart and the fail-fast
+ AGREE: a known default never reaches a pod. Deterministic (unlike a rotate), so it
+ introduces no drift — it fails identically until the operator sets a real value. */}}
+{{- if has $mcpPass $mcpDefaults -}}
+{{- fail "secrets.mcpPassword is set to a shipped default (\"changeme\" / \"mcp-service-changeme\"); the backend rejects it as fatal under ENVIRONMENT=production. Set secrets.mcpPassword to a strong value (the same value the MCP server gets as BNK_FORGE_PASSWORD), or leave it empty to auto-generate one." -}}
+{{- end -}}
+
apiVersion: v1
kind: Secret
metadata:
@@ -37,5 +88,6 @@ stringData:
redis-password: {{ $rdPass | quote }}
jwt-secret-key: {{ $jwt | quote }}
encryption-key: {{ $enc | quote }}
+ admin-password: {{ $adminPass | quote }}
mcp-username: {{ .Values.secrets.mcpUsername | quote }}
- mcp-password: {{ .Values.secrets.mcpPassword | quote }}
+ mcp-password: {{ $mcpPass | quote }}
diff --git a/helm/bnk-forge/templates/worker.yaml b/helm/bnk-forge/templates/worker.yaml
index 492bd6d7..53f412b5 100644
--- a/helm/bnk-forge/templates/worker.yaml
+++ b/helm/bnk-forge/templates/worker.yaml
@@ -13,6 +13,11 @@ spec:
{{- include "bnk-forge.componentLabels" (list . "worker") | nindent 6 }}
template:
metadata:
+ annotations:
+ # #187 (bonnyr-f5 #188): worker consumes the same backendEnv Secret as the
+ # api/mcp pods, so it must roll on the one-shot changeme -> random rotation
+ # too — otherwise it keeps the old credential until something else restarts it.
+ checksum/secret: {{ include (print $.Template.BasePath "/secrets.yaml") . | sha256sum }}
labels:
{{- include "bnk-forge.componentLabels" (list . "worker") | nindent 8 }}
spec:
diff --git a/helm/bnk-forge/values.yaml b/helm/bnk-forge/values.yaml
index bb880602..29af9ea8 100644
--- a/helm/bnk-forge/values.yaml
+++ b/helm/bnk-forge/values.yaml
@@ -16,7 +16,7 @@ global:
image:
pullPolicy: IfNotPresent
- tag: "3.0.1"
+ tag: "3.1.6"
# Generated/explicit secrets. If left empty, helm generates random values on
# first install and reuses them on upgrade (lookup-based).
@@ -25,8 +25,23 @@ secrets:
redisPassword: ""
jwtSecretKey: ""
encryptionKey: ""
- mcpUsername: admin
- mcpPassword: changeme
+ # Empty -> generated on first install and reused on upgrade. The seeded admin
+ # account is must_change_password; retrieve this to log in the first time.
+ adminPassword: ""
+ # #186: whether the seeded admin must change its password before using the API.
+ # Keep true on any real deployment (the shipped-default hazard #184 closes).
+ # Set false only for an ephemeral test stack seeding a known throwaway admin.
+ adminMustChange: true
+ # #186 BLOCKER 2/3 / #187 (bonnyr-f5 r5): NEVER ship a default admin credential.
+ # - mcpUsername was `admin`: it pointed the MCP client at the human admin
+ # account and, once the backend receives MCP_SERVICE_USERNAME, made
+ # ensure_service_user rewrite the human admin row (BLOCKER 3). The MCP
+ # client authenticates as its own dedicated, non-human service account.
+ # - mcpPassword was `changeme`: a live, publicly-known credential on a public
+ # repo. Empty -> generated per-install in the release Secret (mirrors
+ # adminPassword) and reused on upgrade, so no shipped default ever exists.
+ mcpUsername: mcp
+ mcpPassword: ""
# Common pod settings
# fsGroup=1000 so PVC-backed shared volumes are writable by the bnkforge user (UID 1000).
diff --git a/mcp-server/README.md b/mcp-server/README.md
index 62eea793..3a57736a 100644
--- a/mcp-server/README.md
+++ b/mcp-server/README.md
@@ -56,10 +56,11 @@ readiness verification explicit and repeatable.
- `ping` + `tools/list` pass, but `system_version`/`list_clusters` fail with `auth_error`:
MCP transport is up, but runtime auth/bootstrap is not ready.
-- Typical cause: MCP container credentials do not match current backend credentials
- (for example after rotating admin password).
-- Action: set `MCP_USERNAME` / `MCP_PASSWORD` for the MCP service and recreate the
- `mcp` container, then rerun smoke.
+- Typical cause: the MCP service-account password drifted from the backend's seeded
+ value (e.g. `MCP_SERVICE_PASSWORD` changed on one side only). MCP uses its own
+ dedicated `mcp` service account, never the human admin login (#187).
+- Action: set `MCP_USERNAME=mcp` / `MCP_PASSWORD` (backend `MCP_SERVICE_PASSWORD`) for
+ the MCP service and recreate the `mcp` container, then rerun smoke.
### Scope boundaries (intentional)
@@ -94,14 +95,15 @@ pytest tests/
- MCP runtime is healthy only when **both** conditions are true:
1. MCP JSON-RPC endpoint responds (`ping`)
2. MCP can authenticate to backend and execute governed read-only tools
-- If backend admin password is changed (recommended), MCP credentials must be
- updated too (`MCP_USERNAME` / `MCP_PASSWORD` or `BNK_FORGE_TOKEN`).
+- The MCP service-account password (`MCP_SERVICE_PASSWORD`, exposed to MCP as
+ `MCP_PASSWORD`/`BNK_FORGE_PASSWORD`) must match what the backend seeded, or use
+ `BNK_FORGE_TOKEN`. It is decoupled from the human admin password (#187).
- Without this alignment, the MCP container may look healthy at protocol level
while tool execution fails with backend login 401.
### Credential rotation runbook (bounded)
-When backend admin password is rotated:
+When the MCP service-account password (`MCP_SERVICE_PASSWORD`) is rotated:
1. Update MCP runtime credentials in environment (`MCP_USERNAME`, `MCP_PASSWORD`)
2. Recreate MCP so new env values are applied:
diff --git a/mcp-server/src/bnk_forge_mcp/healthcheck.py b/mcp-server/src/bnk_forge_mcp/healthcheck.py
index 69cac3af..49d1ae77 100644
--- a/mcp-server/src/bnk_forge_mcp/healthcheck.py
+++ b/mcp-server/src/bnk_forge_mcp/healthcheck.py
@@ -33,12 +33,25 @@ def probe() -> int:
"""
config = load_config()
- if not config.has_credentials:
- # No credentials at all — can't probe; treat as healthy so we don't
- # flip unhealthy on token-only deployments.
- logger.info("no credentials configured; skipping auth probe")
+ # A bearer token is a self-sufficient auth path. When one is set, the server
+ # authenticates tool calls with it regardless of the password, so a drifted (or
+ # absent) password must NOT fail the healthcheck. This probe can only exercise
+ # username/password via /api/auth/login, so with a token present we skip it and
+ # report healthy; the token is validated on real tool calls. (bonnyr-f5 #188:
+ # previously the token+stale-password row was inverted — the password probe ran
+ # and reported a token-authenticated container UNHEALTHY.)
+ if config.has_token:
+ logger.info("token auth configured — no login probe to run, reporting healthy")
return 0
+ if not config.has_credentials:
+ # bonnyr-f5 #188: neither password NOR token. With MCP_SERVICE_PASSWORD now
+ # shipping empty by default, this means the MCP server CANNOT authenticate —
+ # every tool call 401s. Reporting healthy here made a default `make deploy`
+ # show a green mcp container that does nothing. Fail the probe instead.
+ logger.error("no MCP credentials configured — cannot authenticate to the backend")
+ return 1
+
try:
resp = httpx.post(
f"{config.api_base_url}/api/auth/login",
diff --git a/mcp-server/tests/test_healthcheck.py b/mcp-server/tests/test_healthcheck.py
index 9900d97a..edb8b428 100644
--- a/mcp-server/tests/test_healthcheck.py
+++ b/mcp-server/tests/test_healthcheck.py
@@ -104,27 +104,81 @@ def test_probe_returns_1_when_backend_unreachable() -> None:
# ------------------------------------------------------------------
-def test_probe_returns_0_when_no_credentials_configured() -> None:
- """No username/password set (token-only deployment) → skip probe, return 0."""
+def test_probe_returns_0_for_token_only_deployment() -> None:
+ """No username/password but a bearer token IS set → skip the login probe, return 0.
+
+ The probe authenticates via /api/auth/login (username/password only), so it
+ cannot exercise a token; a token-only deployment is healthy and validated on
+ real tool calls, not here.
+ """
with patch("bnk_forge_mcp.healthcheck.load_config") as mock_cfg:
cfg = MagicMock()
cfg.has_credentials = False
+ cfg.has_token = True
mock_cfg.return_value = cfg
assert probe() == 0
-def test_probe_no_credentials_logs_skip(caplog) -> None: # type: ignore[no-untyped-def]
- """No credentials → info log is emitted so a typo'd env var is greppable."""
+def test_probe_token_only_logs_skip(caplog) -> None: # type: ignore[no-untyped-def]
+ """Token-only → info log is emitted so the skip is greppable."""
import logging
with patch("bnk_forge_mcp.healthcheck.load_config") as mock_cfg:
cfg = MagicMock()
cfg.has_credentials = False
+ cfg.has_token = True
mock_cfg.return_value = cfg
with caplog.at_level(logging.INFO, logger="bnk_forge_mcp.healthcheck"):
result = probe()
assert result == 0
- assert "no credentials configured" in caplog.text
+ assert "token auth configured" in caplog.text
+
+
+def test_probe_returns_0_when_token_present_even_with_stale_password() -> None:
+ """bonnyr-f5 #188: token + a (possibly stale) password → skip the login probe.
+
+ A bearer token is a self-sufficient auth path; the server authenticates tool
+ calls with it regardless of the password. The previous truth table ran the
+ password probe here and reported a token-authenticated container UNHEALTHY when
+ the password had drifted. It must report healthy and never hit the backend.
+ """
+ with patch("bnk_forge_mcp.healthcheck.httpx.post") as mock_post, \
+ patch("bnk_forge_mcp.healthcheck.load_config") as mock_cfg:
+ cfg = MagicMock()
+ cfg.has_credentials = True # a password IS set...
+ cfg.has_token = True # ...but a token is present too
+ mock_cfg.return_value = cfg
+ assert probe() == 0
+ mock_post.assert_not_called() # never probes /api/auth/login
+
+
+def test_probe_returns_1_when_no_auth_configured() -> None:
+ """Neither password NOR token → MCP cannot authenticate at all → exit 1 (UNHEALTHY).
+
+ bonnyr-f5 #188: MCP_SERVICE_PASSWORD ships empty by default, so a default
+ deploy with no token would 401 on every tool call. Report UNHEALTHY, not green.
+ """
+ with patch("bnk_forge_mcp.healthcheck.load_config") as mock_cfg:
+ cfg = MagicMock()
+ cfg.has_credentials = False
+ cfg.has_token = False
+ mock_cfg.return_value = cfg
+ assert probe() == 1
+
+
+def test_probe_no_auth_logs_error(caplog) -> None: # type: ignore[no-untyped-def]
+ """No auth at all → error log names the cause so a typo'd env var is greppable."""
+ import logging
+
+ with patch("bnk_forge_mcp.healthcheck.load_config") as mock_cfg:
+ cfg = MagicMock()
+ cfg.has_credentials = False
+ cfg.has_token = False
+ mock_cfg.return_value = cfg
+ with caplog.at_level(logging.ERROR, logger="bnk_forge_mcp.healthcheck"):
+ result = probe()
+ assert result == 1
+ assert "cannot authenticate" in caplog.text
# ------------------------------------------------------------------
diff --git a/scripts/compute_version_bump.sh b/scripts/compute_version_bump.sh
index e8888827..b6fc83c9 100755
--- a/scripts/compute_version_bump.sh
+++ b/scripts/compute_version_bump.sh
@@ -75,6 +75,44 @@ bump_version() {
esac
}
+# ── Breaking-change detectors ────────────────────────────────────────────────
+# INV-15: _is_breaking_subject and _is_breaking_body MUST stay byte-identical to
+# the copies in extract-breaking-changes.sh. This is an invariant these two files
+# must uphold themselves; the CI job that DIFFS the two copies and fails on any
+# drift lands with #182 (bonnyr-f5 #179 r6 F4) and is NOT present in this tree, so
+# until #182 merges keep the two copies in lock-step by hand.
+#
+# A marker counts as a real footer under two anchors:
+# * preceded by a BLANK line -> accepted with OR without a colon (keeps the #2
+# no-colon paragraph break);
+# * preceded by another TRAILER, or folded directly onto a conventional-commit
+# SUBJECT -> accepted ONLY with a colon (catches a footer folded onto a scoped
+# subject `fix(core): x`, bonnyr-f5 #179 r6 F1, while rejecting a prose header
+# `Before:` / `Note:` followed by colon-less prose, r6 F2).
+# Wrapped prose (a marker after a PROSE line) is still rejected. _is_breaking_subject
+# is BANG-ONLY: the folded-footer-in-subject is caught by running _is_breaking_body
+# on %B (which preserves the newline git folds into %s), and scanning the raw subject
+# for the marker over-bumped on `docs: clarify what BREAKING CHANGE: means` (r5 Minor 1).
+_is_breaking_subject() {
+ grep -qE '^[A-Za-z]+(\([^)]*\))?!:' <<< "$1"
+}
+_is_breaking_body() {
+ awk '
+ BEGIN { prev_blank = 1; prev_trailer = 0 }
+ /^[[:space:]]*$/ { prev_blank = 1; prev_trailer = 0; next }
+ {
+ is_marker = ($0 ~ /^([*-][[:space:]]+)?(\*\*)?BREAKING[[:space:] -]+CHANGE/)
+ is_colon = ($0 ~ /^([*-][[:space:]]+)?(\*\*)?BREAKING[[:space:] -]+CHANGE(\*\*)?:/)
+ is_trailer = ($0 ~ /^[A-Za-z0-9][A-Za-z0-9-]*:([[:space:]]|$)/)
+ if (prev_blank && is_marker) found = 1
+ else if (prev_trailer && is_colon) found = 1
+ is_subject = (NR == 1 && $0 ~ /^[A-Za-z]+(\([^)]*\))?!?:[[:space:]]/)
+ prev_blank = 0; prev_trailer = (is_trailer || is_subject)
+ }
+ END { exit(found ? 0 : 1) }
+ ' <<< "$1"
+}
+
# ── Resolve baseline + since-tag ─────────────────────────────────────────────
# Skipped entirely in SELF_TEST mode: the self-test runner below exercises
# this same script recursively against isolated temp repos, so evaluating it
@@ -88,6 +126,22 @@ else
SINCE_TAG=$(last_final_tag)
fi
+# Fail closed on an unresolvable SINCE_TAG. Without this, an unknown ref makes
+# `git log ..HEAD` empty (2>/dev/null || true), so BOTH the bump loop and
+# the consistency guard read nothing and silently return patch -- a typo in the
+# floor tag would ship a release derived from a range that was never read.
+if [[ -n "$SINCE_TAG" ]] && ! git rev-parse --verify --quiet "${SINCE_TAG}^{commit}" >/dev/null; then
+ echo "::error::SINCE_TAG '${SINCE_TAG}' does not resolve to a commit in this repo -- refusing to derive a version from an empty range." >&2
+ exit 1
+fi
+
+# A resolvable SINCE_TAG whose range is empty (tag == HEAD) still slips through as
+# patch -- a phantom duplicate release (bonnyr-f5 #179). Refuse the empty range.
+if [[ -n "$SINCE_TAG" ]] && [[ -z "$(git log "${SINCE_TAG}..HEAD" --format='%H' 2>/dev/null)" ]]; then
+ echo "::error::Range ${SINCE_TAG}..HEAD is empty (tag == HEAD?) -- refusing to derive a duplicate release." >&2
+ exit 1
+fi
+
if [[ -n "$BASELINE_OVERRIDE" ]]; then
BASELINE="$BASELINE_OVERRIDE"
elif [[ -n "$SINCE_TAG" ]]; then
@@ -108,32 +162,48 @@ fi
BUMP_TYPE="patch"
if [[ -z "$SINCE_TAG" ]]; then
- RANGE_HASHES=$(git log --pretty=format:"%H" 2>/dev/null || true)
+ RANGE_HASHES=$(git log --format='%H' 2>/dev/null || true)
else
- RANGE_HASHES=$(git log "${SINCE_TAG}..HEAD" --pretty=format:"%H" 2>/dev/null || true)
+ RANGE_HASHES=$(git log "${SINCE_TAG}..HEAD" --format='%H' 2>/dev/null || true)
fi
while IFS= read -r sha; do
[[ -z "$sha" ]] && continue
subject=$(git log -1 --format="%s" "$sha" 2>/dev/null || true)
- body=$(git log -1 --format="%b" "$sha" 2>/dev/null || true)
+ # Read the FULL raw message (%B), not just %b: a footer git folded into %s
+ # keeps its newline in %B, so _is_breaking_body catches a folded footer there,
+ # and scanning %B (not the subject) for the marker stops subject prose from
+ # over-matching (bonnyr-f5 #179 r5).
+ message=$(git log -1 --format='%B' "$sha" 2>/dev/null || true)
- # Major: `type!:` in the subject, OR a BREAKING CHANGE / BREAKING-CHANGE marker
- # anywhere in the message (footer or deliberate prose).
- if echo "$subject" | grep -qE '^[a-z]+(\([^)]*\))?!:' \
- || printf '%s\n%s\n' "$subject" "$body" | grep -qE '\bBREAKING[[:space:] -]+CHANGE\b'; then
+ # Major: a `type!:` bang subject, OR a footer-anchored BREAKING CHANGE in the
+ # full message. Both checks are pipe-free here-strings on purpose (PR #177
+ # review): `foo | grep -q` under `set -o pipefail` takes SIGPIPE (141) when grep
+ # matches early on a large body, which pipefail turns into a failed test, so
+ # *finding* the marker silently kept the bump at patch. Detection lives in
+ # _is_breaking_subject / _is_breaking_body (byte-identical to the extractor).
+ if _is_breaking_subject "$subject" || _is_breaking_body "$message"; then
BUMP_TYPE="major"
break
fi
# Minor: feat: in the subject (type is declared in the subject, never the body).
if [[ "$BUMP_TYPE" != "major" ]]; then
- if echo "$subject" | grep -qE '^feat(\([^)]*\))?:'; then
+ if grep -qE '^feat(\([^)]*\))?:' <<< "$subject"; then
BUMP_TYPE="minor"
fi
fi
done <<< "$RANGE_HASHES"
+# NOTE (bonnyr-f5 #179 r6 F6): a second "consistency guard" loop used to sit here,
+# re-deriving "is any commit breaking" and refusing to ship if it disagreed with
+# BUMP_TYPE. It was REMOVED as provably dead code: it iterated the SAME
+# RANGE_HASHES in the SAME order, called the SAME detectors, and broke on the SAME
+# first hit -- so guard_breaking=1 implies the loop above already saw that commit
+# first and set BUMP_TYPE=major, making the `guard_breaking && != major` condition
+# unsatisfiable. It could never fire and no fixture could reach it, so it added a
+# full second range re-scan for zero defence rather than genuine independence.
+
# ── Compute target version ────────────────────────────────────────────────────
TARGET_VERSION=$(bump_version "$BASELINE" "$BUMP_TYPE")
@@ -147,6 +217,13 @@ fi
if [[ "${SELF_TEST:-0}" == "1" ]]; then
echo ""
echo "=== SELF-TEST ==="
+ SELFTEST_FAILURES=0
+ SELFTEST_ASSERTIONS=0
+
+ # Resolve this script's own absolute path from BASH_SOURCE BEFORE unsetting
+ # SELF_TEST, so the recursive invocations below can find it regardless of cwd
+ # (the old "$OLDPWD/$(dirname "$0")" broke any non-cwd-relative call).
+ SELFTEST_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
# SELF_TEST is an inherited environment variable — without unsetting it
# here, run_test's recursive script invocations below would also enter
@@ -156,6 +233,8 @@ if [[ "${SELF_TEST:-0}" == "1" ]]; then
run_test() {
local desc="$1" expected_bump="$2" expected_ver="$3"
local since="$4" baseline="$5" commits_str="$6"
+ local _b
+ SELFTEST_ASSERTIONS=$((SELFTEST_ASSERTIONS + 1))
# Create a temp dir with a fake git repo for deterministic testing
local tmpdir
tmpdir=$(mktemp -d)
@@ -175,21 +254,32 @@ if [[ "${SELF_TEST:-0}" == "1" ]]; then
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
if [[ "$entry" == *"~~BODY~~"* ]]; then
+ # A body may use a literal \n escape for a real newline: entries are
+ # split on newlines, so a raw one would fork into extra commits.
+ _b="${entry#*~~BODY~~}"
+ _b=${_b//\\n/$'\n'}
git -C "$tmpdir" commit --allow-empty \
- -m "${entry%%~~BODY~~*}" -m "${entry#*~~BODY~~}" -q
+ -m "${entry%%~~BODY~~*}" -m "$_b" -q
else
- git -C "$tmpdir" commit --allow-empty -m "$entry" -q
+ # A plain entry may embed a literal \n for a FOLDED single message:
+ # subject and footer on consecutive lines with NO blank between, so git
+ # folds them into %s (leaving %b empty) but %B keeps the newline. One -m
+ # with an embedded newline reproduces exactly that shape.
+ git -C "$tmpdir" commit --allow-empty -m "${entry//\\n/$'\n'}" -q
fi
done <<< "$(echo "$commits_str" | tr ',' '\n')"
# Run the version computer inside the temp repo so it scans the fake range.
local result
- result=$(cd "$tmpdir" && bash "$OLDPWD/$(dirname "$0")/$(basename "$0")" \
+ # Invoke by absolute path resolved from BASH_SOURCE, so the self-test works
+ # regardless of cwd or how the script was called ($OLDPWD/$0 broke any
+ # non-cwd-relative invocation -- bonnyr-f5 #179 r3 nit).
+ result=$(cd "$tmpdir" && bash "$SELFTEST_SCRIPT" \
${since:+--since-tag "$since"} --baseline "$baseline" 2>/dev/null || true)
local got_bump got_ver
- got_bump=$(echo "$result" | grep BUMP_TYPE | cut -d= -f2)
- got_ver=$(echo "$result" | grep TARGET_VERSION | cut -d= -f2)
+ got_bump=$(echo "$result" | grep BUMP_TYPE | cut -d= -f2 || true)
+ got_ver=$(echo "$result" | grep TARGET_VERSION | cut -d= -f2 || true)
rm -rf "$tmpdir"
@@ -199,6 +289,7 @@ if [[ "${SELF_TEST:-0}" == "1" ]]; then
echo " FAIL: $desc"
echo " expected bump=$expected_bump ver=$expected_ver"
echo " got bump=$got_bump ver=$got_ver"
+ SELFTEST_FAILURES=$((SELFTEST_FAILURES + 1))
fi
}
@@ -214,8 +305,17 @@ if [[ "${SELF_TEST:-0}" == "1" ]]; then
run_test "breaking ! → major" "major" "2.0.0" "v1.2.3" "1.2.3" \
"feat!: redesign API,fix(ui): icon"
- # Test 4: no commits → patch bump
- run_test "no commits → patch" "patch" "1.2.4" "v1.2.3" "1.2.3" ""
+ # Test 4: empty range (tag == HEAD) → the guard refuses (no output).
+ run_test "empty range (tag==HEAD) → refused" "" "" "v1.2.3" "1.2.3" ""
+
+ # Test 4b (bonnyr-f5 INV-15): a marker in SUBJECT prose only must NOT bump.
+ run_test "marker in subject prose → patch" "patch" "1.2.4" "v1.2.3" "1.2.3" "docs: explain the BREAKING CHANGE footer"
+
+ # Test 4c (r5 Minor 1): a docs subject that quotes `BREAKING CHANGE:` with a
+ # colon must NOT over-bump. The old subject match was unanchored across the
+ # whole subject; the detector is now bang-only, and %B is a single subject line
+ # so the body anchor finds no footer either.
+ run_test "docs subject quotes marker → patch" "patch" "1.2.4" "v1.2.3" "1.2.3" "docs: clarify what BREAKING CHANGE: means"
# Test 5: BREAKING CHANGE footer in the BODY → major (the PR #177 bug: a
# fix-subject commit whose body declares the break must still bump major).
@@ -227,5 +327,109 @@ if [[ "${SELF_TEST:-0}" == "1" ]]; then
run_test "lowercase breaking change in body → patch" "patch" "1.2.4" "v1.2.3" "1.2.3" \
"fix: tidy up~~BODY~~this is explicitly not a breaking change"
+ # Test 7: marker on an early LINE with ~90 KB of lines after it -- the real
+ # SIGPIPE bug (PR #177 review). grep is line-oriented, so with the marker on
+ # line 1 it matches and exits while the writer still has the tail to push; the
+ # pipe form takes SIGPIPE (141) and reads it as "no match". Deterministically
+ # wrong once the tail clears the 64 KB pipe buffer. A single ~94 KB *line*
+ # would NOT reproduce it (grep must read the whole line before deciding), so
+ # the tail must be many lines. 400 lines x ~200 bytes = ~80 KB comfortably
+ # clears the 64 KB buffer while running ~4x fewer loop iterations than the old
+ # 1500x60 form (bonnyr-f5 #179 r6 runtime nit).
+ _line=$(head -c 200
+ # colon path. Red under r5, green under r6.
+ run_test "F1: scoped folded footer → major" "major" "2.0.0" "v1.2.3" "1.2.3" \
+ "feat(api): drop v1\\nBREAKING CHANGE: all /api/v1 removed"
+
+ # Test 9b (F1 control): the same folded footer WITHOUT a colon on a scoped subject
+ # must NOT trigger major -- the trailer/subject anchor demands a colon (only the
+ # blank-line anchor accepts a colon-less marker), which is what keeps F2 inert. A
+ # `fix(...)` subject is used (not `feat`) so the expected floor is a clean patch
+ # rather than the minor a feat subject would independently earn.
+ run_test "F1 control: scoped folded colonless → patch" "patch" "1.2.4" "v1.2.3" "1.2.3" \
+ "fix(api): drop v1\\nBREAKING CHANGE happened here"
+
+ # Test 10 (bonnyr-f5 #179 r6 F2 MAJOR): a prose section header (`Before:`) is
+ # trailer-shaped, but a colon-LESS marker following it is prose, not a footer, so
+ # it must stay patch. This is the round-4 false positive the r6 trailer->colon
+ # rule closes. Green only because the marker below has no colon.
+ run_test "F2: prose header + colonless marker → patch" "patch" "1.2.4" "v1.2.3" "1.2.3" \
+ "docs: explain migration~~BODY~~Before:\\nBREAKING CHANGE was matched anywhere before."
+
+ # Test 11 (bonnyr-f5 #179 r6 F7 MINOR): a real footer with the separator widened
+ # -- a DOUBLE space `BREAKING CHANGE:` -- must still derive major (the base regex
+ # only allowed a single space/hyphen).
+ run_test "F7: double-space marker → major" "major" "2.0.0" "v1.2.3" "1.2.3" \
+ "fix: rework flags~~BODY~~BREAKING CHANGE: the --legacy flag was removed"
+
+ # Test 11b (F7): a markdown BULLET marker `- BREAKING CHANGE:` (release notes copy
+ # bullets footers) must still derive major.
+ run_test "F7: dash-bullet marker → major" "major" "2.0.0" "v1.2.3" "1.2.3" \
+ "fix: rework flags~~BODY~~- BREAKING CHANGE: the --legacy flag was removed"
+
+ # Test 8 (guard coverage — bonnyr-f5 #179 r3): an unresolvable --since-tag must
+ # fail CLOSED (rc 1, no BUMP_TYPE output), not derive patch from an empty range.
+ # This exercises the unresolvable-SINCE_TAG guard, which previously had none.
+ _grd_tmp=$(mktemp -d)
+ git init -q "$_grd_tmp"
+ git -C "$_grd_tmp" config user.email "selftest@bnk-forge.local"
+ git -C "$_grd_tmp" config user.name "bnk-forge self-test"
+ git -C "$_grd_tmp" commit --allow-empty -m "initial" -q
+ SELFTEST_ASSERTIONS=$((SELFTEST_ASSERTIONS + 1))
+ _grd_rc=0
+ _grd_out=$(cd "$_grd_tmp" && bash "$SELFTEST_SCRIPT" --since-tag v9.9.9 --baseline 1.0.0 2>/dev/null) || _grd_rc=$?
+ rm -rf "$_grd_tmp"
+ if [[ "$_grd_rc" -ne 0 && -z "$_grd_out" ]]; then
+ echo " PASS: unresolvable --since-tag fails closed (rc=$_grd_rc, no output)"
+ else
+ echo " FAIL: unresolvable --since-tag should refuse (got rc=$_grd_rc out='$_grd_out')"
+ SELFTEST_FAILURES=$((SELFTEST_FAILURES + 1))
+ fi
+
echo "=== END SELF-TEST ==="
+ # INV-16: a harness that runs zero assertions must not report success. This
+ # catches a self-test that silently no-ops (e.g. a renamed run_test); #182's
+ # script-selftests job additionally asserts the PASS count and this END marker
+ # externally, so renaming the SELF_TEST guard itself is caught there too.
+ if [[ "$SELFTEST_ASSERTIONS" -eq 0 ]]; then
+ echo "SELF-TEST: zero assertions ran — harness is dead" >&2
+ exit 1
+ fi
+ if [[ "$SELFTEST_FAILURES" -ne 0 ]]; then
+ echo "SELF-TEST: ${SELFTEST_FAILURES} failure(s)" >&2
+ exit 1
+ fi
+ echo "compute_version_bump self-test: OK (${SELFTEST_ASSERTIONS} assertions)"
fi
diff --git a/scripts/e2e/steps.py b/scripts/e2e/steps.py
index b9cac951..1a861378 100644
--- a/scripts/e2e/steps.py
+++ b/scripts/e2e/steps.py
@@ -169,10 +169,11 @@ def step_login(ctx: Context) -> StepResult:
"""Log in as admin and store JWT on the client.
Handles the freshly-seeded `must_change_password=True` case by
- rotating the password to itself. After `make install` the admin
- user is `admin` / `changeme` with the flag set; without this the
- harness would deadlock on the first authed request, and operators
- would have to bounce through the UI before re-running."""
+ rotating the password to itself. The admin password is no longer a
+ fixed default (#184): set DEFAULT_ADMIN_PASSWORD when deploying the
+ target to the same value as `bnk_forge_admin_password` (BNK_FORGE_PASSWORD),
+ or point that config at the generated password. Without the rotation
+ the harness would deadlock on the first authed request."""
with StepRecorder("login") as r:
body = ctx.client.login(
ctx.cfg.bnk_forge_admin_user,
diff --git a/scripts/extract-breaking-changes.sh b/scripts/extract-breaking-changes.sh
index b0c71c6c..674274bf 100644
--- a/scripts/extract-breaking-changes.sh
+++ b/scripts/extract-breaking-changes.sh
@@ -10,27 +10,290 @@
# Prints a "### ⚠️ Breaking Changes" section, or nothing if there are none.
set -euo pipefail
+# ── Breaking-change detectors ────────────────────────────────────────────────
+# INV-15: _is_breaking_subject and _is_breaking_body MUST stay byte-identical to
+# the copies in compute_version_bump.sh. This is an invariant these two files must
+# uphold themselves -- if the extractor is narrower a break bumps the major with no
+# note; if wider a note appears with no bump. The CI job that DIFFS the two copies
+# and fails on any drift lands with #182 (bonnyr-f5 #179 r6 F4); it is not present
+# in this tree, so until #182 merges the invariant is enforced only by review and
+# by the shared self-test fixtures below -- keep the two copies in lock-step by hand.
+#
+# A marker counts as a real footer under two anchors (byte-identical to compute):
+# * preceded by a BLANK line -> accepted with OR without a colon (keeps the #2
+# no-colon paragraph break);
+# * preceded by another TRAILER, or folded directly onto a conventional-commit
+# SUBJECT -> accepted ONLY with a colon (catches a footer folded onto a scoped
+# subject `fix(core): x`, bonnyr-f5 #179 r6 F1, while rejecting a prose header
+# `Before:` / `Note:` followed by colon-less prose, r6 F2).
+# Wrapped prose (a marker after a PROSE line) is still rejected. _is_breaking_subject
+# is BANG-ONLY: the folded-footer-in-subject is caught by running _is_breaking_body
+# on %B (which preserves the newline git folds into %s), and scanning the raw subject
+# for the marker over-bumped on `docs: clarify what BREAKING CHANGE: means` (r5 Minor 1).
+_is_breaking_subject() {
+ grep -qE '^[A-Za-z]+(\([^)]*\))?!:' <<< "$1"
+}
+_is_breaking_body() {
+ awk '
+ BEGIN { prev_blank = 1; prev_trailer = 0 }
+ /^[[:space:]]*$/ { prev_blank = 1; prev_trailer = 0; next }
+ {
+ is_marker = ($0 ~ /^([*-][[:space:]]+)?(\*\*)?BREAKING[[:space:] -]+CHANGE/)
+ is_colon = ($0 ~ /^([*-][[:space:]]+)?(\*\*)?BREAKING[[:space:] -]+CHANGE(\*\*)?:/)
+ is_trailer = ($0 ~ /^[A-Za-z0-9][A-Za-z0-9-]*:([[:space:]]|$)/)
+ if (prev_blank && is_marker) found = 1
+ else if (prev_trailer && is_colon) found = 1
+ is_subject = (NR == 1 && $0 ~ /^[A-Za-z]+(\([^)]*\))?!?:[[:space:]]/)
+ prev_blank = 0; prev_trailer = (is_trailer || is_subject)
+ }
+ END { exit(found ? 0 : 1) }
+ ' <<< "$1"
+}
+
+# Emit the BREAKING CHANGE footer paragraph(s) -- flattened, markdown-bold
+# stripped. Takes the FULL raw message (%B). Capture uses the SAME start rule as
+# _is_breaking_body so trigger and note never disagree: a marker preceded by a
+# blank line anchors with or without a colon; a marker in the trailer block
+# (preceded by another trailer, or folded directly onto a conventional-commit
+# subject) anchors ONLY with a colon. It then captures the WHOLE footer paragraph,
+# including ordinary prose continuation lines that merely contain a colon
+# (`migration: ...`). The round-4 note stopped at the FIRST trailer-shaped
+# continuation line, truncating 7ece9b04's real bullet mid-sentence (bonnyr-f5
+# #179 r5 Major 2).
+#
+# F5 (bonnyr-f5 #179 r6): the note now STOPS at the first REAL git-trailer line
+# rather than merely stripping a TRAILING trailer run -- a trailer block FOLLOWED
+# by prose (`BREAKING CHANGE: x` / `Co-Authored-By: a@b` / `then prose`) used to
+# leak the address because the trailing-strip loop halted on the closing prose
+# line. A "real" trailer is a capitalized `Word(-Word)*:` key (Co-Authored-By,
+# Signed-off-by, Reviewed-by, Acked-by, Cc, Claude-Session, Change-Id, X-* and the
+# no-space `Session:` form all match); a lowercase-prose colon line (`migration:`)
+# does NOT match, so mid-footer prose continuations are kept (r5 Major 2 stays
+# green). Marker lines are excluded from the stop so a hyphen-form `BREAKING-CHANGE:`
+# is never mistaken for a trailer. A trailing CR is stripped so a CRLF commit
+# message does not carry `\r` into CHANGELOG.md.
+_breaking_note() {
+ awk '
+ BEGIN { prev_blank = 1; prev_trailer = 0 }
+ {
+ sub(/\r$/, "")
+ if ($0 ~ /^[[:space:]]*$/) { if (p) para_end = 1; prev_blank = 1; prev_trailer = 0; next }
+ is_marker = ($0 ~ /^([*-][[:space:]]+)?(\*\*)?BREAKING[[:space:] -]+CHANGE/)
+ is_colon = ($0 ~ /^([*-][[:space:]]+)?(\*\*)?BREAKING[[:space:] -]+CHANGE(\*\*)?:/)
+ is_trailer = ($0 ~ /^[A-Za-z0-9][A-Za-z0-9-]*:([[:space:]]|$)/)
+ if (!p) {
+ if ((prev_blank && is_marker) || (prev_trailer && is_colon)) { p = 1; print }
+ } else if (para_end) {
+ if (prev_blank && is_marker) { print ""; print; para_end = 0 } else { exit }
+ } else { print }
+ is_subject = (NR == 1 && $0 ~ /^[A-Za-z]+(\([^)]*\))?!?:[[:space:]]/)
+ prev_blank = 0; prev_trailer = (is_trailer || is_subject)
+ }
+ ' <<< "$1" \
+ | awk '{ a[NR] = $0 } END {
+ n = NR
+ for (i = 1; i <= NR; i++) {
+ if (a[i] ~ /^[A-Z][A-Za-z0-9]*(-[A-Za-z0-9]+)*:([[:space:]]|$)/ \
+ && a[i] !~ /^([*-][[:space:]]+)?(\*\*)?BREAKING[[:space:] -]+CHANGE/) { n = i - 1; break }
+ }
+ for (i = 1; i <= n; i++) print a[i]
+ }' \
+ | sed 's/\*\*//g' | tr '\n' ' ' | sed 's/ */ /g; s/^ *//; s/ *$//'
+}
+
+if [[ "${1:-}" == "--self-test" ]]; then
+ fail=0
+ assertions=0
+ # Assert both the trigger AND the note for one body against expectations.
+ # $1=label $2=body $3=expect-trigger(1/0)
+ _assert() {
+ local label="$1" body="$2" want="$3" note trig
+ note=$(_breaking_note "$body")
+ if _is_breaking_body "$body"; then trig=1; else trig=0; fi
+ assertions=$((assertions + 1))
+ if [[ "$want" == 1 ]]; then
+ # A positive case must BOTH trigger and yield a non-empty note — the old
+ # _expect_nonempty only checked the note inside a failure conjunct, so it
+ # could never actually fail (bonnyr-f5 #179 r3).
+ if [[ "$trig" == 1 && -n "$note" ]]; then
+ echo " ok: $label -> ${note:0:56}"
+ else
+ echo "FAIL: $label — expected trigger+note, got trig=$trig note='${note}'"; fail=1
+ fi
+ else
+ if [[ "$trig" == 0 && -z "$note" ]]; then
+ echo " ok: $label (correctly inert)"
+ else
+ echo "FAIL: $label — expected inert, got trig=$trig note='${note}'"; fail=1
+ fi
+ fi
+ }
+ _assert_subject() { # $1=label $2=subject $3=expect(1/0)
+ local label="$1" subj="$2" want="$3" trig
+ if _is_breaking_subject "$subj"; then trig=1; else trig=0; fi
+ assertions=$((assertions + 1))
+ if [[ "$trig" == "$want" ]]; then echo " ok: $label"; else echo "FAIL: $label (trig=$trig want=$want)"; fail=1; fi
+ }
+
+ # Positive: real footers at line-start, in the forms the spec/markdown allow.
+ _assert "spec footer" $'fix: y\n\nBREAKING CHANGE: USER must become 65532.' 1
+ _assert "hyphen footer" $'fix: y\n\nBREAKING-CHANGE: config key renamed.' 1
+ _assert "markdown-bold footer" $'feat: z\n\n**BREAKING CHANGE:** boom.' 1
+ # Positive: TWO footers in one body — the note must contain BOTH (the old
+ # single-paragraph extractor dropped the second).
+ _assert "two footers both kept" $'feat: q\n\nBREAKING CHANGE: first thing changed.\n\nBREAKING CHANGE: second thing changed.' 1
+ _two=$(_breaking_note $'feat: q\n\nBREAKING CHANGE: first thing changed.\n\nBREAKING CHANGE: second thing changed.')
+ assertions=$((assertions + 1))
+ if [[ "$_two" == *"first thing"* && "$_two" == *"second thing"* ]]; then
+ echo " ok: both footer paragraphs present"
+ else echo "FAIL: second footer dropped -> '$_two'"; fail=1; fi
+
+ # Negative: uppercase marker MID-LINE is prose, not a footer — must NOT trigger
+ # and must NOT produce a note (the exact false-positive of the old detector).
+ _assert "mid-line prose" "This is a BREAKING CHANGE: the API moved." 0
+ _assert "lowercase prose" "this is explicitly not a breaking change" 0
+ _assert "indented non-footer" $'fix: y\n\n BREAKING CHANGE: indented, not a footer' 0
+
+ # Subject-form bang detector.
+ _assert_subject "bang subject triggers" "feat!: drop the v1 API" 1
+ _assert_subject "Capitalised bang triggers" "Feat!: drop the v1 API" 1
+ _assert_subject "scoped bang triggers" "fix(core)!: rename" 1
+ _assert_subject "normal subject inert" "feat: normal change" 0
+
+ # r4 BLOCKER 1 -- WRAPPED prose: a marker at column 1 of a line that is NOT
+ # paragraph-initial (mid-paragraph, the prose wrapped there) must NOT trigger.
+ # This is commit 8415ce1's shape, which defeated the bare `^` anchor.
+ _assert "wrapped-prose mid-paragraph" $'The detector matches a BREAKING\nCHANGE marker anywhere, but the note awk was anchored to\nline-start. A commit whose marker was not at line-start ("... a\nBREAKING CHANGE: ...") therefore bumped major yet produced an empty\nnote.' 0
+
+ # r4 -- a real break declared paragraph-initial with NO colon (the #2 shape)
+ # must still trigger.
+ _assert "paragraph-initial no-colon break" $'Context line about the change.\n\nBREAKING CHANGE, called out deliberately. USER must become 1000.' 1
+
+ # r4 MAJOR -- the note must STOP before a trailer block, or a Co-Authored-By
+ # email / Claude-Session URL leaks into a public release body.
+ _leak=$(_breaking_note $'feat: x\n\nBREAKING CHANGE: the key moved.\nCo-Authored-By: Someone \nClaude-Session: https://claude.ai/code/session_ABC')
+ assertions=$((assertions + 1))
+ if [[ "$_leak" == *"the key moved"* && "$_leak" != *"someone@example.com"* && "$_leak" != *"claude.ai"* ]]; then
+ echo " ok: note stops before trailers (no email/URL leak) -> ${_leak:0:48}"
+ else echo "FAIL: note leaked a trailer -> '$_leak'"; fail=1; fi
+
+ # Subject detector is BANG-ONLY now (r5): prose that merely names the marker,
+ # with or without a colon, must NOT trigger via the subject path.
+ _assert_subject "prose names marker in subj" "docs: explain the BREAKING CHANGE footer" 0
+ _assert_subject "docs quotes marker w/ colon" "docs: clarify what BREAKING CHANGE: means" 0
+
+ # r4 BLOCKER 2 / r5 -- a footer git FOLDED into the subject is a real TWO-LINE
+ # message with no blank line. Derivation/extraction read %B; the subject line is
+ # trailer-shaped, so the marker on the next line anchors as a footer → trigger.
+ _assert "folded footer via %B (two-line)" $'fix: tighten the thing\nBREAKING CHANGE: the config key was renamed' 1
+
+ # r5 Major 1 -- a BREAKING CHANGE footer stacked directly after another trailer
+ # with NO blank line between (conventional-commits' canonical example) triggers,
+ # and the note starts at the marker (the preceding trailer is not captured).
+ _assert "stacked footer (after trailer)" $'feat: x\n\nReviewed-by: Z\nBREAKING CHANGE: drops the old API' 1
+ _stk=$(_breaking_note $'feat: x\n\nReviewed-by: Z\nBREAKING CHANGE: drops the old API')
+ assertions=$((assertions + 1))
+ if [[ "$_stk" == *"drops the old API"* && "$_stk" != *"Reviewed-by"* ]]; then
+ echo " ok: stacked footer note starts at marker -> ${_stk:0:48}"
+ else echo "FAIL: stacked footer note wrong -> '$_stk'"; fail=1; fi
+
+ # r5 Major 2 -- the note must NOT truncate at the first prose `word:` line. A
+ # footer whose continuation contains ordinary prose colons (`migration:`) keeps
+ # the WHOLE paragraph; only a TRAILING run of real trailers is stripped.
+ _notrunc=$(_breaking_note $'feat: y\n\nBREAKING CHANGE: the runner changed.\nmigration: run the tool first.\nThat is the whole story.')
+ assertions=$((assertions + 1))
+ if [[ "$_notrunc" == *"migration: run the tool first."* && "$_notrunc" == *"That is the whole story."* ]]; then
+ echo " ok: note keeps prose continuation -> ${_notrunc:0:56}"
+ else echo "FAIL: note truncated on prose colon -> '$_notrunc'"; fail=1; fi
+
+ # r5 Minor 2 -- a trailer with a digit/dot token (`X-Session-1:`) or a no-space
+ # colon (`Session:` at line end) directly under the footer must be stripped, not
+ # leaked. The old `[A-Za-z][A-Za-z-]*: ` stop missed both.
+ _leak2=$(_breaking_note $'feat: x\n\nBREAKING CHANGE: the key moved.\nX-Session-1: 0decafbad\nClaude-Session: https://claude.ai/code/session_XYZ\nSession:')
+ assertions=$((assertions + 1))
+ if [[ "$_leak2" == *"the key moved"* && "$_leak2" != *"0decafbad"* && "$_leak2" != *"claude.ai"* && "$_leak2" != *"Session"* ]]; then
+ echo " ok: digit-token / no-space trailers stripped -> ${_leak2:0:48}"
+ else echo "FAIL: non-standard trailer leaked -> '$_leak2'"; fail=1; fi
+
+ # r6 F1 BLOCKER -- a footer FOLDED onto a SCOPED subject (`fix(core): x` on the
+ # first line, marker on the second, no blank) must trigger. The r5 anchor reached
+ # the colon only on UNSCOPED subjects because `(` broke the trailer regex; the r6
+ # is_subject anchor arms the trailer->colon path for scoped subjects too.
+ _assert "F1 scoped folded footer" $'feat(api): drop v1\nBREAKING CHANGE: all /api/v1 removed' 1
+ # r6 F1 control -- the SAME scoped fold WITHOUT a colon is prose, not a footer.
+ _assert "F1 scoped folded colonless (inert)" $'feat(api): drop v1\nBREAKING CHANGE happened here' 0
+
+ # r6 F2 MAJOR -- a prose section header (`Before:`) is trailer-shaped, but a
+ # colon-LESS marker following it is prose. Only the trailer->COLON rule anchors a
+ # marker in the trailer block, so this must stay inert (the round-4 false positive).
+ _assert "F2 prose header + colonless marker (inert)" $'docs: x\n\nBefore:\nBREAKING CHANGE was matched anywhere.' 0
+
+ # r6 F7 MINOR -- widened separator/bullet: a DOUBLE-space marker and a dash-bullet
+ # marker are real footers and must trigger with a non-empty note.
+ _assert "F7 double-space marker" $'fix: y\n\nBREAKING CHANGE: the --legacy flag was removed' 1
+ _assert "F7 dash-bullet marker" $'fix: y\n\n- BREAKING CHANGE: the --legacy flag was removed' 1
+
+ # r6 F5 MINOR (note leak) -- a footer FOLLOWED by a `migration:` prose line and
+ # THEN a Co-Authored-By trailer: the note must keep the lowercase-prose `migration:`
+ # continuation but STOP at the first real git-trailer, so the email never leaks.
+ # The r5 trailing-strip halted on the closing `Co-Authored-By` (it was the last
+ # line) here it would leak because prose could follow; the r6 stop-at-first-trailer
+ # cuts it regardless of what follows.
+ _f5=$(_breaking_note $'feat: x\n\nBREAKING CHANGE: the key moved.\nmigration: see the upgrade guide.\nCo-Authored-By: Someone \nthen a trailing prose line.')
+ assertions=$((assertions + 1))
+ if [[ "$_f5" == *"the key moved"* && "$_f5" == *"migration: see the upgrade guide."* \
+ && "$_f5" != *"someone@example.com"* && "$_f5" != *"Co-Authored-By"* && "$_f5" != *"trailing prose"* ]]; then
+ echo " ok: note keeps migration prose, stops at Co-Authored-By -> ${_f5:0:56}"
+ else echo "FAIL: F5 note leaked/truncated wrongly -> '$_f5'"; fail=1; fi
+
+ if [[ $assertions -eq 0 ]]; then
+ echo "FAIL: harness ran zero assertions"; fail=1
+ fi
+ [[ $fail -eq 0 ]] && echo "extract-breaking-changes self-test: OK ($assertions assertions)"
+ exit "$fail"
+fi
+
SINCE="${1:?usage: extract-breaking-changes.sh [until_ref]}"
UNTIL="${2:-HEAD}"
+# Fail CLOSED on an unresolvable range. Without this the `git log` below yields
+# empty output and rc 0 for a typo'd ref, so a release ships with no breaking-
+# change section and no signal that the range was never read (bonnyr-f5 #179 r3:
+# a silent failure that fools a reviewer will fool a release). A VALID range with
+# no breaking commits is still fine — it prints nothing and exits 0.
+#
+# MERGE-ORDER DEPENDENCY (bonnyr-f5 #179 r6 F3): this rc=1 is only *effective*
+# once the `|| true` is dropped from the three call sites in .github/workflows/
+# release.yml (currently `BREAKING=$(bash scripts/extract-breaking-changes.sh ...
+# || true)`), which swallow rc=1 back into rc=0 with BREAKING="" -- exactly the
+# outcome this guard exists to prevent. release.yml is not owned by #179; PR #181
+# removes those `|| true`. Merge #179 WITH or AFTER #181 so this guard actually
+# fails the release instead of being decorative.
+for _ref in "$SINCE" "$UNTIL"; do
+ if ! git rev-parse --verify --quiet "${_ref}^{commit}" >/dev/null 2>&1; then
+ echo "::error::extract-breaking-changes: '${_ref}' does not resolve to a commit — refusing to emit an empty breaking-changes section from a bad range." >&2
+ exit 1
+ fi
+done
+
block=""
while IFS= read -r sha; do
[[ -z "$sha" ]] && continue
- body=$(git log -1 --format="%b" "$sha" 2>/dev/null || true)
- # Uppercase footer/marker only (spec form), so body prose like "not a
- # breaking change" does not false-trigger.
- if printf '%s\n' "$body" | grep -qE '\bBREAKING[[:space:] -]+CHANGE\b'; then
- subj=$(git log -1 --format="%s" "$sha" 2>/dev/null || true)
- # The BREAKING CHANGE line and its paragraph (up to the next blank line),
- # flattened to one line and stripped of markdown bold.
- note=$(printf '%s\n' "$body" \
- | awk '/BREAKING[[:space:] -]+CHANGE/{p=1} p{print} p&&/^$/{exit}' \
- | tr '\n' ' ' | sed 's/\*\*//g; s/ */ /g; s/ *$//')
+ subj=$(git log -1 --format="%s" "$sha" 2>/dev/null || true)
+ subj=${subj%$'\r'} # strip a trailing CR so a CRLF subject never reaches CHANGELOG.md
+ # Full raw message (%B): a folded footer keeps its newline here, and the footer
+ # anchor / note extractor both need the whole message (bonnyr-f5 #179 r5).
+ message=$(git log -1 --format='%B' "$sha" 2>/dev/null || true)
+ if _is_breaking_subject "$subj" || _is_breaking_body "$message"; then
+ note=$(_breaking_note "$message")
+ # Belt-and-suspenders: a `type!:` subject with no body footer yields no note;
+ # point the operator at the commit rather than emitting a bare bullet.
+ [[ -z "$note" ]] && note="(see commit ${sha:0:9} for the breaking-change details)"
block="${block}- **${subj}**
${note}
"
fi
-done < <(git log "${SINCE}..${UNTIL}" --pretty=format:"%H" 2>/dev/null || true)
+done < <(git log "${SINCE}..${UNTIL}" --format='%H')
if [[ -n "$block" ]]; then
printf '### ⚠️ Breaking Changes\n\n%s\n' "$block"
diff --git a/scripts/get_dpu_pwd.sh b/scripts/get_dpu_pwd.sh
index 779352a9..d6f0271f 100644
--- a/scripts/get_dpu_pwd.sh
+++ b/scripts/get_dpu_pwd.sh
@@ -1,3 +1,4 @@
+#!/usr/bin/env bash
docker compose exec -it backend python -c "
from database import SessionLocal
from models.bare_metal import BareMetalHost
diff --git a/scripts/ibm_cloud_bnk_forge.sh b/scripts/ibm_cloud_bnk_forge.sh
index 39d4ff55..65cfd9ae 100644
--- a/scripts/ibm_cloud_bnk_forge.sh
+++ b/scripts/ibm_cloud_bnk_forge.sh
@@ -174,8 +174,10 @@ BNK_FORGE_REGISTRY=__REGISTRY__
BNK_FORGE_VERSION=__VERSION__
POSTGRES_PASSWORD=${PG}
REDIS_PASSWORD=${RD}
-MCP_USERNAME=admin
-MCP_PASSWORD=${MCP}
+# #186/#187: MCP authenticates as the dedicated 'mcp' service account with a
+# per-install random secret, NOT the human admin (whose password #184 generates + gates).
+MCP_SERVICE_USERNAME=mcp
+MCP_SERVICE_PASSWORD=${MCP}
ENV
chmod 600 .env
case "${__XTRACE__}" in *x*) set -x ;; esac
@@ -343,7 +345,7 @@ done
docker compose up -d
# 9. Wait for backend health then drop a ready marker
-for i in $(seq 1 60); do
+for _ in $(seq 1 60); do
curl -sf http://localhost:8000/api/system/health >/dev/null 2>&1 && break || sleep 5
done
touch /opt/bnk-forge/.bnk-forge-ready
@@ -381,6 +383,12 @@ x-backend-env: &backend-env
# Artifact (container-image) engine reaches the Docker daemon through the
# scoped socket proxy below (loopback-published), never the raw host socket.
DOCKER_HOST: ${DOCKER_HOST:-tcp://127.0.0.1:2375}
+ # #186 BLOCKER 1 / #187 (bonnyr-f5 r5): this installer writes a per-install random
+ # MCP_SERVICE_PASSWORD into .env (above). The backend reconciles the mcp account
+ # to it on boot, so it must receive it too — otherwise the backend generates its
+ # own secret and the mcp client can never authenticate.
+ MCP_SERVICE_USERNAME: ${MCP_SERVICE_USERNAME:-mcp}
+ MCP_SERVICE_PASSWORD: ${MCP_SERVICE_PASSWORD:-}
x-worker-volumes: &worker-volumes
- module_catalog:/tmp/bnk-forge-modules
@@ -462,7 +470,7 @@ services:
restart: unless-stopped
backend:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-api:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-api:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-backend
network_mode: host
logging: *default-logging
@@ -495,7 +503,7 @@ services:
start_period: 30s
celery-worker:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-worker:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-worker:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-celery-worker
network_mode: host
logging: *default-logging
@@ -513,7 +521,7 @@ services:
restart: unless-stopped
celery-worker-2:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-worker:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-worker:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-celery-worker-2
network_mode: host
logging: *default-logging
@@ -531,7 +539,7 @@ services:
restart: unless-stopped
celery-beat:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-beat:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-beat:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-celery-beat
network_mode: host
logging: *default-logging
@@ -547,7 +555,7 @@ services:
restart: unless-stopped
frontend:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-frontend:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-frontend:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-frontend
network_mode: host
logging: *default-logging
@@ -563,7 +571,7 @@ services:
start_period: 10s
proxy:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-proxy:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-proxy:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-proxy
network_mode: host
logging: *default-logging
@@ -575,14 +583,15 @@ services:
restart: unless-stopped
mcp:
- image: ${BNK_FORGE_REGISTRY:-ghcr.io/your-org}/bnk-forge-mcp:${BNK_FORGE_VERSION:-latest}
+ image: ${BNK_FORGE_REGISTRY:-ghcr.io/f5devcentral}/bnk-forge-mcp:${BNK_FORGE_VERSION:-latest}
container_name: bnk-forge-mcp
network_mode: host
logging: *default-logging
environment:
BNK_FORGE_API_URL: http://localhost:8000
- BNK_FORGE_USERNAME: ${MCP_USERNAME:-admin}
- BNK_FORGE_PASSWORD: ${MCP_PASSWORD:-changeme}
+ # #186: authenticate as the 'mcp' service account (see .env above), not admin.
+ BNK_FORGE_USERNAME: ${MCP_SERVICE_USERNAME:-mcp}
+ BNK_FORGE_PASSWORD: ${MCP_SERVICE_PASSWORD:-}
MCP_PORT: "8081"
MCP_LOG_LEVEL: INFO
depends_on:
@@ -650,7 +659,7 @@ VM_ID="$(echo "${INST_JSON}" | jq -r '.id')"
[ -n "${VM_ID}" ] && [ "${VM_ID}" != "null" ] || die "Instance creation failed."
log "Waiting for the VSI to reach 'running'..."
-for i in $(seq 1 60); do
+for _ in $(seq 1 60); do
ST="$(ibmcloud is instance "${VM_ID}" --output json | jq -r '.status')"
[ "${ST}" = "running" ] && break
[ "${ST}" = "failed" ] && die "Instance entered 'failed' state."
@@ -684,7 +693,7 @@ log "Floating IP: ${FIP}"
URL="https://${FIP}"
log "Installing bnk-forge on the VSI (this can take 5–10 minutes)..."
READY=0
-for i in $(seq 1 90); do
+for _ in $(seq 1 90); do
CODE="$(curl -sk -o /dev/null -w '%{http_code}' --connect-timeout 5 "${URL}/api/system/health" 2>/dev/null || true)"
if [ "${CODE}" = "200" ]; then READY=1; break; fi
sleep 10
@@ -702,7 +711,7 @@ fi
echo
echo " URL: ${URL}"
echo " (self-signed certificate — accept the browser warning)"
-echo " Login: admin / changeme (change on first login)"
+echo " Login: admin / (see backend logs or /app/keys/initial_admin_password; change on first login)"
echo
echo " Host IP: ${FIP} (SSH: ssh ubuntu@${FIP})"
echo " Region: ${REGION} / ${ZONE} Profile: ${PROFILE} Image: Ubuntu 24.04"
diff --git a/scripts/lint-commit-markers.sh b/scripts/lint-commit-markers.sh
new file mode 100644
index 00000000..442ddf7f
--- /dev/null
+++ b/scripts/lint-commit-markers.sh
@@ -0,0 +1,152 @@
+#!/usr/bin/env bash
+#
+# Enforce the AGENTS.md "Commit conventions" rule -- documentation is not
+# enforcement (#166; bonnyr-f5 #182 r3). Shared by the ci.yml `commit-lint` job,
+# `make commit-lint`, and .githooks/pre-push, so a local run == CI.
+#
+# FAILS a commit-message range on any of:
+#
+# 1. A CI-control marker anywhere in subject or body. GitHub scans the whole
+# message, so one of these sitting even in prose SUPPRESSES the workflow run
+# for that commit -- and the gates that get skipped (ShellCheck, Secret Scan,
+# Script Self-Tests) are exactly the ones that matter. Bit us on #179/#181.
+# The `skip-checks: true` commit-check trailer is caught too (bonnyr-f5 #182
+# r4): it is GitHub's documented way to suppress ALL required checks and is
+# not a bracketed token, so the fixed-string list alone would miss it.
+#
+# 2. A line that STARTS a major-version-bump declaration as prose rather than a
+# real Conventional Commits footer. compute_version_bump.sh bumps major on
+# any `\bBREAKING[[:space:] -]+CHANGE\b`, so a bold "**BREAKING CHANGE**"
+# heading, a bullet "- BREAKING CHANGE", a block-quoted "> BREAKING CHANGE",
+# an indented one, or a bare colon-less line all spuriously ship a major
+# release. A PROPER footer -- a line of the exact canonical form
+# `BREAKING CHANGE: ` (or `BREAKING-CHANGE: `), column 0, no
+# markdown, single separator -- is the intended, documented, self-tested
+# mechanism and is ALLOWED. (Mid-line prose mentions are a separate,
+# pre-existing greediness in the detector itself, owned by the
+# version-tooling PRs #179/#180; this gate does not touch them.)
+#
+# EXEMPT: the release bot's own commits (subject `^release: `). release.yml's
+# promotion commits are of the form "release: vX.Y.Z [skip ci]" -- that marker
+# is DELIBERATE (release.yml's loop-guard filters them so a release push does not
+# re-trigger a release). Linting them would turn the staging->main promotion
+# range red -> the CI Gate fails -> release.yml's preflight refuses that SHA ->
+# main never releases again (bonnyr-f5 #182 r4, INV-4/INV-28: a gate that forbids
+# a token must exempt the machine identity told to emit it). The exemption is
+# scoped to the exact release-bot subject prefix, so a HUMAN quoting a marker in
+# any other commit is still caught.
+#
+# EXEMPT (2nd machine identity, bonnyr-f5 #182 r5, BLOCKER-1): GitHub's own
+# squash-merge composer -- committer `GitHub `, single
+# parent. Its body is machine-composed from the PR description and the commit is
+# already merged (the new tip of staging/main), so it is unamendable and already
+# past the pre-merge gate. On a push to staging/main the range `before..tip`
+# scans this squash tip; without the exemption a BREAKING-CHANGE bullet or a
+# quoted marker in the summarised body reddens the push's ci-gate and release.yml
+# then refuses to release that SHA. Human commits never carry this committer
+# identity, so they are still fully linted in their own PR.
+#
+# RANGE (env): "base..head" to scan. If unset/empty, defaults to
+# @{upstream}..HEAD, else just the tip commit. Never scans all history (old
+# release-bot commits legitimately carry the deliberate skip marker). An
+# explicitly-set RANGE that does not resolve is a HARD failure -- we never
+# silently fall back to scanning the tip while claiming we scanned the range
+# (bonnyr-f5 #182 r4; matches secret-scan.sh's fail-closed behaviour).
+set -uo pipefail
+
+# Resolve the commit list without ever falling back to full history, and fail
+# closed when an explicit RANGE is unresolvable.
+if [ -n "${RANGE:-}" ]; then
+ if ! commits="$(git rev-list "$RANGE" 2>/dev/null)"; then
+ echo "::error::commit-lint: RANGE '$RANGE' is not a resolvable revision range -- the scan did not run"
+ exit 1
+ fi
+elif upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)"; then
+ commits="$(git rev-list "${upstream}..HEAD" 2>/dev/null || true)"
+ [ -z "$commits" ] && commits="$(git rev-list -1 HEAD)"
+else
+ commits="$(git rev-list -1 HEAD)"
+fi
+
+# CI-control markers (matched case-insensitively, as fixed strings).
+markers=('[skip ci]' '[ci skip]' '[no ci]' '[skip actions]' '[actions skip]')
+
+fail=0
+n=0
+while IFS= read -r sha; do
+ [ -z "$sha" ] && continue
+ n=$((n + 1))
+ msg="$(git log -1 --format='%B' "$sha")"
+ subject="$(git log -1 --format='%s' "$sha")"
+
+ # Release-bot commits are exempt (see header): machine identity told to emit
+ # the marker. A human quoting a marker in any non-release commit is still hit.
+ if grep -qE '^release: ' <<< "$subject"; then
+ echo "commit-lint: commit $sha ($subject) is a release-bot commit -- exempt"
+ continue
+ fi
+
+ # GitHub's squash-merge composer is the SECOND machine identity on the
+ # promotion path (bonnyr-f5 #182 r5, BLOCKER-1 / INV-28). When a PR is
+ # squash-merged, GitHub composes the resulting commit's BODY from the PR
+ # description under the identity `GitHub ` with a single
+ # parent. That commit is (a) UNAMENDABLE -- its body is machine-composed, and
+ # (b) ALREADY MERGED -- it is the new tip of staging/main, so re-linting it
+ # serves no pre-merge purpose (the human commits it summarises were linted in
+ # their own PR). On a push to staging/main the range is `before..tip`, so the
+ # just-merged squash tip IS scanned; a stray line-start "BREAKING CHANGE" or a
+ # marker quoted from the summarised PR body then turns the push's ci-gate red
+ # and release.yml's preflight refuses that SHA -- the pipeline stops releasing.
+ # Exempting this machine identity (mirroring the `^release: ` exemption) means
+ # the gate never judges already-merged, machine-composed history, while every
+ # HUMAN-authored commit -- which never carries this committer identity -- is
+ # still linted in its own PR. This is an identity check on the committer, not a
+ # spoofable subject allowlist. Scoped to single-parent commits so a genuine
+ # non-squash merge is not blanket-exempted.
+ committer="$(git log -1 --format='%cn <%ce>' "$sha")"
+ nparents="$(git log -1 --format='%p' "$sha" | wc -w)"
+ if [ "$committer" = "GitHub " ] && [ "$nparents" -eq 1 ]; then
+ echo "commit-lint: commit $sha ($subject) is a GitHub-composed squash commit (already-merged machine identity) -- exempt"
+ continue
+ fi
+
+ for m in "${markers[@]}"; do
+ if grep -iqF -- "$m" <<< "$msg"; then
+ echo "::error::commit $sha ($subject): message contains CI-control marker \"$m\" -- it would suppress the workflow run. Refer to it indirectly (e.g. \"the skip-CI marker\") or split it across backticks."
+ fail=1
+ fi
+ done
+
+ # GitHub's documented commit-check trailer suppresses ALL required checks; it
+ # is a key:value trailer, not a bracketed token, so the fixed-string list above
+ # would miss it.
+ if grep -iqE '^[[:space:]]*skip-checks:[[:space:]]*true\b' <<< "$msg"; then
+ echo "::error::commit $sha ($subject): message carries the 'skip-checks: true' trailer -- it suppresses all required checks. Remove it or refer to it indirectly."
+ fail=1
+ fi
+
+ # A line that OPENS with a major-bump declaration -- in any of the shapes a
+ # human writes (bare, bold, bulleted, block-quoted, indented) -- spuriously
+ # majors a release, because the detector fires on the token anywhere.
+ while IFS= read -r line; do
+ # Peel a leading run of markdown/quote/whitespace/bold so we judge the line
+ # by the shape a human wrote, not just a bare column-0 token.
+ stripped="$(sed -E 's/^[[:space:]]*([>*+-][[:space:]]*)*//' <<< "$line")"
+ if grep -qE '^BREAKING[[:space:] -]+CHANGE' <<< "$stripped"; then
+ # ...allowed ONLY as the exact canonical footer at column 0: no leading
+ # prefix, no markdown, a single separator, real ": ".
+ if grep -qE '^BREAKING[ -]CHANGE: .' <<< "$line"; then
+ continue
+ fi
+ echo "::error::commit $sha ($subject): line \"$line\" starts a BREAKING CHANGE declaration that is not a plain Conventional Commits footer -- it spuriously triggers a major release. Use a real footer 'BREAKING CHANGE: ' at column 0 or reword (e.g. lowercase 'breaking-change')."
+ fail=1
+ fi
+ done <<< "$msg"
+done <<< "$commits"
+
+echo "commit-lint: scanned $n commit(s) in range '${RANGE:-}'"
+if [ "$fail" -ne 0 ]; then
+ echo "::error::commit-lint failed -- see markers above."
+ exit 1
+fi
+echo "commit-lint: OK"
diff --git a/scripts/mcp_live_smoke.py b/scripts/mcp_live_smoke.py
index a8309162..030b1c2a 100644
--- a/scripts/mcp_live_smoke.py
+++ b/scripts/mcp_live_smoke.py
@@ -144,8 +144,8 @@ def _extract_tool_payload(result: dict[str, Any], tool_name: str) -> dict[str, A
if "/api/auth/login" in text or "Invalid username or password" in text:
hint = (
" Hint: MCP backend credentials are likely invalid. "
- "Set correct MCP_USERNAME/MCP_PASSWORD for the MCP container/service "
- "(seeded backend default is admin/changeme unless rotated)."
+ "Set correct MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD for the MCP container/service "
+ "(MCP authenticates as the mcp service account, not admin; set MCP_SERVICE_PASSWORD or read /app/keys/initial_mcp_password) (#186)."
)
raise SmokeFailure(
f"Tool '{tool_name}' execution failed before returning MCP JSON payload: {text}.{hint}"
@@ -228,8 +228,8 @@ def _auth_bootstrap_hint(tool_name: str, payload: dict[str, Any]) -> str:
if "invalid username or password" in detail or "/api/auth/login" in str(error.get("url", "")):
return (
" Hint: MCP endpoint is reachable, but MCP runtime auth/bootstrap failed. "
- "Verify MCP_USERNAME/MCP_PASSWORD match current backend credentials "
- "(default seeded admin password is changeme, unless rotated), then recreate the mcp container."
+ "Verify MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD match the mcp service account the backend reconciles "
+ "(set MCP_SERVICE_PASSWORD or read /app/keys/initial_mcp_password), then recreate the mcp container (#186)."
)
return (
diff --git a/scripts/publish-signed-images.sh b/scripts/publish-signed-images.sh
index c3c0a02d..79ed237a 100755
--- a/scripts/publish-signed-images.sh
+++ b/scripts/publish-signed-images.sh
@@ -20,7 +20,7 @@
# BNK_FORGE_REGISTRY=ghcr.io/your-org BNK_FORGE_VERSION=3.1.6 ./scripts/publish-signed-images.sh --execute
#
# Environment variables:
-# BNK_FORGE_REGISTRY — required; e.g. ghcr.io/jlcode-tech
+# BNK_FORGE_REGISTRY — required; e.g. ghcr.io/f5devcentral
# BNK_FORGE_VERSION — optional; defaults to contents of ./VERSION
# DRY_RUN — set to 0 to execute (equivalent to --execute)
#
@@ -32,8 +32,8 @@
#
# Consumer verification (see docs/DOCKER.md for full details):
# cosign verify @ \
-# --certificate-identity \
-# --certificate-oidc-issuer https://github.com/login/oauth
+# --certificate-identity-regexp 'https://github.com/f5devcentral/bnk-forge/\.github/workflows/release\.yml@.*' \
+# --certificate-oidc-issuer https://token.actions.githubusercontent.com
set -euo pipefail
@@ -294,14 +294,14 @@ else
echo " Verify a signed image:"
echo " cosign verify \\"
echo " ${REGISTRY}/bnk-forge-api@ \\"
- echo " --certificate-identity \\"
- echo " --certificate-oidc-issuer https://github.com/login/oauth"
+ echo " --certificate-identity-regexp 'https://github.com/f5devcentral/bnk-forge/\.github/workflows/release\.yml@.*' \\"
+ echo " --certificate-oidc-issuer https://token.actions.githubusercontent.com"
echo ""
echo " Verify the SBOM attestation:"
echo " cosign verify-attestation \\"
echo " --type cyclonedx \\"
- echo " --certificate-identity \\"
- echo " --certificate-oidc-issuer https://github.com/login/oauth \\"
+ echo " --certificate-identity-regexp 'https://github.com/f5devcentral/bnk-forge/\.github/workflows/release\.yml@.*' \\"
+ echo " --certificate-oidc-issuer https://token.actions.githubusercontent.com \\"
echo " ${REGISTRY}/bnk-forge-api@"
fi
echo "========================================================"
diff --git a/scripts/registry-tag-probe.sh b/scripts/registry-tag-probe.sh
new file mode 100644
index 00000000..c9f37235
--- /dev/null
+++ b/scripts/registry-tag-probe.sh
@@ -0,0 +1,137 @@
+#!/usr/bin/env bash
+# registry-tag-probe.sh — Authoritative "does :VERSION already exist?" probe for
+# the BNK Forge release image set, used to protect the IMMUTABLE :VERSION tag.
+#
+# WHY THIS EXISTS (bonnyr-f5 #181 round 5, F1):
+# The earlier probe shelled out to `docker manifest inspect` and classified by
+# grepping the CLI's combined stdout/stderr. That text CANNOT separate the two
+# things a release must tell apart:
+# • a package/repo that does not exist yet (the first release in a fork or
+# mirror namespace, or a future 8th image) — SAFE to publish, and
+# • no permission to read an existing package — MUST fail closed.
+# Both surface identically as Get "https:///token…": denied . Reading
+# `denied` as "not found" fails OPEN (overwrites an immutable tag); reading it
+# as "unknown" fails CLOSED and hard-fails the very first publish in a
+# namespace — after the tag and GitHub Release are already pushed.
+#
+# The registry HTTP API answers this unambiguously with a STATUS CODE:
+# 200 → the manifest exists -> exists
+# 404 → definitively not found (MANIFEST_UNKNOWN / NAME_UNKNOWN, i.e. the
+# tag is absent OR the repo does not exist yet) -> absent (safe)
+# 401 / 403 → authentication / permission -> unknown (fail closed)
+# 000 / 429 / 5xx / anything else → transient/network -> unknown (fail closed)
+# Only a definitive 404 is treated as "safe to publish"; every other outcome
+# is "unknown" and the CALLER refuses unless a force flag is set.
+#
+# CONTRACT
+# Env in:
+# REGISTRY (required) e.g. ghcr.io/f5devcentral (host + namespace path)
+# VERSION (required) e.g. 3.1.6 (the immutable tag)
+# REGISTRY_USERNAME / REGISTRY_PASSWORD (optional) — Basic creds used when
+# the registry issues a Bearer challenge. Falls back to an anonymous
+# token request (sufficient for public images); a private/absent repo
+# probed anonymously returns 401/403 → unknown → the caller fails closed.
+# Stdout: one TAB-separated line per image:
+# \t\t status ∈ exists | absent | unknown
+# Exit: 0 once every image is classified (regardless of verdict); non-zero
+# only on a usage error. Policy (refuse / force / message) lives in the
+# caller so the CI path and the operator `make push-images` path can
+# share ONE probe but keep their own force semantics.
+#
+# registry-tag-probe.sh --images prints the canonical image list, one per
+# line, so callers single-source it instead of re-hardcoding 7 names
+# (bonnyr-f5 #181 round 5, F6).
+set -euo pipefail
+
+# ─── Canonical image set (single source of truth — F6) ───────────────────────
+# MUST stay in lockstep with the "default" group in docker-bake.hcl and the
+# IMAGES array in scripts/publish-signed-images.sh. The self-test
+# (scripts/tests/registry-tag-probe.test.sh) asserts the count and the
+# docker-bake.hcl parity.
+IMAGES=(
+ "bnk-forge-api"
+ "bnk-forge-worker"
+ "bnk-forge-beat"
+ "bnk-forge-frontend"
+ "bnk-forge-proxy"
+ "bnk-forge-mcp"
+ "bnk-forge-operator"
+)
+
+if [ "${1:-}" = "--images" ]; then
+ printf '%s\n' "${IMAGES[@]}"
+ exit 0
+fi
+
+: "${REGISTRY:?REGISTRY is required (e.g. ghcr.io/your-org)}"
+: "${VERSION:?VERSION is required (e.g. 3.1.6)}"
+
+HOST="${REGISTRY%%/*}" # ghcr.io
+NAMESPACE="${REGISTRY#*/}" # f5devcentral (may be multi-segment)
+if [ "$HOST" = "$REGISTRY" ] || [ -z "$NAMESPACE" ]; then
+ echo "ERROR: REGISTRY must be / (got '$REGISTRY')" >&2
+ exit 2
+fi
+
+ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json'
+
+# Fetch a Bearer token for a pull-scoped challenge. Echoes the token or nothing.
+_fetch_token() {
+ local realm="$1" service="$2" scope="$3"
+ local url="$realm"
+ local sep='?'
+ [ -n "$service" ] && { url="${url}${sep}service=${service}"; sep='&'; }
+ [ -n "$scope" ] && { url="${url}${sep}scope=${scope}"; sep='&'; }
+ local body
+ if [ -n "${REGISTRY_USERNAME:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
+ body="$(curl -sS --max-time 20 -u "${REGISTRY_USERNAME}:${REGISTRY_PASSWORD}" "$url" 2>/dev/null || true)"
+ else
+ body="$(curl -sS --max-time 20 "$url" 2>/dev/null || true)"
+ fi
+ # GHCR/Docker return {"token":...}; some registries use {"access_token":...}.
+ printf '%s' "$body" | sed -n 's/.*"\(access_token\|token\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p' | head -1
+}
+
+# HTTP status for GET , following one Bearer challenge if issued.
+# Echoes a 3-digit code, or 000 on a curl/network failure.
+_manifest_status() {
+ local url="$1"
+ local hdr code
+ hdr="$(mktemp)"
+ code="$(curl -sS --max-time 25 -o /dev/null -D "$hdr" -w '%{http_code}' \
+ -H "Accept: ${ACCEPT}" "$url" 2>/dev/null || echo 000)"
+ if [ "$code" = "401" ]; then
+ local challenge realm service scope
+ challenge="$(grep -i '^www-authenticate:' "$hdr" | head -1 | tr -d '\r')"
+ realm="$(printf '%s' "$challenge" | sed -n 's/.*realm="\([^"]*\)".*/\1/p')"
+ service="$(printf '%s' "$challenge" | sed -n 's/.*service="\([^"]*\)".*/\1/p')"
+ scope="$(printf '%s' "$challenge" | sed -n 's/.*scope="\([^"]*\)".*/\1/p')"
+ if [ -n "$realm" ]; then
+ local token
+ token="$(_fetch_token "$realm" "$service" "$scope")"
+ if [ -n "$token" ]; then
+ code="$(curl -sS --max-time 25 -o /dev/null -w '%{http_code}' \
+ -H "Accept: ${ACCEPT}" -H "Authorization: Bearer ${token}" \
+ "$url" 2>/dev/null || echo 000)"
+ fi
+ # token empty => the token endpoint denied us => leave code=401 (unknown).
+ fi
+ fi
+ rm -f "$hdr"
+ printf '%s' "$code"
+}
+
+for name in "${IMAGES[@]}"; do
+ ref="${REGISTRY}/${name}:${VERSION}"
+ url="https://${HOST}/v2/${NAMESPACE}/${name}/manifests/${VERSION}"
+ code="$(_manifest_status "$url")"
+ case "$code" in
+ 200|203) printf '%s\t%s\t%s\n' exists "$ref" "HTTP ${code}" ;;
+ 404) printf '%s\t%s\t%s\n' absent "$ref" "HTTP 404 (not found)" ;;
+ 401|403) printf '%s\t%s\t%s\n' unknown "$ref" "HTTP ${code} (auth/permission — cannot confirm)" ;;
+ 000) printf '%s\t%s\t%s\n' unknown "$ref" "network/curl failure — cannot confirm" ;;
+ 429) printf '%s\t%s\t%s\n' unknown "$ref" "HTTP 429 (rate limited — cannot confirm)" ;;
+ 5??) printf '%s\t%s\t%s\n' unknown "$ref" "HTTP ${code} (registry error — cannot confirm)" ;;
+ *) printf '%s\t%s\t%s\n' unknown "$ref" "HTTP ${code} (unexpected — cannot confirm)" ;;
+ esac
+done
diff --git a/scripts/secret-scan.sh b/scripts/secret-scan.sh
new file mode 100644
index 00000000..6fd00028
--- /dev/null
+++ b/scripts/secret-scan.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+#
+# Single source of truth for the gitleaks secret scan + its assertion backstop.
+#
+# Called by BOTH .github/workflows/ci.yml (the secret-scan job and the scheduled
+# baseline job) AND `make secret-scan` / `make pre-push`, so a local run is
+# byte-identical to CI -- #166: "a local gate that does not run the CI command is
+# not a gate", and ci.yml's header claims `make pre-push` == CI.
+#
+# The gate must be able to tell "clean scan" from "did not run". gitleaks exits 0
+# on a bad revision range OR a git dubious-ownership refusal, printing
+# "ERR [git] ..." + "0 commits scanned" -- a silently blind green gate
+# (bonnyr-f5 #182 r3 BLOCKER). So we capture the output and FAIL on: any
+# "ERR [git]" line, a missing "commits scanned" line, 0 commits for a non-empty
+# range, or a non-zero gitleaks exit (leaks found).
+#
+# RANGE selection:
+# * If the RANGE env var is SET (even to empty), it is used verbatim -- empty
+# means "scan all reachable history" (the scheduled baseline + first push).
+# CI computes it from the triggering event.
+# * If RANGE is UNSET, a local default is computed: everything since HEAD
+# diverged from its upstream tracking branch, falling back to full history.
+set -uo pipefail
+
+# gitleaks v8.30.1, pinned by digest so a re-tag cannot change what runs
+# (bonnyr-f5 #182 r3 nit). Update the version comment when bumping the digest.
+IMAGE="ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f" # v8.30.1
+
+repo_dir="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
+
+# Resolve the range (see header). ${RANGE+set} distinguishes unset from empty.
+if [ -n "${RANGE+set}" ]; then
+ range="$RANGE"
+else
+ range=""
+ if upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)"; then
+ if base="$(git merge-base "$upstream" HEAD 2>/dev/null)"; then
+ range="${base}..HEAD"
+ fi
+ fi
+fi
+
+echo "gitleaks scanning range: ${range:-} (repo: $repo_dir)"
+
+# safe.directory whitelists the mount so git 2.35.2+ does not refuse it for
+# dubious ownership (the image runs as root; the checkout is owned by another
+# uid). GIT_CONFIG_* needs no writable HOME, unlike `git config --global`.
+# --max-archive-depth 2: without it gitleaks defaults to 0 and NEVER looks inside
+# tracked archives, so a secret shipped in a tarball is invisible (bonnyr-f5 #182
+# r3 Major). Depth 2 covers e.g. a key inside a .tar.gz inside a .zip.
+out="$(docker run --rm \
+ -e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0=/repo \
+ -v "$repo_dir:/repo:ro" -w /repo "$IMAGE" detect \
+ --source=/repo --config=/repo/.gitleaks.toml --redact --verbose \
+ --max-archive-depth 2 \
+ ${range:+--log-opts="$range"} 2>&1)"
+rc=$?
+printf '%s\n' "$out"
+
+# Strip ANSI so parsing is robust whether or not gitleaks colourises.
+clean="$(printf '%s\n' "$out" | sed -E 's/\x1b\[[0-9;]*m//g')"
+
+# 1) Any git error (bad range, dubious ownership) means the scan never saw the
+# repo/range -- fail even though gitleaks exited 0.
+if grep -qE 'ERR \[git\]' <<< "$clean"; then
+ echo "::error::gitleaks hit a git error (bad revision range or dubious ownership) -- the scan did not run"
+ exit 1
+fi
+
+# 2) Positive evidence the scan ran: gitleaks always prints " commits scanned"
+# in git mode. No such line == silence == must not pass.
+scanned="$(grep -oE '[0-9]+ commits scanned' <<< "$clean" | grep -oE '^[0-9]+' | tail -n1)"
+echo "gitleaks reported commits scanned: ${scanned:-}"
+if [ -z "$scanned" ]; then
+ echo "::error::gitleaks printed no 'commits scanned' line -- no evidence the scan ran"
+ exit 1
+fi
+
+# 3) A non-empty range that scanned 0 commits scanned NOTHING.
+if [ -n "$range" ] && [ "$scanned" -eq 0 ]; then
+ echo "::error::gitleaks scanned 0 commits for range $range -- the gate would have passed blind"
+ exit 1
+fi
+
+# 4) Real leaks make gitleaks exit non-zero -- that must still fail here.
+if [ "$rc" -ne 0 ]; then
+ echo "::error::gitleaks exited $rc (leaks found or scan error)"
+ exit "$rc"
+fi
+
+echo "secret-scan: OK"
diff --git a/scripts/sync-version-artifacts.sh b/scripts/sync-version-artifacts.sh
new file mode 100644
index 00000000..ff27b040
--- /dev/null
+++ b/scripts/sync-version-artifacts.sh
@@ -0,0 +1,193 @@
+#!/usr/bin/env bash
+# Keep the version-bearing release artifacts in lockstep with VERSION:
+# - the bnk-forge Helm chart image tag (values.yaml) and Chart `appVersion`
+# - the frontend package.json version
+# - the sibling bnk-operator chart image tag (values.yaml) and `appVersion`
+#
+# All five publish at :${VERSION} on the release train — docker-bake.hcl's
+# `default` group builds the operator image alongside the rest — so any drift
+# means an image tag the release never publishes -> ImagePullBackOff. This is the
+# one place that writes THESE FIVE, and --check verifies them in CI so drift
+# can't reappear silently.
+#
+# Scope, precisely: this owns the five release-train image-pin artifacts above —
+# NOT every version string in the repo. Deliberately out of scope, and NOT
+# claimed here: frontend-v2/package-lock.json's root `version` (npm owns it; it
+# desyncs harmlessly — `npm ci` tolerates it), and the dist/ documentation
+# copies (dist/.env.example, dist/README.md), which are packaged separately and
+# tracked under PR #183. Don't read "the one place" as "every version site."
+#
+# Synced: the bnk-forge image tag + appVersion, the frontend package.json, AND
+# the bnk-operator image tag + appVersion. NOT synced, deliberately: each
+# Chart.yaml's own `version:` — Helm treats the chart version and appVersion as
+# independent, and release.yml neither packages nor pushes the chart, so a static
+# chart version publishes nothing wrong. Leave it alone rather than "fixing" it.
+#
+# Usage:
+# sync-version-artifacts.sh --write # set all artifacts to
+# sync-version-artifacts.sh --check # verify all == VERSION; exit 1 if not
+# sync-version-artifacts.sh --list # print artifact paths (repo-relative)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+VALUES="$ROOT/helm/bnk-forge/values.yaml"
+CHART="$ROOT/helm/bnk-forge/Chart.yaml"
+PKG="$ROOT/frontend-v2/package.json"
+# The sibling operator chart is on the VERSION train (its image publishes at
+# :${VERSION}), so it is synced here too rather than pinned.
+OPVALUES="$ROOT/bnk-operator/charts/bnk-operator/values.yaml"
+OPCHART="$ROOT/bnk-operator/charts/bnk-operator/Chart.yaml"
+
+# Canonical artifact list. --write, --list, and the release job's `git add` all
+# derive the file set from HERE, so the writer and its stager cannot diverge and
+# leave a synced-but-unstaged file behind (bonnyr-f5 #180 r3, BLOCKER 1).
+SYNCED_FILES=("$VALUES" "$CHART" "$PKG" "$OPVALUES" "$OPCHART")
+
+# ── Value readers ─────────────────────────────────────────────────────────────
+# Each reads EVERY matching version line (not grep -m1), so a second occurrence
+# can't drift unseen behind a global write (bonnyr-f5 #180 r3). `^ tag: ` (two
+# spaces) matches only the top-level image tag — the postgres/redis/per-service
+# tags are 4-space and never match.
+TAG_RE='^ tag: '
+TAG_SED='s/^ tag: "?([^"]*)"?.*/\1/'
+APPVER_RE='^appVersion:'
+APPVER_SED='s/^appVersion: "?([^"]*)"?.*/\1/'
+PKGVER_RE='^ "version":'
+PKGVER_SED='s/^ "version": "([^"]*)".*/\1/'
+
+# The image tag lives inside the top-level `image:` block. The WRITER scopes its
+# substitution to that block (sed range below); the READERS (--check and --write's
+# post-write verify) MUST use the SAME range, or writer and checker diverge:
+# a `tag:` the writer can't reach (a column-0 comment closing the block early) or
+# a stray 2-space `tag:` under another key would be read by a file-global checker
+# but never written — CI green while the next release hard-fails, or CI red on a
+# line --write can't fix (bonnyr-f5 #180 r5, F1). One expression, used by both.
+IMG_RANGE='/^image:/,/^[^[:space:]]/'
+
+# Emit the candidate version lines for a reader. When RANGE is given, the grep is
+# scoped to that sed address range (the image-tag case) so the reader sees EXACTLY
+# the site set the writer's ranged sed touches; otherwise it is file-global.
+_version_lines() { # file, grep-ERE, range(optional)
+ local file="$1" gre="$2" range="${3:-}"
+ if [ -n "$range" ]; then
+ sed -nE "${range}{/${gre}/p;}" "$file"
+ else
+ grep -E "$gre" "$file" || true
+ fi
+}
+
+case "${1:-}" in
+ --write)
+ V="${2:?usage: sync-version-artifacts.sh --write }"
+ # V is interpolated into sed replacement strings, so a `|`/`&`/`\`/`"` would
+ # corrupt the substitution. It only fails-closed at the post-write verify
+ # today (bonnyr-f5 #180 r5 nit) — reject metacharacters up front with a clear
+ # message. The class is permissive enough for full semver incl. prerelease and
+ # build metadata (e.g. 1.2.3-rc.1+build.5).
+ if ! printf '%s' "$V" | grep -qE '^[A-Za-z0-9._+-]+$'; then
+ echo "::error::--write version '$V' contains characters outside [A-Za-z0-9._+-] — refusing (would corrupt the sed substitution)" >&2
+ exit 2
+ fi
+ # Anchor every substitution to its key PATH, not a bare 2-space `tag:`. The
+ # image-tag writes are scoped to the top-level `image:` block via a sed range
+ # (`/^image:/` to the next column-0 key) so a future unrelated 2-space `tag:`
+ # elsewhere is never repinned to VERSION (bonnyr-f5 #180 r3, unbounded writer).
+ # -i.syncbak (attached suffix) is the one in-place form both GNU and BSD sed
+ # accept; `-i -E` makes BSD swallow -E as the suffix and litter *-E files.
+ sed -i.syncbak -E "${IMG_RANGE} s|^ tag: .*| tag: \"${V}\"|" "$VALUES"
+ sed -i.syncbak -E "s|^appVersion: .*|appVersion: \"${V}\"|" "$CHART"
+ sed -i.syncbak -E "s|^ \"version\": \"[^\"]*\"| \"version\": \"${V}\"|" "$PKG"
+ sed -i.syncbak -E "${IMG_RANGE} s|^ tag: .*| tag: \"${V}\"|" "$OPVALUES"
+ sed -i.syncbak -E "s|^appVersion: .*|appVersion: \"${V}\"|" "$OPCHART"
+ for f in "${SYNCED_FILES[@]}"; do rm -f "${f}.syncbak"; done
+
+ # Fail closed: a sed whose pattern matched nothing no-ops silently, and the
+ # caller would commit the unchanged file believing it synced (#177 review).
+ # Re-read every version line in every artifact with the SAME readers --check
+ # uses and confirm each one actually took ${V} — and that at least one line
+ # matched per artifact, so a renamed key can't pass as "nothing to change".
+ rc=0
+ _verify_file() { # label, file, grep-ERE, extract-sed, range(optional)
+ local label="$1" file="$2" gre="$3" ext="$4" range="${5:-}" n=0 line val
+ while IFS= read -r line; do
+ val=$(sed -E "$ext" <<< "$line"); n=$((n + 1))
+ if [ "$val" != "$V" ]; then
+ echo "::error::--write did not take on $label: it is '$val', expected '$V' (the sed pattern matched nothing — the artifact's format changed)" >&2
+ rc=1
+ fi
+ done < <(_version_lines "$file" "$gre" "$range")
+ if [ "$n" -eq 0 ]; then
+ echo "::error::--write found no '$label' line in $file (key renamed/removed?) — nothing was synced" >&2
+ rc=1
+ fi
+ }
+ # range ↓ (tag only: same scope as the writer)
+ _verify_file "helm image.tag" "$VALUES" "$TAG_RE" "$TAG_SED" "$IMG_RANGE"
+ _verify_file "Chart appVersion" "$CHART" "$APPVER_RE" "$APPVER_SED"
+ _verify_file "frontend version" "$PKG" "$PKGVER_RE" "$PKGVER_SED"
+ _verify_file "operator image.tag" "$OPVALUES" "$TAG_RE" "$TAG_SED" "$IMG_RANGE"
+ _verify_file "operator appVersion" "$OPCHART" "$APPVER_RE" "$APPVER_SED"
+ [ "$rc" -eq 0 ] || exit 1
+ echo "synced bnk-forge tag+appVersion, frontend package.json, operator tag+appVersion -> ${V}" >&2
+ ;;
+
+ --check)
+ EXPECTED="$(cat "$ROOT/VERSION")"
+ # VERSION itself must be non-empty, or every artifact would "match" an empty
+ # string and the gate would pass on a tree with no version data at all
+ # (bonnyr-f5 #180 r3, BLOCKER 2).
+ if [ -z "$EXPECTED" ]; then
+ echo "::error::VERSION is empty — refusing to validate artifacts against nothing" >&2
+ exit 1
+ fi
+ rc=0; total=0
+ # Assert EVERY version line is NON-EMPTY and equals VERSION, and that each
+ # artifact contributed at least one matched line. `total` counts MATCHED
+ # LINES, never loop iterations — an artifact whose key vanished contributes
+ # zero and both trips its own error and lowers the vacuity floor (bonnyr-f5
+ # #180 r3: the old `checked` counted a literal 5-item list, so its >=5 guard
+ # was unreachable and --check was green on an empty tree).
+ _check_file() { # label, file, grep-ERE, extract-sed, range(optional)
+ local label="$1" file="$2" gre="$3" ext="$4" range="${5:-}" n=0 line val
+ while IFS= read -r line; do
+ val=$(sed -E "$ext" <<< "$line"); n=$((n + 1)); total=$((total + 1))
+ if [ -z "$val" ]; then
+ echo "::error::$label in $file has an empty version — expected '$EXPECTED'"; rc=1
+ elif [ "$val" != "$EXPECTED" ]; then
+ echo "::error::$label is '$val' but VERSION is '$EXPECTED' — the release publishes only :\${VERSION}, so a mismatch means ImagePullBackOff / drift. Run scripts/sync-version-artifacts.sh --write $EXPECTED"; rc=1
+ else
+ echo " OK $label = $val"
+ fi
+ done < <(_version_lines "$file" "$gre" "$range")
+ if [ "$n" -eq 0 ]; then
+ echo "::error::$label: no version line matched in $file (key renamed/removed?) — vacuous check"; rc=1
+ fi
+ }
+ # range ↓ (tag only: same scope as the writer)
+ _check_file "helm image.tag" "$VALUES" "$TAG_RE" "$TAG_SED" "$IMG_RANGE"
+ _check_file "Chart appVersion" "$CHART" "$APPVER_RE" "$APPVER_SED"
+ _check_file "frontend version" "$PKG" "$PKGVER_RE" "$PKGVER_SED"
+ _check_file "operator image.tag" "$OPVALUES" "$TAG_RE" "$TAG_SED" "$IMG_RANGE"
+ _check_file "operator appVersion" "$OPCHART" "$APPVER_RE" "$APPVER_SED"
+ # Backstop: five artifacts, each with >=1 version line, is the minimum a
+ # healthy tree yields. Fewer means a key vanished — treat as vacuous.
+ if [ "$total" -lt 5 ]; then
+ echo "::error::--check matched only $total version lines (expected >=5) — vacuous" >&2
+ exit 1
+ fi
+ exit "$rc"
+ ;;
+
+ --list)
+ # Print the canonical artifact paths (repo-relative) so the release job stages
+ # EXACTLY what --write touches. Adding an artifact above updates all three.
+ for f in "${SYNCED_FILES[@]}"; do
+ printf '%s\n' "${f#"$ROOT"/}"
+ done
+ ;;
+
+ *)
+ echo "usage: sync-version-artifacts.sh --write | --check | --list" >&2
+ exit 2
+ ;;
+esac
diff --git a/scripts/test-backup-restore.sh b/scripts/test-backup-restore.sh
index 3810d79f..420f1247 100755
--- a/scripts/test-backup-restore.sh
+++ b/scripts/test-backup-restore.sh
@@ -202,7 +202,7 @@ echo ""
info "Open the UI and create a backup:"
echo ""
echo " 1. Go to https://localhost (accept the self-signed cert warning)"
-echo " 2. Log in with admin / changeme"
+echo " 2. Log in as admin (password: docker exec bnk-forge-backend cat /app/keys/initial_admin_password)"
echo " 3. Navigate to System → Backup & Restore tab"
echo " 4. Enter a passphrase (12+ chars) — REMEMBER IT!"
echo " 5. Click 'Create Backup'"
@@ -300,7 +300,7 @@ echo ""
info "Open the UI on the FRESH instance and restore your backup:"
echo ""
echo " 1. Go to https://localhost"
-echo " 2. Log in with the DEFAULT credentials: admin / changeme"
+echo " 2. Log in as admin (password from /app/keys/initial_admin_password or DEFAULT_ADMIN_PASSWORD)"
echo " 3. Navigate to System → Backup & Restore tab"
echo " 4. Upload the .tar.gz backup file you saved in Phase 2"
echo " 5. Enter the SAME passphrase you used when creating the backup"
diff --git a/scripts/tests/registry-tag-probe.test.sh b/scripts/tests/registry-tag-probe.test.sh
new file mode 100644
index 00000000..3140a94c
--- /dev/null
+++ b/scripts/tests/registry-tag-probe.test.sh
@@ -0,0 +1,104 @@
+#!/usr/bin/env bash
+# Mutation tests for scripts/registry-tag-probe.sh (bonnyr-f5 #181 round 5, F1).
+#
+# A fake `curl` on PATH simulates the registry HTTP API so the four required
+# outcomes are proven WITHOUT a live registry:
+# absent → publish · no-permission → refuse · network → refuse · exists → refuse
+# plus the first-publish case F1 is about: a NONEXISTENT repo classifies absent
+# (safe), NOT unknown — which the old CLI-text probe got wrong.
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+PROBE="$HERE/../registry-tag-probe.sh"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+# ─── fake curl ───────────────────────────────────────────────────────────────
+# Behaviour is driven by $SCENARIO. It recognises three call shapes the probe
+# makes: (1) initial manifest GET (has -D ), (2) token fetch (URL
+# contains /token), (3) authed manifest GET (has "Authorization: Bearer").
+cat > "$WORK/curl" <<'FAKE'
+#!/usr/bin/env bash
+hdrfile=""; is_token=0; is_authed=0
+prev=""
+for a in "$@"; do
+ case "$prev" in -D) hdrfile="$a" ;; esac
+ case "$a" in
+ */token*|*"/token?"*) is_token=1 ;;
+ "Authorization: Bearer "*) is_authed=1 ;;
+ esac
+ case "$a" in *"/token"*) is_token=1 ;; esac
+ prev="$a"
+done
+
+emit_challenge() {
+ [ -n "$hdrfile" ] && printf 'www-authenticate: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:o/i:pull"\r\n' > "$hdrfile"
+}
+
+case "$SCENARIO" in
+ exists)
+ if [ "$is_token" = 1 ]; then echo '{"token":"T"}';
+ elif [ "$is_authed" = 1 ]; then echo 200;
+ else emit_challenge; echo 401; fi ;;
+ absent|nonexistent_repo)
+ if [ "$is_token" = 1 ]; then echo '{"token":"T"}';
+ elif [ "$is_authed" = 1 ]; then echo 404;
+ else emit_challenge; echo 401; fi ;;
+ no_permission_token) # token endpoint denies (bonnyr's `denied`)
+ if [ "$is_token" = 1 ]; then echo '{"errors":[{"code":"DENIED"}]}';
+ elif [ "$is_authed" = 1 ]; then echo 200; # never reached (no token)
+ else emit_challenge; echo 401; fi ;;
+ no_permission_manifest) # token issued but manifest read forbidden
+ if [ "$is_token" = 1 ]; then echo '{"token":"T"}';
+ elif [ "$is_authed" = 1 ]; then echo 403;
+ else emit_challenge; echo 401; fi ;;
+ network)
+ echo 000 ;;
+ ratelimit)
+ if [ "$is_token" = 1 ]; then echo '{"token":"T"}';
+ elif [ "$is_authed" = 1 ]; then echo 429;
+ else emit_challenge; echo 401; fi ;;
+esac
+FAKE
+chmod +x "$WORK/curl"
+
+run() { SCENARIO="$1" PATH="$WORK:$PInitial" REGISTRY=ghcr.io/o VERSION=3.1.6 bash "$PROBE"; }
+PInitial="$PATH"
+
+# aggregate one scenario into the caller's verdict: EXISTS/UNKNOWN/SAFE
+verdict() {
+ local out; out="$(run "$1")"
+ if printf '%s' "$out" | grep -q '^exists'; then echo EXISTS
+ elif printf '%s' "$out" | grep -q '^unknown'; then echo UNKNOWN
+ else echo SAFE; fi
+}
+
+fail=0
+check() {
+ local name="$1" got="$2" want="$3"
+ if [ "$got" = "$want" ]; then printf 'PASS %-28s -> %s\n' "$name" "$got"
+ else printf 'FAIL %-28s -> got %s want %s\n' "$name" "$got" "$want"; fail=1; fi
+}
+
+# The caller policy: SAFE => publish; EXISTS/UNKNOWN => refuse (unless force).
+check "absent -> publish" "$(verdict absent)" SAFE
+check "nonexistent-repo -> publish" "$(verdict nonexistent_repo)" SAFE
+check "no-permission(token)->refuse" "$(verdict no_permission_token)" UNKNOWN
+check "no-permission(manifest)->ref" "$(verdict no_permission_manifest)" UNKNOWN
+check "network -> refuse" "$(verdict network)" UNKNOWN
+check "rate-limit -> refuse" "$(verdict ratelimit)" UNKNOWN
+check "exists -> refuse" "$(verdict exists)" EXISTS
+
+# ─── F6: image-list single-source parity with docker-bake.hcl ────────────────
+mapfile -t LIST < <(bash "$PROBE" --images)
+check "image count is 7" "${#LIST[@]}" 7
+BAKE_TARGETS="$(sed -n 's/.*targets = \[\(.*\)\].*/\1/p' "$HERE/../../docker-bake.hcl" | tr -d '" ' | tr ',' '\n' | sort)"
+LIST_TARGETS="$(printf '%s\n' "${LIST[@]}" | sed 's/^bnk-forge-//' | sort)"
+if [ "$BAKE_TARGETS" = "$LIST_TARGETS" ]; then
+ check "image list == bake group" match match
+else
+ printf 'FAIL image list vs bake group differ:\n--bake--\n%s\n--list--\n%s\n' "$BAKE_TARGETS" "$LIST_TARGETS"; fail=1
+fi
+
+echo "----"
+[ "$fail" = 0 ] && echo "ALL PASS" || { echo "FAILURES"; exit 1; }
diff --git a/tests/e2e/E2E_STRATEGY.md b/tests/e2e/E2E_STRATEGY.md
index 0b602730..e1c3fc7e 100644
--- a/tests/e2e/E2E_STRATEGY.md
+++ b/tests/e2e/E2E_STRATEGY.md
@@ -27,7 +27,7 @@ cleanup behavior.
| **Duration** | Under 5 minutes |
| **Data isolation** | Fresh DB per run (Docker volume reset) |
| **Cleanup** | Automatic — containers torn down after run |
-| **Auth** | Default `admin/changeme` credentials |
+| **Auth** | `admin` + DEFAULT_ADMIN_PASSWORD (set at deploy; generated if unset, #184) |
| **Specs** | `tests/00-*.spec.ts` through `tests/11-*.spec.ts` |
| **Parallelism** | Serial (single worker) to avoid port/resource conflicts |
diff --git a/tests/e2e/config/test-config.ts b/tests/e2e/config/test-config.ts
index 4179255d..e08f2b83 100644
--- a/tests/e2e/config/test-config.ts
+++ b/tests/e2e/config/test-config.ts
@@ -12,7 +12,7 @@ export const TEST_CONFIG = {
// Default credentials
credentials: {
- admin: { username: 'admin', password: 'changeme' },
+ admin: { username: 'admin', password: 'e2e-Admin-Pass-1' },
// operator and viewer roles — created by system admin tests or pre-seeded
operator: { username: 'e2e-operator', password: 'changeme' },
viewer: { username: 'e2e-viewer', password: 'changeme' },
diff --git a/tests/e2e/pages/login.page.ts b/tests/e2e/pages/login.page.ts
index ec6a1aa2..1b8fc7d9 100644
--- a/tests/e2e/pages/login.page.ts
+++ b/tests/e2e/pages/login.page.ts
@@ -63,7 +63,7 @@ export class LoginPage {
* Login via API and inject token into localStorage.
* Much faster than UI login — use for tests that don't test the login flow itself.
*/
- async loginViaApi(username: string = 'admin', password: string = 'changeme') {
+ async loginViaApi(username: string = 'admin', password: string = 'e2e-Admin-Pass-1') {
// Call the login API directly
const response = await this.page.request.post(`${TEST_CONFIG.apiUrl}/api/auth/login`, {
data: { username, password },
diff --git a/user-pack/install-guide.html b/user-pack/install-guide.html
index 1c2109cc..26c48a8f 100644
--- a/user-pack/install-guide.html
+++ b/user-pack/install-guide.html
@@ -63,12 +63,6 @@
code{background:#eef0f2;border:1px solid var(--bd);border-radius:3px;padding:1px 5px;
font-size:12.5px;color:var(--ink2);font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;}
- /* ── Callout: credentials (red left border) ── */
- .callout-cred{background:#fff5f5;border-left:4px solid var(--f5red);border-radius:0 6px 6px 0;
- padding:14px 18px;margin:14px 0;box-shadow:0 1px 2px rgba(20,30,40,.04);}
- .callout-cred .callout-title{font-weight:700;color:var(--f5red);font-size:13px;
- text-transform:uppercase;letter-spacing:.6px;margin-bottom:6px;}
-
/* ── Callout: warning (amber) ── */
.callout-warn{background:var(--warn-bg);border-left:4px solid var(--warn-bd);border-radius:0 6px 6px 0;
padding:14px 18px;margin:14px 0;color:var(--warn);}
@@ -100,7 +94,7 @@
@media print{
header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}
pre{white-space:pre-wrap;word-break:break-all;}
- .callout-cred,.callout-warn,.callout-info{-webkit-print-color-adjust:exact;print-color-adjust:exact;}
+ .callout-warn,.callout-info{-webkit-print-color-adjust:exact;print-color-adjust:exact;}
body{font-size:13px;}
main{padding:10px 20px 30px;}
}
@@ -113,7 +107,7 @@
F5
BNK Forge — Install Guide
-
Private registry edition | customer-build distribution | registry: ghcr.io/jlcode-tech
+
Public registry edition | current-release distribution | registry: ghcr.io/f5devcentral
@@ -122,9 +116,9 @@
BNK Forge — Install Guide
- This guide walks you through installing BNK Forge from the private GitHub Container Registry.
- You will authenticate to the registry using the read-only bot credential you were provided,
- download the install package, make a small configuration change, and run a single script.
+ This guide walks you through installing BNK Forge from the public GitHub Container Registry.
+ You download the install package, make a small configuration change, and run a single
+ script. The images are public, so no registry login is required.
The entire process takes under ten minutes on a fast connection; the first image pull may
take a few minutes depending on bandwidth.
@@ -134,40 +128,12 @@
Prerequisites
Docker Engine 24+ — on macOS or Windows, Docker Desktop satisfies both this and the Compose requirement.
Read-access token — the <READ_TOKEN> provided to you separately (see Step 1).
~5–10 GB free disk space — for images and persistent data volumes.
Network access to ghcr.io — outbound HTTPS (port 443) must be allowed.
-
Step 1 — Authenticate to the registry
-
-
-
Credentials — handle with care
- Your read-access token is provided separately (out-of-band). It is a GitHub PAT scoped to
- read:packages for the ghcr.io/jlcode-tech registry.
- Do not share it, commit it to version control, or embed it in scripts.
- If you believe the token has been exposed, contact the person who gave it to you immediately.
-
-
-
Run the following command, replacing <READ_TOKEN> with the token you received.
- The bot username is fixed — use it exactly as shown:
- Docker stores the credential in your OS keychain (or ~/.docker/config.json).
- You only need to log in once per machine. If you later see a denied or
- unauthorized error during a pull, re-run the command above — the token may have been
- rotated. See the Troubleshooting section for details.
-
-
-
-
Step 2 — Download & extract the package
+
Step 1 — Download & extract the package
You should have received a bnk-forge-<version>.tar.gz archive alongside this
guide. Save it to a convenient location, then extract it:
@@ -177,8 +143,8 @@
Step 2 — Download & extract the package
All subsequent commands are run from inside this directory.
-
-
Step 3 — Configure
+
+
Step 2 — Configure
Copy the example environment file and open it in your editor:
@@ -195,13 +161,13 @@
Step 3 — Configure
BNK_FORGE_REGISTRY
-
ghcr.io/jlcode-tech
-
Points to the private registry.
+
ghcr.io/f5devcentral
+
Points to the public registry.
BNK_FORGE_VERSION
-
customer-build
-
Rolling latest build. To pin a specific build, use a tag like 3.0.1-cb.<sha>.
+
latest
+
Rolling latest release. To pin a specific version, use its tag, e.g. 3.1.6.
POSTGRES_PASSWORD
@@ -214,21 +180,46 @@
Step 3 — Configure
Change from the default. Used for the internal cache/queue.
-
MCP_PASSWORD
+
MCP_SERVICE_PASSWORD
your choice
-
Change from the default. Used by the MCP integration layer.
+
Credential the bundled MCP server uses to authenticate to BNK Forge. MCP logs in as its
+ own dedicated, non-human service account (MCP_SERVICE_USERNAME, default
+ mcp) — never the human admin login. No default
+ ships: choose a strong value here and the backend seeds/reconciles the mcp
+ account to it on every boot, so the two always agree. Leave it empty and MCP stays
+ unavailable until you set it (the backend refuses to seed a guessable credential, and
+ the old mcp-service-changeme default can no longer authenticate). It is
+ re-read from .env on every boot, so to rotate it edit .env and
+ re-run docker compose up -d (a plain docker compose restart
+ does not re-read .env).
-
Change all three passwords
- The defaults in .env.example are well-known placeholders.
- Replace POSTGRES_PASSWORD, REDIS_PASSWORD, and MCP_PASSWORD
- before running the installer — they cannot be changed easily after the stack first starts.
+
Set strong passwords before first start
+ POSTGRES_PASSWORD and REDIS_PASSWORD replace well-known
+ placeholder defaults, and they are baked in when the database and cache first
+ initialize — get them right before running the installer, as they cannot be changed
+ easily afterward. MCP_SERVICE_PASSWORD is independent of the admin password:
+ MCP authenticates as its own dedicated mcp service account, so set it to a strong
+ secret of your choosing (no shipped default) and the backend provisions the mcp
+ account from it. It is re-read on every boot, so to rotate it edit .env and re-run
+ docker compose up -d (a plain docker compose restart does not re-read
+ .env).
+
A dedicated MCP service account — mcp. Besides the human
+ admin, this build seeds a non-human, admin-role service account named
+ mcp that the bundled MCP server authenticates as. It carries no shipped
+ default: the backend provisions it from MCP_SERVICE_PASSWORD and reconciles
+ the stored hash to that value on every boot, so rotating the secret is simply a matter of
+ editing .env and re-running docker compose up -d. The old shipped
+ mcp-service-changeme default has been removed and can no longer authenticate, and
+ MCP no longer borrows the human admin login (#186). If you leave
+ MCP_SERVICE_PASSWORD unset, the backend disables any stale service account carried
+ over from an upgrade and MCP stays unavailable until you configure it.
-
-
Step 4 — Install
+
+
Step 3 — Install
Run the installer for your platform. It will pull the images and bring the full stack up.
The first run may take a few minutes while images download.
@@ -251,8 +242,8 @@
Step 4 — Install
When the installer finishes you will see:
✅ Installation complete!
-
-
Step 5 — First login
+
+
Step 4 — First login
@@ -267,12 +258,16 @@
Step 5 — First login
(Firefox). This is expected.
-
Log in with the default credentials:
- Username: admin / Password: changeme
+
Log in as admin.
+ No default password ships. Unless you set DEFAULT_ADMIN_PASSWORD before
+ install, the backend generates a random one on first start and writes it to a file
+ (the plaintext is never written to the logs). Retrieve it with:
- The default password is well-known. Go to User menu → Change Password as your
- very first action after login.
+ This generated password is a one-time bootstrap credential. Go to
+ User menu → Change Password as your very first action after login — the API
+ refuses every other call until you do.
@@ -309,7 +304,7 @@
Verify the stack is healthy
Updating to a newer build
-
Because BNK_FORGE_VERSION=customer-build is a rolling tag, updating is simple.
+
Because BNK_FORGE_VERSION=latest is a rolling tag, updating is simple.
From the install directory:
# Recommended — uses the installer for any migration steps:
@@ -322,6 +317,31 @@
Updating to a newer build
If you pinned a specific build tag in .env, update BNK_FORGE_VERSION
to the new tag before running the command above.
+
+
Before upgrading an older install to 4.0.0
+ If your .env predates this release, reconcile it first, or the upgrade will fail
+ to pull or refuse to boot:
+
+
Registry & version. Set BNK_FORGE_REGISTRY=ghcr.io/f5devcentral
+ (older packages pointed at a private org that no longer resolves), and replace any pinned
+ BNK_FORGE_VERSION such as 3.0.1 with latest or a
+ current tag like 3.1.6 — a stale pin pulls a tag that no longer exists.
+
Non-root artifact images. The container runner now refuses any image whose
+ USER is root or a named user (e.g. USER nonroot). Rebuild
+ your own runner images with a numeric USER 1000 before upgrading.
+
MCP service credential (from 4.0.0). The bundled MCP server now authenticates as a
+ dedicated mcp service account, not the human admin. Set
+ MCP_SERVICE_PASSWORD to a strong secret in this .env: this
+ compose file passes it to the backend (which provisions the mcp account from
+ it) and to the MCP container (as BNK_FORGE_PASSWORD), so the two stay
+ in sync. The old MCP_PASSWORD/admin-password coupling is gone, and the shipped
+ mcp-service-changeme default can no longer authenticate. Under
+ ENVIRONMENT=staging/production the backend refuses to start while
+ MCP_SERVICE_PASSWORD is unset or set to a shipped default
+ (#186 + #188), so set it to a real secret before upgrading.
+
+
+
Uninstall
@@ -335,10 +355,10 @@
Troubleshooting
- denied: denied or unauthorized: unauthenticated during pull
- Your docker login session has lapsed, or the token does not have the
- read:packages scope. Re-run Step 1 with your current token.
- If the problem persists, contact the person who issued the token.
+ manifest unknown, or a pull that hangs or times out
+ The images are public, so no login is required. Check that BNK_FORGE_REGISTRY
+ is ghcr.io/f5devcentral and that the version tag exists, and that outbound
+ HTTPS to ghcr.io (port 443) is allowed through any proxy or firewall.