Stop shipping a default admin credential; generate it and enforce rotation - #186
Stop shipping a default admin credential; generate it and enforce rotation#186jgruberf5 wants to merge 18 commits into
Conversation
…e rotation (#184) Every fresh deployment seeded admin/changeme, and it was a live API-reachable credential: the password is published in this repo, and `must_change_password` was surfaced in the login response but never enforced server-side, so a client could skip the change-password screen and call every endpoint with the seed credential. gitleaks can't catch it -- a runtime-seeded default plus an unenforced flag, not a committed secret string. - core/config.py: DEFAULT_ADMIN_PASSWORD defaults to None (never a hardcoded value), mirroring how JWT_SECRET_KEY / ENCRYPTION_KEY are already handled. - auth_service.seed_admin_user: when unset, generate a strong random password (secrets.token_urlsafe) and log it ONCE so the operator can retrieve it; the account stays must_change_password. - routes/auth.get_current_user: enforce must_change_password server-side -- refuse every endpoint except change-password / me / logout until the password is rotated, for both the JWT and API-token paths. - Helm: generate an admin-password secret (randAlphaNum, reused across upgrades) and inject DEFAULT_ADMIN_PASSWORD into the backend pods; values.yaml gains adminPassword: "" (empty -> generated). - Docs sweep: README, INSTALLATION, DEPLOYMENT, the Helm NOTES, the user-pack install guide, and the adr424 compose header no longer say admin/changeme -- they explain retrieving the generated password (backend logs for compose, the secret for Helm) and that rotation is enforced. Tests: seed generates a random password when unset (published "changeme" no longer authenticates) and uses an explicit one when set; a must-change user is refused on a protected endpoint (403), can still reach /me and change-password, and is unblocked afterwards; a normal user is not gated. 99 auth tests pass, ruff clean, OpenAPI unchanged, helm lints clean. Scoped to the admin credential. The `mcp` service account ships the same class of default (MCP_SERVICE_PASSWORD = "mcp-service-changeme"); its fix is entangled with the chart's mcp-password wiring and is tracked as a follow-up. Fixes #184 Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…m logout Self-review of #186: - The exempt list included /auth/logout, but there is no server-side logout endpoint (logout is discarding the token client-side) -- dead exemption, removed. - Matched exempt endpoints by `endswith(suffix)`. For a security gate that's too loose -- it would also exempt any unrelated route that happened to end in "/auth/me". The auth router prefix is fixed at /api/auth, so switched to exact full-path matching (with trailing-slash normalization) against {/api/auth/change-password, /api/auth/me}. Also re-verified non-vacuity properly: with BOTH enforcement calls removed (the JWT and API-token paths), the enforcement test fails (the must-change user gets 200 on /api/auth/users); with them in place it's 403 then 200 after the change. My first check only stripped one call and missed it. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
mwiget
left a comment
There was a problem hiding this comment.
This is the right diagnosis and mostly the right fix -- must_change_password returned in the login response but never consulted server-side is exactly the kind of gap that reads as "handled" for years. The seed-generation half I have no objection to. The enforcement half has two holes, and one of them leaves the most dangerous surface in the product wide open.
Verified good
DEFAULT_ADMIN_PASSWORD: str | None = Nonemirrors the existingJWT_SECRET_KEY/ENCRYPTION_KEYshape, andsecrets.token_urlsafe(18)is a sound generator.- The Helm block matches the four secrets above it exactly and reuses the value across upgrades, so nothing rotates under a running install.
- The integration test is the right test: login as a must-change user, assert 403 on a protected endpoint, assert
/meandchange-passwordstill work, assert the same endpoint works after the change. Plus a regression guard that an ordinary user isn't gated. - The docs sweep is thorough -- README, INSTALLATION, DEPLOYMENT, NOTES.txt, the user-pack guide and the adr424 compose header all stop saying
changeme.
Blocking 1 -- WebSockets bypass the gate completely
_enforce_password_change lives in get_current_user, but the WebSocket routes never go through it. k8s_websocket.py::_validate_ws_token (and the identical one in dpus_websocket.py) decodes the JWT and checks the role claim -- it never loads the User row, so must_change_password cannot be consulted:
payload = decode_token(token)
role = payload.get("role", "")
if role not in ("admin", "operator", "viewer"):
...
return TrueSo with the seed credential and its perfectly valid token, a must-change admin still reaches:
| endpoint | what it grants |
|---|---|
/ws/k8s/clusters/{id}/pods/{pod}/exec |
interactive shell in a cluster pod |
/ws/k8s/clusters/{id}/pods/{pod}/logs/follow |
pod log stream |
/ws/projects/{p}/dpus/{d}/serial-console |
DPU serial console |
/ws/projects/{p}/dpus/{d}/ssh-bmc / ssh-os |
SSH to the BMC / host OS |
Every one of those is strictly more powerful than the REST endpoints the gate does block. The PR body says "a client could skip the change-password screen and call every endpoint directly with the seed credential" -- that sentence is still true, and it's now true of pod exec and BMC SSH specifically while /api/auth/users is refused.
The fix wants the flag checked where the user is loaded, not where the request is routed: _validate_ws_token has to resolve the token subject to a User and refuse when must_change_password is set. A shared helper both paths call would keep them from drifting again.
Blocking 2 -- the suffix match is bypassable
path.endswith(suffix) tests the raw URL, and the repo has one route with a :path converter -- state_viewer.py:269, @router.get("/module/{module_id}/resource/{resource_address:path}") under prefix /api/state. :path matches slashes, so the tail is attacker-chosen. Reproduced with a minimal FastAPI app using this exact gate and route shape:
403 /api/auth/users
403 /api/state/module/1/resource/aws_instance.web
200 /api/state/module/1/resource/x/auth/me -> {"gate":"EXEMPT","resource_address":"x/auth/me"}
A must-change user reads terraform state -- which routinely carries sensitive outputs -- by appending /auth/me to the resource address.
Match the resolved route instead of the raw path; FastAPI has already done the routing by the time this dependency runs:
_PASSWORD_CHANGE_EXEMPT_ROUTES = {
"/api/auth/change-password",
"/api/auth/me",
"/api/auth/logout",
}
route = request.scope.get("route")
if route is not None and route.path in _PASSWORD_CHANGE_EXEMPT_ROUTES:
returnThat is exact rather than suffix-based, and it can't be widened by anything in the URL. It also fails closed if route is missing.
Non-blocking
benchmarks.py::_require_agent_authis the same shape as the WS check -- role claim out of the JWT, noUserload -- so the agent write endpoints are ungated too. Lower impact than pod exec, but it's the same fix.- Logging the generated password at WARNING is the only channel compose has, and the "shown once" framing is right -- worth noting in the code that it lands in any log aggregator the deployment ships to, and that Helm avoids it entirely because the env var is always set.
- Conflicts with #180 (approved) in
helm/bnk-forge/templates/secrets.yamlandvalues.yaml-- both add a$...Passblock in the same place. This branch is based on pre-#180 staging, so itsvalues.yamlstill readsmcpPassword: changeme; after the rebase that line is already handled. MCP_SERVICE_PASSWORD = "mcp-service-changeme"on arole=adminaccount is the same vulnerability with the same blast radius, and I don't see an open issue for it -- #184 is this one, #185 is branch protection. Worth filing before this closes #184, so it doesn't leave with the box ticked.
| but the exempt endpoints until the password is rotated. | ||
| """ | ||
| if not getattr(user, "must_change_password", False): | ||
| return |
There was a problem hiding this comment.
endswith on the raw URL is bypassable, because one route in the repo takes an attacker-chosen tail: state_viewer.py:269,
@router.get("/module/{module_id}/resource/{resource_address:path}", dependencies=[Depends(require_viewer)])under prefix="/api/state". The :path converter matches slashes, so anything can follow. I built a minimal FastAPI app with this exact gate and route shape:
403 /api/auth/users
403 /api/state/module/1/resource/aws_instance.web
200 /api/state/module/1/resource/x/auth/me -> {"gate":"EXEMPT","resource_address":"x/auth/me"}
Terraform state is a reasonable thing to want behind the gate -- outputs land there.
FastAPI has already resolved the route by the time this dependency runs, so match the route template rather than the URL:
_PASSWORD_CHANGE_EXEMPT_ROUTES = {
"/api/auth/change-password",
"/api/auth/me",
"/api/auth/logout",
}
route = request.scope.get("route")
if route is not None and route.path in _PASSWORD_CHANGE_EXEMPT_ROUTES:
returnExact match, nothing in the URL can widen it, and a missing route falls through to the refusal rather than past it.
Worth a test alongside the two you added -- a must-change user hitting a :path route with /auth/me appended should be 403. That's the case that would have caught this.
|
|
||
| return get_user_from_token(db, token) | ||
| user = get_user_from_token(db, token) | ||
| _enforce_password_change(request, user) |
There was a problem hiding this comment.
The gate lives here, but the WebSocket routes never call get_current_user. k8s_websocket.py::_validate_ws_token -- and its twin in dpus_websocket.py -- authenticates off the JWT payload alone:
payload = decode_token(token)
role = payload.get("role", "")
if role not in ("admin", "operator", "viewer"):
await websocket.close(code=4401, ...)
return False
return TrueNo User row is loaded, so must_change_password is never in scope. A seeded admin who has not rotated the password holds a fully valid token and still reaches:
/ws/k8s/clusters/{cluster_id}/pods/{pod_name}/exec— interactive shell in a cluster pod/ws/k8s/clusters/{cluster_id}/pods/{pod_name}/logs/follow/ws/projects/{project_id}/dpus/{dpu_id}/serial-console/ws/projects/{project_id}/dpus/{dpu_id}/ssh-bmcand/ssh-os
/api/auth/users is refused while pod exec is not, which inverts the risk ordering the gate is meant to impose.
Because the check needs the DB row, the natural shape is a shared helper the WS validators also call -- resolve payload["sub"] to a User, refuse on must_change_password, and have both _validate_ws_tokens go through it. Keeping it in one place is what stops the two paths drifting apart again, which is how this gap arose in the first place.
mwiget
left a comment
There was a problem hiding this comment.
Blocker 2 is fixed and I re-tested it. Blocker 1 is untouched -- k8s_websocket.py, dpus_websocket.py and benchmarks.py are byte-identical to the previous head, so a must-change admin still gets pod exec and BMC SSH with the seed credential.
Blocker 2 -- fixed
Exact-path matching on request.url.path is sound here, and I was wrong to imply scope["route"] was the only way: if the raw path equals /api/auth/me, the router sends it to that endpoint, so exact equality can't be widened by a :path tail. Re-ran the harness against your new logic:
200 /api/auth/me
403 /api/auth/users
403 /api/state/module/1/resource/x/auth/me <- was 200
404 /api/state/module/1/resource/../../../api/auth/me <- never reaches the exempt branch
403 /api/state/module/1/resource/x/api/auth/me
The rstrip("/") is harmless -- a trailing-slash variant either redirects or 404s, so nothing gets past on it.
You're also right that there's no server-side logout: the only logout in backend/routes/ after this commit is your own comment. Dropping it rather than leaving a dead exemption in a security gate is the better call.
Blocker 1 -- still open
$ git diff 3c158a0c 9d0e607c --stat -- backend/routes/k8s_websocket.py backend/routes/dpus_websocket.py backend/routes/benchmarks.py
# no output
_validate_ws_token is unchanged and still authenticates off the JWT claims alone:
payload = decode_token(token)
role = payload.get("role", "")
if role not in ("admin", "operator", "viewer"):
...
return TrueNo User row, so no must_change_password. /ws/k8s/clusters/{id}/pods/{pod}/exec, /logs/follow, and the three DPU consoles (serial-console, ssh-bmc, ssh-os) all still accept a seed-credential token. As it stands this PR refuses GET /api/auth/users to a user it will hand an interactive shell inside a cluster pod, which is the wrong way round.
The check has to move to where the user is loaded. A small shared helper -- resolve payload["sub"] to a User, refuse when must_change_password -- called from both _validate_ws_tokens keeps the REST and WS paths from drifting apart again, which is how the original gap arose.
Everything else from the last review stands unchanged: benchmarks.py::_require_agent_auth has the same shape (non-blocking), the #180 conflict in secrets.yaml/values.yaml is still there, and MCP_SERVICE_PASSWORD still wants an issue before this one closes #184.
| """ | ||
| if not getattr(user, "must_change_password", False): | ||
| return | ||
| if request.url.path.rstrip("/") in _PASSWORD_CHANGE_EXEMPT_PATHS: |
There was a problem hiding this comment.
Good -- and better than what I suggested. Exact equality on the raw path is sound on its own: if request.url.path is /api/auth/me, the router has sent the request to that endpoint, so no :path tail can produce a match. Re-ran my harness with this logic:
200 /api/auth/me
403 /api/auth/users
403 /api/state/module/1/resource/x/auth/me <- was 200
404 /api/state/module/1/resource/../../../api/auth/me
403 /api/state/module/1/resource/x/api/auth/me
rstrip("/") doesn't open anything either -- a trailing-slash variant redirects or 404s rather than reaching a handler.
And dropping /auth/logout is right: the only logout left in backend/routes/ is the comment on line 119. A dead entry in a security allowlist is the kind of thing that gets copied into the next one.
|
|
||
| return get_user_from_token(db, token) | ||
| user = get_user_from_token(db, token) | ||
| _enforce_password_change(request, user) |
There was a problem hiding this comment.
Still the blocker -- unchanged at this head:
$ git diff 3c158a0c 9d0e607c --stat -- backend/routes/k8s_websocket.py \
backend/routes/dpus_websocket.py backend/routes/benchmarks.py
# no output
_validate_ws_token never loads the User, so must_change_password can't be consulted, and these stay reachable with the seed credential:
/ws/k8s/clusters/{cluster_id}/pods/{pod_name}/exec/ws/k8s/clusters/{cluster_id}/pods/{pod_name}/logs/follow/ws/projects/{project_id}/dpus/{dpu_id}/serial-console/ws/projects/{project_id}/dpus/{dpu_id}/ssh-bmcand/ssh-os
The gate now refuses GET /api/auth/users to a user it will still give an interactive shell in a cluster pod and SSH to a BMC. That ordering is backwards, and it's the specific claim in the PR body -- "a client could skip the change-password screen and call every endpoint directly" -- still holding for the endpoints that matter most.
Because the flag lives on the row rather than in the token, this can't be fixed at the routing layer; it needs the same lookup get_current_user does. One helper both WS validators call, refusing on must_change_password, is what stops the two paths diverging again.
…ssword retrieval mwiget's review of #186. BLOCKER 1 — the REST gate left the WebSocket routes wide open. k8s_websocket and dpus_websocket `_validate_ws_token` authenticate off the JWT claims alone and never load the User, so a must-change admin still reached pod exec, pod logs/follow, and the DPU serial-console / ssh-bmc / ssh-os with the seed credential -- while REST refused GET /api/auth/users, inverting the risk order. Added a shared helper `token_requires_password_change(token)` (loads the User row via get_user_from_token, returns must_change_password) and routed BOTH WS validators through it, so REST and WS enforce one gate from one place and can't drift. Tests: the helper is true for a must-change user, false for a normal one, false on garbage; plus a REST test that a `:path` route with `/auth/me` appended (the exact bypass mwiget found) is still 403. Robust password retrieval (from the review discussion): a one-time boot log is easy to miss and logging the plaintext is an aggregation-exposure risk, so the generated password is now written to /app/keys/initial_admin_password (mode 600, on the persisted bnk-forge-keys volume) and the log records a POINTER to it, not the secret. Falls back to logging the value only if the file can't be written. Docs/scripts sweep for the generated password: .env.example, the e2e step_login docstring (the deploy must now set DEFAULT_ADMIN_PASSWORD to match, or read the generated one), mcp_live_smoke guidance, the ibm_cloud and test-backup-restore login echoes, and the e2e strategy doc no longer say admin/changeme. 56 auth tests pass, ruff clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…ssword retrieval mwiget's review of #186. BLOCKER 1 — the REST gate left the WebSocket routes wide open. k8s_websocket and dpus_websocket `_validate_ws_token` authenticate off the JWT claims alone and never load the User, so a must-change admin still reached pod exec, pod logs/follow, and the DPU serial-console / ssh-bmc / ssh-os with the seed credential -- while REST refused GET /api/auth/users, inverting the risk order. Added a shared helper `token_requires_password_change(token)` (loads the User row via get_user_from_token, returns must_change_password) and routed BOTH WS validators through it, so REST and WS enforce one gate from one place and can't drift. Tests: the helper is true for a must-change user, false for a normal one, false on garbage; plus a REST test that a `:path` route with `/auth/me` appended (the exact bypass mwiget found) is still 403. Robust password retrieval (from the review discussion): a one-time boot log is easy to miss and logging the plaintext is an aggregation-exposure risk, so the generated password is now written to /app/keys/initial_admin_password (mode 600, on the persisted bnk-forge-keys volume) and the log records a POINTER to it, not the secret. Falls back to logging the value only if the file can't be written. Docs/scripts sweep for the generated password: .env.example, the e2e step_login docstring (the deploy must now set DEFAULT_ADMIN_PASSWORD to match, or read the generated one), mcp_live_smoke guidance, the ibm_cloud and test-backup-restore login echoes, and the e2e strategy doc no longer say admin/changeme. 56 auth tests pass, ruff clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
fd15629 to
d3882e7
Compare
… the tree The seed now persists a generated password to KEYS_DIR (/app/keys by default); an autouse fixture points it at tmp_path for every TestSeedAdminUser test, and .gitignore covers backend/keys/ as a backstop. (A stray file slipped into an earlier push and was amended out.) Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
Blocker 1 fixed — you were right, and it's the dangerous one: refusing The gate now lives where the User is loaded, shared between REST and WS. New Tests: the helper is true for a must-change user, false for a normal one, false on a garbage token; and the Also from the thread:
(Transparency: a test-generated |
mwiget
left a comment
There was a problem hiding this comment.
Blocker 1 is closed the right way -- one helper, both WS validators calling it, three unit tests plus the :path regression test I asked for. Nearly there. One thing in the new helper needs changing, and it's a few lines.
Fixed
token_requires_password_changeloads theUserrow instead of trusting claims, and both_validate_ws_tokens call it, so pod exec, log follow, DPU serial console and BMC/OS SSH now refuse a seed-credential token.test_path_route_with_auth_me_suffix_is_not_exemptedpins the exact bypass I reproduced -- that is the test that would have caught it.KEYS_DIRis the established variable (core/config.py:20, same/app/keysdefault, same volume as the JWT and encryption keys), so the file lands where operators already look, and.gitignorecoversbackend/keys/. Moving the secret out of the log line and leaving a pointer is better than what I would have settled for.
Blocking -- the new gate fails open
except Exception:
return FalseFalse means "no password change owed", so every failure resolving the user is an allow. The docstring's justification -- "an invalid/expired token is refused by the caller" -- is true of decode_token, but that is not the only thing that raises here. get_user_from_token also raises UnauthorizedError for:
"User not found"-- the account was deleted"Account is disabled"-- the account was deactivated
Both are swallowed into "allowed". So a deleted or deactivated admin keeps pod exec and BMC SSH until their JWT expires. That is not a regression -- the WS path was claims-only before this PR, so it was already true -- but the lookup is now right there, and discarding its answer is a choice. The third case, a transient DB error re-opening the gate, is new.
Distinguish "resolved, no change owed" from "could not resolve" and let the caller refuse on the second:
def token_user_state(token: str) -> User | None:
# Resolve the JWT's user, or None if it cannot be resolved.
from database import get_db_context
try:
with get_db_context() as db:
return get_user_from_token(db, token)
except Exception:
return Noneuser = token_user_state(token)
if user is None or user.must_change_password:
await websocket.close(code=4401, reason="Unauthorized")
return FalseSame shape, fails closed, and it brings the WS paths in line with get_current_user -- which does refuse a disabled account -- rather than leaving them permanently more permissive. The existing test_false_on_garbage_token becomes "returns None", and a deactivated-user case is worth adding beside it.
Non-blocking
open(pw_path, "w")thenos.chmod(0o600)creates the file at0666 & ~umaskfirst -- typically0644-- and narrows it after. Small window, container-local, but it is a plaintext credential:os.open(pw_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)never widens it.os.makedirsmatches whatconfig.pyalready does, so I would leave that alone.- The fallback that logs the plaintext when the write fails is the right call -- better than locking the operator out -- and it cannot fire under Helm, where
DEFAULT_ADMIN_PASSWORDis always set andgeneratedstays False. - Nothing removes the file after first login. Fine given the account is
must_change_password, and the log line says to delete it; just worth knowing it persists on the volume. - Still standing: the #180 conflict in
secrets.yaml/values.yaml, andMCP_SERVICE_PASSWORDwanting an issue before this closes #184.
| user = get_user_from_token(db, token) | ||
| return bool(getattr(user, "must_change_password", False)) | ||
| except Exception: | ||
| return False |
There was a problem hiding this comment.
return False is "no password change owed", so this turns every resolution failure into an allow.
The docstring's reasoning covers decode_token, but that is not the only raiser on this path. get_user_from_token raises UnauthorizedError for two states the caller has not already screened:
user = db.query(User).filter(User.username == username).first()
if not user:
raise UnauthorizedError("User not found")
if not user.is_active:
raise UnauthorizedError("Account is disabled")Both land here and become "allowed" -- so a deleted or deactivated admin keeps pod exec, log follow and BMC/OS SSH until the JWT expires. Not a regression (the WS path was claims-only before), but the row is loaded now and this discards what it says. A transient DB error re-opening the gate is new.
Returning the user rather than a bool lets the caller fail closed and fixes all three at once:
def token_user_state(token: str) -> User | None:
# Resolve the JWT's user, or None if it cannot be resolved.
from database import get_db_context
try:
with get_db_context() as db:
return get_user_from_token(db, token)
except Exception:
return Noneuser = token_user_state(token)
if user is None or user.must_change_password:
await websocket.close(code=4401, reason="Unauthorized")
return FalseThat also puts the WS paths on the same footing as get_current_user, which does refuse a disabled account today. test_false_on_garbage_token becomes an is None assertion, and a deactivated-user test belongs next to it.
| persisted = False | ||
| try: | ||
| os.makedirs(keys_dir, exist_ok=True) | ||
| with open(pw_path, "w") as fh: |
There was a problem hiding this comment.
Non-blocking: the file is created before it is narrowed. open(pw_path, "w") uses 0666 & ~umask -- 0644 under the usual container umask -- and the chmod on the next line closes it after. A plaintext admin credential exists world-readable for that window.
fd = os.open(pw_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as fh:
fh.write(seed_password + "\n")Never wider than 0600, and O_EXCL means a pre-existing file is an error rather than a silent overwrite -- which is what you want for a write that only ever happens on first boot.
os.makedirs(keys_dir, exist_ok=True) matches what core/config.py:56 already does for the JWT and encryption keys, so I would leave that consistent rather than special-case it here.
mwiget's re-review of #186. BLOCKER — the new helper failed open. `except Exception: return False` meant "no password change owed" == allow, so ANY failure resolving the user was an allow: get_user_from_token also raises for a deleted account ("User not found") and a disabled one ("Account is disabled"), and a transient DB error would re-open pod exec / BMC SSH -- a new failure mode. Replaced token_requires_password_change (bool) with token_user_state(token) -> User|None: it returns the row on success and None on ANY resolution failure. Both WS validators now refuse on `user is None or user.must_change_password`, so the gate fails CLOSED and matches get_current_user, which already refuses a disabled account -- the WS paths are no longer permanently more permissive than REST. Tests reworked to the new shape, plus the two cases the fail-open hid: a deactivated account and a deleted account both resolve to None (WS refuses). Non-blocking, adopted: the generated password file is created with os.open(..., O_WRONLY|O_CREAT|O_TRUNC, 0o600) so the plaintext credential is never momentarily 0644 (open()+chmod would widen-then-narrow). O_TRUNC handles a stale file from a prior seed without failing. 53 auth tests pass, ruff clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
Fixed — you're right, and it's a worse hole than "not a regression" implies: a disabled admin holding pod exec until token expiry is exactly the account you'd have just disabled to cut off access.
Tests reworked to the new shape, and I added the two cases the fail-open was hiding: Non-blocking adopted: the password file is now created with Standing items acknowledged: the #180 |
mwiget
left a comment
There was a problem hiding this comment.
Both of my blockers are fixed, and I checked the fixes rather than reading them. CI has gone red though, and the failures are worth being precise about: they are the old contract failing, not the new code being wrong, and the fix belongs in the tests.
Fixed
- The gate fails closed.
token_user_statereturnsUser | None, both validators refuse onNone or must_change_password. The tests you added cover exactly the cases I named -- deactivated account, deleted account, garbage token -- so a disabled admin no longer keeps pod exec until their JWT expires. - File mode.
os.open(..., O_CREAT | O_TRUNC, 0o600)never widens. UsingO_TRUNCrather than theO_EXCLI suggested is the better call and I'd keep it:O_EXCLwould hard-fail on a stale file left by a prior seed on a reused volume, which locks the operator out of the very thing the file exists to give them.
I also checked the trap this refactor invites, since it would have been an ugly one: token_user_state now returns the ORM instance and the caller reads must_change_password after the session has closed. That works only because get_db_context closes without committing, so the loaded column stays readable on the detached instance -- verified against a minimal SQLAlchemy repro. Worth a line in the docstring, because if anyone later adds a db.commit() to get_db_context, expire_on_commit=True expires that attribute and every WebSocket starts failing closed with no obvious cause.
Blocking -- CI is red
FAILED tests/component/test_k8s_websocket.py::TestValidateWsToken::test_valid_admin_token_returns_true - assert False is True
FAILED tests/component/test_k8s_websocket.py::TestValidateWsToken::test_valid_operator_token_returns_true - assert False is True
FAILED tests/component/test_k8s_websocket.py::TestValidateWsToken::test_valid_viewer_token_returns_true - assert False is True
3 failed, 3140 passed
These three mint create_access_token({"sub": "testadmin", "role": "admin"}) with no User row behind it, which is precisely the claims-only contract this PR is removing. token_user_state can't resolve testadmin, returns None, and the validator refuses -- correct behaviour, stale test.
Fix the tests, not the gate. Relaxing token_user_state back toward fail-open to make them pass would put the hole straight back.
I checked whether any real token would break the same way, since that would be a different and much worse story. It won't: the only subjects that aren't usernames are the agent tokens --
create_access_token({"agent_id": agent.id, "role": "agent", "sub": agent.name}, ...) # benchmark_agent_provision_service
create_access_token({"sub": "forge-builtin-agent", "role": "agent"}, ...) # startup_steps-- and both validators reject role == "agent" before they ever reach the lookup (("admin","operator","viewer") for k8s, ("admin","operator") for dpus). Every token that gets as far as token_user_state was minted at /api/auth/login from a real user.username. So this is genuinely fixture-only.
While you're in that file: it currently tests the helper in isolation and the validator only for the reject paths. A case at the _validate_ws_token level -- real must-change user refused, real normal user allowed -- would pin the wiring between the two, which is the part that actually regressed here.
Still standing
benchmarks.py::_require_agent_authreads the role claim without loading a row (non-blocking, lower impact).- Conflicts with #180 (approved) in
helm/bnk-forge/templates/secrets.yamlandvalues.yaml. MCP_SERVICE_PASSWORD = "mcp-service-changeme"on arole=adminaccount still has no tracking issue -- worth filing before this closes #184.
| # 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: |
There was a problem hiding this comment.
This is right, and I verified the non-obvious part: token_user_state returns the ORM instance from inside with get_db_context() as db:, and must_change_password is read here, after that session has closed.
It works — but only because get_db_context ends with a bare db.close() and never commits, so the loaded column value survives on the detached instance. I confirmed the exact pattern against SQLAlchemy 2.0:
must_change_password after close -> True
Worth a sentence in the token_user_state docstring, because the failure mode is nasty and remote: add a db.commit() to get_db_context at any point in the future and expire_on_commit=True expires this attribute, the access raises DetachedInstanceError, the surrounding except Exception catches it, and every WebSocket — pod exec, log follow, all three DPU consoles — starts refusing with 4401 and no diagnostic. Failing closed is the right direction, but nobody would find that from the symptom.
Returning a plain tuple or a small dataclass ((exists: bool, must_change: bool)) read inside the session would sidestep it entirely, if you'd rather not rely on the detached-read.
…end the tests CI (P2 Component) caught that the existing TestValidateWsToken tests craft a valid-claims JWT with no User row, so the fail-closed gate (#184) returns False. That breakage is correct -- the gate must load the row -- so the fixture is what needs updating: the valid-token tests now create a committed must_change=False user, matching how the token_user_state helper resolves it. Added two WS-boundary tests for the enforcement itself: a must_change user is refused (ws.close awaited) and a token for a missing user is refused (fail-closed), so the pod-exec / DPU-console gate is covered at the WS layer, not just via the helper unit test. test_k8s_websocket.py: 36 passed. (dpus_websocket has no valid-token tests, so nothing to update there.) Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…en_user_state mwiget's note on #186: the caller reads must_change_password on the returned ORM instance after the session closes, which only works because get_db_context() closes without committing. Documented so a future db.commit() there (with expire_on_commit=True) doesn't silently make every WebSocket fail closed. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
mwiget
left a comment
There was a problem hiding this comment.
The test fix is exactly right -- you updated the tests to the new contract rather than relaxing the gate, which was the thing I most wanted not to happen. The two cases you added on top (test_must_change_user_refused, test_token_for_missing_user_refused) are the ones that pin the wiring between token_user_state and _validate_ws_token, which is precisely what regressed.
CI is still red, but only on lint, and it's mechanical:
=== Backend Lint (ruff) ===
I001 [*] Import block is un-sorted or un-formatted
--> tests/component/test_k8s_websocket.py:62:9
--> tests/component/test_k8s_websocket.py:77:9
--> tests/component/test_k8s_websocket.py:91:9
Found 3 errors.
All three are the same shape -- a second from services.auth_service import ... added a couple of lines below the first, which breaks the sorted block. Folding them into one import clears it:
from routes.k8s_websocket import _validate_ws_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()I001 carries [*], so ruff check --fix backend/tests/component/test_k8s_websocket.py will do all three. The two new tests further down already use the combined form, so it's only the three you edited in place.
Nothing else outstanding from my side on the code -- the fail-closed gate, the 0600 file and the test coverage all check out. Once lint is green and the component suite goes with it, I'll approve.
Still worth carrying (unchanged, none of it blocking):
- A line in the
token_user_statedocstring about the detached-instance read, since a futuredb.commit()inget_db_contextwould silently take every WebSocket down. benchmarks.py::_require_agent_authreads the role claim without loading a row.- The
secrets.yaml/values.yamloverlap with #180 (approved) and now #188 as well -- three PRs editing the same generated-secret block, so the rebase order matters.
…hange mwiget's review of #188. Blocking 1 — existing installs kept "changeme" forever. $mcpPass falls back to the deployed Secret's value, which on any already-installed chart IS the published "changeme" default, and nothing ever rotated it (the mcp account is role=admin, must_change_password=False, so #186's gate deliberately doesn't apply). Since this is a generated shared secret whose only consumers are the two pods that read it from the same Secret -- not an operator-chosen value -- it can be rotated safely: detect the known default and regenerate it. Blocking-adjacent — the auto-rotate needs the pods to roll together, or a helm upgrade could roll the backend (which re-seeds the mcp account with the new value) while the MCP server keeps the old one until its next restart. Added a checksum/secret annotation to the api and mcp pod templates so both roll when the Secret changes. (On a stable upgrade every value is loaded from the existing Secret via lookup, so the checksum is stable and pods don't roll needlessly.) BREAKING CHANGE: MCP_SERVICE_PASSWORD is now required in staging/production. Deployments that relied on the shipped default ("mcp-service-changeme") will exit at startup (validate_production) until it is set to the same value the MCP server receives as BNK_FORGE_PASSWORD. New installs generate it (Helm) or require it in .env (compose); existing Helm installs on the old "changeme" secret are rotated automatically on the next upgrade. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
CI is green again — the three
And the detached-instance trap you flagged is now documented (5392e16): Still-standing items: |
The three restored valid-token tests imported create_access_token and create_user as two separate statements with a blank line between them; ruff's isort (I001) flagged all three. Merged into a single sorted import, matching the two tests added alongside them. Verified with the whole-tree 'ruff check .' the CI runs (not a per-file check) — 36 passed. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…r row
Same fail-closed contract as the component tests: _validate_ws_token now resolves
the token subject to a real User. test_exec_valid_{viewer,operator}_token_accepted
minted tokens for testviewer/testoperator but only pulled in sample_user
(testadmin), so those subjects had no row and the gate correctly refused ->
WebSocketDisconnect. Wired sample_viewer_user / sample_operator_user in; the admin
case already had its row via sample_user. Full TestPodExecAuth passes (5).
Checked for the sibling: no dpus-websocket test files exist and only these two
k8s files touch _validate_ws_token, so nothing else mints a rowless valid token.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
mwiget
left a comment
There was a problem hiding this comment.
Lint is green and the component suite passed, so the WS test fix landed correctly. P3 · Integration Tests · Backend is now red for the same fixture reason one layer up -- same class, same remedy.
FAILED tests/integration/test_routes_k8s_websocket.py::TestPodExecAuth::test_exec_valid_viewer_token_accepted - starlette.websockets.WebSocketDisconnect
FAILED tests/integration/test_routes_k8s_websocket.py::TestPodExecAuth::test_exec_valid_operator_token_accepted - starlette.websockets.WebSocketDisconnect
2 failed, 752 passed
The cause is neat once you line it up:
def _make_token(role: str = "admin") -> str:
return create_access_token(data={"sub": f"test{role}", "role": role})so the three tests mint testadmin, testviewer, testoperator -- and sample_user (conftest.py:220) creates exactly one user, testadmin, with must_change_password=False. That is precisely why the admin case passes and the other two don't: testviewer and testoperator have no row, token_user_state returns None, and the validator refuses. Correct behaviour, stale fixture -- same as the component tests, and again the fix belongs in the tests rather than the gate.
Note _make_token("operator") gives testoperator, not the testop you created in the component suite, so it needs its own row.
Sweep for the rest
I checked every other test that drives a WS validator, so this shouldn't need a third round:
| file | verdict |
|---|---|
tests/component/test_k8s_websocket.py |
clean — every sub it mints now has a row, and the one that doesn't ("sub": "user", role guest) is the invalid-role test, rejected before the lookup |
tests/integration/test_routes_k8s_websocket.py |
the two failures above |
tests/integration/test_routes_benchmarks.py |
unaffected — the agent WS uses its own _agent_ws_authorized, which this PR doesn't touch |
tests/integration/test_routes_operator_ws.py |
unaffected — /ws/operator uses a registration token, not _validate_ws_token |
One gap worth knowing rather than fixing here: there are no tests for dpus_websocket._validate_ws_token at all (backend/tests/ has nothing matching dpus). It got the identical fail-closed change, so serial-console / ssh-bmc / ssh-os auth is currently unpinned in either direction. Not this PR's debt to pay, but it's the reason the k8s side caught two rounds of fixture drift and the DPU side caught none — there was nothing there to catch it.
Everything else on this PR is settled from my side: fail-closed gate, exact-path exemption with its regression test, 0600 password file, and the docstring on the detached-instance trap. Once these two fixtures have rows I'll approve.
|
Fixed — same class, same remedy one layer up (f83474b). Thanks for the sweep table — I confirmed the same: the only files touching On the real gap you named: |
…word Two corrections to the customer install guide. Public registry (bonnyrf5 #183 finding — the stale jlcode-forgebot login): now that the images are public on ghcr.io/f5devcentral, no docker login is needed to pull. Removed the entire "Step 1 — Authenticate" flow, the read-access-token prerequisite, the credentials callout, and the auth troubleshooting entry; renumbered the remaining steps 1-4; and reworded the intro/sub-header/registry note from "private" to "public". Admin password (new settable-default scheme, #184/#186): replaced the admin/changeme login with DEFAULT_ADMIN_PASSWORD. Added it to the .env table, and rewrote First login: log in with the password you set, or — if unset — retrieve the random one generated on first launch from /app/keys/initial_admin_password (docker compose exec ... cat) or the startup logs. The account requires a password change on first login either way. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…scheme The user flagged that many docs still tell operators to log in with admin/changeme, which #184/#186 removed — no default ships; the backend generates a random admin password on first startup, writes it to /app/keys/initial_admin_password (mode 600, boot log points there), and forces a change on first login. DEFAULT_ADMIN_PASSWORD sets a known one instead. Swept the stale instructions to match the actual behavior: - docs/INSTALLATION.md: 4 prose spots (local, linux-server x2, VM note); also tightened the canonical credentials block to name the file path and give both the `docker exec ... cat` and `docker logs ... grep "GENERATED password"` retrieval commands. - Makefile: both deploy-summary echoes. - The_BNK_Forge_Developers_Guide.md: quickstart + summary. - dist/README.md, dist/install.sh: customer login instructions. - vm-bnk-forge/README.md: quickstart line + the security "Default credentials" note. Left alone: config.py's explanatory comment, the historical archive review, and the MCP-credential lines (dist/.env.example) which belong with the MCP scheme in #188. install-guide.html was handled on #183 to keep that file on one branch. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…word scheme The cloud bootstrap script still printed 'admin / changeme' at the end. Aligned it with the #184/#186 scheme (generated password at /app/keys/initial_admin_password, or DEFAULT_ADMIN_PASSWORD) alongside this PR's MCP edit to the same file, so the script is fixed on one branch rather than conflicting across two. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
mwiget
left a comment
There was a problem hiding this comment.
The fixtures are fixed and the suite proves it. Approving at 241c768e.
P3 · Integration Tests · Backend: 754 passed, 0 failed (job 96416511755, success). The previous run on this branch was 2 failed, 752 passed — so the count moved 752 → 754 with the failures gone, which is exactly the two tests I flagged and nothing else changing behavior. f83474b4 wires sample_viewer_user / sample_operator_user into test_exec_valid_{viewer,operator}_token_accepted; I confirmed those fixtures exist at backend/tests/conftest.py:237 and :254 and create testoperator / testviewer with must_change_password=False, which is what _make_token's sub = f"test{role}" needs to resolve.
Worth saying plainly: those two failures were the gate working. _validate_ws_token now refuses a token whose subject has no User row, and a rowless valid-signature token is precisely what an attacker holding a JWT for a deleted account would present. A test suite that kept passing through that change would have been the bad outcome.
241c768e — the doc sweep is thorough and the commands in it are correct. Seven surfaces updated (Makefile ×2, Developers Guide ×2, dist/README.md, dist/install.sh, docs/INSTALLATION.md, vm-bnk-forge/README.md), and I checked the two claims a reader would actually type:
docker exec bnk-forge-backend cat /app/keys/initial_admin_password—container_name: bnk-forge-backendat docker-compose.yml:115 ✅, and/app/keysis the volume-backed mountbnk-forge-keys:/app/keys✅, so it survives a restart rather than living only in a dead container's layer.- The
vm-bnk-forge/README.mdsecurity section now says "generatedadminpassword" instead of "comes up withadmin/changeme" — the right place to fix it, since that paragraph is specifically warning about attaching a public IP.
Scoping the MCP credential lines in dist/.env.example out to #188 is correct; that file's remaining changeme belongs to the MCP scheme, not this one.
I swept for anything the sweep missed. The remaining changeme literals are scripts/e2e/config.py:184 and tests/e2e/config/test-config.ts:15-18 — both are harness defaults, and scripts/e2e/steps.py:173 plus tests/e2e/E2E_STRATEGY.md:30 already tell the operator to deploy with DEFAULT_ADMIN_PASSWORD set, so they're coherent rather than stale. E2E isn't wired into ci.yml, so nothing breaks either way.
Everything I raised across this PR is now settled: fail-closed token_user_state with the detached-instance docstring, exact-path exemption plus its regression test, the 0600-at-creation password file, and now the WS fixtures. The DPU-side gap I flagged — dpus_websocket._validate_ws_token got the identical change with no tests either way — is tracked as #189, which is the right place for it.
One note for merge order: #183's install-guide rewrite documents this PR's DEFAULT_ADMIN_PASSWORD / /app/keys/initial_admin_password scheme, and that code exists only here. Merging #186 before #183 keeps the customer guide honest at every point; the other order opens a window where it describes a file that doesn't exist yet. I've said the same on #183.
mwiget non-blocking note on #188: after this PR a bare 'docker compose up' no longer yields a working MCP without setting the variable first — deliberate and in .env.example, but a first-run 'MCP tools return auth errors' is confusing. Added a short note by the Quick Start start command (kept away from #186's Login table edit so the two README changes merge cleanly). Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…reak e2e login bonnyr-f5 round-2 BLOCK of #186. Two auth bypasses survived my round-1 fix, and the release-gating e2e couldn't log in. All reproduced; all mine. BLOCKER 1 — the middleware failed OPEN. My round-1 code skipped the gate when token_user_state returned None ("agent token"), but decode_token has already succeeded there, so None is a VALID JWT whose user can't be resolved (deleted/disabled/DB error) — the fail-CLOSED case its own contract and the WS validators enforce. Now refuses on None. (Agent tokens reach their endpoints via AGENT_PUBLIC_ENDPOINTS, which the middleware skips before this point.) BLOCKER 2 — benchmarks._require_agent_bearer never applied the gate; a must-change admin could create an agent (201). Now, for the human roles (operator/admin), it resolves the token's user and refuses if a password change is owed. Agent/service tokens (no User row) keep the endpoint's role-based behaviour. Regression test: a real must-change admin now gets 400 AGENT_AUTH_PASSWORD_CHANGE_REQUIRED. BLOCKER 3 — the e2e suite hardcodes changeme, but the seeded admin is now generated + must-change, so it would fail at login. Added DEFAULT_ADMIN_MUST_CHANGE (defaults True; production never disables the gate) and seed a KNOWN, non-default admin with it off in the ephemeral e2e stack; updated the two test configs to match. 116 backend auth/ws/benchmarks tests pass; ruff clean; e2e YAML valid. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@bonnyr-f5 round 2 — both bypasses were real and survived my round-1 fix. Fixed + verified (116 auth/ws/benchmark tests) in
|
Review: BLOCKRound 3, cold re-audit of A lot of this is now genuinely solid, and I attacked it rather than reading it:
What blocks is where the sweep stopped, not the design. BLOCKER 1 —
|
Round-3 BLOCK: the guide instructed credential routes that fail on the tree it ships in. BLOCKER 1 (First login): both documented routes were #186's, absent here. DEFAULT_ADMIN_PASSWORD reaches no process (config.py declares no env_file and no compose/helm file wires it), so the seed is always changeme; and /app/keys/initial_admin_password exists nowhere but the guide sentence. The diff had also deleted the only true instruction. Restore the accurate admin/changeme login plus the "change immediately" callout, matching what dist/install.sh and dist/README.md already print in the same tarball. BLOCKER 2 (MCP_PASSWORD): "no default ships / leaving it empty disables MCP" is false — dist/.env.example ships MCP_PASSWORD=changeme and empty resolves to admin/changeme via ${MCP_PASSWORD:-changeme}. Reword to the real scheme: a well-known default to change, re-read each boot, that must match a real BNK Forge user. Drop the inert DEFAULT_ADMIN_PASSWORD row. Every credential the guide now names resolves to code in this tree. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
… gate; fail closed on agent bearer BLOCKER 1 — _rotate_known_default_admin now REMOVES the capability, not just requests removal. A pre-#184 install holds admin/'changeme' (published in dist/README.md, user-pack/install-guide.html, scripts/ibm_cloud_bnk_forge.sh). Setting must_change_password=True left that credential usable via the gate-exempt /api/auth/change-password endpoint, which verifies current_password against the stored 'changeme' hash — an attacker could log in and rotate before the operator. Now on detecting a known default we OVERWRITE the hash with a fresh secrets.token_urlsafe(18), leave must_change_password=True, and surface the new secret exactly like a fresh install (extracted the shared _persist_generated_admin_password: mode-0600 file at /app/keys, pointer logged, plaintext never logged). The published default stops authenticating the moment this runs; rotation is idempotent across boots. BLOCKER 2 — DEFAULT_ADMIN_MUST_CHANGE now reaches the process in every deployment mode, mirroring DEFAULT_ADMIN_PASSWORD (INV-17). Added ${DEFAULT_ADMIN_MUST_CHANGE:-true} to docker-compose.yml, docker-compose.local.yml and dist/docker-compose.yml, and DEFAULT_ADMIN_MUST_CHANGE from .Values.secrets.adminMustChange (default true) to _helpers.tpl. Verified: `docker compose config` with the e2e env now emits "false" to the container (release-gate e2e no longer 403s every protected route); helm renders "true" by default, "false" on override; helm lint clean. Major INV-20 — routes/benchmarks.py _require_agent_bearer failed OPEN: the `if agent_user is not None:` had no else, so a validly-signed admin/operator token resolving to no live User skipped the password-change gate (proven 201). token_user_state's contract is fail CLOSED; now None → 400 AGENT_AUTH_INVALID, matching the other three call sites (middleware, dpus_ws, k8s_ws). Minor docs — DEPLOYMENT.md/INSTALLATION.md no longer tell operators to grep logs for a password written only to a file; INSTALLATION.md duplicate "either" command replaced with distinct compose/Helm retrieval; Helm NOTES.txt notes the install secret no longer authenticates after the first-login change. Tests: strengthened the rotation test to assert 'changeme' no longer authenticates and a fresh secret is surfaced; added idempotency and fail-closed-on-phantom-user regression tests; updated two agent-bearer tests to use real User rows (a human token always has one). ruff clean, mypy core/schemas clean, auth + benchmark suites pass. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@bonnyr-f5 Thanks for the round-3 cold audit — every blocker reproduced and is fixed at BLOCKER 1 —
|
Review: BLOCKRound 4, cold re-audit of BLOCKER 1 — a second shipped default admin credential stays live and is exempt from the new gate
BLOCKER 2 — every shipped distro still points MCP at
|
…istro MCP off admin/changeme BLOCKER 1 (bonnyr-f5 r4): the mcp service account (role=admin, must_change_password=False) was re-seeded every boot from the published default MCP_SERVICE_PASSWORD="mcp-service-changeme" and is EXEMPT from the #184 must-change gate -- a second live, publicly-known admin credential. Closed the same way as the human admin: MCP_SERVICE_PASSWORD now defaults to None, and ensure_service_user refuses any known published default (mcp-service-changeme/changeme) as a seed value, generates a strong random secret on a fresh row, and OVERWRITES an existing row still holding a published default with a fresh random secret. The published value can no longer authenticate; the reconcile stays idempotent for a generated/operator secret so reboots don't churn it. BLOCKER 2 (#186's share): every docker-compose/.env/shell distro pointed the MCP client at admin/changeme, which #184 already made unusable (admin password generated + gated). Repointed dist/docker-compose.yml, dist/docker-compose.local.yml, dist/.env.example and scripts/ibm_cloud_bnk_forge.sh at the mcp service account (MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD, no shipped default), mirroring what this PR already did to the root compose; dropped the now-dead :-mcp-service-changeme fallback from the root compose files. The Helm mcp-username/mcp-password provenance (generate the secret + inject MCP_SERVICE_* into the backend from it) is #188's remit and left untouched. Also fixes the #186-introduced _helpers.tpl crashloop: adminMustChange: null rendered a bare `value:` (pydantic rejects the empty string); nil now falls back to the secure "true" while an explicit false still renders "false". Tests: added ensure_service_user mutation tests proving the published default cannot authenticate (fresh seed, None seed, upgrade rotation, idempotency, operator override). 143 auth/service/route/websocket tests pass; ruff + mypy clean; helm template/lint and docker compose config green. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@bonnyr-f5 — round-4 blockers addressed in BLOCKER 1 — second shipped admin default (
|
…ntext, fix secret provenance, gate the agent WS bonnyr-f5 round-4 classes all Major items as blockers (a PASS requires zero Majors). This addresses every Major finding plus the quick, clearly-correct Minors, and defers the MCP-secret provenance surface to #188. Majors: - auth_service: an unwritable /app/keys no longer LOGS the generated plaintext. _persist_generated_password now raises GeneratedCredentialPersistError (no secret in the message) and every path persists BEFORE committing the DB row, so a failure leaves nothing committed and is re-runnable. seed_auth_step converts it to SystemExit (escapes main.py's best-effort except Exception) => refuse to start. Mutation-tested: reintroducing the leak fails the new tests. - Secret provenance: the upgrade rotation now honors DEFAULT_ADMIN_PASSWORD (Helm wires it from the admin-password Secret) when it is not a published default, so the Secret the docs tell operators to read is authoritative on upgrade too; otherwise it generates + writes the keys-file. NOTES.txt, docs/DEPLOYMENT.md and docs/INSTALLATION.md rewritten to the single rule. - user-pack/install-guide.html: point at the keys-file (the grep target was never logged) and drop the stale "default password is well-known" callout. - helm secrets.yaml: normalize a nil .data map to an empty dict once, so an existing Secret with no .data no longer errors "index of untyped nil"; all five keys share the hasKey guard. Proven across 4 topologies. Minors (quick, clearly-correct): - benchmarks agent WS Layer-2 now enforces the must-change gate (INV-10): a human token owing a change, or one that no longer resolves, is refused; agent-role tokens still connect. - rotation/reconcile use SELECT FOR UPDATE (INV-8) to stop two api replicas desyncing the stored hash from the keys-file. - docs/DEPLOYMENT.md runbook uses MCP_SERVICE_* (MCP runs its own mcp account). Deferred to #188: chart mcp-password provenance across the 9-file surface, and the name-keyed remediation (the only shipped role=admin defaults, admin + the mcp service account, are both handled). Tests: auth_service 49, startup_steps 10, benchmark_agent_auth 28 (all pass); mutation-verified the plaintext-log guard; helm lint/template green. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@bonnyr-f5 — round-4 Major findings addressed on head Majors1.
|
Review: BLOCKRound 5, cold re-audit of Merges standalone: mechanically yes (rc=0, 0 conflicts). Functionally no — The core of this PR is correct, and it was proven by executionCredit where it's earned — the round-4 Majors are genuinely closed:
Every blocker below is in the second account and its distribution — not in the admin rotation BLOCKER-1 ·
|
|
Cross-PR merge-order constraints for this series are now tracked in #192. Relevant here: this PR and #188 conflict on 16 files under both strategies, across the whole credential surface and both auth test modules — so the safety net merges at the same time as the code it guards. There is also a coupling worth noting: the reserved-name refusal that stops |
…p admin, commit merge ordering Round-5 review (bonnyr-f5) fixes, verified against this tree: F1 (BLOCKER): the 4.0.0 upgrade step told dist operators to set MCP_SERVICE_PASSWORD, which the dist backend never reads — the dist &backend-env anchor passes no such variable, and config.py declares no env_file (process env only). The dist MCP client authenticates with MCP_PASSWORD (dist/docker-compose.yml:357 -> BNK_FORGE_PASSWORD). Rewrote the step to name MCP_PASSWORD, state MCP_SERVICE_PASSWORD is inert here, and mark the #188 boot-check as forward-looking (from 4.0.0). F2 (Major): disclosed the seeded `mcp` admin-role account whose default password (mcp-service-changeme) is published in the public config.py and is reconciled back on every boot, so it cannot be rotated in this bundle; noted #186 removes it. No longer hidden. F3 (Major): documented that MCP borrows the human admin login only until #186 wires the dedicated `mcp` service account; do not prescribe the human credential as the permanent machine identity. F4 (Major, INV-4): committed the #183 x #186 merge ordering in CHANGELOG (with/after #186 + #188), not only in a PR comment, incl. conflict- resolution guidance for user-pack/install-guide.html. Minors: swept dist/install.sh registry line ("images are public") to match dist/docker-compose.yml; made the dist/README.md end-user download URL an explicit version placeholder (no v3.1.6 release/asset exists); added the valid `merged` status to ROADMAP_PROCESS.md, roadmap-add.py help, and the roadmap-gen stats tally. Roadmap regenerates byte-identical. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…hart+docs (#188) Round-5 review (bonnyr-f5) BLOCK. Reproduce-first; each fix carries a test. BLOCKER-1 — remediation unreachable on the diligent-operator path. disable_stale_service_user lived in the `else` of the MCP_SERVICE_PASSWORD check, so an operator who did the right thing (set a strong MCP_PASSWORD) but left MCP_USERNAME at the legacy 'admin' hit the reconcile branch, where ensure_service_user raises a reserved-name ValueError (swallowed) and the legacy 'mcp' row was never disabled — still authenticating with 'mcp-service-changeme'. Make the provenance-keyed disable UNCONDITIONAL, before the reconcile; the reconcile re-activates the one configured account. Reproduced via a legacy-install fixture driving the real seed_auth_step (test_startup_seed_auth.py, the previously zero-coverage function); the diligent + dedicated-name cases failed pre-fix, pass post-fix. Mutation-tested: removing the unconditional disable fails 4/5 tests; name-keying the disable (INV-11 regression) fails 2/5. Major-2 — chart declined to rotate a value the PR makes fatal. secrets.yaml passed an operator-supplied `mcpPassword: changeme` straight through, but validate_production treats it as fatal under production -> crashloop. A rotate is non-idempotent for a values-supplied value (round-4 drift), so refuse it at render with `fail` — chart and fail-fast now agree. Also rotate a persisted default of EITHER known form (round 4 only caught "changeme"). helm template verified: empty->auto-gen, changeme/mcp-service-changeme->fail, real->passthrough. Major-3 — documented fail-fast unreachable on the dist population. ENVIRONMENT is never set anywhere in dist/, so validate_production returns early and the backend never "refuses to boot". Reworded dist/.env.example to state precisely that the account is simply left unseeded (MCP unavailable) unless ENVIRONMENT=staging| production, which the package does not set. Major-4 — INV-23 recurrence. ibm_cloud_bnk_forge.sh referenced /app/keys/initial_admin_password, a #186-only mechanism absent from this tree; restored the accurate admin / DEFAULT_ADMIN_PASSWORD-else-'changeme' line. Minors — expose is_service_account on the users listing so the UI toggle isn't blind (schema + _user_to_dict + guard test); add seed_auth_step coverage. Suites (py3.11/uv venv): auth+config+startup+migration+rbac 110 passed, 5 skipped; alembic single head v2_155; ruff clean; helm lint clean. Deferred to the #186+#188 integration branch (documented in PR comment with evidence): MCP auth-probe on the remaining 2/4 surfaces (Helm tcpSocket, image HEALTHCHECK) needs credential wiring entangled with #186; the two PRs overlap on 20 files and conflict on the credential surface. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…cp admin/changeme, guard the reserved admin identity, and unblock the event loop Round-5 blockers (bonnyr-f5). The admin-rotation core was proven correct; every fix here is in the second (mcp) account, its distribution, and the middleware. BLOCKER-1 (backend never received MCP_SERVICE_PASSWORD): the var reached the mcp client on every channel but the backend on none, so the backend generated a random secret while the client used the operator value -- they could never agree. Plumb MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD into the backend on every deploy mode: the x-backend-env anchors in docker-compose.yml, docker-compose.local.yml and dist/docker-compose.yml (dist local inherits via env merge), the ibm installer's embedded compose, and the Helm shared-env in _helpers.tpl (sourced from the release Secret's mcp-* keys, identical to mcp.yaml). Verified with docker compose config + helm template. BLOCKER-2 (chart shipped mcpUsername: admin / mcpPassword: changeme): a live, publicly-known default admin credential in the PR that removes exactly that. values.yaml now ships mcpUsername: mcp and mcpPassword: "" with secrets.yaml generating a per-install secret and reusing it on upgrade (same treatment as admin-password). No shipped default renders. BLOCKER-3 (ensure_service_user had no identity check): it resolved its target purely by username and force-set role=admin. Pointed at admin it rewrote the human admin row and cleared the must-change gate. Add _RESERVED_HUMAN_USERNAMES and fail closed before any lookup (identifier kept identical to #188's guard so the two land cleanly on the integration branch). Major (blocking sync DB query in async dispatch): the must-change gate ran a synchronous DB session on every authenticated request straight on the event-loop thread. Move _verify_api_token and token_user_state onto run_in_threadpool so the blocking round-trip never stalls the loop. Minors: cover the bnk_ API-token middleware branch (must-change 403 + settled pass, exercising the threadpool path); correct the with_for_update comment to state the create path is serialised by the username UNIQUE constraint, not the row lock. Integration split (#186 + #188): the reserved-name guard and the Helm mcp-secret surface (values.yaml, secrets.yaml, _helpers.tpl) are shared with #188; the guard uses #188's identifier and the chart changes complete #186's own class, so the integration branch reconciles rather than conflicts. Tests: 192 passed across the auth/middleware/route suites (incl. 4 new); mutation-checked the reserved-name guard and the API-token gate (both caught). helm lint/template + docker compose config green on all changed deploy modes. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@bonnyr-f5 — thanks for the round-5 cold re-audit. You're right on every blocker; they were all in the BLOCKER-1 — backend now receives
|
…ff I001) The round-5 test added a function-local `import pytest` / `from models import User` block; ruff I001 requires a blank line between the third-party and first-party (models is known-first-party) groups. Fixes the Lint Backend gate. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Combine the two complementary halves of the credential model: - #188 (already on the branch): is_service_account provenance, the v2_154/v2_155 migrations, the UNCONDITIONAL provenance-keyed disable_stale_service_user before reconcile, the _RESERVED_HUMAN_USERNAMES guard, and the openapi schema. - #186: the backend MCP_SERVICE_PASSWORD wiring across compose/helm/ibm, the chart mcpUsername/mcpPassword + per-install secret generation, run_in_threadpool for the sync DB calls in the async auth dispatch, and the published-default seed/rotate remediation. ensure_service_user unions both: provenance + reserved-name guard + published -default generate/rotate, with the provenance guard allowing rotation of a row that still authenticates with a shipped default (a stale pre-provenance service credential). seed_auth_step keeps #188's disable-first + gated reconcile and #186's GeneratedCredentialPersistError fail-closed. Infra files union the env vars/chart keys; dist unifies on the canonical MCP_SERVICE_* names.
Resolve user-pack/install-guide.html for the merged tree. With #184/#186/#188 now all present, convert #183's forward-looking hedges to present tense: the admin password is generated (no shipped default) and the API refuses every call until first-login change; MCP authenticates as a dedicated non-human `mcp` service account via MCP_SERVICE_PASSWORD (not MCP_PASSWORD/admin), the shipped mcp-service-changeme default is removed, and the backend fails fast in staging/production when it is unset or a shipped default.
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
|
Superseded by the consolidated integration PR #193 (branch Closing unmerged, not abandoning: the branch is retained and all review history stays on this page for reference. See #193 for the integrated, validated result and #192 for the cross-PR conflict analysis. |
…he credential seed Addresses mwiget's three CHANGES_REQUESTED blockers on the #177 integration PR (#193), plus follow-up findings from a max-effort review of the same credential surface. The merge chose #188's "unset MCP_SERVICE_PASSWORD -> disable" over #186's "unset -> generate", so the generate/rotate-on-unset code was left unreachable but still documented, and the release-notes footer was missing. BLOCKING 2 (core/config.py): the MCP_SERVICE_PASSWORD comment claimed ensure_service_user "generates a random secret and surfaces it once" when unset, contradicting the merged behaviour. Rewrote it to state the truth: when unset (or a known default) seed_auth_step calls disable_stale_service_user, leaving the mcp account disabled/unavailable until an operator configures a real password; a published default is refused and rotated out; the backend receives MCP_SERVICE_PASSWORD on every deploy mode and reconciles to it when set. Also removed the duplicate #186 block that sat above the wrong field. BLOCKING 3 (services/auth_service.py): removed the unreachable usable_password is None branches from ensure_service_user (the generate-on-create and rotate-on-unset paths). seed_auth_step only calls it under the _mcp_pw_usable gate, so password is never None/default in production. ensure_service_user now requires a usable password and only creates/reconciles with it (failing closed and loudly if handed an unusable one); the unset case is owned entirely by disable_stale_service_user. Dropped the now-dead _log_generated_service_password helper and the service-account token_urlsafe/_persist_generated_password calls (_persist_generated_password is still used by the admin seed). Kept the reserved-name guard, the provenance check, the adopt-a-published-default remediation, and disable_stale_service_user fully intact. Updated the affected unit tests (published-default/None now refused; added a reachable adopt-and-reconcile test; stale-row setup builds the legacy row directly) and fixed scripts/mcp_live_smoke.py, which pointed operators at /app/keys/initial_mcp_password, a file no reachable path writes. CR-1 (services/auth_service.py, seed_admin_user): fixed a concurrent-first-boot admin lockout. With DEFAULT_ADMIN_PASSWORD unset and 2+ api replicas, both generated different passwords and the loser overwrote the keys file while its INSERT rolled back, so the file and the committed row disagreed. The fresh seed now creates+flushes first (the loser's INSERT raises IntegrityError -> rollback, no file write) and persists the keys file only after winning but before commit, so the file can only ever hold the committed row's password. Added a losing-replica test. CR-5 (services/auth_service.py, _persist_generated_password): os.open's 0600 mode only applies on create, so a pre-existing 0644 file was truncated in place and kept 0644, writing the secret world-readable. Added os.fchmod(fd, 0o600) and a test that a pre-existing 0644 file is tightened to 0600. CR-2 (routes/k8s_websocket.py, dpus_websocket.py, benchmarks.py): the WS auth validators called the blocking sync token_user_state directly on the event loop. Moved it off the loop via run_in_threadpool, matching core/auth_middleware.py. Validation: make lint-backend clean; mypy core/ schemas/ unchanged; auth (57) + ws/benchmark/startup (79) suites pass; alembic heads single v2_155; helm lint/template OK and --set secrets.mcpPassword=changeme fails the render; docker compose config OK on all modes; extract-breaking-changes and compute_version_bump self-tests pass; lint-commit-markers clean. BREAKING CHANGE: MCP_SERVICE_PASSWORD is now required — the backend SystemExits under ENVIRONMENT=staging|production when it is unset or a known default; the shipped admin/changeme default is removed and rotated out on upgrade; the dist bundle renames MCP_USERNAME/MCP_PASSWORD to MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD; and the Helm chart ships mcpUsername=mcp with a generated mcpPassword instead of admin/changeme. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Follow-up to the #177 integration on pr177-integration, addressing the CREDENTIAL/BACKEND/HELM/DIST/DOCS half of bonnyr-f5's BLOCK on PR #193. B1 (SECURITY): ensure_service_user no longer adopts any human account whose password is a known default. The adoption exception is now scoped to v2_155's exact backfill fingerprint (username=='mcp' AND email=='mcp@bnk-forge.local'), matching the migration's own conservative rule, and must_change_password is no longer cleared on an adopted row. Adds tests proving a human operator/changeme row (and a wrong-email mcp row) is REFUSED, not taken over. B2/B2b: all five compose files (root + dist docker-compose{,.local}.yml and the IBM embedded compose) honor legacy MCP_USERNAME/MCP_PASSWORD as aliases for MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD on both backend and mcp env, so an existing customer .env keeps working after upgrade. Docs (dist/README.md, dist/.env.example, user-pack/install-guide.html) document MCP_SERVICE_PASSWORD as canonical with MCP_PASSWORD honored as a legacy alias. B3: ENVIRONMENT is plumbed to the backend in every compose file, so an operator who sets ENVIRONMENT=staging|production actually reaches config.py's MCP fail-fast. Helm already routes ENVIRONMENT=production onto api/worker/beat. M1: the unset-MCP behavior stays "disabled" (#188 over #186); added an explicit deliberate-consolidation comment at the decision point. M2: disable_stale_service_user skips the about-to-be-reconciled row and the "no usable MCP_SERVICE_PASSWORD" warning is conditional on a configured password, so a correctly-configured install no longer logs a false warning or commits an inactive MCP window on every boot. M7: the Helm mcp deployment and the IBM installer's mcp service now run the exec auth-probe healthcheck (python -m bnk_forge_mcp.healthcheck) instead of a bare tcpSocket probe. M8: the chart fails the render for a reserved mcpUsername (admin), mirroring the mcpPassword guard, and NOTES.txt/values.yaml call it out. Minors: deterministic checksum/secret via a shared helper (stable across renders, identical across api/worker/beat/mcp); vestigial _persist_generated_password filename docstring; false "backend generates its own secret" rationale corrected in compose/helpers/ibm; mcp_live_smoke.py #186/#188 attribution; e2e/config.py changeme default note; .env.example :58/:73/:94 fixes; unified BNK_FORGE_VERSION to latest across dist. Validation: ruff clean; typecheck-backend (core/ schemas/) Success 38 files; 199 auth/credential/startup/ws/migration tests pass incl. new B1 tests; helm lint + template stable checksums, --set secrets.mcpUsername=admin and secrets.mcpPassword=changeme both FAIL; docker compose config on all five modes shows the backend receiving MCP_SERVICE_PASSWORD (via either alias) and ENVIRONMENT. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
) * 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 * fix(#193): reconcile the merged unset-MCP-password model and harden the credential seed Addresses mwiget's three CHANGES_REQUESTED blockers on the #177 integration PR (#193), plus follow-up findings from a max-effort review of the same credential surface. The merge chose #188's "unset MCP_SERVICE_PASSWORD -> disable" over #186's "unset -> generate", so the generate/rotate-on-unset code was left unreachable but still documented, and the release-notes footer was missing. BLOCKING 2 (core/config.py): the MCP_SERVICE_PASSWORD comment claimed ensure_service_user "generates a random secret and surfaces it once" when unset, contradicting the merged behaviour. Rewrote it to state the truth: when unset (or a known default) seed_auth_step calls disable_stale_service_user, leaving the mcp account disabled/unavailable until an operator configures a real password; a published default is refused and rotated out; the backend receives MCP_SERVICE_PASSWORD on every deploy mode and reconciles to it when set. Also removed the duplicate #186 block that sat above the wrong field. BLOCKING 3 (services/auth_service.py): removed the unreachable usable_password is None branches from ensure_service_user (the generate-on-create and rotate-on-unset paths). seed_auth_step only calls it under the _mcp_pw_usable gate, so password is never None/default in production. ensure_service_user now requires a usable password and only creates/reconciles with it (failing closed and loudly if handed an unusable one); the unset case is owned entirely by disable_stale_service_user. Dropped the now-dead _log_generated_service_password helper and the service-account token_urlsafe/_persist_generated_password calls (_persist_generated_password is still used by the admin seed). Kept the reserved-name guard, the provenance check, the adopt-a-published-default remediation, and disable_stale_service_user fully intact. Updated the affected unit tests (published-default/None now refused; added a reachable adopt-and-reconcile test; stale-row setup builds the legacy row directly) and fixed scripts/mcp_live_smoke.py, which pointed operators at /app/keys/initial_mcp_password, a file no reachable path writes. CR-1 (services/auth_service.py, seed_admin_user): fixed a concurrent-first-boot admin lockout. With DEFAULT_ADMIN_PASSWORD unset and 2+ api replicas, both generated different passwords and the loser overwrote the keys file while its INSERT rolled back, so the file and the committed row disagreed. The fresh seed now creates+flushes first (the loser's INSERT raises IntegrityError -> rollback, no file write) and persists the keys file only after winning but before commit, so the file can only ever hold the committed row's password. Added a losing-replica test. CR-5 (services/auth_service.py, _persist_generated_password): os.open's 0600 mode only applies on create, so a pre-existing 0644 file was truncated in place and kept 0644, writing the secret world-readable. Added os.fchmod(fd, 0o600) and a test that a pre-existing 0644 file is tightened to 0600. CR-2 (routes/k8s_websocket.py, dpus_websocket.py, benchmarks.py): the WS auth validators called the blocking sync token_user_state directly on the event loop. Moved it off the loop via run_in_threadpool, matching core/auth_middleware.py. Validation: make lint-backend clean; mypy core/ schemas/ unchanged; auth (57) + ws/benchmark/startup (79) suites pass; alembic heads single v2_155; helm lint/template OK and --set secrets.mcpPassword=changeme fails the render; docker compose config OK on all modes; extract-breaking-changes and compute_version_bump self-tests pass; lint-commit-markers clean. BREAKING CHANGE: MCP_SERVICE_PASSWORD is now required — the backend SystemExits under ENVIRONMENT=staging|production when it is unset or a known default; the shipped admin/changeme default is removed and rotated out on upgrade; the dist bundle renames MCP_USERNAME/MCP_PASSWORD to MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD; and the Helm chart ships mcpUsername=mcp with a generated mcpPassword instead of admin/changeme. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193): close bonnyr-f5's credential/backend/helm/dist/docs blockers Follow-up to the #177 integration on pr177-integration, addressing the CREDENTIAL/BACKEND/HELM/DIST/DOCS half of bonnyr-f5's BLOCK on PR #193. B1 (SECURITY): ensure_service_user no longer adopts any human account whose password is a known default. The adoption exception is now scoped to v2_155's exact backfill fingerprint (username=='mcp' AND email=='mcp@bnk-forge.local'), matching the migration's own conservative rule, and must_change_password is no longer cleared on an adopted row. Adds tests proving a human operator/changeme row (and a wrong-email mcp row) is REFUSED, not taken over. B2/B2b: all five compose files (root + dist docker-compose{,.local}.yml and the IBM embedded compose) honor legacy MCP_USERNAME/MCP_PASSWORD as aliases for MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD on both backend and mcp env, so an existing customer .env keeps working after upgrade. Docs (dist/README.md, dist/.env.example, user-pack/install-guide.html) document MCP_SERVICE_PASSWORD as canonical with MCP_PASSWORD honored as a legacy alias. B3: ENVIRONMENT is plumbed to the backend in every compose file, so an operator who sets ENVIRONMENT=staging|production actually reaches config.py's MCP fail-fast. Helm already routes ENVIRONMENT=production onto api/worker/beat. M1: the unset-MCP behavior stays "disabled" (#188 over #186); added an explicit deliberate-consolidation comment at the decision point. M2: disable_stale_service_user skips the about-to-be-reconciled row and the "no usable MCP_SERVICE_PASSWORD" warning is conditional on a configured password, so a correctly-configured install no longer logs a false warning or commits an inactive MCP window on every boot. M7: the Helm mcp deployment and the IBM installer's mcp service now run the exec auth-probe healthcheck (python -m bnk_forge_mcp.healthcheck) instead of a bare tcpSocket probe. M8: the chart fails the render for a reserved mcpUsername (admin), mirroring the mcpPassword guard, and NOTES.txt/values.yaml call it out. Minors: deterministic checksum/secret via a shared helper (stable across renders, identical across api/worker/beat/mcp); vestigial _persist_generated_password filename docstring; false "backend generates its own secret" rationale corrected in compose/helpers/ibm; mcp_live_smoke.py #186/#188 attribution; e2e/config.py changeme default note; .env.example :58/:73/:94 fixes; unified BNK_FORGE_VERSION to latest across dist. Validation: ruff clean; typecheck-backend (core/ schemas/) Success 38 files; 199 auth/credential/startup/ws/migration tests pass incl. new B1 tests; helm lint + template stable checksums, --set secrets.mcpUsername=admin and secrets.mcpPassword=changeme both FAIL; docker compose config on all five modes shows the backend receiving MCP_SERVICE_PASSWORD (via either alias) and ENVIRONMENT. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193): close bonnyr-f5 CI/RELEASE/SCRIPTS blockers, majors, and minors Blockers: - B4 registry-tag-probe.sh: replace GNU-only BRE alternation (\|) with portable `sed -nE (access_token|token)` so the token parse works on BSD/ macOS sed; on BSD the empty token classified every image `unknown` and routed the operator into FORCE_LATEST=1, overwriting immutable :VERSION manifests (INV-24). Reproduced under `sed --posix`, fix proven to parse T2. - B5 registry-tag-probe.test.sh had no caller and was red on BSD. Both the Makefile script-selftests target and ci.yml's script-selftests job now enumerate and run every scripts/tests/*.test.sh, failing on an empty enumeration or any non-zero rc. - B6 lint-commit-markers.sh: replace the spoofable committer-identity exemption (GitHub <noreply@github.com> + single parent) with an unspoofable "already reachable from origin/main|origin/staging" check; lint the PR title (PR_TITLE via env) on pull_request events; split the rules so machine/already-merged is exempt for the marker rule but the spurious-major rule always applies. Majors: - M3 release.yml overwrite guard: derive the vacuity floor from an independent source (docker-bake.hcl default group, sourced from the workflow-ref tooling) and assert the probe's exit status before trusting its output, so an unavailable probe fails closed instead of "safe". - M4 (INV-31): generate release notes and run the registry existence-probe BEFORE the irreversible push in release-final/release-manual (new shared scripts/registry-overwrite-guard.sh); release-publish keeps its own in-critical-section re-check. - M5 make script-selftests now runs the INV-15 detector-parity diff (extracted to scripts/tests/detector-parity.test.sh) so local == CI. - M6 extractor self-test runs unconditionally with anti-vacuity assertions (ok lines + END marker), no longer gated on grepping its own --self-test. Minors: stale cross-PR comments in release.yml and extract-breaking-changes.sh; removed the duplicate Makefile version-check target; `git add dist/VERSION` no longer swallows failures; first-ever-release notes range fixed; CHANGELOG insertion asserts a non-no-op before committing; refreshed .trivyignore CVE-2026-7598 review deadline; removed e2e-tests.yml dead `|| true`; documented the new Docker dependency in the pre-push hook. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193 r2): close credential/backend/helm/dist blockers — drop the username alias, make ENVIRONMENT=production satisfiable, and fix the mcp liveness probe bonnyr-f5 round-2 BLOCK, credential/backend/helm/dist/docs half. B-1 (INV-12): the compose files aliased the SERVICE username (MCP_SERVICE_USERNAME:-${MCP_USERNAME:-mcp}); a legacy .env with MCP_USERNAME=admin resolved it to `admin`, and against the guardless image `latest` still points at, the old ensure_service_user rewrites the human admin row to `changeme` every boot. Drop the username alias across all five compose files + the ibm embedded compose (keep the harmless password alias), and default BNK_FORGE_VERSION to 4.0.0 (the release this tree becomes, first image with the guards) instead of `latest`, so a compose file can never hand the new credential contract to a pre-guard image. B-2: ENVIRONMENT=production reaches validate_production, which also gates on JWT_SECRET_KEY / ENCRYPTION_KEY / ALLOWED_ORIGINS — none of which were deliverable from a compose install, so the switch bricked the backend. Plumb all three into every x-backend-env anchor (four compose files + ibm) and document them in the env examples; treat an empty ("" from ${VAR:-}) key as unset in config.py so the plumbed empty default auto-generates rather than passing as a real empty key. _persist_or_load_key now flags only keys WE generated as auto_generated (sidecar .autogen marker), so an operator-provisioned key on the volume validates while a fresh prod boot still fail-fasts permanently. M-4: mcp liveness returned non-zero when the BACKEND was unreachable, so k8s restarted the pod for a dependency outage. Move the auth-probe to readiness only; liveness is tcpSocket. Fix mcp-server/README + mcp_live_smoke hints to name only the vars each process actually reads (container: BNK_FORGE_*, backend: MCP_SERVICE_*). Minors: refuse a known-default DEFAULT_ADMIN_PASSWORD on fresh seed + helm adminPassword fail-guard; guard secrets.yaml mcpUsername nil with kindIs "invalid"; make the Python reserved-name check case-insensitive/trim to match Helm; neutralise the hash when disabling a stale service account; correct the benchmarks.py JWT-gate comment; surface an empty MCP_SERVICE_PASSWORD in install.sh; fix the .env.example "No .env file is needed!" contradiction. Tests: config B-2 satisfiability + provenance-marker tests; seed_auth_step against an admin-still-holds-changeme upgrade DB; case-insensitive reserved-name and disable-hash-neutralisation cases. ruff clean, mypy clean, 4831 unit pass, helm lint/template green, docker compose config verified on all modes. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193): close r2 CI/RELEASE blockers B-3, M-1..M-3 + scripts/release minors B-3 (commit-lint exemptions): key the already-merged exemption on the range BASE (github.event.before), not the post-push tip, so a new skip-CI marker on a push to main/staging is caught while a genuinely-already-merged base commit stays exempt; replace the self-settable `^release: ` subject exemption with release.yml's own version+trailing-skip fingerprint. M-1 (spurious-major rule): redefine rule 2 as the exact complement of the detectors, sourced from the shared predicate, so it flags only a marker the detectors would MISS (never dash-bullet, markdown-bold or indented shapes); give it the same already-merged exemption; and lint inputs.release_notes through the script before it becomes a release commit/tag. M-2 (overwrite guard floor): derive the vacuity floor from `docker buildx bake --print default | jq '.group.default.targets | length'`, scoped to the default group, so a second bake group no longer wedges the release; separate bake-file parse failures from registry-unreachable in the messaging. Single-source the policy: release-publish and make push-images now call the one guard. M-3 (portability): drop bash-4 mapfile from the probe test; rebuild the compute self-test newline expansion with awk to avoid the bash-3.2 parameter-expansion cliff, so make script-selftests runs under stock macOS bash 3.2. Detector single-sourced into scripts/lib/breaking-change-detect.sh (compute, extract, lint all source it); detector-parity test asserts the wiring; added mutation tests for the lint rules and the overwrite guard. Minors: fix version-consistency misdiagnosis of a column-0 YAML comment; correct the compute/extract parity docstrings and the docker-bake four-push-paths note; wire artifact-network-self-test into ci-gates; make the pre-push hook migration message reachable under set -e; omit the false provenance buildStartedOn; filter the release CI-status poll by commit SHA. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193): seed the re-enable-guard test's default-hash row directly The round-2 disable_stale_service_user hash-scrub (bonnyr-f5 #193 minor) collided with the re-enable guard's own regression test: _seed_disabled_default_mcp built its "disabled while holding the published default" state BY CALLING disable_stale, which now scrubs the hash -- so holds_known_default_password was false and the PUT re-enable was allowed (200) instead of refused (400). The guard defends a row taken inactive by a path that LEAVES the credential intact (a manual operator PUT), not one disable_stale scrubbed. Seed that state directly (set is_active=False on the default-hash row) so the guard's real scenario is exercised; assert the default hash survives the seed. Corrected the now-stale guard comment in routes/auth.py that still claimed disable "only flips is_active". Neutralisation and its asserting tests are unchanged. Verified: TestServiceAccountReEnableGuard 2/2 pass; the three affected auth files (test_startup_seed_auth, component/test_auth_service, integration/test_routes_auth) 97/97 pass; ruff clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193 r3): fail-close key provenance (B-2) + credential/auth minors Own the round-3 CREDENTIAL/AUTH findings. B-2 (BLOCKER): _persist_or_load_key classified a marker-less key file as operator-provided, so every upgrade keys volume (key present, no marker) let SEC-006's fail-fast pass OPEN on an auto-generated JWT/ENCRYPTION secret in production. Invert the sentinel: a marker-less key now classifies AUTO-GENERATED (fail closed); an operator asserts provenance with an explicit <filename>.operator opt-out marker. No marker is written on generation, which also removes the second trigger (a partial marker write can no longer downgrade provenance). Regression tests seed the PREVIOUS-RELEASE on-disk shape and assert validate_production raises under ENVIRONMENT=production. Minors: - Write the key via os.open(0o600)+fchmod so the secret is never briefly 0644. - Single-source the MCP known-default denylist: delete the local tuple in auth_service and use core.config.MCP_KNOWN_DEFAULT_PASSWORDS (comment notes the helm copy is deploy-owned). - Correct holds_known_default_password docstring (disable_stale now scrubs the hash; this guard covers the other disable paths). - Reconcile ENCRYPTION_KEY docs with reality: the keys-file is the source of truth for at-rest crypto; the env var only drives the production gate (encryption.py comment + .env.example). - Clarify the v2_155 custom-username remedy in disable_stale docstring. Test-gaps: - Normalise the service username (trim/casefold) at the reconcile lookup and the disable skip filter, so " mcp "/"MCP" reconciles the existing mcp row instead of minting a second service account and disabling the live one. - Honest seed_admin_user log/logic under DEFAULT_ADMIN_MUST_CHANGE=false (no more "must change on first login" when no gate was applied). - disable_stale_service_user(skip_username=...) leaves the live row wholly untouched (no inactive window), variant included. - db.commit() failure after the keys file is written leaves a retriable state (published default still authenticates, orphan file password does not). All owned-suite tests pass; ruff clean. Each fix reproduced then mutation-tested. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193 r3): close the release/CI blocker + major + every release/CI minor M-6 (blocker): commit-lint no longer reds unamendable merge history. - rule 2 now flags ONLY a mis-anchored DECLARATIVE marker (the colon form); a colonless marker-shaped PROSE line the detectors treat as inert (an already-merged body such as "- <MARKER> footer in the body ...") is no longer flagged, so the push-to-main range (before..head, which INCLUDES the PR merge-base) goes green without a history rewrite. Detection of a real mis-anchored marker is unchanged. - deleted the already-merged exemption as dead code: base..head excludes the base by construction, so no scanned commit can ever be an ancestor of it. Removed the BEFORE derivation, _already_merged, tests B3.2/M1.5, and the ~20-line header claim. The release-bot exemption stays. - rule 2 now scans the whole body via _under_detected_markers and reports EVERY mis-anchored marker, not just the first. M-7 (major): secret-scan no longer false-fails a delete-only range. A delete-only commit has rev-list count > 0 but gitleaks scans 0 (it scans added content), so the count-based backstop is replaced by a range- resolvability check plus gitleaks' exit status. Release/CI minors: - release-bot fingerprint single-sourced to scripts/lib/is-release-bot-subject.sh; release.yml's inline copy byte-locked by a parity self-test; dropped the false unforgeability claim and documented the residual honestly. - registry-overwrite-guard: added a fail-closed default arm for an unrecognised/empty probe status (+ malformed/empty test scenarios). - Makefile push-images: FORCE_LATEST now overrides ONLY the recency guard; a new FORCE_OVERWRITE overrides ONLY the immutable-tag guard; fixed the missing-jq remediation text. - registry-tag-probe: the network arm now matches the real doubled "000000" curl-failure shape (was dead code); test fixture reproduces it. - INV-15: single-sourced the marker regex (one canonical value + a detector-parity assertion that every embedded copy is byte-identical). - release.yml Publish summary counts what buildx actually pushed (bake --metadata-file), not the static target list. - registry-tag-probe test enumerates the bake DEFAULT group (scoped), matching the guard's enumeration. - added scripts/tests/secret-scan.test.sh (fake-docker mutation suite). release.yml: added a post-push step running scripts/verify-image-pins.sh so a release cannot complete while shipping an unpublished image pin (script owned by the deploy agent; referenced by path from .release-tooling). Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193 r3): single-source every deploy version pin + close deploy majors/minors B-1/B-1b: dist/docker-compose{,.local}.yml, dist/.env.example and ibm_cloud_bnk_forge.sh hard-pinned 4.0.0, a tag that has never been published, while Helm pinned 3.1.6 -- two shipped paths naming two versions, one of which does not exist. Bring all of them under sync-version-artifacts.sh (new PIN/ DISTENV readers+writers, --check, --list) so every pin derives from VERSION (3.1.6, which exists) and the release re-stamps them atomically via the existing --write $NEW; drop the dist/ disclaimer. Add scripts/verify-image-pins.sh (+ selftest) that resolves every shipped compose image: pin against the registry and fails on manifest unknown, wired post-push in the release job. M-1..M-5: reword NOTES/compose/chart comments that asserted post-guard behaviour as already-true on the pre-guard pinned image (they land with the guard-carrying release); NOTES now leads with the required ALLOWED_ORIGINS override; dist/README and the install guide stop recommending latest/3.1.6 and the keys-file cat the pinned image does not write; install.sh strips quotes and rejects the known- default MCP passwords so the "MCP not active" warning fires instead of a green lie; add deploy-version-lockstep + helm-known-defaults-lockstep selftests. Deploy minors: MCP_USERNAME "do not set" made consistent across docs/Makefile; chart ENCRYPTION_KEY now emits a valid Fernet key; DEFAULT_ADMIN_* added to the ibm P3 backend env; secrets.mcpUsername defaults to "mcp" on null; ibm mapfile -> portable while-read. Verified: sync --check exit 0; --write round-trip moves every pin and restores; helm lint/template clean (default + origin override); script selftests green; bash -n + shellcheck clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193 r4): unify the at-rest encryption key (B-3), fail-close key marker (M-1), row-matches-client (M-2) + credential minors B-3: validate_production gated ENCRYPTION_KEY while a second, independent generator in core/encryption.py produced the real at-rest Fernet key unchecked — setting ENCRYPTION_KEY (the documented remedy: token_hex(16), not even a Fernet key) turned the gate green while encryption auto-generated a different key. Unify: one key file (_encryption_key_path == core.encryption.ENCRYPTION_KEY_FILE), one generator. When ENCRYPTION_KEY is set it is VALIDATED as a real Fernet key (fail clearly if not), written to that file with a .operator marker, and consumed by core.encryption and services.backup_service; the provenance flag reflects the value that actually protects data. Never clobber an operator-marked key on a mismatch. config.py:319 and .env.example now print the Fernet recipe. M-1: _persist_or_load_key required a regular-FILE marker (os.path.isfile, not os.path.exists — a directory no longer counts) and treats "marker present, key file absent" as a provisioning error: generate but do NOT persist, so the stale-marker rotation gesture can never heal into auto=False on the next boot. M-2 (regression this PR introduced): ensure_service_user normalised the username before lookup/create, so MCP_SERVICE_USERNAME=MCP seeded 'mcp' while the client sends the raw 'MCP' and authenticate_user matched exactly -> login denied. Create/ reconcile under the RAW value (what the client sends); the disable_stale skip keys on the same raw value; only the reserved-name guard normalises. Fixed the false "Matches the Helm chart lower|trim" docstring. Credential minors: non-vacuous localhost-CORS test (valid MCP password so only the CORS branch fails) + wildcard is now an exact origin-list entry, not a substring; new DPU-websocket must-change/unresolvable-user tests mirroring the k8s twin; middleware unresolvable-JWT-subject-refused and exact-vs-suffix exempt-path tests; corrected the denylist copy count (4th copy in dist/install.sh); corrected v2_155's rationale (v2_154 is new in this diff, not "already shipped"); documented why ensure_service_user's adoption branch is kept. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193 r4): close deploy-surface blocker + majors (B1, M7-M12) and deploy minors B-1: a fresh install of the pinned 3.1.6 bundle seeded an admin nobody could log in as. `${DEFAULT_ADMIN_PASSWORD:-}` delivers the var present-and-empty; 3.1.6's `DEFAULT_ADMIN_PASSWORD: str = "changeme"` is then overridden by "" and the login schema rejects "" (422). The prescribed `${VAR}` form does NOT omit on this compose (map interpolation still renders ""); the working omit-when-unset form is a map entry with NO value (passthrough / `docker run -e KEY` semantics). Converted DEFAULT_ADMIN_PASSWORD to passthrough across all four compose paths (dist base+local, root base+local, IBM embedded). Swept the class: JWT_SECRET_KEY/ENCRYPTION_KEY also converted (3.1.6 uses `if KEY is None`, so "" is used literally, not auto-generated); MCP_SERVICE_PASSWORD kept as `${...:-...}` because omitting it restores 3.1.6's known default "mcp-service-changeme". Added a commented DEFAULT_ADMIN_PASSWORD block to dist/.env.example. Verified via `docker compose config` + real container env both directions (unset -> omitted; set in .env -> forwarded). M-7: chart checksum/secret did not change on mcp-password rotation (3 renders, 3 generated passwords, one identical checksum). Made all generate/rotate fallbacks deterministic (deriveSecret, release-seeded) so the Secret is stable across renders and includes, and hash the RENDERED Secret so the annotation tracks every resolved value. Now stable across renders, identical across the 4 deployments, and it flips when any resolved value changes. M-8: dist/install.sh credential guard failed open on `changeme ` (whitespace). Trim leading/trailing whitespace around the quote-strip before the known-default compare. M-9: removed newly-added forward-dated 4.0.0 prose (install-guide.html x2, DOCKER.md). M-10: default helm install crashlooped (production + localhost). Added a render-time guard mirroring backend validate_production (fail on wildcard under staging/production, localhost under production); defaulted ALLOWED_ORIGINS to empty so the bare render boots (empty is neither wildcard nor localhost). `helm lint` and bare `helm template` stay green; the guard fires with a clear message on a real fatal posture. M-11: portable in-place sed in the IBM installer (`sed -i.bak … && rm`), both sites. M-12: dist/ no longer ships published default DB/redis creds on host networking. install.sh generates strong POSTGRES_PASSWORD/REDIS_PASSWORD on the fresh .env (like Helm/IBM); .env.example ships them empty; a pre-existing default triggers a warning. Deploy minors: extended deploy-version-lockstep.test.sh to the bnk-operator chart (appVersion + image.tag) and dist/VERSION; brought dist/VERSION under sync-version-artifacts.sh (--check/--list/--write green); reworded install.sh's MCP UNHEALTHY assertion to match what the pinned image actually reports; added scripts/tests/ibm-compose-drift.test.sh freezing the credential/hardening env so the IBM embedded compose and dist/docker-compose.yml cannot silently diverge. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193 r4): make the pin verifier reachable pre-push, de-vacuum the parity/self-test harnesses, and close the release minors B-2 (blocker): scripts/verify-image-pins.sh could never pass where the release job runs it — from the 4-file sparse .release-tooling checkout that holds no compose file (ROOT resolved there), with REGISTRY/VERSION handed in as env vars the script reads only as flags, and the whole step wired AFTER the tag/Release/push/signing. Fixes, end to end: - add a consistency mode (--expect-version) that asserts every shipped first-party pin already renders to $NEW without a registry probe, and run it as the PRIMARY PRE-push gate in release-final and release-manual (before anything irreversible); - fix the post-push existence step to pass REGISTRY/VERSION as FLAGS and the compose files explicitly by --file (they live at the tag checkout at the workspace root), keeping it as a secondary confirmation; - widen the default file set to include the IBM Cloud installer's embedded compose; - add a dryrun-release-tooling job that rebuilds the exact publish-job layout and exercises both invocations against a fake probe, and gate release-publish on it, so a step that cannot execute is caught before it is wired ahead of a signature. The sibling registry-tag-probe.sh / registry-overwrite-guard.sh read nothing $ROOT-relative outside the sparse set, so they are unaffected. M-3: detector-parity.test.sh enumerated the marker copies with the very token that drifts, so a copy that drifted in the token vanished from enumeration (drifting :96-97 dropped the count 5->3 yet stayed green). Enumerate by position/count instead: an exact per-file canonical count plus a stable-anchor site scan that flags any drifted site even under a compensating add. M-4: the filesystem self-test loop checked only a non-empty enumeration and each file's exit 0, so a test gutted to a no-op passed and deleting 7 of 8 stayed green. It now requires each file to emit PASS lines, no FAIL line, and an ALL PASS terminal marker, plus a count floor derived from git's tracked *.test.sh set (detector-parity was conformed to that output convention). M-5: release-rc created and pushed the RC tag before the fail-closed notes step; the tag is now created locally, notes generated, then the tag pushed. M-6: added mutation-tested coverage for this PR's four previously-uncovered lint fixes (the PR-title lint, the pending-message lint, the RANGE fail-closed branch, and the skip-checks trailer rule). LEAD: the anti-vacuity staging floor derived the count from a stale literal while --list grew to 8 paths; both sites now derive it from --list and require every listed path to stage, and the stale comments are corrected. Release minors: scope the release-bot commit-lint exemption to the range tip (a forged release subject buried mid-range is no longer exempt) and add a REACHABLE published-history exemption anchored to the last release tag so a mis-anchored marker in unamendable history cannot red the release; add fixtures for the untested registry probe/guard arms (5xx, unexpected code, unknown status, bake-parse failure); ancestry-filter the LAST_FINAL tag queries so a tag on another branch cannot skew the notes range; reconcile the empty-RANGE handling (commit-lint now fails closed on an explicit empty RANGE, ci.yml leaves it unset); give the pre-push hook a clear message and a fetch fallback when the remote tip is absent locally; derive the cosign verify-identity org from REGISTRY instead of hardcoding it. Flagged: gate Makefile push-customer-build through registry-overwrite-guard.sh, the last documented push path that was still unguarded. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193 r4): keep M-7 checksum-tracks-rotation WITHOUT predictable secrets The r4 deploy fix closed M-7 (checksum did not move on a secret rotation, so pods never rolled) by making the generated fallbacks deterministic -- deriveSecret = sha256(Release.Name|Namespace|fullname|purpose). Every input is public (they appear in resource labels and the chart source), so that made the JWT signing key, the at-rest Fernet key and the admin/mcp passwords COMPUTABLE by anyone who can read a label -- a forge-any-token / decrypt-all-secrets exposure, strictly worse than the cosmetic churn it fixed. Revert generation to randAlphaNum (unpredictable) and instead hash the DETERMINISTIC inputs that determine the Secret -- values.secrets, the persisted .data (reused via lookup), plus a per-credential "rotating-from-default" marker for admin/mcp whose persisted value is a known published default. That tracks every rotation (operator edit, persisted-value change, rotate-away-from-default) so the pods roll, is stable across renders including a bare no-cluster `helm template` (the hashed inputs carry no randomness), and never derives a secret from public identity. deriveSecret removed. New scripts/tests/helm-secret-checksum.test.sh locks all three: stable-across-renders, changes-on-rotation, and generated-value-is-random -- so the determinism cannot return. Verified: helm lint 0-failed; bare + override template OK; the M-10 render guard still fires on production+localhost; the new selftest ALL PASS. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193 r4): self-review — close the encryption-key data-loss BLOCKER + deploy minors A cold adversarial self-review (three auditors mirroring the reviewer's method) of the r4 changeset found a data-loss BLOCKER we introduced this round, plus deploy minors. Fixing before it ships. B-3 (BLOCKER, data loss): the r4 encryption-key unification made ENCRYPTION_KEY env OVERWRITE an existing at-rest key file on boot. Traced only to the "operator freshly sets the key" consumer, never to (a) backup_service restore, which writes the backup's key to that file, or (b) the r3->r4 upgrade population, whose data was encrypted under a marker-less auto-gen FILE key (r3 core.encryption used the file regardless of env). The first r4 boot clobbered those -> restored/existing data undecryptable (or bricked the boot on a marker mismatch). A new test even LOCKED the clobber with a false premise. Fix: the at-rest key FILE is the single source of truth. ENCRYPTION_KEY only SEEDS the file when it is ABSENT and never overwrites an existing one; the gate reads the FILE's .operator provenance. backup restore now drops the .operator marker so a restored key passes the gate without a clobber. Rewrote the clobber-locking tests to lock the no-clobber invariant; added upgrade-shape, production-fail-without-data-loss, and restore-marker tests. M-7 (deploy self-review): removed the rotation-marker from secretsChecksum — the auditor proved it redundant (the same .data change already moves the digest; deleting it left the test green) and its admin branch dead. Kept the input-hash; documented the genuine trilemma (cluster-less-template-stable / tracks-generated-rotation / unpredictable-secrets — pick two; determinism is the predictable-secret hole). M-10: the render guard's wildcard check is now an exact comma-split entry, matching the backend's `"*" in cors_origins` since r4, so a legitimate `https://*.example.com` is no longer blocked; the localhost check stays a substring to match the backend. .env.example: the admin-password template was an empty assignment that uncomments into a lockout; it now carries a replace-me placeholder. Verified: backend 8082 passed; config/encryption 66, backup 13; script-selftests all pass; helm lint/template clean (subdomain-wildcard passes, bare '*'/prod+localhost fail); ruff clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 * fix(#193 r5): fail closed instead of regenerating over an existing at-rest key (I-1) bonnyr-f5 #193 round-5 closure blocker. B-3 wrote down the invariant "the at-rest key file is the single source of truth; nothing overwrites it once it holds bytes" and config.py honoured it -- but core.encryption.get_encryption_key() did not. A file present but under 32 bytes (truncated / partial write / disk full / bad restore) logged "Invalid key, regenerating" and OVERWROTE it with a fresh key, permanently destroying the key any existing data was encrypted under -- silently, on a GREEN production boot, because the intact .operator marker keeps validate_production passing. Fix: the read path now fails CLOSED. A genuinely absent (or 0-byte) file still generates and persists a new key. A file that HOLDS bytes is validated as a real Fernet key: valid -> returned untouched; unusable -> SystemExit with a clear message, never regenerated. A crashloop is recoverable; an overwritten key is not. This also closes r5 note #3 -- the old `len >= 32` check accepted any blob and surfaced a mis-shaped key as a later cipher error; it now Fernet-validates and says so plainly. Locked by TestAtRestKeyFileNeverRegeneratedOverBytes: a 30-byte truncated key -> SystemExit AND the original bytes survive on disk (recoverable); a valid key -> returned untouched; an absent file -> generates a valid key. Verified: backend 8086 passed; encryption unit tests 19 passed; ruff clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 --------- Co-authored-by: John Gruber <john.t.gruber@gmail.com>
Fixes #184 — the pre-4.0.0 release blocker.
The problem
Every fresh deployment seeded
admin / changeme, and it was a live, API-reachable credential:config.py,NOTES.txt, compose).must_change_passwordwas returned in the login response but never enforced server-side —get_current_user/require_rolenever consulted it — so a client could skip the change-password screen and call every endpoint directly with the seed credential.The fix
DEFAULT_ADMIN_PASSWORD→None(never a hardcoded value), mirroring the existingJWT_SECRET_KEY/ENCRYPTION_KEYhandling.seed_admin_usergenerates a strong random password when unset and logs it once; the account staysmust_change_password.get_current_user: refuse every endpoint exceptchange-password/me/logoutuntil the password is rotated — for both JWT and API-token auth.admin-passwordsecret (reused across upgrades) and injectsDEFAULT_ADMIN_PASSWORDinto the backend pods;values.yamlgainsadminPassword: ""(empty → generated).Testing
changemeno longer authenticates — and uses an explicit one when set./meandchange-password, and is unblocked after changing it; a normal user is not gated.helm lintclean.Scope
The admin credential (the critical, API-reachable one). The
mcpservice account ships the same class of default (MCP_SERVICE_PASSWORD = "mcp-service-changeme", and that account isrole=admin) — its fix is entangled with the chart'smcp-passwordwiring, so it's tracked as a follow-up rather than bundled here.Fixes #184
https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4