From b1c2637a56cb94355700e6c0a6149bac6ba9175e Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 17:34:42 -0500 Subject: [PATCH 1/4] fix(auth): guard the zero-factor state on TOTP-disable, not just passkey-delete (BACKLOG #1022) delete_webauthn_credential refused to remove the last enrolled second factor while MFA was required; disable_mfa consulted neither has_webauthn_credentials nor _mfa_required_for. A user holding TOTP plus one passkey could therefore delete the passkey (permitted while TOTP remained) and then disable TOTP unguarded, reaching zero enrolled factors -- the state ADR 0068 AC-10 says the system shall refuse. Only the ORDER of the two removals decided whether it was allowed. disable_mfa now carries the same guard, keyed the same way (has_webauthn_credentials, then _mfa_required_for(..., second_factor_enrolled=False) on identity.roles). It is deliberately not a TOTP-only check -- a user who keeps a passkey stays free to drop TOTP -- and is gated on totp_enabled so a disable that removes nothing stays idempotent rather than refusing a no-op. The ValueError is mapped at both call sites, which would otherwise have 500'd: DELETE /me/mfa returns 400 with the refusal text, and the console's /ui/account/mfa/disable renders it on the account page exactly as ui_webauthn_delete renders the passkey twin. ADR 0068 AC-10 is widened from the passkey ROUTE to the zero-factor STATE, since as written it forbade one path to that state rather than the state itself; the ADR's parity-follow-up note and the two docs/SECURITY.md claims (the DELETE /me/mfa route-table row, which recorded the absence, and the TOTP paragraph) move with it. Not a bypass and not an MFA-enforcement change: login already issues mfa_verified=not mfa_required and require() applies the second factor as an ASVS 6.3.3 access gate, so the pre-guard outcome was a forced re-enrollment, not single-factor access. Nothing is deployed, so there is no migration cost here. Red-first, all three directions measured on the pre-fix code: the passkey-then-TOTP ordering reached zero factors (DID NOT RAISE); dropping the route try/except gives "unhandled error on DELETE /me/mfa: ValueError"; dropping the console except-arm loses the HTML error page. test_disable_and_admin_reset_clear_mfa now pins require_mfa=False -- the documented opt-out -- because under the secure default the new guard correctly refuses to strip the account's only factor. The new guard tests are deliberately extra-free (they stage a passkey through the AuthStore surface, the tests/_webauthn_store_contract.py precedent): an importorskip("webauthn") on this path would skip the guard on every leg that installs without the optional extra. --- docs/SECURITY.md | 6 +- ...8-browser-webauthn-passkeys-offloopback.md | 22 ++- messagefoundry/api/auth_routes.py | 8 +- messagefoundry/auth/service.py | 22 ++- messagefoundry_webconsole/routes/account.py | 10 +- .../tests/test_webui.py | 24 +++ tests/test_api_auth.py | 23 +++ tests/test_mfa.py | 139 +++++++++++++++++- 8 files changed, 240 insertions(+), 14 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index a4fad12c..772ed329 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -328,7 +328,7 @@ tuple: they act only on the caller's own account. | `GET` | `/me/mfa` | `require` | | | `POST` | `/me/mfa/enroll` | `require_reauth_only_action` (action `mfa_enroll`) | password-only step-up — the MFA gate is skipped so a required-but-unenrolled user cannot deadlock | | `POST` | `/me/mfa/confirm` | `require_reauth_only_action` (action `mfa_confirm`) | per-actor ceremony limiter; password-only step-up | -| `DELETE` | `/me/mfa` | `require_step_up_action` (action `mfa_disable`) | step-up bound to the disable action (current factor + a fresh password). ⚠️ **No last-factor guard** — this is the TOTP path (`disable_mfa`), and it does **not** refuse when it would leave the account with zero enrolled factors. The passkey removal path does refuse; see BACKLOG #1022 for the asymmetry | +| `DELETE` | `/me/mfa` | `require_step_up_action` (action `mfa_disable`) | step-up bound to the disable action (current factor + a fresh password). **400** when TOTP is the account's last enrolled factor and MFA is still required — the same refusal the passkey-removal path makes, so the zero-factor state is unreachable by either removal order (ADR 0068 AC-10) | | `GET` | `/me/sessions` | `require` | | | `GET` | `/me/security-events` | `require` | | | `DELETE` | `/me/sessions/{session_id}` | `require_reauth_only` | password-only step-up | @@ -736,7 +736,9 @@ dual-control approval above (the requester re-verifies; an independent approver Local accounts can enroll a native **RFC 6238 TOTP** second factor (ASVS 6.3.3): `POST /me/mfa/enroll` returns a setup key + `otpauth://` URI for an authenticator app, `POST /me/mfa/confirm` activates it and returns the **single-use recovery codes** (shown once), and `POST /auth/mfa-verify` satisfies a session's -second factor with a TOTP code or a recovery code. `DELETE /me/mfa` disables it; an administrator clears a +second factor with a TOTP code or a recovery code. `DELETE /me/mfa` disables it — **refused when TOTP is +your last enrolled factor and MFA is still required for you** ("enroll another factor first"), the same +refusal the passkey-removal path makes; an administrator clears a lost authenticator via `POST /users/{id}/reset-mfa` (which also revokes the user's sessions). With `[auth].require_mfa` on — **the default since BACKLOG #187 (secure-by-default, including the loopback bind)** — the **Administrator** role must satisfy MFA before any step-up operation (the gate returns diff --git a/docs/adr/0068-browser-webauthn-passkeys-offloopback.md b/docs/adr/0068-browser-webauthn-passkeys-offloopback.md index 43d484da..c01bf516 100644 --- a/docs/adr/0068-browser-webauthn-passkeys-offloopback.md +++ b/docs/adr/0068-browser-webauthn-passkeys-offloopback.md @@ -135,9 +135,14 @@ TOTP-specific (a WebAuthn-only user's TOTP code gets "not enrolled", never a loc **No WebAuthn recovery codes** — they are phishable knowledge secrets that undercut the phishing-resistant tier. Recovery = enroll ≥2 passkeys (UI nudge) / keep TOTP alongside / -`admin_reset_mfa`, which is **extended to also delete all WebAuthn credentials**. Deleting the -**last remaining second factor while MFA is required is refused** ("enroll another factor first"); -TOTP-disable keeps its existing behavior this lane (parity follow-up recorded). Documented +`admin_reset_mfa`, which is **extended to also delete all WebAuthn credentials**. Removing the +**last remaining second factor while MFA is required is refused** ("enroll another factor first"). +This lane guarded the passkey-delete route only, recording a parity follow-up for TOTP-disable; that +follow-up **landed under BACKLOG #1022** (in [`docs/BACKLOG.md`](../BACKLOG.md) until archived, then +`docs/archive/backlog/BACKLOG-CLOSED.md`), so `disable_mfa` now carries the same guard, keyed the +same way. The refusal is stated over the resulting **state**, not over one route: with the guard on +one side only, a user holding TOTP plus one passkey could delete the passkey (permitted while TOTP +remained) and then disable TOTP, reaching zero enrolled factors by ordering alone. Documented consequence: a passkey-only local user cannot satisfy the TOTP-shaped JSON `/auth/mfa-verify`, so desktop-console step-up actions become unavailable to them (enroll-page warning; owner-accepted). An extra-less install with enrolled credentials gets a **startup advisory** naming @@ -283,9 +288,14 @@ advisory-only, restated). - **AC-9** — WHEN any new `/ui` POST (enroll, verify, delete, `/ui/reauth/webauthn`) receives a cross-site request, THE SYSTEM SHALL reject it with 403. → `tests/test_webui.py::test_all_admin_posts_reject_cross_site` -- **AC-10** — IF deleting a WebAuthn credential would remove the user's last second factor WHILE MFA - is required for them, THEN THE SYSTEM SHALL refuse with "enroll another factor first". - → `tests/test_webauthn.py::test_last_factor_delete_refused_while_required` +- **AC-10** — IF removing a second factor — **either** deleting a WebAuthn credential **or** disabling + TOTP — would leave the user with no enrolled factor WHILE MFA is required for them, THEN THE SYSTEM + SHALL refuse with "enroll another factor first". Scoping this to the passkey route alone forbade one + *path* to the zero-factor state rather than the state itself, so the guard is stated over the state + and both removal orders are covered (BACKLOG #1022 widened it). + → `tests/test_webauthn.py::test_last_factor_delete_refused_while_required` (passkey route), + `tests/test_mfa.py::test_disable_mfa_refused_when_totp_is_the_last_factor` (TOTP route), + `tests/test_mfa.py::test_zero_factor_state_is_unreachable_by_either_removal_order` (the state) - **AC-11** — WHEN `admin_reset_mfa` runs, THE SYSTEM SHALL delete the user's WebAuthn credentials alongside TOTP state (existing session-revoke semantics unchanged). → `tests/test_webauthn.py::test_admin_reset_mfa_clears_webauthn_credentials` diff --git a/messagefoundry/api/auth_routes.py b/messagefoundry/api/auth_routes.py index c1a0a68a..f8ff4007 100644 --- a/messagefoundry/api/auth_routes.py +++ b/messagefoundry/api/auth_routes.py @@ -434,8 +434,12 @@ async def disable_my_mfa( ) -> SimpleMessage: """Self-service: turn off the caller's TOTP MFA. Step-up gated — you prove your current factor (a TOTP or recovery code via ``/auth/mfa-verify``) and a fresh password BOUND to this disable - action (ADR 0077): a hijacked session inside the login window can't silently strip MFA.""" - await service.disable_mfa(identity, client=_client(request)) + action (ADR 0077): a hijacked session inside the login window can't silently strip MFA. **400** + when TOTP is your last enrolled factor and MFA is still required (ADR 0068 AC-10).""" + try: + await service.disable_mfa(identity, client=_client(request)) + except ValueError as exc: # last-required-factor refusal — mirrors the confirm 400 above + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc return SimpleMessage(detail="MFA disabled") # --- self-service session inventory (WP-10, ASVS 7.5.2/7.4.5) ------------- diff --git a/messagefoundry/auth/service.py b/messagefoundry/auth/service.py index 70f2c50e..7f69702a 100644 --- a/messagefoundry/auth/service.py +++ b/messagefoundry/auth/service.py @@ -2139,8 +2139,28 @@ async def _verify_second_factor(self, user: UserRecord, code: str) -> bool: async def disable_mfa(self, identity: Identity, *, client: str | None = None) -> None: """Self-service: turn off the caller's TOTP MFA (the API gates this behind step-up). Audited + - the user is notified out-of-band (ASVS 6.3.7).""" + the user is notified out-of-band (ASVS 6.3.7). + + Raises :class:`ValueError` when TOTP is the caller's LAST enrolled second factor and MFA is + still required for them — the same refusal, keyed the same way, as + :meth:`delete_webauthn_credential` (ADR 0068 AC-10). The invariant is a property of the + resulting STATE, not of one removal route: guarding only the passkey path left zero enrolled + factors reachable by ordering, since a user holding TOTP plus one passkey could delete the + passkey (permitted while TOTP remained) and then disable TOTP unguarded. + + Deliberately NOT a TOTP-only check — it consults ``has_webauthn_credentials`` first, so a user + who keeps a passkey stays free to drop TOTP. Deliberately gated on ``totp_enabled`` — a + disable that removes nothing stays idempotent rather than refusing a no-op.""" user = await self._store.get_user(identity.user_id) + if user is not None and user.totp_enabled: + keeps_a_passkey = await self._store.has_webauthn_credentials(identity.user_id) + if not keeps_a_passkey and self._mfa_required_for( + user, identity.roles, second_factor_enrolled=False + ): + raise ValueError( + "this is your last second factor and MFA is required for your account — " + "enroll another factor first" + ) await self._store.disable_totp(identity.user_id) await self._audit( "auth.mfa_disabled", diff --git a/messagefoundry_webconsole/routes/account.py b/messagefoundry_webconsole/routes/account.py index 307f0913..4608ef3d 100644 --- a/messagefoundry_webconsole/routes/account.py +++ b/messagefoundry_webconsole/routes/account.py @@ -278,7 +278,15 @@ async def ui_mfa_disable( identity: Identity = Depends(require_ui_step_up_action(STEP_UP_ACTION_MFA_DISABLE)), ) -> Response: assert_same_origin(request) - await admin.disable_my_mfa(request=request, service=service, identity=identity) + try: + await admin.disable_my_mfa(request=request, service=service, identity=identity) + except HTTPException as exc: + # The seam's ONLY in-body raise is the last-required-factor refusal (ADR 0068 AC-10) — + # the step-up/CSRF gates above have already run. Render it on the account page exactly as + # ui_webauthn_delete renders the passkey twin, instead of leaking the API's JSON body. + return await _account_response( + service, identity, request, error=str(exc.detail), status_code=exc.status_code + ) return RedirectResponse("/ui/account?m=mfa_off", status_code=303) # --- L6b: self-service session management (#75 parity — desktop sessions.py twin) --- diff --git a/packaging/messagefoundry-webconsole/tests/test_webui.py b/packaging/messagefoundry-webconsole/tests/test_webui.py index e243678b..cb6c7614 100644 --- a/packaging/messagefoundry-webconsole/tests/test_webui.py +++ b/packaging/messagefoundry-webconsole/tests/test_webui.py @@ -3260,6 +3260,30 @@ async def test_disable_mfa_enforces_full_stepup_when_stale(engine: Engine) -> No assert user is not None and user.totp_enabled # still on — the gate held +async def test_disable_mfa_last_factor_refusal_renders_on_the_account_page( + engine: Engine, +) -> None: + # BACKLOG #1022: the console's disable action reaches the SAME seam the JSON DELETE /me/mfa does, + # so the last-factor refusal arrives as the route's HTTPException. It must render on the account + # page exactly as the passkey twin does (ui_webauthn_delete), not leak the API's JSON error body. + # RED when the except-HTTPException arm is dropped (the JSON detail surfaces) or when the service + # guard stops firing (303 to /ui/account?m=mfa_off — MFA stripped to zero enrolled factors). + service = AuthService(engine.store, AuthSettings()) # require_mfa on (secure default) + await service.initialize() + await _add(service, "op", Role.OPERATOR) + async with _client(engine, service) as c: + await _cookie_login(c, "op") + await _enroll_mfa(c, service, "op") + uid = await _uid(service, "op") + await _mint_action(c, "/ui/account/mfa/disable") + r = await c.post("/ui/account/mfa/disable", headers={"Sec-Fetch-Site": "same-origin"}) + assert r.status_code == 400 + assert "enroll another factor first" in r.text + assert r.headers["content-type"].startswith("text/html") + user = await service.store.get_user(uid) + assert user is not None and user.totp_enabled # still on — the guard held + + async def test_mfa_posts_reject_cross_site(engine: Engine) -> None: service = await _service(engine) await _add(service, "op", Role.OPERATOR) diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 91a92775..11ab7417 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -282,6 +282,29 @@ async def test_require_mfa_admin_is_not_bootstrap_locked_out(engine: Engine) -> assert status["enabled"] is True and status["required"] is True +async def test_delete_me_mfa_maps_the_last_factor_refusal_to_400(engine: Engine) -> None: + # BACKLOG #1022: the guard added to disable_mfa raises ValueError; without this mapping the route + # would 500. RED when the try/except around service.disable_mfa is dropped (500, not 400) or when + # the guard itself stops firing (200 — MFA silently stripped to zero enrolled factors). + service = await _service(engine, AuthSettings(login_rate_limit_enabled=False)) # require_mfa on + await _add(service, "adm", Role.ADMINISTRATOR) + async with _client(engine, service) as c: + tok = (await _login(c, "adm")).json()["token"] + await _reauth(c, tok, purpose="mfa_enroll") + secret = (await c.post("/me/mfa/enroll", headers=_auth(tok))).json()["secret"] + await _reauth(c, tok, purpose="mfa_confirm") + assert ( + await c.post("/me/mfa/confirm", json={"code": fresh_totp(secret)}, headers=_auth(tok)) + ).status_code == 200 + # Confirming marked the session MFA-verified, so the disable step-up is password-only. + assert (await _reauth(c, tok, purpose="mfa_disable")).status_code == 200 + + r = await c.request("DELETE", "/me/mfa", headers=_auth(tok)) + assert r.status_code == 400 + assert "enroll another factor first" in r.json()["detail"] + assert (await c.get("/me/mfa", headers=_auth(tok))).json()["enabled"] is True + + async def test_security_events_feed_payload_is_phi_free(engine: Engine) -> None: # The feed carries only non-PHI audit metadata (ts/action/detail) — never message bodies or # credential material. Mirrors the PHI-free assertion already made on the email-notification body. diff --git a/tests/test_mfa.py b/tests/test_mfa.py index 2b0eceae..c3278981 100644 --- a/tests/test_mfa.py +++ b/tests/test_mfa.py @@ -21,7 +21,7 @@ from messagefoundry.auth.notifications import MFA_DISABLED, MFA_ENABLED, SecurityEvent from messagefoundry.auth.service import AuthService from messagefoundry.config.settings import AuthSettings -from messagefoundry.store.store import MessageStore +from messagefoundry.store.store import MessageStore, WebAuthnCredential class _FakeNotifier: @@ -226,8 +226,17 @@ async def test_disable_and_admin_reset_clear_mfa(monkeypatch: pytest.MonkeyPatch store = await _store() try: notifier = _FakeNotifier() + # ``require_mfa=False`` is load-bearing, not incidental: this test's subject is that disable and + # admin-reset CLEAR the enrollment, and under the default (``require_mfa=True``, + # ``every_local_account``) the last-factor guard added for BACKLOG #1022 correctly refuses to + # strip the account's only factor. The refusal itself is covered below, under the default; here + # the documented opt-out puts the clear-semantics back in view. Enrolling a passkey instead + # would have been the other way to get here — rejected because it would have made this test + # depend on the optional [webauthn] extra. service = AuthService( - store, AuthSettings(mfa_recovery_code_count=2), security_notifier=notifier + store, + AuthSettings(mfa_recovery_code_count=2, require_mfa=False), + security_notifier=notifier, ) identity, token, _ = await _bootstrap_login(service) enroll = await service.begin_mfa_enrollment(identity) @@ -260,6 +269,132 @@ async def test_disable_and_admin_reset_clear_mfa(monkeypatch: pytest.MonkeyPatch await store.close() +# --- last-factor guard parity: TOTP-disable vs passkey-delete (BACKLOG #1022) --- +# +# Deliberately EXTRA-FREE (ADR 0068 section 4). The guard is a property of the account's factor state +# and has nothing to do with the optional ``[webauthn]`` extra, so the passkey side is staged through +# the ``AuthStore`` surface (the ``tests/_webauthn_store_contract.py`` precedent) rather than a real +# ceremony. A ``pytest.importorskip("webauthn")`` on this path would silently skip the guard on every +# leg that installs without the extra — a skip wearing a pass. + + +async def _stage_passkey(store: MessageStore, user_id: str, *, id_hash: str) -> None: + await store.add_webauthn_credential( + WebAuthnCredential( + credential_id_hash=id_hash, + credential_id=f"cred-{id_hash}", + user_id=user_id, + rp_id="t", + public_key="cose-public-key-b64url", + sign_count=0, + transports=None, + device_type="multi_device", + backed_up=True, + label=id_hash, + aaguid="aaguid-0000", + created_at=1000.0, + last_used_at=None, + ) + ) + + +async def _enable_totp(service: AuthService, identity: Identity, token: str, at: float) -> None: + enroll = await service.begin_mfa_enrollment(identity) + await service.confirm_mfa_enrollment(identity, totp.totp(enroll.secret, now=at), token=token) + assert (await service.mfa_status(identity)).enabled is True + + +async def test_disable_mfa_refused_when_totp_is_the_last_factor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # BACKLOG #1022 / ADR 0068 AC-10: TOTP-disable now refuses exactly where passkey-delete does. + # RED when disable_mfa stops consulting has_webauthn_credentials + _mfa_required_for. + store = await _store() + try: + service = AuthService(store, AuthSettings()) # require_mfa on by default + identity, token, _ = await _bootstrap_login(service) + pin_totp_clock(monkeypatch, 1_000_000.0) + await _enable_totp(service, identity, token, 1_000_000.0) + + with pytest.raises(ValueError, match="enroll another factor first"): + await service.disable_mfa(identity) + assert (await service.mfa_status(identity)).enabled is True # still on — the guard held + finally: + await store.close() + + +async def test_disable_mfa_allowed_while_a_passkey_remains( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The guard must NOT be a TOTP-only check: a user who keeps a passkey is still enrolled and stays + # free to drop TOTP. RED when the guard degenerates into "an enrolled user keeps TOTP". + store = await _store() + try: + service = AuthService(store, AuthSettings()) + identity, token, _ = await _bootstrap_login(service) + pin_totp_clock(monkeypatch, 1_000_000.0) + await _enable_totp(service, identity, token, 1_000_000.0) + await _stage_passkey(store, identity.user_id, id_hash="pk1") + + await service.disable_mfa(identity) + assert (await service.mfa_status(identity)).enabled is False + assert await store.has_webauthn_credentials(identity.user_id) is True + finally: + await store.close() + + +async def test_disable_mfa_allowed_when_mfa_is_not_required( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The guard keys on _mfa_required_for, exactly as the passkey path does, so the documented + # ``[auth].require_mfa = false`` opt-out still lets a voluntarily-enrolled user turn TOTP off. + store = await _store() + try: + service = AuthService(store, AuthSettings(require_mfa=False)) + identity, token, _ = await _bootstrap_login(service) + pin_totp_clock(monkeypatch, 1_000_000.0) + await _enable_totp(service, identity, token, 1_000_000.0) + + await service.disable_mfa(identity) + assert (await service.mfa_status(identity)).enabled is False + finally: + await store.close() + + +async def test_zero_factor_state_is_unreachable_by_either_removal_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The item's actual claim, and the reason the guard is stated over the STATE rather than over one + route: with TOTP plus one passkey enrolled, EITHER removal is permitted first and the SECOND one is + refused. Before the guard, the passkey-then-TOTP order reached zero enrolled factors while the + TOTP-then-passkey order did not — the same end state, allowed or refused purely by ordering.""" + store = await _store() + try: + service = AuthService(store, AuthSettings()) # require_mfa on + identity, token, _ = await _bootstrap_login(service) + pin_totp_clock(monkeypatch, 1_000_000.0) + + # Order A — passkey first, then TOTP. This is the ordering that used to reach zero factors. + await _enable_totp(service, identity, token, 1_000_000.0) + await _stage_passkey(store, identity.user_id, id_hash="pk-a") + assert await service.delete_webauthn_credential(identity, "pk-a") is True # TOTP remains + with pytest.raises(ValueError, match="enroll another factor first"): + await service.disable_mfa(identity) + assert (await service.mfa_status(identity)).enabled is True + + # Order B — TOTP first, then the passkey. Refused at the second step, as it always was. + await _stage_passkey(store, identity.user_id, id_hash="pk-b") + await service.disable_mfa(identity) # a passkey remains, so this is permitted + with pytest.raises(ValueError, match="enroll another factor first"): + await service.delete_webauthn_credential(identity, "pk-b") + + # Whichever order was taken, exactly one factor survives. + status = await service.mfa_status(identity) + assert (status.enabled, status.webauthn_enrolled) == (False, True) + finally: + await store.close() + + async def test_ad_login_is_mfa_satisfied_by_delegation() -> None: store = await _store() try: From e3c8276eed96aad9d828f2d12cc068431c67fdc9 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 18:06:58 -0500 Subject: [PATCH 2/4] feat(auth)!: retire the implicit first-run bootstrap Administrator; add `messagefoundry admin-create` (BACKLOG #1020) #1020 asked for the first-run bootstrap Administrator to carry a deliverable email so the PHI notification gate could see it. ASVS 6.3.2 wants that account NOT TO EXIST, and both could not land. Under the owner ruling of 2026-08-10 the stricter end state wins: the account is retired, so #1020 closes as SUPERSEDED rather than as built -- an account that does not exist needs no mailbox. THE REMOVAL WAS GATED ON A REPLACEMENT PATH, AND NONE SHIPPED. Searched by construct, not assumed: no `users`/`admin` CLI subcommand exists in `_DISPATCH`; `create_local_user` is reachable only through `POST /users` / the console under `users:manage`, which needs an account already; and the directory paths cannot bootstrap either, because `roles_for_ad_groups` reads an AD group map that lives ONLY in the store and is written by `set_ad_group_map` -- itself an authenticated admin action. A fresh store had exactly one route to a first administrator, and it was the implicit one. So the smallest explicit route is built in the same change. `messagefoundry admin-create --username ` opens the engine's own resolved store and creates a local Administrator: password from a no-echo confirmed prompt or `--password-stdin`, NEVER an argv flag (argv is readable by other accounts on the box); held to the deployment's own `[auth]` policy via `password_violations`, so the CLI is not a laxer second path into the same account store; audited `user.created`; `must_change_password` clear, because the operator standing at the box chose their own password and there is no second party a forced rotation would protect. It prints the store it actually wrote, so a `--db` / `MEFOR_STORE_*` typo surfaces now rather than as a login failure later. Removed with the account, because each existed only to manage its lifetime: `bootstrap-admin.txt` and `_emit_bootstrap_admin` (with it, the cleartext-credential-at-rest risk ADR 0034 accepted -- that row is now marked withdrawn rather than deleted, so the register can still be reconciled against a re-scan); `_ensure_bootstrap_admin`, `_retire_superseded_bootstrap`, `bootstrap_expiry_warning` and the `BOOTSTRAP_USERNAME` login carve-outs; `[auth]` bootstrap_expiry_hours / bootstrap_warn_hours; and the `bootstrap_admin_expiring` alert event (rule-targetable event types 18 -> 17). `initialize()` now seeds roles and returns None. The ASVS 6.4.1 initial-password gate loses its bootstrap exemption, which is a strengthening: the carve-out is no longer a username special case, and every unclaimed admin-issued temp is held to `initial_password_expiry_hours`. The first administrator is exempt for the right reason -- its `must_change_password` is clear because the operator set the password. The IDE moves with it. `statusBar.ts`'s store-less fork confirm, the setup-page copy and the two tests pinning them said "creates a NEW database and a bootstrap admin"; that is now false, and a shipped modal promising an account the engine will not create is worse than the fork it guards. They now say "a NEW empty database with no accounts". ADRs 0110 and 0112 carry dated amendments rather than edits -- their decisions stand, two factual premises under them did not. Nothing is deployed, so there is no migration to stage and no compatibility shim to write; a deploying site would simply run one command it does not run today. Red-first, both directions: tests/test_admin_create_cli.py (10) drives a FRESH store to an authenticated session that reaches the `users:manage`-gated GET /users, and separately asserts that two `initialize()` calls leave zero users and no `admin` account -- the half that is easy to lose, since a reintroduced bootstrap would leave every other test in the suite green. Its env is pinned: measured 2026-08-10, an ambient MEFOR_STORE_PATH plus MEFOR_AUTH_PASSWORD_MIN_LENGTH failed 7 of those 10, and test_the_env_pin_is_load_bearing_and_the_hazard_is_real proves both that the pin clears the prefix and that the redirect it pins against is real. tests/test_bootstrap_admin_perms.py is deleted: it asserted the 0600/O_EXCL/symlink-refusal properties of a credential file that is no longer written anywhere. `_CONTEXT_TABLE_A_ROWS` moves 35 -> 34 in the same commit as the SECURITY.md row it counts -- that gate exists to catch an undeclared row deletion, and it caught this one. --- docs/ANTIVIRUS-FIREWALL.md | 9 +- docs/CONFIGURATION.md | 8 +- docs/EARLY-ADOPTER-GUIDE.md | 24 +- docs/PHI.md | 4 +- docs/SECURITY.md | 60 ++-- docs/VERSION-CONTROL.md | 2 +- ...is-triage-policy-accepted-risk-register.md | 4 +- ...ells-the-truth-about-the-promote-target.md | 17 + ...tus-bar-pill-guarded-start-stop-restart.md | 13 + docs/adr/README.md | 4 +- docs/testing/FEATURE-COVERAGE-PLAN.md | 2 +- docs/testing/WIN2025-TEST-PLAN.md | 3 +- .../01-environments-data-and-tooling.md | 3 +- .../10-auth-rbac-and-active-directory.md | 12 +- .../12-vs-code-ide-extension.md | 7 +- .../15-alerting-and-observability.md | 2 +- ide/src/engineControlModel.ts | 2 +- ide/src/engineSetupContent.ts | 4 +- ide/src/engineStatusModel.ts | 2 +- ide/src/statusBar.ts | 12 +- ide/src/test/suite/engine-setup.test.ts | 8 +- ide/src/test/suite/engine-status.test.ts | 2 +- messagefoundry/__main__.py | 142 +++++++++ messagefoundry/api/app.py | 105 +------ messagefoundry/auth/service.py | 160 ++-------- messagefoundry/config/settings.py | 22 +- messagefoundry/pipeline/alert_sinks.py | 15 - messagefoundry/pipeline/alerts.py | 21 -- messagefoundry/scaffold.py | 2 - .../tests/test_ui_mfa_gate.py | 17 +- tests/_first_admin.py | 44 +++ tests/test_admin_create_cli.py | 294 ++++++++++++++++++ tests/test_admin_new_ip.py | 6 +- tests/test_alert_rules.py | 14 - tests/test_alert_sinks.py | 36 --- tests/test_asvs_phase0.py | 8 +- tests/test_auth_hardening.py | 121 +++---- tests/test_auth_service.py | 267 +++------------- tests/test_bootstrap_admin_perms.py | 77 ----- tests/test_last_admin_guard.py | 20 +- tests/test_mfa.py | 18 +- tests/test_scaffold.py | 4 - tests/test_security_doc_drift.py | 14 +- tests/test_security_doc_rate_limits.py | 12 +- tests/test_settings.py | 1 - tests/test_webauthn.py | 13 +- 46 files changed, 776 insertions(+), 861 deletions(-) create mode 100644 tests/_first_admin.py create mode 100644 tests/test_admin_create_cli.py delete mode 100644 tests/test_bootstrap_admin_perms.py diff --git a/docs/ANTIVIRUS-FIREWALL.md b/docs/ANTIVIRUS-FIREWALL.md index 20e15a45..650fdae0 100644 --- a/docs/ANTIVIRUS-FIREWALL.md +++ b/docs/ANTIVIRUS-FIREWALL.md @@ -28,7 +28,6 @@ If an exclusion or rule is not justified below for *your* deployment, do not add | Rollback journal | **Does not exist** — the store runs `PRAGMA journal_mode=WAL` unconditionally (no setting disables it), so no `-journal` sidecar appears in any supported deployment | | Logs | `C:\ProgramData\MessageFoundry\logs\service.out.log`, `...\service.err.log` (rotated at ~10 MiB) | | API bind (default) | `127.0.0.1:8765` (loopback) | -| Bootstrap secret | `C:\ProgramData\MessageFoundry\bootstrap-admin.txt` (owner-only `0o600`, alongside the DB) | > The service you see in `services.msc` and Task Manager is `messagefoundry.exe`, but the process that actually owns the listening/connecting sockets is the venv **`python.exe`** it launches. That distinction matters for program-scoped firewall rules below. @@ -52,9 +51,8 @@ Exclude these specific paths (substitute your real `DataDir`, repo path, and con | `C:\ProgramData\MessageFoundry\messagefoundry.db` | SQLite message store | Live, constantly-written queue/inbox/outbox | Lock contention, **WAL corruption**, quarantine → engine fail-closed | | `C:\ProgramData\MessageFoundry\messagefoundry.db-wal` | WAL sidecar | Write-ahead log, written on every commit | Corruption of in-flight messages; lost/duplicated delivery | | `C:\ProgramData\MessageFoundry\messagefoundry.db-shm` | Shared-memory index | WAL coordination file | WAL breakage; failed opens | -| `C:\ProgramData\MessageFoundry` (DataDir folder) | Data dir | Holds DB trio, `bootstrap-admin.txt`, etc. | Misc. data-at-rest quarantine | +| `C:\ProgramData\MessageFoundry` (DataDir folder) | Data dir | Holds the DB trio, etc. | Misc. data-at-rest quarantine | | `C:\ProgramData\MessageFoundry\logs\` | Service logs | High-frequency append + rotation | Rotation failures, scan thrash | -| `C:\ProgramData\MessageFoundry\bootstrap-admin.txt` | One-time bootstrap admin secret (`0o600`) | Owner-only secret, written next to the DB | **Also exclude from AV cloud-sample upload** — never let it leave the host | | **`[store].encryption_key_file`** (operator-configured path — **may sit outside DataDir**) | DPAPI-wrapped store encryption key (for `key_provider=dpapi`/`auto`) | Decrypted into memory at startup to unlock the store | **Quarantine/lock → `DpapiError` (fail-closed) → store key cannot be provisioned → engine won't start and PHI at rest is unreadable.** Exclude its *actual* path. | | Connector **key/cert files** referenced by path (operator-configured, **may be anywhere on disk**) | JWS signing key (`signing.py` `private_key`), SMART Backend Services key (`smart_private_key` PEM file path), SOAP mTLS `client_cert_file` / `client_key_file` | Read on demand for outbound signing / SMART token / mTLS | Quarantine → broken outbound **signing / SMART auth / mutual-TLS delivery** to those partners. *(Inline-PEM-via `env()` deployments have no file to exclude.)* | | File-connector **inbound (source) directory** + its `.processed` and `.error` subdirs | Watched intake dir (default poll **1.0 s**) and move-aside targets | Constant directory polling + file moves | Scan thrash, file-move races, quarantined intake | @@ -103,7 +101,6 @@ Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\messagefoundry.db Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\messagefoundry.db-wal' Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\messagefoundry.db-shm' Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\logs' -Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\bootstrap-admin.txt' Add-MpPreference -ExclusionPath 'C:\Path\To\MessageFoundry\.venv' Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\bin\nssm.exe' # DPAPI store key + connector key/cert files — exclude their ACTUAL configured paths: @@ -119,7 +116,7 @@ Add-MpPreference -ExclusionProcess 'C:\Path\To\MessageFoundry\.venv\Scripts\mess Add-MpPreference -ExclusionProcess 'C:\ProgramData\MessageFoundry\bin\nssm.exe' ``` -**Third-party EDR (CrowdStrike, SentinelOne, Defender for Endpoint, Sophos, etc.):** create the equivalent **file/folder exclusions** and **process/executable exclusions** for the same paths and processes through your management console, and ensure `bootstrap-admin.txt` and any key/cert files are excluded from **cloud sample submission / upload**, not just on-access scanning. +**Third-party EDR (CrowdStrike, SentinelOne, Defender for Endpoint, Sophos, etc.):** create the equivalent **file/folder exclusions** and **process/executable exclusions** for the same paths and processes through your management console, and ensure any key/cert files are excluded from **cloud sample submission / upload**, not just on-access scanning. --- @@ -209,7 +206,7 @@ After applying exclusions and firewall rules: - [ ] **API health on loopback** — `Invoke-WebRequest http://127.0.0.1:8765/health` succeeds **without** any inbound firewall rule (proves loopback needs none). - [ ] **MLLP round-trip** — send a synthetic test message to a configured inbound port and confirm an `AA` ACK and a `PROCESSED` disposition. - [ ] **DB sidecars intact** — `messagefoundry.db`, `messagefoundry.db-wal`, and `messagefoundry.db-shm` all present and untouched; no `-journal` file (expected — WAL mode). -- [ ] **Clean quarantine log** — AV/EDR quarantine history shows **no** MessageFoundry DB, sidecar, key/cert, `bootstrap-admin.txt`, or interpreter detections. +- [ ] **Clean quarantine log** — AV/EDR quarantine history shows **no** MessageFoundry DB, sidecar, key/cert, or interpreter detections. - [ ] **Outbound reaches partners** — a test delivery to each configured downstream succeeds, and each is covered by both a firewall rule **and** the matching `[egress]` allowlist entry. --- diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 300238c1..2d69331b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -551,9 +551,7 @@ document ([SECURITY-DOCS-POLICY.md](SECURITY-DOCS-POLICY.md)). | `password_breach_corpus_file` | path | — | optional path to a **larger offline breach corpus** that augments the bundled top-10k list (ASVS 6.2.12): a plaintext list **or** an HIBP-style SHA-1 hash export (`HASH[:count]` lines, auto-detected). Fully offline — still no live HIBP call. Use a curated subset, not the full ~40 GB HIBP set (it is loaded into memory). A path, not a secret | | `lockout_threshold` | int | 5 | failed logins before lock (per account) | | `lockout_minutes` | int | 15 | lockout duration | -| `bootstrap_expiry_hours` | int | 72 | the first-run bootstrap admin is auto-disabled once a second administrator exists, and — while still unclaimed (never password-changed) — this many hours after creation. `0` = no time expiry | -| `bootstrap_warn_hours` | int | 24 | **(ASVS 6.4.5):** how long *before* that deadline to remind an operator that the still-unclaimed bootstrap credential is about to be retired — a **`bootstrap_admin_expiring`** [`[alerts]`](#alerts) event, raised once while `now` is inside `[expiry − this, expiry)`. Advisory only (it disables nothing); meaningful only when `bootstrap_expiry_hours > 0`. The deadline itself is also written into `bootstrap-admin.txt` at issuance. | -| `initial_password_expiry_hours` | int | 72 | **(ASVS 6.4.1):** an admin-issued initial/reset credential (a `must_change_password` temp password) that is never claimed **expires** this many hours after it was set. Without it an unused reset password grants an authenticated session indefinitely — and the one action it permits is to *set the password*, i.e. account takeover. Keyed on `password_changed_at`; a user who set their own password has `must_change_password = false` and is unaffected, and the bootstrap admin has its own `bootstrap_expiry_hours` path (exempt). `0` = no expiry (not recommended on a PHI instance) | +| `initial_password_expiry_hours` | int | 72 | **(ASVS 6.4.1):** an admin-issued initial/reset credential (a `must_change_password` temp password) that is never claimed **expires** this many hours after it was set. Without it an unused reset password grants an authenticated session indefinitely — and the one action it permits is to *set the password*, i.e. account takeover. Keyed on `password_changed_at`; a user who set their own password has `must_change_password = false` and is unaffected — including the first administrator, which `messagefoundry admin-create` creates with the operator's own password. `0` = no expiry (not recommended on a PHI instance) | | `login_rate_limit_enabled` | bool | `true` | in-process sliding-window limiter on the **sign-in surface** — `/auth/login`, `/auth/negotiate`, `/auth/mfa-verify` plus the four console entry routes (`POST /ui/login`, `GET /ui/sso`, `GET /ui/oidc/start`, `GET /ui/oidc/callback`) — in front of the per-account lockout. The **same flag** also constructs the per-actor **credential-ceremony** limiter covering `/me/password`, `/me/reauth`, `/me/mfa/confirm` (+ the console re-auth routes); turning it off removes **both** (see [SECURITY.md](SECURITY.md) "Route → limiter map"). | | `login_rate_limit_per_ip` | int | 10 | max attempts per client IP per window (`0` disables). **One number, two limiters:** it is also the per-**actor** budget of the credential-**ceremony** limiter (`/me/password`, `/me/reauth`, `/me/mfa/confirm` + the console re-auth routes) — the `_per_ip` name is historical, and retuning it retunes both | | `login_rate_limit_global` | int | 60 | max attempts across all clients per window (`0` disables). Sign-in window only — the ceremony limiter has **no** global dimension (`glob=0`) | @@ -1032,7 +1030,7 @@ silences an event you didn't name. Matching is pure config (no code/`eval`). | Key | Type | Default | Notes | |---|---|---|---| -| `event_type` | str | `any` | match this event. The validator (`AlertRule._check_event_type`) accepts `any` plus exactly these **18**, and **rejects anything else at config load**, so a typo is loud rather than a rule that never matches: `backup_failed`, `bootstrap_admin_expiring`, `cert_expiry`, `connection_error`, `connection_stopped`, `content_match`, `dr_activated`, `gcm_invocations`, `integrity_drift`, `lane_stuck`, `leadership_acquired`, `message_stall`, `queue_buildup`, `rcsi_off_degraded`, `saturation`, `secret_rotation`, `storage_threshold`, `update_available`. Note the **event names are shorter than the prose names** used elsewhere in this file — the secret-rotation reminder is routed as `secret_rotation`, not `secret_rotation_due` | +| `event_type` | str | `any` | match this event. The validator (`AlertRule._check_event_type`) accepts `any` plus exactly these **17**, and **rejects anything else at config load**, so a typo is loud rather than a rule that never matches: `backup_failed`, `cert_expiry`, `connection_error`, `connection_stopped`, `content_match`, `dr_activated`, `gcm_invocations`, `integrity_drift`, `lane_stuck`, `leadership_acquired`, `message_stall`, `queue_buildup`, `rcsi_off_degraded`, `saturation`, `secret_rotation`, `storage_threshold`, `update_available`. Note the **event names are shorter than the prose names** used elsewhere in this file — the secret-rotation reminder is routed as `secret_rotation`, not `secret_rotation_due` | | `connection` | str (glob) | `*` | glob over the connection name (e.g. `OB_*`, `IB_ACME_*`) | | `min_depth` | int | _unset_ | `queue_buildup` only — match only when pending depth is at/over this | | `min_oldest_seconds` | num | _unset_ | `queue_buildup` only — …or the oldest pending message has waited at least this long | @@ -1496,7 +1494,7 @@ and a PHI weakening under **strict enforcement** (`enforcement = enforce`, the d | `encrypt_stored_data` | bool | `true` | PHI encrypted at rest (key from the environment) | | `allow_unencrypted_phi` | bool | `false` | audited escape: start a PHI instance with **no** key | | `allow_unencrypted_phi_under_strict_enforcement` | bool | `false` | the **second acknowledgment** required to start a PHI instance keyless under strict enforcement ([ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md)). Under `enforcement = enforce`, `allow_unencrypted_phi = true` on its own is **not** enough — `serve` still refuses to start (exit 2) unless this is also set, so the highest-risk posture (real PHI + strict enforcement) is never one flag away from plaintext at rest. Under `enforcement = warn` the single `allow_unencrypted_phi` flag still governs. With both set the instance starts with PHI bodies, summary/metadata and the error columns **unencrypted at rest**, and the startup AUDIT line names **both** flags. A **loosening** — `security_loosenings()` reports it, so it is never silent | -| `allow_single_factor_admin_when_exposed` | bool | `false` | permit **single-factor admin on an exposed PHI instance** (ADR 0140). With `require_sign_in` on, `require_mfa` explicitly off, and the instance exposed — a **non-loopback bind**, **or** a declared TLS-terminating proxy (`[api].tls_terminated_upstream`) — a PHI instance under `enforcement = enforce` **refuses to start** (exit 2) — the Administrator role would authenticate with a single factor over the network. Setting this permits that start; it is recorded in a WARNING-level AUDIT line and the ordinary exposure warning still prints. A **loosening** — `security_loosenings()` reports it. **The exposure test does not consult the browser console** ([BACKLOG #326](archive/backlog/BACKLOG-CLOSED.md#326-mfa-at-exposure-refusal-reads-serve_ui-after-it-is-flipped-off); ADR 0140 amendment). It did, and that made the arm miss the topology this document recommends: a loopback bind behind a declared terminator with `serve_web_console` left at its default, where the ADR 0143 auto-degrade clears the console flag in place before the gate reads it. The exposed surface that authenticates with one factor is the **JSON operator API**, which the proxy serves whether or not `/ui` is mounted, so the predicate is the bind-and-proxy posture alone and the refusal fires on at least: an off-loopback bind; a declared proxy with the console left default-on; and a declared proxy with `serve_web_console = false`. **This refusal is the one exception to the "a new refusal fires only on a new opt-in" scoping rule** stated three rows below on `require_memory_encryption_declaration` — by owner ruling of 2026-08-04, recorded in the [ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment, which is the single source for why. Nothing new gates it. **One residual is deliberately left open:** an **undeclared** proxy — `web_console_public_address` set with no `tls_terminated_upstream` — does not count as exposed here, because nothing was declared, so exposure would be an *inference*, and an inference must not refuse. It **warns** instead, on its own dedicated arm: on a PHI instance with `require_mfa` explicitly off, startup prints that if that origin is served by an undeclared proxy the Administrator role is single-factor over the network and this refusal cannot see it. Do **not** read the ADR 0068 §8 undeclared-proxy warning as that control — it is about the `/ui` session cookie and HSTS, and it is suppressed entirely when the ADR 0143 auto-degrade clears the console flag, which the same `web_console_public_address` triggers. **Prefer `require_mfa = true` — and know its scope.** Under the shipped `require_mfa_scope = "every_local_account"` it requires a second factor from **every** local account, *not* only Administrators, so a non-interactive **local** bearer-token service account becomes MFA-pending and cannot enrol unattended. **There are two remedies, not three.** Either make it a **directory (AD/Kerberos) principal** — those are out of scope under either value, their factor delegated to the directory — or set `require_mfa_scope = "administrators"` (itself reported as a loosening, and it leaves every local Administrator in scope) — see that row below. **mTLS is *not* the third.** A `[api].tls_client_cert_identities` mapping does grant a cert-identity that never meets the MFA gate, but that plane is admitted on exactly **one** route (`GET /service/identity`, `require_service_cert`) and carries no session, so an account "moved to mTLS" can read back its own identity and nothing else — it cannot replay, purge, poll status, or do any work a service account exists for. The `[api].tls_client_cert_identities` row above is the authority on that reach. Directory identities being out of scope also means an AD-only deployment is safe **for its AD users**; its local bootstrap admin and any local service accounts are still in scope | +| `allow_single_factor_admin_when_exposed` | bool | `false` | permit **single-factor admin on an exposed PHI instance** (ADR 0140). With `require_sign_in` on, `require_mfa` explicitly off, and the instance exposed — a **non-loopback bind**, **or** a declared TLS-terminating proxy (`[api].tls_terminated_upstream`) — a PHI instance under `enforcement = enforce` **refuses to start** (exit 2) — the Administrator role would authenticate with a single factor over the network. Setting this permits that start; it is recorded in a WARNING-level AUDIT line and the ordinary exposure warning still prints. A **loosening** — `security_loosenings()` reports it. **The exposure test does not consult the browser console** ([BACKLOG #326](archive/backlog/BACKLOG-CLOSED.md#326-mfa-at-exposure-refusal-reads-serve_ui-after-it-is-flipped-off); ADR 0140 amendment). It did, and that made the arm miss the topology this document recommends: a loopback bind behind a declared terminator with `serve_web_console` left at its default, where the ADR 0143 auto-degrade clears the console flag in place before the gate reads it. The exposed surface that authenticates with one factor is the **JSON operator API**, which the proxy serves whether or not `/ui` is mounted, so the predicate is the bind-and-proxy posture alone and the refusal fires on at least: an off-loopback bind; a declared proxy with the console left default-on; and a declared proxy with `serve_web_console = false`. **This refusal is the one exception to the "a new refusal fires only on a new opt-in" scoping rule** stated three rows below on `require_memory_encryption_declaration` — by owner ruling of 2026-08-04, recorded in the [ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment, which is the single source for why. Nothing new gates it. **One residual is deliberately left open:** an **undeclared** proxy — `web_console_public_address` set with no `tls_terminated_upstream` — does not count as exposed here, because nothing was declared, so exposure would be an *inference*, and an inference must not refuse. It **warns** instead, on its own dedicated arm: on a PHI instance with `require_mfa` explicitly off, startup prints that if that origin is served by an undeclared proxy the Administrator role is single-factor over the network and this refusal cannot see it. Do **not** read the ADR 0068 §8 undeclared-proxy warning as that control — it is about the `/ui` session cookie and HSTS, and it is suppressed entirely when the ADR 0143 auto-degrade clears the console flag, which the same `web_console_public_address` triggers. **Prefer `require_mfa = true` — and know its scope.** Under the shipped `require_mfa_scope = "every_local_account"` it requires a second factor from **every** local account, *not* only Administrators, so a non-interactive **local** bearer-token service account becomes MFA-pending and cannot enrol unattended. **There are two remedies, not three.** Either make it a **directory (AD/Kerberos) principal** — those are out of scope under either value, their factor delegated to the directory — or set `require_mfa_scope = "administrators"` (itself reported as a loosening, and it leaves every local Administrator in scope) — see that row below. **mTLS is *not* the third.** A `[api].tls_client_cert_identities` mapping does grant a cert-identity that never meets the MFA gate, but that plane is admitted on exactly **one** route (`GET /service/identity`, `require_service_cert`) and carries no session, so an account "moved to mTLS" can read back its own identity and nothing else — it cannot replay, purge, poll status, or do any work a service account exists for. The `[api].tls_client_cert_identities` row above is the authority on that reach. Directory identities being out of scope also means an AD-only deployment is safe **for its AD users**; its local administrator accounts and any local service accounts are still in scope | | `allow_unverified_alert_smtp_tls` | bool | `false` | the **acknowledgment** required to start an enforcing PHI instance whose `[alerts]` SMTP hop does not authenticate the relay — i.e. `[alerts].email_use_tls = false` (cleartext) or `[alerts].email_tls_verify = false` (encrypted but accepts any certificate) ([#323](archive/backlog/BACKLOG-CLOSED.md#323-smtp-tls-is-unverified-on-all-three-send-paths)). Covers BOTH shapes deliberately: cleartext is strictly worse than unauthenticated TLS, so gating only the second would hand an operator a bypass onto the worse posture. Without it `serve` refuses to start (exit 2); with it the start is permitted and named in a WARNING-level `AUDIT:` line. An **acknowledgment switch rather than the clamped `MEFOR_ALLOW_INSECURE_TLS` escape** the connectors use, because this cell is constructed outside the `active_hop_posture` scope where that clamp would be inert. A **loosening** — `security_loosenings()` reports it, so it is never silent | | `memory_encryption_operator_declared` | bool | `false` | **`[BUILT]` ([ADR 0152](adr/0152-in-use-data-protection-for-phi-platform-memory-encryption-attestation-asvs-11-7-1.md) rung 2, ASVS 11.7.1):** the operator's **declaration** that this host provides hardware memory encryption (AMD SEV-SNP / Intel TDX), so PHI is protected in RAM **while it is being processed**. The engine cannot verify it — a local CPU flag is emitted by the OS whose integrity the requirement protects against — so this records **who took responsibility**, the same discipline as `MEFOR_TLS_REVOCATION_ATTESTED`. It is deliberately **not** called "attested": in confidential computing that word means a CPU-signed quote verified against the silicon vendor's root PKI (ADR 0152 rung 3, **not built**). An **exposed** PHI instance without it **warns and starts** — on every environment, at both `enforcement` settings; it refuses only if `require_memory_encryption_declaration` is also set. A **positive platform read-out does not substitute for it** (a read-out must never relax a control). **Loopback and synthetic instances are byte-identical** (never consulted). If the platform read-out positively contradicts this, the contradiction is **warned at start and reported** as `memory_encryption_readout_contradicts_declaration` on `GET /security/posture` — but **never refused** (the read-out is a self-report, not evidence, and has known false negatives: driver not loaded, container without the device node mapped, Azure CVM paravisor). **Setting this does not make the instance ASVS 11.7.1-compliant** — see the read-out note below the table. Env: `MEFOR_SECURITY_MEMORY_ENCRYPTION_OPERATOR_DECLARED` | | `require_memory_encryption_declaration` | bool | `false` | **`[BUILT]` (ADR 0152 rung 2):** turn the row-12 warning above into a **refusal** — an **exposed** PHI instance with no `memory_encryption_operator_declared` then **refuses to start** under `enforcement=enforce` (and still warns under `warn`). **Opt-in by design, and the default is load-bearing:** the property is a **host** property that no operator can satisfy on Windows (the read-out is always `null` there), and "exposed" includes the recommended loopback-behind-proxy topology, so a refusal by default would stop working dev/staging/prod deployments from booting on upgrade over something they cannot change. Same scoping rule as `[security].allowed_client_networks`' companion refusal (ADR 0151): a new refusal fires only on a new opt-in. **One exception exists, and it is recorded:** the `allow_single_factor_admin_when_exposed` refusal three rows above was corrected under BACKLOG #326 and fires with no new opt-in gating it — see that row and the [ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment for the reasoning; do not generalise it. Set it in an estate that has standardized on confidential-computing hosts and wants a missing declaration to be fatal. Env: `MEFOR_SECURITY_REQUIRE_MEMORY_ENCRYPTION_DECLARATION` | diff --git a/docs/EARLY-ADOPTER-GUIDE.md b/docs/EARLY-ADOPTER-GUIDE.md index 93afcd69..c380152c 100644 --- a/docs/EARLY-ADOPTER-GUIDE.md +++ b/docs/EARLY-ADOPTER-GUIDE.md @@ -291,18 +291,20 @@ account. Service defaults: name `MessageFoundry`, data dir `C:\ProgramData\Messa > upgrade is an explicit, reviewable act. *(A contributor running the **editable** install instead > serves whatever branch is checked out — treat that checkout as the release artifact; see §13.)* -### 4.5 First-run admin bootstrap +### 4.5 Create the first administrator -Auth is **enabled by default**. On the first start against an empty store, MEFOR creates a one-time -bootstrap admin (`admin`) and writes its password to an **owner-only `bootstrap-admin.txt`** next to -the store (only the file *location* is logged — never the password). Then: +Auth is **enabled by default**, and a fresh store has **no accounts at all** — no default username, no +generated password, nothing written to disk (ASVS 6.3.2). Create the first administrator yourself, on +the server: -1. Log in as `admin`; you are **forced to change the password** on first use. -2. **Create a second real administrator** promptly. -3. **Delete `bootstrap-admin.txt`.** +1. `messagefoundry admin-create --username --email ` — it prompts for the + password (no echo, confirmed) and holds it to your `[auth]` policy. +2. Sign in to the console at `/ui` with it. +3. **Create a second administrator** promptly, so no single lost credential strands the estate. -The bootstrap account auto-retires once a second admin exists, or — while still unclaimed — 72h after -creation. +The command works whether the engine is running or stopped, and it is also the recovery path if every +administrator is ever locked out — which is why the server and the store file need the same protection +as any credential. ### 4.6 Verify it runs @@ -392,8 +394,8 @@ Full references: **[SECURITY.md](SECURITY.md)**, **[PHI.md](PHI.md)**, and **[DE app-encrypted and rely on volume encryption. - [ ] **Run under a least-privilege account** (the virtual account from §4.4) and lock down the store directory and any File-connector spill directories. **Treat backups as PHI.** -- [ ] **Finish the bootstrap-admin handoff** (§4.5): change the password, create a second admin, - delete `bootstrap-admin.txt`. +- [ ] **Create the first administrator and a second one** (§4.5), each with an email address so the + out-of-band security notices have a mailbox. - [ ] **For Active Directory:** use **LDAPS** with a trusted CA, never set `MEFOR_ALLOW_INSECURE_TLS` in production, and configure the directory's lockout/complexity policy (the engine's account lockout covers local accounts only). AD/Entra MFA is enforced by your directory; **local diff --git a/docs/PHI.md b/docs/PHI.md index 7e5f9edb..299bf0b7 100644 --- a/docs/PHI.md +++ b/docs/PHI.md @@ -969,7 +969,7 @@ with materially different PHI profiles, so they get their own rows; stream 4 is | **5. `audit_log` table** (SQLite, Postgres, SQL Server) | who / what / **where-from** / when of auth + PHI *access* and admin actions — plus, when the opt-in `[security].audit_all_authorization_decisions` is on (**default `false`**; the internal field it desugars to is `audit_all_authz`, whose old `[diagnostics]` TOML spelling is **refused at load** — ADR 0118), an `authz` row for **every** authorization decision including successes, which multiplies this stream's volume — `actor`, `action`, `channel_id`, `client`, `detail`, `row_hash` | JSON `detail`; **tamper-evident hash chain** over `prev_hash` + the row (the `client` address is **inside** the chained payload — ADR 0150) | the store database | HIPAA §164.312(b) audit controls; incident response; `verify_audit_chain` integrity checks | `GET /audit` requires **`audit:read`**; `GET /audit/export` requires the separate **`audit:export`** and streams CSV with formula-injection neutralisation, recording its own `audit.export` row *before* streaming; `GET /me/security-events` is a per-user view of the same table | **`[retention].audit_days` is reserved and NOT enforced — keep-forever by design** (deleting rows would break the chain; HIPAA expects ~6 years) | `detail` is stored **in the clear** (it is not a cipher-covered column): its protection is that writers only ever store filter shapes, counts and ids — never bodies or credentials — plus the store ACL and the volume layer | | **6. `message_events` table** | the per-message disposition timeline — the **complete** vocabulary is `received`, `routed`, `unrouted`, `filtered`, `transformed`, `delivered`, `failed`, `dead`, `error`, `replayed`, `resent`, `reingressed`, `passthrough`, `passthrough_dropped`, `cancelled`, `edit_resend`, `edit_resubmit`, `viewed`, `not_deployed`, and the ADR 0154 synchronous-reply pair `reply_returned` / `reply_timeout` (names, counts and `waited_ms` only — **never** a fragment of the partner's reply body) (CI asserts this list against the engine's own `MESSAGE_EVENT_KINDS`). `[diagnostics].message_events` can thin the set, but never below the compliance floor `viewed` / `dead` / `error` / `failed` / `not_deployed` / `reply_timeout` | rows: `message_id`, `ts`, `event`, `destination`, `detail` | the store database | operator timeline on the message-detail view; the `viewed` row is the HIPAA PHI-access record | `GET /messages/{id}` under **`messages:view_raw`** + `require_phi_read`; the read itself writes a `viewed` event **and** a `message_view` audit row | no dedicated window — `purge_message_bodies` sets `message_events.detail` to `NULL` in the same transaction that blanks the body, so it inherits `[retention].messages_days` | `detail` is `safe_text()`-scrubbed **then** cipher-encrypted (AAD `("message_events","detail",message_id,ts,event)`). Verbosity gate `[diagnostics].message_events` = `all` (default) / `errors` / `off`, with a **compliance floor that can never be thinned**: `viewed`, `dead`, `error`, `failed`, `not_deployed`, `reply_timeout` are retained at every level (`reply_timeout` is the one row that explains a "we called you and got a 504" complaint, so an instance that thinned its logs would lose exactly the record it is later asked for) | | **7. `connection_event` table — DEFAULT ON** (`[diagnostics].connection_events = true`) | transport/lifecycle events per connection: `established`, `closed` (reason `eof` or `idle_timeout` — no path produces any other), `idle_timeout`, `at_capacity`, `peer_not_allowlisted`, `frame_oversize`, `framing_error`, `peer_reset`, the inbound-HTTP intake-auth refusals `intake_auth_failed` / `auth_subject_denied` / `auth_rate_limited` (ADR 0154 D6 — peer address and mode only; **never** the credential, a prefix of it, or its length. Each of these also writes a tamper-evident audit-log row — the copy that survives an operator turning this diagnostics stream off), plus the runner's `connection_lost` / `connection_restored`. That is the whole vocabulary, asserted in CI against the literal emit call sites in `transports/` and the pipeline runner **and** cross-checked against the console's own filter tuple. The MLLP, raw-TCP and HTTP listeners emit these; the **DICOM inbound C-STORE SCP** and the **`ISA`/`IEA`-framed X12 inbound** emit none — the runner injects the sink onto **every** source (`wiring_runner.py`, over the base-class `on_connection_event` field), so both connectors *have* the wiring and simply never call it — so this stream covers those three listeners plus the runner's outbound-lane transitions — not literally every connection. An X12 feed's connects, allow-list refusals and at-capacity refusals are therefore **absent** from this stream | rows: `ts`, `connection`, `transport`, `direction`, `kind`, `peer_host`, `message_id` (correlation hint), `reason` | the store database, **all three backends** | Corepoint-style transport diagnostics — "did the sender connect, and why did it drop" | `GET /events` and `GET /connections/{name}/events` under **`monitoring:read`** (**not** a PHI permission) with per-channel RBAC — an out-of-scope `connection=` is 403'd *and* audited — server-clamped to ≤1000 rows | `[retention].connection_event_retention_hours` (its own **hours** window); 0 inherits `[retention].messages_days`; both 0 = keep forever. Plain age `DELETE` (metadata-only) | **`reason` is free text that can carry sensitive fragments.** Defended twice — `safe_exc()` at the source, `safe_text(reason)[:200]` at the store — then cipher-encrypted (AAD `("connection_event","reason",connection,ts,kind)`). Every other column is config metadata; the table is documented **metadata-only** — never a frame, body or HL7 field value. Writes are a pure side observer: a bounded in-memory queue drained by a background task outside any handoff transaction, so a flood can never block a listener or pin a message disposition | -| **8. `alert_instance` table — default on wherever an `[alerts]` notifier exists** | resolvable operator alerts: `connection_stopped`, `queue_buildup`, `lane_stuck`, `message_stall`, `saturation`, `connection_error`, `content_match`, `storage_threshold`, `cert_expiry`, `secret_rotation`, `bootstrap_admin_expiring` (the UNCLAIMED first-run bootstrap admin nearing its auto-disable deadline — ASVS 6.4.5; its payload carries only the ISO deadline plus whole hours remaining, never the password or any secret), `integrity_drift`, `update_available`, `backup_failed`, `rcsi_off_degraded`, `leadership_acquired`, `dr_activated`, `gcm_invocations` (the per-key AES-GCM invocation bound crossing its 2^31 soft warn — ASVS 11.3.4; its payload carries a one-way `key_id` fingerprint plus counters, never key bytes) The three reachable **inverse** signals — `connection_restored`, `leadership_lost`, `dr_released` — are never rows here: `_record_state` routes an inverse through `_AUTO_RESOLVE` to `resolve_alert_instances_for`, never to `upsert_alert_instance`. (A fourth mapped key, `connection_started`, is emitted by no code path today.) | rows: `event_type`, `connection`, `severity`, `status`, `first_seen`, `last_seen`, `count`, `reason`, `acked_by`, `acked_at`, `resolved_at`, `suspended_until`, `escalation_tier` | the store database, **all three backends** | the operator alert list — acknowledge / resolve / suspend. Durable state is recorded **before** any suppression or throttle return, so a muted alert still leaves a record | `GET /alerts/active` under **`monitoring:diagnose`** (**not** a PHI permission) with the same per-channel scope; ack/resolve/suspend/**resume** are POSTs on the same tier, and the separate read-only `GET /alerts/rules` view sits on its own gate | shares the connection-event window; **only RESOLVED instances are DELETEd**, by `resolved_at` — an open or acknowledged condition is never aged out from under an operator | **`reason` is free text** taken from the event's `detail`/`reason`/`label`: `safe_text(reason)[:200]` then cipher-encrypted (AAD `("alert_instance","reason",event_type,connection)` — the de-dup grain, so one AAD covers both the INSERT and the re-fire UPDATE). `content_match` is **PHI-free by contract**: the sink method takes no value parameter, only the connection, an operator label and an optional rule id | +| **8. `alert_instance` table — default on wherever an `[alerts]` notifier exists** | resolvable operator alerts: `connection_stopped`, `queue_buildup`, `lane_stuck`, `message_stall`, `saturation`, `connection_error`, `content_match`, `storage_threshold`, `cert_expiry`, `secret_rotation`, `integrity_drift`, `update_available`, `backup_failed`, `rcsi_off_degraded`, `leadership_acquired`, `dr_activated`, `gcm_invocations` (the per-key AES-GCM invocation bound crossing its 2^31 soft warn — ASVS 11.3.4; its payload carries a one-way `key_id` fingerprint plus counters, never key bytes) The three reachable **inverse** signals — `connection_restored`, `leadership_lost`, `dr_released` — are never rows here: `_record_state` routes an inverse through `_AUTO_RESOLVE` to `resolve_alert_instances_for`, never to `upsert_alert_instance`. (A fourth mapped key, `connection_started`, is emitted by no code path today.) | rows: `event_type`, `connection`, `severity`, `status`, `first_seen`, `last_seen`, `count`, `reason`, `acked_by`, `acked_at`, `resolved_at`, `suspended_until`, `escalation_tier` | the store database, **all three backends** | the operator alert list — acknowledge / resolve / suspend. Durable state is recorded **before** any suppression or throttle return, so a muted alert still leaves a record | `GET /alerts/active` under **`monitoring:diagnose`** (**not** a PHI permission) with the same per-channel scope; ack/resolve/suspend/**resume** are POSTs on the same tier, and the separate read-only `GET /alerts/rules` view sits on its own gate | shares the connection-event window; **only RESOLVED instances are DELETEd**, by `resolved_at` — an open or acknowledged condition is never aged out from under an operator | **`reason` is free text** taken from the event's `detail`/`reason`/`label`: `safe_text(reason)[:200]` then cipher-encrypted (AAD `("alert_instance","reason",event_type,connection)` — the de-dup grain, so one AAD covers both the INSERT and the re-fire UPDATE). `content_match` is **PHI-free by contract**: the sink method takes no value parameter, only the connection, an operator label and an optional rule id | | **9. `response` rows with `kind='ack_sent'` — DEFAULT ON** (`[diagnostics].response_sent = true`) | the ACK/NAK the engine returned to an inbound sender, under a sentinel destination `\x1fack:` | rows: `ack_code` (`AA`/`AE`/`AR`/`CA`/`CE`/`CR`), `ack_phase` (`decode`/`parse`/`strict`/`ingest`), `outcome`, `body`, `detail` | the store database | "what did we actually reply, and why" — the operator's answer to a sender disputing an ACK | `GET /messages/{id}/responses` under `messages:read` + `require_phi_read`; the `body` only for a caller who also holds `messages:view_raw`; every read writes a `response.read` audit row | `body`, `detail` and `resp_headers` are set to `NULL` in place by `purge_message_bodies` on the message-body window, on all three backends | **PHI fail-safe:** the ACK **body** is stored **only when the store cipher is active** — on a keyless store it is `NULL` rather than plaintext — and every NAK passes no body at all, so the offending field value is never persisted. The disposition metadata (`ack_code`/`ack_phase`/`outcome`) is non-PHI and always captured; `detail` is `safe_text`-scrubbed, 200-char bounded and encrypted | | **10. `[alerts]` webhook transport** (off by default — `webhook_url` unset) | one HTTPS POST per alert, carrying every non-underscore event key as JSON | JSON | the operator's webhook endpoint (Slack/Teams/PagerDuty/custom) | operator notification | **`https` only** — a plaintext `http://` webhook URL is refused at construction unless the `MEFOR_ALLOW_INSECURE_TLS` escape is set (and then a warning is logged); since #329 this path routes that escape through the clamped `weakened_tls_escape_permitted(posture)` (the instance posture threaded from the API lifespan), so on an enforcing-PHI instance the escape is inert and a cleartext webhook POST stays refused — the same clamp as the connectors, no longer the raw escape. Redirects are refused; an optional `webhook_allowed_hosts` egress allowlist gates the host | the endpoint's | **carries the alert's `detail`/`reason` free text** (`safe_exc()`-scrubbed at the emit sites, but **not** re-run through `safe_text` on this path). Internal `_`-prefixed keys (per-rule recipients, rule id, cooldown) are stripped before send, so recipient addresses never cross the wire | | **11. `[alerts]` SMTP transport — operator alert list** (off unless `email_smtp_host` + `email_from` + ≥1 `email_to`) | one email per alert; default subject `[MessageFoundry] `, default body every non-underscore event key as `k: v` | plain text (always kept — never HTML-only); optional HTML alternative | the operators' mailboxes | operator notification | `smtp_allowed_hosts` egress allowlist; the SMTP password comes from `MEFOR_ALERTS_EMAIL_PASSWORD` or a `[secrets]` provider, never the config file; per-send timeout `email_timeout` | the mail system's | carries the same `detail`/`reason` free text as the webhook. #138 operator templates are constrained to a **closed non-PHI variable allowlist** validated fail-closed at config load. **Transport posture:** `send_plain_email` builds an explicit **verifying** context (chain + hostname + strict RFC 5280, TLS 1.2 floor) via `tls_policy.build_smtp_tls_context()` and passes it to `starttls()`, anchored to the OS roots, `[alerts].email_tls_ca_file`, or `[tls].internal_ca_file` — the same factory the EMAIL and DIRECT *message destinations* use, so all three SMTP cells now share one policy ([#323](archive/backlog/BACKLOG-CLOSED.md#323-smtp-tls-is-unverified-on-all-three-send-paths), closed 2026-08-02). Before that this call passed **no** context and Python's stdlib default applied (`ssl._create_stdlib_context` **is** `ssl._create_unverified_context` — `CERT_NONE`, `check_hostname = False`), leaving the hop encrypted but unauthenticated. There is still **no hop gradient or attestation on this path** — unlike the connectors, this cell is constructed outside the `active_hop_posture` scope, so its deviations (`email_use_tls = false`, or `email_tls_verify = false`) are gated by a `[security].allow_unverified_alert_smtp_tls` **acknowledgment switch at the serve gate** rather than by the clamped escape: on an enforcing PHI instance `serve` refuses to start without it, and permits + `AUDIT`-logs the start with it. Both deviations are named by `security_loosenings()` and reported by `messagefoundry check`'s `alert-smtp-tls` advisory | @@ -1204,7 +1204,7 @@ For operators standing up the engine (see also [SERVICE.md](SERVICE.md)): - [ ] **Never run at `DEBUG`** in production. - [ ] **Treat backups as PHI** — encrypt and access-control them; never copy `*.db` or File-connector output to source control, tickets, or shared drives. -- [ ] **Change the bootstrap admin password immediately** (see [SECURITY.md](SECURITY.md)). +- [ ] **Create the first administrator with `messagefoundry admin-create`** (see [SECURITY.md](SECURITY.md)) — a fresh store has no account until you make one. - [ ] **Supply secrets via env**, never the TOML (`MEFOR_STORE_PASSWORD`, `MEFOR_AUTH_AD_BIND_PASSWORD`, future `MEFOR_STORE_ENCRYPTION_KEY`). - [ ] **Never feed real PHI to `dryrun`/`generate`** or redirect their output to shared locations (§7). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 772ed329..e0b4d295 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -42,23 +42,36 @@ network in cleartext, so it's refused). So there is no way to be accidentally se unauthenticated full access — or to silently void the loopback assumption with a stray `[api].host` edit (SYS-1). -### First-run bootstrap admin - -On first start against an empty store, the engine creates a single **bootstrap admin** -(username `admin`, role `Administrator`) with a random one-time password **generated through the -active password policy**. The password is **written to an owner-only file** (`bootstrap-admin.txt`, -next to the store) — **never to the log** — and only the file's location is logged, so the credential -doesn't land in NSSM's broadly-readable stdout capture. Sign in with it, change the password -immediately (enforced — the account is flagged `must_change_password`), and delete the file. After any -user exists, no further bootstrap occurs. - -**Auto-retirement (WP-3).** The bootstrap account exists only to seed the first real admin, so it -self-retires while still **unclaimed** (never password-changed): it is **disabled once a second -administrator exists**, and — if left unclaimed — **disabled `[auth].bootstrap_expiry_hours` after -creation** (default 72 h; `0` disables the timer). Once you change its password it becomes a normal -admin account and is never auto-disabled, so a single-admin deployment can't be locked out. A retired -bootstrap login is refused like any other invalid credential and the retirement is audited -(`auth.bootstrap_admin_retired`). +### The first administrator (ASVS 6.3.2 — no default account) + +**A fresh store has no users.** Starting the engine seeds the built-in roles and creates **no +account**: there is no default username, no machine-generated credential, and no credential file +written next to the store. Nothing privileged exists until an operator decides it should. + +Create the first administrator **on the box**, with the engine stopped or running: + +``` +messagefoundry admin-create --username --email
+``` + +It prompts for the password (no echo, confirmed) — or reads one line from stdin under +`--password-stdin` for an unattended install. **The password is never an argv flag**, because argv is +readable by other accounts on the host. The password is held to the deployment's own `[auth]` policy, +so the CLI is not a laxer second path into the same account store; the account is created with the +`Administrator` role, `must_change_password` clear (the operator chose the password, so there is no +second party a forced rotation would protect), and an audited `user.created` row. Every account after +the first comes from `POST /users` / the console, which is where role assignment belongs. + +**Set `--email`.** The out-of-band security notices (lockout, password/roles change, sign-in after +failures) need a mailbox; without one only the audited `GET /me/security-events` feed records them. +The command says so at creation time. + +An earlier design created an implicit first-run `admin` account with a one-time password in an +owner-only `bootstrap-admin.txt`, auto-retired on a timer. It was retired outright rather than +patched: **6.3.2 wants that account not to exist**, and while it existed the engine had to ship a +standing credential in a file for someone to find, plus a retirement timer, an expiry warning alert +and a login carve-out to manage its lifetime. None of that is needed once the account is never +created. (BACKLOG #1020, closed as superseded by 6.3.2 rather than as built.) ### Admin password reset (WP-L3-12, ASVS 6.4.6) @@ -744,7 +757,7 @@ lost authenticator via `POST /users/{id}/reset-mfa` (which also revokes the user bind)** — the **Administrator** role must satisfy MFA before any step-up operation (the gate returns `403` + `X-MFA-Required` until verified); other users may opt in by enrolling. A required-but-unenrolled admin is never locked out — the enroll/confirm routes sit behind an action-bound **password** step-up, -not the MFA gate, so the bootstrap admin enrolls then satisfies it. The documented org opt-out is +not the MFA gate, so the first administrator enrolls then satisfies it. The documented org opt-out is `[auth].require_mfa = false`. **AD/Kerberos MFA is delegated to the directory** (Entra Conditional Access / an MFA proxy) — a directory login is never prompted for an engine TOTP and is MFA-satisfied at issuance. The TOTP secret is stored **encrypted at rest** (the store cipher) and recovery codes are @@ -1125,7 +1138,6 @@ one-to-one — that is why the bind/exposure posture occupies two rows and the A | Declared token class of a federated assertion | the `typ` JOSE header, and the presence of an `events` claim, on a **signature-verified** JWS | `typ` present and — normalised `.strip().lower()` then `application/`-stripped — not `jwt`, so `at+jwt` (RFC 9068 access token), `logout+jwt` and `secevent+jwt` are refused while an **absent** `typ` is allowed (RFC 7519 §5.1 makes the header advisory); or the claim set carries `events`, i.e. an RFC 8417 security event token. Every such token is minted by the **same issuer under the same key**, so no signature or key rung distinguishes it | **DENY** the sign-in — `ClaimsError("wrong_token_type")` at the key-selection rung, `ClaimsError("unexpected_events_claim")` ahead of the nonce compare (a logout token carries no nonce, so a later check would misreport it as a browser-binding failure) | on | (no knob) | | Federated authentication-context claims (`amr` / `acr`) | the `amr` list / `acr` string of a **signature-verified** `id_token` | `oidc_require_mfa_claim` on **and** neither an `amr` value in `[auth].oidc_mfa_amr_values` (default `["mfa"]`) nor an `acr` in `oidc_required_acr_values` (default `[]`, so the `amr` arm alone decides) | **DENY** the sign-in — `ClaimsError("mfa_claim_missing")`. An IdP **assertion**, never a proof | on, `["mfa"]` / `[]` | `[auth].oidc_require_mfa_claim`, `oidc_mfa_amr_values`, `oidc_required_acr_values` | | UPN suffix of the federated username claim | the suffix after the FIRST `@` of the username claim | `oidc_username_strip_domain` on (default) **and** the suffix is not in `oidc_allowed_username_domains` (or `[auth].ad_domain`). With stripping **off** the claim is used verbatim and no suffix check runs | **DENY** the sign-in — `ClaimsError("username_domain_not_allowed")` | on | `[auth].oidc_allowed_username_domains`, `oidc_username_strip_domain` | -| Bootstrap-admin age × admin population | `users.created_at` for the built-in bootstrap account × whether a second enabled Administrator exists | still unclaimed (`must_change_password` set) **and** (`now ≥ created_at + bootstrap_expiry_hours × 3600` **or** another enabled admin exists); `0` = no time expiry | **DENY** — the account is disabled, **all** its sessions revoked, `auth.bootstrap_admin_retired` audited. A *claimed* (password-changed) bootstrap account is never touched | 72 h | `[auth].bootstrap_expiry_hours` | | Browser `Origin` at the WebSocket handshake | the `Origin` header on the upgrade | absent (a native client) → allowed; present → must be an exact member of the list, whose default `[]` rejects **every** browser Origin | **DENY** before `accept()`, so the route never runs | `[]` | `[api].ws_allowed_origins` | | Cross-site request signal on a `/ui` state change | `Sec-Fetch-Site` (preferred) else `Origin` vs our own origin (`[api].public_origin` is authoritative when set; `Host` is the fallback) | `Sec-Fetch-Site` ∈ {cross-site, same-site}, or a non-matching `Origin` | **DENY** 403 — defence-in-depth over the `SameSite=Strict` cookie, deliberately token-free | on | `[api].public_origin` | @@ -1576,8 +1588,8 @@ Every enforced limit, with both dimensions stated even where one is hard-coded o **and** globally" is the requirement's own wording. **Enforcement scope is stated per row, because it is not uniform.** The four sliding-window limiters (sign-in, credential ceremony, PHI read, admin write) and the two pending-flow caches are **in-process, per API process** — N engine shards multiply -*those* budgets by N. The account lockout, the concurrent-session cap and the bootstrap-admin timer are -**store-backed** (`record_login_failure` / `enforce_session_cap` / `set_user_disabled` against the one +*those* budgets by N. The account lockout and the concurrent-session cap are +**store-backed** (`record_login_failure` / `enforce_session_cap` against the one unified store), so they are **shared** by every API process and are **not** multiplied by N. The request-body cap is **stateless** — a per-request test that carries no budget at all. An exposed or multi-host deployment must additionally front the API with a proxy/WAF limiter and TLS. @@ -1590,7 +1602,6 @@ multi-host deployment must additionally front the API with a proxy/WAF limiter a | PHI reads | `[auth].phi_read_rate_limit_enabled`, `phi_read_rate_limit_per_actor`, `phi_read_rate_limit_global`, `phi_read_rate_limit_window_seconds` | on / 120 / **0 = off** / 60.0 s | 60 s | **yes** (120) | off by default | no | **in-process** — 7 JSON routes via `require_phi_read`, 4 bulk-PHI step-up GETs charged at admission, 5 `/ui` views via `require_ui(phi=True)`, and 3 further `/ui` GETs that inherit the charge by delegating into the handler body | 429 + `Retry-After: 10`, logged | | Admin writes | `[auth].admin_write_rate_limit_enabled`, `admin_write_rate_limit_per_actor`, `admin_write_rate_limit_window_seconds` | on / 12 / 1.0 s | 1.0 s | **yes** (12) | no (`glob=0`) | no | **in-process** — **non-GET only**, JSON API only, via `require_step_up` **and** `require_paced`; **no `/ui` route charges it** | 429 + `Retry-After: 1`, logged | | Concurrent sessions | `[auth].max_sessions_per_user` | 5 (`0` = unlimited) | — | **yes** | no | no | **store-backed** — every login | the user's oldest session is revoked | -| Bootstrap-admin lifetime | `[auth].bootstrap_expiry_hours` | 72 h (`0` = no timer) | — | n/a | n/a | n/a | **store-backed** — the unclaimed bootstrap account | disabled + audited | | Request body | `[store].max_upload_bytes` (the `/uploads` routes only) | 1 MiB elsewhere | per request | no | no | no | **stateless** — every route, in ASGI middleware | **413** over the cap, **400** on ambiguous CL+TE framing or an invalid `Content-Length`, **411** on a chunked body | | OIDC pending flows | `[auth].oidc_flow_cache_max` (global), `DEFAULT_PER_IP_CAP` (per-IP, no knob), `oidc_flow_ttl_seconds` | 512 / 16 / 300 s | 300 s TTL | no | **yes** (512) | **yes** (16) | **in-process** — `GET /ui/oidc/start` — reject-when-full, never evict | 303 → `/ui/login?e=rate_limited`, WARNING-logged, **never** audited | | WebAuthn pending ceremonies | `GLOBAL_PENDING_CAP`, `PER_USER_PENDING_CAP`, `CHALLENGE_TTL_SECONDS` (module constants, no knobs) | 4096 / 16 / 120 s | 120 s TTL | **yes** (16) | **yes** (4096) | no | **in-process** — every passkey registration + assertion ceremony | per-user: evicts that user's **own** oldest pending ceremony (silent); global: `ChallengeCacheFullError` naming the cause + the `admin_reset_mfa` recovery path | @@ -1781,8 +1792,9 @@ runs bulk AES-256-GCM. #198 closes the **application-code-feasible** half and ac - **Person/entity authentication** (required) — local argon2id and/or AD bind; lockout on brute force. - **Audit controls** (required) — durable, user-attributed audit trail (append-only via the store API). - **Automatic logoff** (addressable) — idle + absolute session timeouts. -- **Emergency access** (required) — the bootstrap admin provides break-glass; treat its credential as - a sealed secret. +- **Emergency access** (required) — `messagefoundry admin-create` on the box mints an administrator + without needing an existing session, so a locked-out estate is recoverable by whoever holds the + host. Protect the host and the store accordingly: filesystem access to them IS this break-glass. --- diff --git a/docs/VERSION-CONTROL.md b/docs/VERSION-CONTROL.md index 06f89c7a..e2e97814 100644 --- a/docs/VERSION-CONTROL.md +++ b/docs/VERSION-CONTROL.md @@ -156,7 +156,7 @@ credentials, and the design keeps secrets out of the repo entirely: versioned `environments/.toml` files hold only **non-secret** per-environment values. - **No real PHI in the repo.** Test fixtures are **synthetic only**. Real message bodies live in the engine's message store, never in git. See [PHI.md](PHI.md). -- **`*.db`, `.env`, captures, and `bootstrap-admin.txt`** are git-ignored by the scaffold. +- **`*.db`, `.env` and captures** are git-ignored by the scaffold. This holds regardless of where the repo is stored — a private on-prem remote does not change what may be committed. When in doubt, review the diff before you commit. diff --git a/docs/adr/0034-static-analysis-triage-policy-accepted-risk-register.md b/docs/adr/0034-static-analysis-triage-policy-accepted-risk-register.md index bf2aab56..54b0f327 100644 --- a/docs/adr/0034-static-analysis-triage-policy-accepted-risk-register.md +++ b/docs/adr/0034-static-analysis-triage-policy-accepted-risk-register.md @@ -33,7 +33,7 @@ A scanner re-runs on every publish, and a finding dismissed only via a per-alert - `py/overly-permissive-file` — the FILE outbound's cross-filesystem **copy fallback** created delivered files `0o644` (world-readable) while the `mkstemp` temp and the `os.link`/`os.replace` paths all yield `0o600`; tightened the fallback to `0o600`. **Accepted risk (1, `won't fix`):** -- `py/clear-text-storage-sensitive-data` — the **one-time bootstrap-admin password** is written in cleartext to an **owner-only** file (`_secure_file` → `chmod 0o600` / NTFS owner-only DACL), the log records only its location, and server-side `must_change_password` forces rotation at first login. Conveying a first-run credential to the operator requires writing it somewhere; an owner-only, force-rotated file is the chosen, compensated mechanism. Revisit if the bootstrap flow changes. +- `py/clear-text-storage-sensitive-data` — **WITHDRAWN 2026-08-10; the accepted risk no longer exists.** It covered the one-time bootstrap-admin password written in cleartext to an owner-only file, compensated by `0o600`/NTFS owner-only perms plus forced first-login rotation. BACKLOG #1020 retired the implicit first-run account outright (ASVS 6.3.2), so nothing writes a credential to disk: `messagefoundry admin-create` takes the operator's own password on a no-echo prompt or stdin and never persists it in cleartext. The finding it accepted has no site left to fire on. Recorded rather than deleted, because a register that silently loses a row cannot be reconciled against a re-scan. **Dismissed as false positive (11) / used in tests (2)** — class rationale: - *Protocol-/format-mandated hashing* — `weak-sensitive-data-hashing` on SHA-256 of a high-entropy session token (not a low-entropy password), SHA-1 for HaveIBeenPwned breach-corpus interop (`usedforsecurity=False`), and SHA-1 mandated by the WS-Security UsernameToken Digest profile. @@ -83,7 +83,7 @@ Scorecard runs on the same mirror and surfaced **48 findings**. These are **repo **Positive** — one durable, reviewable record; future scans converge instead of re-litigating; the single accepted risk is logged and revisitable; the dataflow-verification gate (AC-4) is written down, not folklore. -**Negative / risks** — a register can go stale: it MUST be updated whenever new findings are triaged, or it misleads. The accepted risk (#5) remains a cleartext-at-rest credential — mitigated by owner-only perms + forced first-login rotation, but a residual to revisit if the bootstrap flow changes. +**Negative / risks** — a register can go stale: it MUST be updated whenever new findings are triaged, or it misleads. The cleartext-at-rest credential accepted as #5 was withdrawn on 2026-08-10 when the account that produced it was retired (see the amended row above); that is the shape of maintenance this register needs. **Out of scope** — enabling GHAS on the private repo; pursuing the *proper* Docker/Fuzzing/badge hardening above (deferred, not warranted now); and the operational mirror **publish** that re-runs CodeQL/Scorecard and auto-closes the fixed/stale findings (`publish.ps1`, owner-run). diff --git a/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md b/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md index f3f2618d..ece8672d 100644 --- a/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md +++ b/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md @@ -271,3 +271,20 @@ prevent. silently revert ADR 0035's SEC-005/CWE-918 machine-scoping. **Refusing to build the form is the security decision.** - **Paint "signed out" with a warning background** — rejected: it is the author's harmless steady state; a permanently amber item gets ignored, and then it cannot warn when something is actually broken. + + +## Amendment (2026-08-10) — the fork hazard no longer includes an account (BACKLOG #1020) + +Nothing decided here changes; two factual premises it rests on do, and a reader would otherwise act on +stale text. BACKLOG #1020 retired the implicit first-run bootstrap Administrator (ASVS 6.3.2 — no +default accounts), so: + +- **§5's fork hazard is now "a brand-new EMPTY database", with no account at all.** The forked engine is + one nobody can sign into until an operator runs `messagefoundry admin-create` against it. That is a + *worse* accident to have silently, not a lesser one, so the guard's reason stands unweakened. +- **The "auto-retired bootstrap admin" explain-only case is gone**, along with `[auth].bootstrap_expiry_hours`. + There is no auto-retiring account and no such setting to look up. The RBAC-403 and TLS explain-only + cases are unaffected. + +Item #1020 lives in [`docs/BACKLOG.md`](../BACKLOG.md) until archived, then +`docs/archive/backlog/BACKLOG-CLOSED.md`. diff --git a/docs/adr/0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md b/docs/adr/0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md index 6129e315..2757fed5 100644 --- a/docs/adr/0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md +++ b/docs/adr/0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md @@ -170,3 +170,16 @@ running here" is the normal state of an authoring checkout. (`CMD.openEngineSetup`) and `CMD.startEngine` SHALL NOT be offered from the pill; WHEN `hasStore == true` the plain Start is unchanged. → `engine-control.test.ts` (rewritten store-less assertion + the known-CMD sweep), `engine-setup.test.ts` (content-model commands ⊆ `Object.values(CMD)`; dev-engine button === `CMD.startEngine`). + + +## Amendment (2026-08-10) — the store-less confirm no longer promises an admin account (BACKLOG #1020) + +The guard, its gating and its acceptance criteria are unchanged. What changed is what the guard is +warning about: BACKLOG #1020 retired the implicit first-run bootstrap Administrator (ASVS 6.3.2), so a +store-less start creates a **new EMPTY database with no accounts** — an engine nobody can sign into +until `messagefoundry admin-create` runs against it. The modal, the setup page copy and the two tests +that pin them now say exactly that; leaving the old wording would have had the IDE promise an account +the engine no longer creates. + +Item #1020 lives in [`docs/BACKLOG.md`](../BACKLOG.md) until archived, then +`docs/archive/backlog/BACKLOG-CLOSED.md`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 000fad53..75b0f356 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -141,9 +141,9 @@ what is withheld and what you can request. | [0107](0107-phase-4-is-closed-transaction-reduction-is-a-measured-dead-end.md) | **Phase 4 is CLOSED — transaction reduction is a measured dead end.** The P0 falsifier [ADR 0099](0099-phase-4-group-commit-amortize-the-per-event-transaction-cost.md) pre-registered has run and returned **ABANDON**. Inline stage-fusion ([ADR 0057](0057-inline-step-a-fast-path.md)) **works** — it cut `committed_txns/msg` **10.47 → 7.49** (−28.5%; manipulation check passed, disarmed-arm trap avoided at `H=D=dests=1`) — and **buys nothing**: throughput moved **−0.56%**, inside the pre-registered NULL band and below either arm's replicate spread. **That null IS the verdict** (pre-registered primary A/B). **Arm E** adds the number worth remembering: sweeping H∈{1,2,4,8} on the unmodified split path, a **~3× swing in committed transactions (9.89→29.20/msg) moves throughput only −11.7%** → **elasticity −0.115** — the txn→throughput coupling is real but **far too weak to be a lever**. ⚠️ **CORRECTION (same-day, adversarial verify):** an earlier draft claimed arm E proves *"F2 cannot clear the bar at any shape"* and that F2's ceiling *"lands inside B5's rejected band"* — **both FALSE.** F2's arm-E ceiling at H=8 is **+13.2%**, which is **ABOVE** the +8% PROCEED bar and above B5's +6.5…+10% band; even net of the *measured* H=1 give-back (−4.49 pts) it is **+8.75%**. **The data does NOT exclude F2 clearing the bar at high H** — and **F2 cannot be measured without being built** (the fusion gate is `len(names)==1`, so inline fusion is H=1-only *by construction*). We therefore **decline on cost/risk/evidence, NOT on a proof of impossibility**: fusion buys nothing at the only measurable shape; the deratings that would sink it are argued, not measured; ADR 0071 B5 is the precedent (6× fewer round-trips → +6.5…+10%, NO-GO); and F2 is a large permanent 3-backend surface. Decisions: no F2/F3; **ADR 0057 ⛔ DO NOT PROMOTE, default-OFF permanently**; state the conclusion precisely (**NOT** "the wall is per-message" — transactions *do* matter, just far too weakly); **F1 survives on latency/cleanliness merit only, never a throughput claim**; **do NOT open a fifth store-side falsifier** (four negative: C5/C6/C7/P0). **Frontier: the ENGINE side has never been ATTRIBUTED** — note shardcert already publishes per-shard PIDs for an external per-PID CPU capture that nobody has ever taken | Accepted (2026-07-13) — closes options; authorizes no build | | [0108](0108-steps-view-accumulator-send-fan-out-copy-on-send-authoring.md) | **Steps-view accumulator Send fan-out** — author multi-destination, copy-on-Send fan-out ([ADR 0104](0104-copy-on-send-outbound-message-model-recognition-first-handler-message-type-and-hl7-field-picker.md)) as first-class mid-body **actions**, never a `Send`-in-a-`return`. The owner rejected both `return Send(...)` and the named-sends `return [a, b]` form: a send should read as an action, not the function's return. **Decision:** recognize + author the **accumulator idiom** `sends = []; sends.append(Send("OB", msg)); ...transform...; sends.append(Send("OB2", msg)); return sends` — each `sends.append(Send(...))` is an editable `send` row (additive `appended:true`) positioned AT the append; the `sends = []` init + bare `return sends` footer are managed **read-only scaffold** (`scaffold:"collector_init"/"return_collector"`, kind stays `code`); deleting every append leaves an empty accumulator = **FILTERED**, no name-scrub. The three legacy returned forms stay **byte-identical**; the palette's **Send** item repoints `template:"send"` → `op:"insert_send"`, plus a per-row **+ dest** `add_destination` button. **No engine runtime change** for this ADR — pure recognizer + rewrite + view (see the ADR's §2 invariant for the `_partition` behaviour it rests on, amended 2026-08-04 by BACKLOG #341; do not restate it here). **Honesty gate:** an append is a send row only where its collector is a *clean delivering accumulator* (single top-level `[]` init, top-level `return NAME`, bound nowhere else); a discarded/aliased/rebound/closure-local append degrades to a read-only `code` row. IDE keys insert-after suppression on a new `isReturnRow` (append allows after; return/footer suppresses) across the six position sites + the CSP mirror. Adversarial pass folded in 10 fixes (stale import index → `_leading_import_end`; ruff quote escape; delivering-accumulator gate; nested-guard convert; non-empty-tuple convert refused; nested-anchor placement) — see the ADR's §7 for each rationale. Extends [ADR 0076](0076-typed-action-vocabulary-action-list-lens.md)/[ADR 0089](0089-recognition-first-lens-native-idioms.md); repoints the [ADR 0106](0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) Send item; #26-clean | Accepted (2026-07-14) — owner-directed; engine + IDE built, adversarially verified; **amended 2026-08-04** (two `_partition` rationales corrected by BACKLOG #341 — nothing built changed) | | [0109](0109-at-rest-encryption-fail-closed-on-an-undeclared-phi-posture.md) | At-rest encryption fail-closed on an **undeclared PHI posture** — default an undeclared/underived `[ai].data_class` to **PHI** so a keyless store refuses to start (reusing the built `__main__.py:980-1004` PHI serve gate) unless the operator declares `data_class=synthetic`, configures a key, or sets the audited `[store].allow_unencrypted_phi`. Makes cleartext-at-rest an explicit, audited opt-out at every posture (prod is already fail-closed); keyless→keyed reads stay back-compat. Closes [Secure Build Scorecard](../Secure_Build_Scorecard_MEFOR.md) gap #4 (→ B+ → A−). Layer-2 first-run auto-key deferred to a follow-on ADR. **Tier S2×P2 ⇒ T3.** Extends [ADR 0002](0002-phase2-transport-security-and-strong-auth.md); audited-escape precedent [ADR 0036](0036-windows-config-source-trust.md) | **Rejected (2026-07-14)** — premise refuted on code review: `serve` already fails closed for every PHI posture via `require_posture()` (env mandatory; `staging`/`prod`→PHI; custom-env-undeclared raises); cleartext only for declared/derived synthetic (no real PHI). No code shipped; scorecard gap #4 corrected | -| [0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) | **IDE engine-link doctor — the status bar tells the truth about the promote target** (BACKLOG #232). The `MEFOR: ` item painted a green check whenever *anything* answered `GET /health`: `classifyProbe` folded **every** HTTP status — **including a 401** — into `"reachable"`, and the probe **discarded the body**. `AuthSettings.enabled` defaults True, so a plain `serve` = engine up + IDE holding **no session** + every authenticated call 401ing + **a confident green check** (a live probe of the owner's engine returned `{"status":"ok","version":null}` — `/health` discloses `version` only to an authenticated caller, WP-L3-07). The click's menu diagnosed nothing, and its `Open engine URL in browser` opened the **bare** engine URL — a live **404** (there is no `/` route). **Decision:** green means *"the IDE can USE this engine"*, not *"a socket answered"* — a closed link-state union (`unreachable{code}`/`foreign`/`signedOut`/`unverified`/`blocked{reason}`/`drifted`/`ok`) in the vscode-free model; the tokenless poll reads the `/health` body (`version: null` ⇒ **signedOut**; a version present ⇒ **`unverified`, NEVER green** — `optional_identity` applies no RBAC and no must-change gate, and `/auth/me` is must-change-**exempt**, so neither endpoint can prove a session is usable); a green check is **EARNED** only by a **user-initiated** deep probe of a non-exempt protected route (`GET /config/provenance` — `monitoring:read`, no step-up, yields `drift` free) and it **DECAYS**. **The periodic poll MUST stay TOKENLESS** — `identity_for_token(..., activity=True)` refreshes the session idle clock, so a bearer on a 15s timer would make the engine's 30-min idle timeout unreachable forever (CWE-613). Surface is **native chrome, NOT a webview**: a `MarkdownString` hover (the primary diagnosis — renders at the item, needs no command dispatch), a state-gated QuickPick (`title:`, never a `placeHolder:`), a `$(sync~spin)` flip at the click site, and the extension's **first engine `LogOutputChannel`** (URLs/status/errno/duration/verdict — **never a body, never a token**). **Boundary made executable:** the IDE renders and repairs the **LINK**, never the **WORKLOAD** — container poverty + the model emitting **command ids, never data** + **two frozen CI allowlists** (an `EngineLink` field list with no connection/message/queue/count/rate field, and a probe-endpoint list of only `/health`, `/ai/policy`, `/config/provenance`; `/messages`/`/connections`/`/stats` break the build). Exposes `engineSignIn`/`engineSignOut` (already-written flows that were unreachable; `signIn()` inherits ADR 0035's pre-prompt `assertTargetAllowed()` refusal). **Declined:** a webview panel; a bearer on the poll; a "Reload config" button (`require_step_up` + 300s window while `withAuth` never retries a 403); inline change-password/MFA (deep-link to `/ui/login` — the Console owns credentials); "Start local engine" (a terminal cwd'd at a worktree forks a brand-new store + bootstrap admin). Extends [ADR 0100](0100-ide-native-surface-polish-and-open-to-messagefoundry-startup-experience-backlog-221.md) (supersedes its "opens engine settings" record) / [ADR 0035](0035-ide-extension-workspace-trust-and-scope.md); bounded by [ADR 0065](0065-web-ops-dashboard.md); #26-clean | Accepted (2026-07-14) — owner-directed; built + verified against the live engine (IDE v0.0.28) | +| [0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) | **IDE engine-link doctor — the status bar tells the truth about the promote target** (BACKLOG #232). The `MEFOR: ` item painted a green check whenever *anything* answered `GET /health`: `classifyProbe` folded **every** HTTP status — **including a 401** — into `"reachable"`, and the probe **discarded the body**. `AuthSettings.enabled` defaults True, so a plain `serve` = engine up + IDE holding **no session** + every authenticated call 401ing + **a confident green check** (a live probe of the owner's engine returned `{"status":"ok","version":null}` — `/health` discloses `version` only to an authenticated caller, WP-L3-07). The click's menu diagnosed nothing, and its `Open engine URL in browser` opened the **bare** engine URL — a live **404** (there is no `/` route). **Decision:** green means *"the IDE can USE this engine"*, not *"a socket answered"* — a closed link-state union (`unreachable{code}`/`foreign`/`signedOut`/`unverified`/`blocked{reason}`/`drifted`/`ok`) in the vscode-free model; the tokenless poll reads the `/health` body (`version: null` ⇒ **signedOut**; a version present ⇒ **`unverified`, NEVER green** — `optional_identity` applies no RBAC and no must-change gate, and `/auth/me` is must-change-**exempt**, so neither endpoint can prove a session is usable); a green check is **EARNED** only by a **user-initiated** deep probe of a non-exempt protected route (`GET /config/provenance` — `monitoring:read`, no step-up, yields `drift` free) and it **DECAYS**. **The periodic poll MUST stay TOKENLESS** — `identity_for_token(..., activity=True)` refreshes the session idle clock, so a bearer on a 15s timer would make the engine's 30-min idle timeout unreachable forever (CWE-613). Surface is **native chrome, NOT a webview**: a `MarkdownString` hover (the primary diagnosis — renders at the item, needs no command dispatch), a state-gated QuickPick (`title:`, never a `placeHolder:`), a `$(sync~spin)` flip at the click site, and the extension's **first engine `LogOutputChannel`** (URLs/status/errno/duration/verdict — **never a body, never a token**). **Boundary made executable:** the IDE renders and repairs the **LINK**, never the **WORKLOAD** — container poverty + the model emitting **command ids, never data** + **two frozen CI allowlists** (an `EngineLink` field list with no connection/message/queue/count/rate field, and a probe-endpoint list of only `/health`, `/ai/policy`, `/config/provenance`; `/messages`/`/connections`/`/stats` break the build). Exposes `engineSignIn`/`engineSignOut` (already-written flows that were unreachable; `signIn()` inherits ADR 0035's pre-prompt `assertTargetAllowed()` refusal). **Declined:** a webview panel; a bearer on the poll; a "Reload config" button (`require_step_up` + 300s window while `withAuth` never retries a 403); inline change-password/MFA (deep-link to `/ui/login` — the Console owns credentials); "Start local engine" (a terminal cwd'd at a worktree forks a brand-new store + bootstrap admin). Extends [ADR 0100](0100-ide-native-surface-polish-and-open-to-messagefoundry-startup-experience-backlog-221.md) (supersedes its "opens engine settings" record) / [ADR 0035](0035-ide-extension-workspace-trust-and-scope.md); bounded by [ADR 0065](0065-web-ops-dashboard.md); #26-clean | Accepted (2026-07-14) — owner-directed; built + verified against the live engine (IDE v0.0.28) — **amended 2026-08-10**: the §5 fork hazard is now an EMPTY database with no account, and the auto-retiring bootstrap admin it cites is gone (BACKLOG #1020) | | [0111](0111-not-deployed-connections.md) | **Connection present but not deployed** (BACKLOG #233) — a first-class `deployed: bool = True` on **both** connection models so a config can carry a real, reviewed, dark connection (retired partner, superseded duplicate send, a relay pulled from prod) that is **never wired, never started, never queued to, and whose `env()` is never resolved**. Removes DEGRADED-on-**every**-boot, which is indistinguishable from a real regression — a permanent alarm is a disabled alarm. **Key finding:** `auto_start=False` already dodges `resolve_env_settings` on the *cold serve* path (both boot gates return before `_source_config`/`_dest_config`), **but `_build_check_connectors` (`wiring_runner.py:5181`/`:5186`) loops EVERY inbound and EVERY outbound with NO gate** — so `messagefoundry check` (the **required** commit gate), every reload, every promote and every `connection upsert` still explode, and one unresolvable connection blocks edits to *every other* connection in the file. **Honoring the flag there IS the feature.** `deployed=False` **WINS over `auto_start`** (deploying is a *config* change, not a runtime action → `start`/`restart` 409). **Three-way distinction that must never be conflated:** **SIMULATED** (#15 — built, receives rows, suppresses egress, finalizes `PROCESSED`) vs **PARKED** ([ADR 0048](0048-third-tier-disaster-recovery-standby.md)/[ADR 0095](0095-connection-lifecycle-scheduler-and-credential-fault-stop.md) — rows **RETAINED**, queued, retried) vs **NOT DEPLOYED** (no row is ever created). Enforced at the **`transform_one` Send-materialization seam** (`pipeline/dryrun.py:403-416`) — structurally mirroring [ADR 0084](0084-accepts-router-seam.md)'s `accepts=` seam for the router half — which covers the split, [ADR 0057](0057-inline-step-a-fast-path.md) inline and [ADR 0071](0071-cut-executor-round-trips-b5.md) fused paths plus dry-run/`check`/Test Bench in **one** edit; plus a **separate** 409 guard on the [ADR 0090](0090-resend-a-stored-message-to-an-alternate-outbound-connection.md) resend/edit-resend path, which inserts an outbound row directly and bypasses `transform_one` entirely. **Count-and-log (CLAUDE.md §12) preserved** by a per-destination `message_events` row **added to `_AUDIT_FLOOR_EVENTS`** (else the #63 verbosity gate evaporates it at `errors`/`off`, re-creating the accept-and-drop) + a **7th `MessageStatus.NOT_DEPLOYED`** used only when **every** selected destination was declined — because the finalizer decides `FILTERED` **by absence**, merely dropping the target would silently report "the handler filtered this" with **zero code changes and zero test failures** (the trap). **At-least-once ([ADR 0001](0001-staged-pipeline-architecture.md)) preserved:** the decline is keyed on the **REGISTRY FLAG** (a property of the graph), never on live runner state, so a re-run re-derives an identical delivery set. Connection **stays in `Registry.outbound`** (the orphan sweep keys on the registry — removing it would dead-letter already-queued rows). No DDL, no migration, no schema-hash change. **Not built here:** the `ide/` form controls (three TS field enumerations — deferred to avoid colliding with in-flight [ADR 0106](0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) work; the flag works via hand-edited TOML + code-first without them) and **BACKLOG #234** (`_SCALAR_FIELDS` is a 12-key whitelist with no passthrough → a GUI save silently strips `priority`/`schedule`/`shard`/`metadata`/`batch`/`stall`/`dead_letter_days`; a **pre-existing** data-loss bug, filed only). Extends [ADR 0007](0007-gui-manageable-connections-toml.md) | Accepted (2026-07-14) — owner-directed; built (#233) | -| [0112](0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md) | **IDE engine lifecycle from the status-bar pill — guarded start / stop / restart** — the `MEFOR: ` pill may now **run** the engine, not just hand over the command. **Supersedes [ADR 0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) §5's "Start local engine" decline** (everything else in 0110 stands — the link-state model, the tokenless poll, the earned/decaying green, and the **LINK-not-WORKLOAD boundary §4**, left byte-for-byte intact). §5 declined Start because a `createTerminal` cwd'd at a worktree would fork a **brand-new empty DB + bootstrap admin**; the copy-only v1 also **did not fix the actual failure** — the clipboard's bare `python -m messagefoundry serve …`, pasted into a fresh terminal, resolves `python` to the PATH shim (on Windows the Store shim), which lacks the deps → the reported **`ModuleNotFoundError: No module named 'pydantic'`**. **Decision:** Start runs the **exact blessed command** (`serve --config `, **no `--db`/`--env`/`--port`** — the service TOML stays the sole authority; ADR 0110 rejected `engineEnv` for this reason) but with `python` = `pythonPath()` (the auto-detected workspace `.venv`), launched `createTerminal({shellPath,shellArgs})` = argv, no shell re-parse. The §5 fork is **neutralised, not reintroduced**: Start is gated `canControl` = **loopback** (M-29) + **trusted** (SEC-004/CWE-426) + a workspace, and **never silently creates a store** — a run dir with no service TOML and no `*.db` gets a modal "creates a NEW database + bootstrap admin" confirm (`runDirHasEngine`). **Stop/Restart act ONLY on a terminal this IDE started** (`exitStatus === undefined`), never a port-kill — parallel worktrees can share a port. **"Set up environment"** bootstraps `.venv` + install for the fresh-clone (missing-deps) case. **The ADR 0110 §4 boundary is untouched:** lifecycle actions are **command ids** from the pure `planActions` (new optional `EngineControlContext`, default no-control → every existing call site unchanged); **no `EngineLink` field, no probe endpoint, no setting** added — the two frozen CI allowlists and the settings-scope invariant stay green. Adversarial self-review caught + fixed two shell defects (a post-`dispose` re-probe touching a disposed item; a `startEngine` reentrancy window double-launching). Node-side suite green (348, +20 for this change); tsc + esbuild clean. Supersedes [ADR 0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) §5; extends [ADR 0035](0035-ide-extension-workspace-trust-and-scope.md); #26-clean | Accepted (2026-07-15) — owner-directed; built (IDE extension v0.0.29) — Amended 2026-07-16: store-less pill leads with a guided setup page (BACKLOG #238; ratified, built (IDE extension v0.0.32), Plan-12 `ide-238-setup`) | +| [0112](0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md) | **IDE engine lifecycle from the status-bar pill — guarded start / stop / restart** — the `MEFOR: ` pill may now **run** the engine, not just hand over the command. **Supersedes [ADR 0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) §5's "Start local engine" decline** (everything else in 0110 stands — the link-state model, the tokenless poll, the earned/decaying green, and the **LINK-not-WORKLOAD boundary §4**, left byte-for-byte intact). §5 declined Start because a `createTerminal` cwd'd at a worktree would fork a **brand-new empty DB + bootstrap admin**; the copy-only v1 also **did not fix the actual failure** — the clipboard's bare `python -m messagefoundry serve …`, pasted into a fresh terminal, resolves `python` to the PATH shim (on Windows the Store shim), which lacks the deps → the reported **`ModuleNotFoundError: No module named 'pydantic'`**. **Decision:** Start runs the **exact blessed command** (`serve --config `, **no `--db`/`--env`/`--port`** — the service TOML stays the sole authority; ADR 0110 rejected `engineEnv` for this reason) but with `python` = `pythonPath()` (the auto-detected workspace `.venv`), launched `createTerminal({shellPath,shellArgs})` = argv, no shell re-parse. The §5 fork is **neutralised, not reintroduced**: Start is gated `canControl` = **loopback** (M-29) + **trusted** (SEC-004/CWE-426) + a workspace, and **never silently creates a store** — a run dir with no service TOML and no `*.db` gets a modal "creates a NEW database + bootstrap admin" confirm (`runDirHasEngine`). **Stop/Restart act ONLY on a terminal this IDE started** (`exitStatus === undefined`), never a port-kill — parallel worktrees can share a port. **"Set up environment"** bootstraps `.venv` + install for the fresh-clone (missing-deps) case. **The ADR 0110 §4 boundary is untouched:** lifecycle actions are **command ids** from the pure `planActions` (new optional `EngineControlContext`, default no-control → every existing call site unchanged); **no `EngineLink` field, no probe endpoint, no setting** added — the two frozen CI allowlists and the settings-scope invariant stay green. Adversarial self-review caught + fixed two shell defects (a post-`dispose` re-probe touching a disposed item; a `startEngine` reentrancy window double-launching). Node-side suite green (348, +20 for this change); tsc + esbuild clean. Supersedes [ADR 0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) §5; extends [ADR 0035](0035-ide-extension-workspace-trust-and-scope.md); #26-clean | Accepted (2026-07-15) — owner-directed; built (IDE extension v0.0.29) — Amended 2026-07-16: store-less pill leads with a guided setup page (BACKLOG #238; ratified, built (IDE extension v0.0.32), Plan-12 `ide-238-setup`) — Amended 2026-08-10: the store-less confirm promises an EMPTY database with no accounts, not a bootstrap admin (BACKLOG #1020) | | [0113](0113-windows-tray-service-manager-stdlib-ctypes-tokenless.md) | **Windows tray service-manager — stdlib ctypes, tokenless** (BACKLOG #239) — a Windows notification-area app for the NSSM engine service: shows engine status at a glance, opens the console (`/ui`) / VS Code / service log, and start/stop/restarts the service via per-action UAC elevation. **No PySide6 — no Qt** (owner-chosen over a `QSystemTrayIcon` design), so §10's "no new PySide6 operator surfaces" is **untouched** — this is the "tiny standalone tray/service-manager" [#103](../archive/backlog/BACKLOG-CLOSED.md#103-retire-the-pyside6-desktop-console-in-favor-of-the-web-console-p3-owner-decision) sanctioned, an ADR **ratification** not an amendment. **Not a second console:** tokenless `GET /health` + local SCM state **only** — no token ever, no message/queue/count field (a frozen snapshot-field allowlist + a `/health`-stays-tokenless test enforce it, mirroring the IDE `ENGINE_LINK_FIELDS`/`POLL_PLAN` doctrine). Built on stdlib `ctypes` (`Shell_NotifyIcon` + owned message pump) + `apiclient` (**zero** new locked deps). **Elevation writes no new privileged code:** reuse the shipped, injection-hardened `messagefoundry.service.control_service` (System32 `cmd`/`net`, single UAC prompt even for restart), **patched** `ShellExecuteW`→`ShellExecuteExW`+`GetExitCodeProcess` so a UAC-cancel (`ERROR_CANCELLED` 1223) is distinguishable — never elevating the user-writable venv interpreter. Nine-state machine (incl. `WEDGED`/`FOREIGN`/`RUNNING_UNMANAGED`/`UNKNOWN`) over `QueryServiceStatusEx` (`dwCheckPoint`/`dwWaitHint`) with monotonic grace windows; local-single-box scope (**remote** → monitor-only; *amended 2026-07-22* — a **local https** engine is fully managed and its cert verified against the OS trust store, no `verify=False` path; scheme ≠ locality). Ratifies the `messagefoundry.service`/`service_status` client-import carve-out. Extends [ADR 0088](0088-apiclient-service-cli-extraction.md); mirrors [ADR 0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md)/[ADR 0112](0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md); bounded by [ADR 0065](0065-web-ops-dashboard.md); #26-clean | Accepted (2026-07-16) — owner chose the no-Qt ctypes spine + accepted the §10 clarification; design + plan complete (25-agent workflow, adversarially verified); build phased, pushes/PR owner-approved | | [0114](0114-phase-4-claim-path-call-complexity-reduction-driver-interface-redesign-ingress-routed-reset-fold.md) | **Phase-4 claim-path call-complexity reduction — driver-interface redesign + the INGRESS/ROUTED reset fold** — the frozen D1 verdict locates ~9.7 ms/call of the pooled `claim_fifo_heads` cost (18.0-18.2 ms at the N=4 240-offered pin) **inside the ODBC/TDS driver, per-call, ~2/3 fixed (6.5-7.0 ms), call-complexity-shaped** (the same driver runs a trivial 7-statement batch in 0.335 ms), so the remedy axis is what the call *carries*: three sub-levers, **one default-OFF SqlServerStore-only flag each** (`fifo_claim_fold_reset` / `fifo_claim_proc` / `fifo_claim_prepared`; PG/SQLite provably untouched). **C (fold, measured 1.87-1.91 ms/call, licensed ceiling +8.0% ingress+routed):** the finally-guard's `SET LOCK_TIMEOUT -1` + write-less commit#2 fold into the batch's confirmed-clean success path at INGRESS/ROUTED only (H2-noop code-confirmed + runtime-guarded); the shielded B1/M-6 guard is retained **verbatim on every non-clean exit** (1222, kept≠claimed, cancellation, any error). **A (proc-ification):** TWO lane-family versioned procs (`mefor_claim_fifo_heads_cid_v1`/`_dst_v1` — the lane column is a code literal), fixed-arity 9-param CALL with one JSON lanes parameter, bodies = the batch verbatim (no TRY/CATCH, no txn statements, no reset outside the conditional `@fold_reset` tail — `SET LOCK_TIMEOUT`'s session persistence past proc exit is load-bearing at outbound), guarded DDL that can never break a flag-OFF open, and a startup gate hashing `OBJECT_DEFINITION` + compat ≥ 130 that degrades loudly to the batch. **B (stable text + retained prepared cursor):** the non-DDL fallback, fail-closed-coupled to the fold, on store-owned dedicated connections reconciled with EF-6/STORE-3 — structural feasibility itself is a gate question. FIFO-always semantics preserved exactly; commit-amortization stays D2-scoped-out; **no throughput projections** — pre-registered ms/call bench gates (fixed 240-pin, replicate pairs, invariant battery, total accept/kill rules) are the only forward-looking numbers; the composed 6.35-6.64 ms/call removal (claim 18.8-19.0 → 12.2-12.65) is a target evaluated only at the post-build re-measure; the certification run remains the arbiter | Proposed (2026-07-16) — owner D0-accept + D2-GO (TO-ENGINE-033); design of record, build ships later, all flags default OFF, each flipped only after its own pre-registered bench gate | | [0115](0115-asvs-l3-drive-to-pass-secure-by-default-flips-and-residual-closure.md) | **ASVS L3 drive-to-Pass — secure-by-default flips and residual closure** (BACKLOG #242–#246). The 2026-07-16 ASVS re-score left **50/51 Partials + 2 Fails**; almost every Partial is a *shipped* control scored Partial because it is opt-in / off-by-default / delegated. Owner chose to **drive-to-Pass** rather than leave them all as accepted residuals — but a naïve "flip every default on" would break a valid deployment (a dev box with no SMTP collector, a partner with no JWS verifier, a single-operator loopback install). **Decision:** a **secure-by-default-where-safe, runbook-instructed-otherwise** posture applied per control — (1) flip the global default ON only where it is already gated on the PHI posture so a synthetic/CI box stays byte-identical (bounded retention 14.2.4/14.2.7, egress deny-by-default 13.2.4/13.2.5, cleartext-egress refusal 12.2.1); (2) **instruct** the control in `OFF-LOOPBACK-DEPLOYMENT.md` (+ a fail-closed prod-PHI serve gate where refusing to start is defensible) where a global flip would break a valid install (approvals 2.3.5, JWS signing 4.1.5, off-box forwarding 16.4.3, WebAuthn 6.3.3/6.5.7/6.7.2) — so the *documented deployment* earns Pass while loopback/no-collector/partner-less defaults are unchanged; (3) **build the small last-mile controls** (AEAD context-binding 11.3.3, AES-GCM invocation counter 11.3.4, time-sync enforce 16.2.2, log-all-authz 16.3.2, keyed audit chain default 16.4.2, magic-byte validation 5.2.2, extended pacing 2.4.2, action-bound step-up 7.5.1/7.5.2); (4) **refresh the drifted inventories** (11.1.2, 13.1.1, 13.1.4); (5) **formally accept** the genuinely delegated residuals (WP #246 — proxy-TLS 12.1.x/12.3.x the engine cannot inspect because it terminates no browser TLS, org-delegated backend creds 13.2.1/13.2.2, SMART AS enforcement 10.4.16, AV 5.4.3) into the register. **Out of scope (stay signed residuals):** the runtime sandbox (15.2.5), HSM key custody (13.3.1/13.3.3), the ECH/in-use-memory platform gaps (12.1.5/11.7.2), and the tolerant-HL7 accepted deviations (2.2.1/2.2.3). Each flip/build amends its owning feature ADR (0018 signing, 0080 forwarding, 0068 WebAuthn, 0014 approvals/notify, 0019 AAD/nonce, 0004 magic-byte, 0077 step-up, 0105 served-filename); no secure posture changes without its ADR record updated in the same work. Plan + per-cell mapping: `ASVS-REMEDIATION-2026-07.md`. Drives `ASVS-L3-ASSESSMENT-2026-07-16.md`; residuals owned in `ASVS-L3-RISK-ACCEPTANCE-REGISTER.md`; #26-clean | Accepted (2026-07-16) — owner-directed scope decision (drive-to-Pass); builds phased across BACKLOG #242–#246, pushes/PR owner-approved | diff --git a/docs/testing/FEATURE-COVERAGE-PLAN.md b/docs/testing/FEATURE-COVERAGE-PLAN.md index 163c9833..5ead7795 100644 --- a/docs/testing/FEATURE-COVERAGE-PLAN.md +++ b/docs/testing/FEATURE-COVERAGE-PLAN.md @@ -1069,7 +1069,7 @@ Coverage summary: 18 of 22 features are well-covered on the primary functional + | AUTHN-14 | Session inventory + targeted revoke (list, own-by-id 404, others-keep-current, admin revoke-all) | 0002 | test_api_auth (4 session tests) | covered | none material | med | S | | AUTHN-15 | Step-up re-verification — session window (reauth pw/AD-rebind, MFA gate, admin-write pacing, new-IP force + re-anchor) | 0077,0002 | test_step_up, test_admin_new_ip, test_console_step_up | covered | none material | high | S | | AUTHN-16 | Action-bound step-up (0077 single-use per-action grants, login/verify_mfa never mint, no-deadlock, opt-out, X-Step-Up-Action) | 0077 | test_step_up (6 action tests), test_console_step_up::test_action_step_up_binds_purpose_into_reauth | covered | ha: process-local grant restart/multi-node re-prompt not asserted | high | S | -| AUTHN-17 | Bootstrap admin (create, policy pw, expiry/supersession retire, unclaimed guard, 0600 file, symlink refusal, not logged) | 0002 | test_auth_service (5), test_bootstrap_admin_perms (4), test_auth_hardening::test_bootstrap_password_written_to_file_not_log | covered | none material | high | S | +| AUTHN-17 | First administrator: `admin-create` provisions one on a fresh store (policy password, audited, Administrator role); starting the engine provisions NOTHING | 0002 | test_admin_create_cli (9), test_auth_service::test_initialize_seeds_roles_and_creates_no_account | covered | none material | high | S | | AUTHN-18 | mTLS client-cert -> Identity (require_service_cert allow-list, deny-by-default, PHI-fenced, identity_for_username) | 0002 | tests/test_api_tls.py | partial | evidence in transport-TLS sibling; no auth-suite test pins CN/SAN mapping matrix | high | M | | AUTHN-19 | Kerberos/IdP session-lifetime coordination | 0079 | (none) | none | DESIGN-ONLY / DEFERRED — unbuilt; AD-disable/CA-revoke doesn't propagate before absolute/idle timeout | med | L | | AUTHN-20 | Security-event notifications + self feed (isolated best-effort notify, caller-scoped PHI-free feed) | 0002 | test_auth_service (notifier tests), test_api_auth::test_security_events_feed_is_scoped_to_caller/_phi_free | covered | overlaps audit sibling | med | S | diff --git a/docs/testing/WIN2025-TEST-PLAN.md b/docs/testing/WIN2025-TEST-PLAN.md index c75f5a6d..dc2ba998 100644 --- a/docs/testing/WIN2025-TEST-PLAN.md +++ b/docs/testing/WIN2025-TEST-PLAN.md @@ -324,7 +324,7 @@ These are the rows CI structurally cannot reach and `verify` reports MANUAL. Eac These are the eight gap-map tests (Tiers 1–3), **S2.1–S2.8** mapping 1:1 to gap-map #1–#8. Each attacks a path that `verify`/`check`/CI reports green (or simply never exercises) but that is unproven under real Windows Server 2025 service identity, real outbound delivery, real crash, real TLS, or real adversarial input. **Tier 1 (S2.1, S2.2) runs first.** Tools used: the functional harness Receive/Compose/Monitor tabs (GUI, desktop session) and the Qt-free headless `--scenario` path, plus `verify` where it applies. All require the box `serve`-ing the matching graph. -> **Auth note for headless harness runs (applies to all of Sections 2–4).** S2.8/S1.AC-API prove the API binds loopback and **requires auth**. Against that production-posture (auth-on) engine the load runner validates the token via `/auth/me` and the scenario runner polls auth-gated `/messages`/`/dead-letters` — so **every headless `--scenario`/`--load`/`--failover` invocation must pass `--token `** (mint a bearer for the service-identity engine via the console/API auth route, or via the runbook's bootstrap-admin flow). The failover orchestrator is the one exception: it spawns its own nodes with `MEFOR_SECURITY_REQUIRE_SIGN_IN=false`, so it needs no `--token`. If you prefer, serve the test engine with auth disabled for the harness runs and re-enable it for the S2.8 attack; the commands below assume auth-on + `--token`. +> **Auth note for headless harness runs (applies to all of Sections 2–4).** S2.8/S1.AC-API prove the API binds loopback and **requires auth**. Against that production-posture (auth-on) engine the load runner validates the token via `/auth/me` and the scenario runner polls auth-gated `/messages`/`/dead-letters` — so **every headless `--scenario`/`--load`/`--failover` invocation must pass `--token `** (mint a bearer for the service-identity engine via the console/API auth route, for an administrator created with `messagefoundry admin-create`). The failover orchestrator is the one exception: it spawns its own nodes with `MEFOR_SECURITY_REQUIRE_SIGN_IN=false`, so it needs no `--token`. If you prefer, serve the test engine with auth disabled for the harness runs and re-enable it for the S2.8 attack; the commands below assume auth-on + `--token`. ### S2.1 (gap #1) — Healthy message → PROCESSED UNDER the NSSM service account @@ -1536,7 +1536,6 @@ These have no runnable PASS gate the harness can assert; a human observes and st | S4.10 (manual half) | Live DB-restart drill | Bounce the SQL Server/PostgreSQL service mid-flight; engine recovers, no loss | B6 | | S2.2 (remedy) | DPAPI boundary remedy confirmation | Chosen remedy (machine-scope DPAPI / env-var key / mint-as-service) lets the svc identity decrypt the key | S2.2 | | S4.9 (timing) | Windows port-rebind recovery timing | Record seconds for a killed listener to rebind `:2600` (host-variable; the box-owned number) | S4.9 | -| M-BOOTSTRAP | `bootstrap-admin.txt` handling | One-time admin password written to repo root on first `serve`; rotate + secure/delete after capture (D11) | runbook | ### F. Phase-2 (customer-network) backlog diff --git a/docs/testing/master-test-plan/01-environments-data-and-tooling.md b/docs/testing/master-test-plan/01-environments-data-and-tooling.md index df81684c..3fa2b0eb 100644 --- a/docs/testing/master-test-plan/01-environments-data-and-tooling.md +++ b/docs/testing/master-test-plan/01-environments-data-and-tooling.md @@ -252,8 +252,7 @@ deliberately fail-closed: operator-local profiles tuned to a specific deployment's volume. - `scripts/security/scan-tokens.local.txt` — the real customer/vendor token list. Only the **synthetic** `.example` is ever committed. -- `.env`, `.env.*`, `*.key`, `*.pem`, `*.pfx`, `secrets/`, `/docker/secrets.env`, `/docker/tls/`, - `bootstrap-admin.txt`. +- `.env`, `.env.*`, `*.key`, `*.pem`, `*.pfx`, `secrets/`, `/docker/secrets.env`, `/docker/tls/`. Two CI contexts back this up: **`forbidden-content (customer/PHI leak guard)`** ([`security.yml:367`](../../../.github/workflows/security.yml), running diff --git a/docs/testing/master-test-plan/10-auth-rbac-and-active-directory.md b/docs/testing/master-test-plan/10-auth-rbac-and-active-directory.md index 81785d36..6d5b56d8 100644 --- a/docs/testing/master-test-plan/10-auth-rbac-and-active-directory.md +++ b/docs/testing/master-test-plan/10-auth-rbac-and-active-directory.md @@ -15,7 +15,7 @@ This chapter covers the whole authentication/authorization core and, specificall - **Active Directory** — LDAPS simple-bind and nested-group resolution (`auth/ldap.py:86-282`), Kerberos/SPNEGO for both the JSON `POST /auth/negotiate` leg and the browser `GET /ui/sso` RFC 4559 flow (`auth/ldap.py:285-364`, `messagefoundry_webconsole/routes/sso.py`), AD group → role and AD group → per-connection scope maps, domain-join dependency, keytab/SPN preflight and its legible degradation, referrals / multi-domain / nested groups, AD outage and slow-LDAP behaviour, disabled / locked / expired-password accounts, service-account rights, LDAPS posture and channel binding. - **Federated SSO** — the OIDC authorization-code + PKCE relying party (ADR 0142, `auth/oidc/`, `auth/oidc_http.py`, `messagefoundry_webconsole/routes/oidc.py`). - **RBAC** — the 27-entry `Permission` catalog and 6 fixed `Role`s (`auth/permissions.py`, counted from the AST: `Permission=27`, `Role=6`), ADR 0045 custom roles, and the per-connection authorization scope. -- **Sessions & re-proof** — opaque tokens, idle/absolute/cap/rotation/inventory, TOTP + recovery codes, WebAuthn passkeys, session-window and ADR 0077 action-bound step-up, the new-client-IP re-anchor, bootstrap admin. +- **Sessions & re-proof** — opaque tokens, idle/absolute/cap/rotation/inventory, TOTP + recovery codes, WebAuthn passkeys, session-window and ADR 0077 action-bound step-up, the new-client-IP re-anchor, first-administrator provisioning. - **Audit** — the hash-chained tamper-evident `audit_log`, the ADR 0150 client address as a conditional 7th chain element, and the off-box tee. - **The AD lab environment** itself, without which none of the directory legs can be tested for real. @@ -48,9 +48,10 @@ This chapter covers the whole authentication/authorization core and, specificall | Evidence | What it proves | |---|---| -| `tests/test_auth_service.py` (29) | Login paths, AD role sync, lockout notification + security events, bootstrap-admin lifecycle | +| `tests/test_auth_service.py` (26) | Login paths, AD role sync, lockout notification + security events, first-administrator provisioning (a fresh store has zero users) | +| `tests/test_admin_create_cli.py` (9) | `messagefoundry admin-create`: a fresh store reaches an authenticated, USERS_MANAGE-gated API session by the operator route, and no implicit account appears without it | | `tests/test_api_auth.py` (61, 1402 lines) | End-to-end API auth: login/logout/me, session routes, users CRUD, forced first-login rotation, caller-scoped PHI-free security-event feed, ADR-0150 client reaching the audit row (`:1361`) | -| `tests/test_auth_hardening.py` (24) | Unknown-user timing equalizer, lockout window reset, LDAPS posture / RFC 4515 escaping / disabled-account / local-account conflict, session reaper, bootstrap password written to file not log, WS audit, HTTP grant/deny precision helper | +| `tests/test_auth_hardening.py` (24) | Unknown-user timing equalizer, lockout window reset, LDAPS posture / RFC 4515 escaping / disabled-account / local-account conflict, session reaper, WS audit, HTTP grant/deny precision helper | | `tests/test_auth_core.py` (13) + `tests/test_auth_store.py` (7) | argon2 roundtrip + rejections, password-policy rules, token uniqueness and hash-only storability, auth store tables | | `tests/test_auth_entry_hardening.py` (9) | Sliding-window limiter per-IP + global + monotonic clock; entry routes fail closed | | `tests/test_auth_session_lifecycle.py` (9) | Idle/absolute expiry, backward-clock revoke, activity-only refresh, Kerberos reject audited, AD role change revokes other sessions | @@ -73,7 +74,7 @@ This chapter covers the whole authentication/authorization core and, specificall | `tests/test_security_doc_drift.py` (~40) | The route-map meta-guard: every engine and `/ui` route appears in `docs/SECURITY.md` with its exact permission set **and** gate wrapper, both directions, with planted-mutation self-tests; permission catalogue == enum; role matrix == `BUILTIN_ROLE_PERMISSIONS`; ungated allow-lists pinned | | `tests/test_security_doc_rate_limits.py`, `tests/test_docs_security_pathways.py` | Every rate-limit setting documented with its shipped default; `/auth/providers` reports what is CONFIGURED, not what is reachable | | `tests/test_trust_anchors.py` (27) | SHA-256 anchor pin match/mismatch (refuses at both enforcement levels), `icacls` + POSIX writability detection, dormant when unconfigured, baseline/changed/pin-mismatch audit rows | -| `tests/test_bootstrap_admin_perms.py` (4), `tests/test_dr_rbac.py` (4), `tests/test_field_authz*.py` | Bootstrap file permissions / symlink refusal, `DR_OPERATE` gating, field-level PHI redaction + metadata drift guard | +| `tests/test_dr_rbac.py` (4), `tests/test_field_authz*.py` | `DR_OPERATE` gating, field-level PHI redaction + metadata drift guard | | `tests/test_approvals.py` (9) | Dual-control maker-checker release for gated high-value actions (ASVS 2.3.5) | | `tests/test_admin_new_ip.py` (11) | New-client-IP force-step-up, re-anchor on reauth and on `verify_mfa`, dedupe, loopback equivalence, never overrides RBAC, default-OFF no-op | | `tests/test_verify_federation.py` (17) | Offline `verify --section federation`: pinned endpoints as MANUAL, secret resolution + TLS-context build PASS/FAIL, a captured `id_token` replayed through the real ladder with a verdict per rung | @@ -170,7 +171,6 @@ Nine of the eighteen P0 rows sit behind the AD campaign gate, and that gate is * | AUTH-41 | Derived drift guard over the two hand-maintained gate exemption sets | Negative/Security | pytest | dev-PC | n/a | T | P2 | Every entry of `_MUST_CHANGE_EXEMPT_PATHS` and `_MFA_EXEMPT_ROUTES` resolves to a live `(method, path)` in `create_app()`'s route table; a stale entry fails; a newly added entry without a reviewed reason string fails; the existing shape assertions in `tests/test_mfa_access_gate.py:145` still hold | | AUTH-42 | Per-connection scope enforcement on the server backends | Cross-backend | pytest | container-CI | x2 | T | P1 | On SQL Server and PostgreSQL, a scoped identity gets 200 on its own connection's list/detail/replay/purge/connection-control/graph-edges and 403 on another's — the same six assertions `tests/test_channel_rbac.py` makes on SQLite | | AUTH-43 | mTLS client-cert → `Identity` mapping matrix (ADR 0083) | Negative/Security | pytest | dev-PC | SQLite | T | P1 | Unmapped subject → no identity (401); a CN spoofing a pinned DNS SAN → no identity; a mapped but **disabled** account → 401; `require_service_cert` refuses at **app build** to gate any route asking for `MESSAGES_VIEW_SUMMARY`/`_VIEW_RAW`; a cert identity can never satisfy a step-up or MFA gate | -| AUTH-44 | `bootstrap-admin.txt` handling on the box | Usability | manual | W2025-box | SQLite | T | P2 | Captured on first `serve`, then rotated and the file deleted or ACL'd to the service identity only; the password never appears in the general log. (Closes `docs/testing/WIN2025-TEST-PLAN.md` Appendix E **`W25:M-BOOTSTRAP`** — record there) | | AUTH-45 | Real roaming FIDO2 hardware key: enroll + assert, Chrome **and** Firefox | Functional | browser | browser-matrix | SQLite | T | P1 | Against an off-loopback engine with `[api].public_origin` set: enrollment behind the password re-proof succeeds and the credential lands in the store with a sign count; a subsequent assertion satisfies the MFA leg; the RP id derived from `public_origin` matches or the ceremony fails **legibly** (not a 500); both browsers recorded separately | | AUTH-46 | Windows Hello platform authenticator (scope decision pending — see Q11) | Functional | browser | browser-matrix | SQLite | C | P2 | If in scope: a platform authenticator enrolls and asserts on the same engine, and the credential is distinguishable from the roaming key in `GET /me/mfa`. If out of scope: recorded as a declined dimension with the reason | | AUTH-47 | TOTP enrollment with a real authenticator app | Functional | manual | W2025-box | SQLite | T | P2 | A current code admits; a wrong code and a code from the previous 30 s step are both rejected (`totp_skew_steps=0`); enrollment is audited. (Closes `docs/testing/WIN2025-TEST-PLAN.md` **`W25:S1.AC-MFA`** — record there) | @@ -186,7 +186,7 @@ Nine of the eighteen P0 rows sit behind the AD campaign gate, and that gate is * | AUTH-57 | Off-box collector actually receives the teed rows from the box | Functional | manual | W2025-box | x3 | T | P2 | The collector shows the run window's auth + PHI-access rows with matching `row_hash` values and non-NULL client addresses; no PHI body is present in any received record | | AUTH-58 | Trust-anchor preflight against the real AD FS / AD CS PEMs | Negative/Security | pytest + manual | dev-PC, AD-lab | SQLite | T | P2 | A configured `ad_tls_ca_cert_pin` / `oidc_tls_ca_cert_pin` that does not match the PEM **refuses at both enforcement levels**; a group-/world-writable anchor refuses at `[security].enforcement = enforce` and warns + audits at `warn`; an anchor change writes exactly one `auth.trust_anchor` row; with no anchor configured the preflight is dormant (zero rows) | | AUTH-59 | Engine-shard locality of the in-process auth budgets, fail-safe direction | Negative/Security | pytest | dev-PC | SQLite | T | P1 | Two `AuthService` instances over **one unified store**: an ADR 0077 action grant minted on A is **not** honoured on B (B re-prompts, never passes); a WebAuthn challenge started on A cannot be completed on B; a reconcile strike recorded on A is not visible to B. Every miss is a re-prompt, never a bypass | -| AUTH-60 | Engine-shard: the store-backed controls are **not** multiplied | Negative/Security | pytest | dev-PC | SQLite | T | P1 | Across the same two instances: the account lockout counter (5/15 min), the per-user session cap (5) and the bootstrap-admin expiry are shared — 3 failures on A plus 2 on B locks the account; a 6th session on B evicts A's oldest. Confirms the split stated at `docs/SECURITY.md:1538-1541` | +| AUTH-60 | Engine-shard: the store-backed controls are **not** multiplied | Negative/Security | pytest | dev-PC | SQLite | T | P1 | Across the same two instances: the account lockout counter (5/15 min) and the per-user session cap (5) are shared — 3 failures on A plus 2 on B locks the account; a 6th session on B evicts A's oldest. Confirms the split stated at `docs/SECURITY.md:1538-1541` | | AUTH-61 | Real-DOM `/ui` auth surface | Usability | browser | browser-matrix | SQLite | T | P1 | In a real browser: the login form renders and submits under the shipped CSP; the `__Host-` session cookie is actually stored with `Secure`+`SameSite=Strict`; the SSO link is hidden when the provider is degraded and no dead link is reachable; the OIDC 200 + meta-refresh landing navigates; the RFC 4559 401 is answered. Recorded per browser | | AUTH-62 | Advisory mutation + diff coverage scoped to `messagefoundry/auth/` and `api/security.py` | Compat | CI-leg | container-CI | n/a | C | P2 | The drafted jobs from `docs/quality-gates/HANDOFF-mutation-coverage.md` run advisory (`--exit-zero`), publish a surviving-mutant list for the auth scope, and never block a merge; a planted `and`→`or` mutation inside `require()` appears in the surviving-mutant report or is killed | | AUTH-63 | Documentation-drift closure for this area | Compat | pytest + manual | dev-PC | n/a | T | P2 | `docs/FEATURE-MAP.md` no longer says `require_mfa` is Administrator-scoped (`:129`), no longer defers federated SSO to 0.2 (`:132`), and no longer says the PySide6 desktop console "stays (additive)" (`:130`); ADR 0045 custom roles appear in the map; the two stale reconcile-default comments are fixed (see AUTH-37); the `FCP:AUTHN-11` (`docs/testing/FEATURE-COVERAGE-PLAN.md:1066`) and `FCP:RBAC-17` (`:1113`) notes are corrected | diff --git a/docs/testing/master-test-plan/12-vs-code-ide-extension.md b/docs/testing/master-test-plan/12-vs-code-ide-extension.md index daa0734d..197d831a 100644 --- a/docs/testing/master-test-plan/12-vs-code-ide-extension.md +++ b/docs/testing/master-test-plan/12-vs-code-ide-extension.md @@ -137,7 +137,7 @@ TypeScript and Python, and the **delivery vehicle**. | `engineLog.logState`/`logAction` (`engineLog.ts:79-92`) have no test; nothing forbids a caller passing a token/username/server `detail` as `detail` | A bearer token or attacker-controlled FastAPI `detail` lands in a channel users paste into public bug reports | Credential leak into a public issue | No — only `logProbe`'s route allowlist is indirectly anchored | P1 | | `home.ts:88-93` executes an **arbitrary** command id posted by the webview — no `CMD` allowlist, unlike `engineSetup.ts` | Any future path that gets attacker-influenced text into that webview escalates to command execution; the pattern gets copied into the next webview | Command execution from webview content | No | P1 | | `liveStatus.ts` is a **second, unpinned** poller (`:69-102`) | A regression promoting the background poll to an authenticating call refreshes the engine idle clock on a timer | The 30-minute idle timeout becomes unreachable (CWE-613) — the defect ADR 0110 AC-3 froze for `statusBar` only | No | P1 | -| `statusBar.ts` shell untested (ADR 0112 concedes it): `controlContext` trust+loopback gate (`:446-462`), trust refusal (`:516`), no-store fork-guard modal (`:524-535`), double-launch guard (`:490-501`), terminal ownership for Stop | Creating a rogue empty engine database with a fresh bootstrap admin; or terminating an engine the IDE does not own | Destructive and operator-invisible | No | P1 | +| `statusBar.ts` shell untested (ADR 0112 concedes it): `controlContext` trust+loopback gate (`:446-462`), trust refusal (`:516`), no-store fork-guard modal (`:524-535`), double-launch guard (`:490-501`), terminal ownership for Stop | Creating a rogue empty engine database; or terminating an engine the IDE does not own | Destructive and operator-invisible | No | P1 | | Activation is `onLanguage:python` + `workspaceContains:**/*.py`, and `validate.ts:29` raises an **error toast** when the CLI is unavailable | Every unrelated Python repo gets three subprocess launches and a red "MessageFoundry: validate failed" toast on open and on every save | The most visible install-quality defect; the most likely one-star review | No | P1 | | `debugpy` is an undeclared dependency (`testBench.ts:437` launches `type: "debugpy"`; `ide/package.json` declares no `extensionDependencies`) | Without `ms-python.python`, the advertised Test Bench "Debug" button fails with a raw VS Code error | A broken advertised feature on a fresh install | No | P1 | | The `test:unit` `--ignore` list (`package.json:822`) is hand-maintained, and the split is by **transitive** `vscode` import (e.g. `promote-target.test.ts` is excluded only because `promoteTarget.ts` imports a value from `cli.ts`) | A new test file added without an entry reds the ubuntu leg; a file that stops importing `vscode` stays Windows-only forever. On a fork (ubuntu-only matrix) the 86 excluded tests — **including every ADR 0035 test** — run nowhere | Security tests silently stop running | No | P1 | @@ -460,7 +460,8 @@ synthetic bodies, but the residue itself is the defect to report. half is the *unreachable-engine* branch, where the local token must still be forgotten (`auth.ts:73-86`'s `finally`). Getting this wrong makes "signed out" a lie on a shared workstation. -**Preconditions.** A real loopback engine with auth enabled and a bootstrap admin: +**Preconditions.** A real loopback engine with auth enabled and an administrator created by +`messagefoundry admin-create`: `python -m messagefoundry serve --config samples/config --db ./messagefoundry.db --env dev`. Credentials come from the environment (`MEFOR_*`), never from a file in the repo. @@ -661,7 +662,7 @@ testable one. **Services and accounts** -- **A loopback MessageFoundry engine**, auth-enabled with a bootstrap admin, for IDE-10, IDE-52, +- **A loopback MessageFoundry engine**, auth-enabled with an `admin-create` administrator, for IDE-10, IDE-52, IDE-61 and the status-pill/deep-probe paths: `python -m messagefoundry serve --config samples/config --db ./messagefoundry.db --env dev`. Scratch store only — never a real one. diff --git a/docs/testing/master-test-plan/15-alerting-and-observability.md b/docs/testing/master-test-plan/15-alerting-and-observability.md index 76f21c5c..8b49349d 100644 --- a/docs/testing/master-test-plan/15-alerting-and-observability.md +++ b/docs/testing/master-test-plan/15-alerting-and-observability.md @@ -74,7 +74,7 @@ can suffer, (2) the telling is **routable, suppressible and clearable** by rule | Evidence | What it proves | |---|---| -| `tests/test_alert_rules.py` (422 lines, ~28 cases) | `AlertRuleSet` matcher: event-type / connection glob / `min_depth` / `min_oldest_seconds` / AND-conjunction / first-match-wins / case-sensitive glob; severity tag; transport subset; `[]` suppression; cooldown override; factory fail-loud on an unconfigured transport; model validation. Lines 357/370/381/395 pin `lane_stuck`, `rcsi_off_degraded` and `bootstrap_admin_expiring` as rule-targetable end to end | +| `tests/test_alert_rules.py` (422 lines, ~28 cases) | `AlertRuleSet` matcher: event-type / connection glob / `min_depth` / `min_oldest_seconds` / AND-conjunction / first-match-wins / case-sensitive glob; severity tag; transport subset; `[]` suppression; cooldown override; factory fail-loud on an unconfigured transport; model validation. It pins `lane_stuck` and `rcsi_off_degraded` as rule-targetable end to end | | `tests/test_alert_sinks.py` (342 lines, 22 cases) | Fan-out to every transport; one failing transport does not starve the others; re-alert throttle; suspend / resume / window-expiry; per-rule mute; the no-message-body payload assertion; webhook JSON POST; SMTP send; SMTP allowlist refusal; `notifier_from_settings` variants; webhook URL length bound | | `tests/test_alert_state.py` (548 lines) | ADR 0044 lifecycle: first-fire open, refire dedupe on the throttle key, ack / resolve / reopen, auto-resolve on an inverse, `count_open_by_connection`, `reason` encrypted at rest, purge resolved-only, `escalation_tier` persisted monotonic, suspend/resume durable, side-observer never pins a disposition, a state-write failure never raises, three-backend method + column parity | | `tests/test_alert_escalation.py` (211 lines) | ADR 0133 AC-1..AC-4: occurrence escalation, highest-satisfied-tier wins, schedule-aware `decide`, `content_label` routing, `content_match` PHI-free payload, `content_match` re-emit idempotence — **all at the sink, none through a Handler** (see gap G2) | diff --git a/ide/src/engineControlModel.ts b/ide/src/engineControlModel.ts index 8c8a2d03..f3374160 100644 --- a/ide/src/engineControlModel.ts +++ b/ide/src/engineControlModel.ts @@ -72,7 +72,7 @@ export function classifyPreflight(result: { /** * Whether a REAL engine store already lives where `serve` would run. Pure over what the shell reads from * the run directory. When false, Start must not silently create a store — it confirms it will make a NEW - * database + bootstrap admin (the ADR 0110 §5 fork hazard, now an explicit, labelled choice). + * empty database with no accounts (the ADR 0110 §5 fork hazard, now an explicit, labelled choice). * * The heuristic is deliberately permissive-of-presence: the service TOML OR any `*.db` file is enough to * say "an engine already lives here", because either one means a `serve` run here adopts an existing engine diff --git a/ide/src/engineSetupContent.ts b/ide/src/engineSetupContent.ts index 1e0ea39e..82733f0e 100644 --- a/ide/src/engineSetupContent.ts +++ b/ide/src/engineSetupContent.ts @@ -98,8 +98,8 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ // The amendment's conditional truth, near-verbatim: the command is palette-visible and this page // is context-blind, so the copy states BOTH outcomes (runDirHasEngine guards only the store-less // launch — a has-store launch shows no modal). - "If no engine store exists here, you'll be asked to confirm creating a NEW database and a " + - "bootstrap admin; if one exists, this starts that engine.", + "If no engine store exists here, you'll be asked to confirm creating a NEW empty database " + + "with no accounts; if one exists, this starts that engine.", ], button: { id: "start-dev-engine", diff --git a/ide/src/engineStatusModel.ts b/ide/src/engineStatusModel.ts index ddefcf35..c718d398 100644 --- a/ide/src/engineStatusModel.ts +++ b/ide/src/engineStatusModel.ts @@ -635,7 +635,7 @@ export const CMD = { * - `canControl` — the target is loopback AND the workspace is trusted AND there is a workspace to run in * (a remote engine reads its OWN filesystem; an untrusted workspace must not exec a repo-supplied venv). * - `hasStore` — a real engine store already lives where `serve` would run; when false, Start does not - * silently create one — the shell confirms it will make a NEW database + bootstrap admin first. + * silently create one — the shell confirms it will make a NEW empty database first. * - `weStartedIt`— the IDE owns a LIVE engine process it launched; only then may Stop/Restart act. We never * kill an engine we did not start (with parallel worktrees a port-kill could down another session's). */ diff --git a/ide/src/statusBar.ts b/ide/src/statusBar.ts index f1cd4024..c8f79d43 100644 --- a/ide/src/statusBar.ts +++ b/ide/src/statusBar.ts @@ -441,7 +441,7 @@ export class EngineStatusBar implements vscode.Disposable { // ── Engine lifecycle (ADR 0112 — supersedes ADR 0110 §5) ───────────────────────────────────────── // The IDE may now RUN `serve`, not just hand over the command. Two safety properties, both enforced // here, keep this from reintroducing ADR 0110 §5's fork hazard (a stray `serve` that creates a rogue - // empty database + bootstrap admin): it runs ONLY for a loopback target in a trusted workspace, and it + // empty database with no accounts): it runs ONLY for a loopback target in a trusted workspace, and it // never SILENTLY creates a store — a run dir with no engine gets an explicit "create a new one" confirm. // Stop/Restart act ONLY on a process this IDE owns — never a port-kill of an engine started elsewhere. @@ -530,11 +530,13 @@ export class EngineStatusBar implements vscode.Disposable { notify("the engine is already running from here — use Restart to reload it."); return; } - // Fork guard (ADR 0110 §5): with no engine store here, starting would create a NEW database and a fresh - // bootstrap admin. That is sometimes exactly what the user wants — but it must be a deliberate choice. + // Fork guard (ADR 0110 §5): with no engine store here, starting would create a NEW, EMPTY database + // — no accounts (BACKLOG #1020 retired the implicit first-run admin), so the forked engine is one + // nobody can sign into until `messagefoundry admin-create` runs against it. That is sometimes + // exactly what the user wants — but it must be a deliberate choice. if (!this.controlContext().hasStore) { const go = await vscode.window.showWarningMessage( - `No engine store found in ${ws}. Starting here creates a NEW database and a bootstrap admin. Continue?`, + `No engine store found in ${ws}. Starting here creates a NEW empty database with no accounts. Continue?`, { modal: true }, "Create new engine", ); @@ -830,7 +832,7 @@ export function registerEngineStatusBar(context: vscode.ExtensionContext): Engin vscode.commands.registerCommand("messagefoundry.engineCopyStartCommand", async () => { // Deliberately COPY, not run. A terminal spawned here defaults its cwd to the workspace folder — // in a git worktree that has no service TOML and no store, so `serve` would quietly create a - // BRAND-NEW empty database and a fresh bootstrap admin, forking the user's engine. Handing them + // BRAND-NEW empty database with no accounts, forking the user's engine. Handing them // the command lets them run it where they mean to. No --db/--env overrides: the service TOML is // the authority on which store and which environment this engine is. const cmd = `python -m messagefoundry serve --config ${configDir()}`; diff --git a/ide/src/test/suite/engine-setup.test.ts b/ide/src/test/suite/engine-setup.test.ts index 43b1d0a2..6ccf5d12 100644 --- a/ide/src/test/suite/engine-setup.test.ts +++ b/ide/src/test/suite/engine-setup.test.ts @@ -80,10 +80,12 @@ suite("engine setup page — the test-only dev engine is separated and context-h const dev = SETUP_SECTIONS.find((s) => s.tone === "dev"); assert.ok(dev, "the dev-engine section is missing"); const body = dev.body.join(" "); - // No-store half: the modal create-DB confirm (runDirHasEngine guards only this case). + // No-store half: the modal create-DB confirm (runDirHasEngine guards only this case). BACKLOG + // #1020 retired the implicit first-run admin, so the promise is an EMPTY database — a forked + // engine nobody can sign into until `messagefoundry admin-create` runs against it. assert.ok( - /NEW database and a bootstrap admin/i.test(body), - "must state that a store-less launch confirms creating a NEW database and a bootstrap admin", + /NEW empty database with no accounts/i.test(body), + "must state that a store-less launch confirms creating a NEW empty database with no accounts", ); // Has-store half: a launch where a store exists shows no modal — it just starts that engine. assert.ok( diff --git a/ide/src/test/suite/engine-status.test.ts b/ide/src/test/suite/engine-status.test.ts index f0887b58..15d1bc08 100644 --- a/ide/src/test/suite/engine-status.test.ts +++ b/ide/src/test/suite/engine-status.test.ts @@ -79,7 +79,7 @@ suite("engineStatusModel — the tokenless /health probe cannot lie", () => { test("version present ⇒ unverified — NOT ok, and NOT green", () => { // The other half of the same bug. `optional_identity` applies no RBAC and no must-change gate, so a - // version merely proves that *a* session exists — a bootstrap admin locked out of every route still + // version merely proves that *a* session exists — a must-change account locked out of every route still // gets one back. Only a protected route can earn `ok` (see classifyDeep). const link = classifyHealth({ kind: "ok", body: { status: "ok", version: "0.3.0" } }, T, NOW, false); assert.strictEqual(link.state, "unverified"); diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index cac6da31..bb0f1154 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -454,6 +454,37 @@ def main(argv: list[str] | None = None) -> int: ) init.add_argument("--json", action="store_true", help="emit JSON") + admin_create = sub.add_parser( + "admin-create", + help="create a local Administrator directly on the engine's store — the explicit, " + "operator-invoked route to the FIRST account on a fresh install (there is no implicit " + "first-run account: ASVS 6.3.2, BACKLOG #1020). Run it on the box, with the engine stopped " + "or running, then sign in to the console", + ) + admin_create.add_argument("--username", required=True, help="the account name to create") + admin_create.add_argument("--display-name", default=None, help="optional display name") + admin_create.add_argument( + "--email", + default=None, + help="optional address for the out-of-band security notices (lockout, password/roles change). " + "Without one those notices have no mailbox to reach and only the audited " + "GET /me/security-events feed records them", + ) + admin_create.add_argument( + "--db", default=None, help="store path (overrides [store].path for this run)" + ) + admin_create.add_argument( + "--service-config", + default="messagefoundry.toml", + help="service settings TOML the [store] + [auth] sections come from", + ) + admin_create.add_argument( + "--password-stdin", + action="store_true", + help="read the password from stdin (first line) instead of prompting — for an unattended " + "install. The password is NEVER an argv flag: argv is readable by every process on the box", + ) + support_bundle = sub.add_parser( "support-bundle", help="write a SECRET-FREE / PHI-free support zip (engine version + config summary + a " @@ -3936,6 +3967,116 @@ async def run() -> tuple[int, str]: return 0 +def _read_new_password(from_stdin: bool) -> str | None: + """The password for ``admin-create``: one stdin line, or a confirmed no-echo prompt. + + Never an argv flag. On every supported platform ``/proc//cmdline``, ``ps`` or the Windows + process list makes another user's argv readable, so a ``--password`` option would publish the + engine's most privileged credential to every account on the box for the life of the process. + Returns ``None`` (message already printed) when the operator supplied nothing usable.""" + import getpass + + if from_stdin: + password = sys.stdin.readline().rstrip("\r\n") + else: + password = getpass.getpass("New administrator password: ") + if password != getpass.getpass("Confirm password: "): + print("error: the two passwords did not match", file=sys.stderr) + return None + if not password: + print("error: no password supplied", file=sys.stderr) + return None + return password + + +def _admin_create(args: argparse.Namespace) -> int: + """Create a local Administrator on the engine's store (BACKLOG #1020). + + This is the ONLY route to the first account. A fresh store has no users at all: the implicit + first-run ``admin`` was retired because ASVS 6.3.2 wants no default account to exist, and the two + could not both hold. What replaces it is explicit rather than implicit — an operator standing at + the box names the account and chooses its password, so nothing privileged exists until somebody + decides it should, and no credential is written to disk for a later reader to find. + + Runs against the SAME resolved settings ``serve`` uses, so the password is held to the deployment's + own ``[auth]`` policy rather than a CLI-local default. The role is Administrator: the command exists + to break the chicken-and-egg (``POST /users`` needs ``users:manage``, which needs an account), and a + lesser role would not. Creating further accounts is the API's job, not this command's.""" + import asyncio + + from pydantic import ValidationError + + from messagefoundry.auth.permissions import Role + from messagefoundry.auth.service import AuthService + from messagefoundry.config.secretprovider import resolve_secret_provider + from messagefoundry.config.settings import StoreBackend, load_settings + from messagefoundry.store.base import open_store + + cli: dict[str, dict[str, object]] = {} + if args.db is not None: + cli.setdefault("store", {})["path"] = args.db + try: + settings = load_settings(config_path=args.service_config, cli=cli) + except (FileNotFoundError, ValueError, ValidationError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + password = _read_new_password(args.password_stdin) + if password is None: + return 2 + + async def run() -> tuple[int, str]: + # Same provider serve resolves, so an [auth] secret reference (e.g. the AD bind password) + # behaves identically here — the CLI must not be a second, laxer configuration path. + secret_provider = resolve_secret_provider(settings.secrets) + store = await open_store(settings.store) + try: + auth = AuthService(store, settings.auth, secret_provider=secret_provider) + await auth.initialize() # seeds the built-in roles; creates no account + if await store.get_user_by_username(args.username) is not None: + return 2, f"error: a user named {args.username!r} already exists" + violations = auth.password_violations(password, username=args.username) + if violations: + return 2, "error: password must " + "; ".join(violations) + await auth.create_local_user( + username=args.username, + password=password, + display_name=args.display_name, + email=args.email, + roles=[Role.ADMINISTRATOR.value], + actor="cli", + # The operator IS the account holder here, so there is no second party a forced + # rotation would protect — unlike an admin-issued credential via POST /users. + must_change_password=False, + ) + finally: + await store.close() + return 0, "" + + rc, message = asyncio.run(run()) + if rc != 0: + print(message, file=sys.stderr) + return rc + # Name the store that was actually written: an --db/[store].path typo otherwise shows up only as a + # login failure against the engine's real database, long after the cause. + store = settings.store + # Name the store that was actually written. A --db / [store].path / MEFOR_STORE_* typo otherwise + # surfaces only as a login failure against the engine's REAL database, long after the cause. + where = ( + repr(store.path) + if store.backend is StoreBackend.SQLITE + else f"{store.database!r} on {store.server!r}" + ) + print(f"created Administrator {args.username!r} in the {store.backend.value} store at {where}") + if not args.email: + print( + "note: no --email — the out-of-band security notices for this account have no mailbox " + "to reach. Set one from the console (Users -> edit) to enable them.", + file=sys.stderr, + ) + return 0 + + def _rekey_audit(args: argparse.Namespace) -> int: """Enable HMAC keying of an EXISTING keyless audit chain (#190-D migration). @@ -4910,6 +5051,7 @@ def _emit_error(message: str, *, as_json: bool) -> int: "supervise": _supervise, "import": _import, "init": _init, + "admin-create": _admin_create, "validate": _validate, "graph": _graph, "dryrun": _dryrun, diff --git a/messagefoundry/api/app.py b/messagefoundry/api/app.py index 216acf14..e41e701a 100644 --- a/messagefoundry/api/app.py +++ b/messagefoundry/api/app.py @@ -24,7 +24,6 @@ import asyncio import base64 import binascii -import datetime import json import logging import mimetypes @@ -182,7 +181,7 @@ # behavior is preserved via three seams the console installs: app.state.ui_csp, # app.state.ui_ws_authorize, app.state.ui_connections_render (read by the always-on middleware/routes). from messagefoundry.auth import Identity, Permission -from messagefoundry.auth.service import AuthService, BootstrapAdmin +from messagefoundry.auth.service import AuthService from messagefoundry.auth.trust_anchors import ( AnchorSpec, TrustAnchorError, @@ -262,7 +261,7 @@ from messagefoundry.parsing.sniff import attachment_mime_agrees, nontext_upload_reason from messagefoundry.pipeline import ConfigReloadDenied, Engine from messagefoundry.pipeline.alert_sinks import EmailTransport, notifier_from_settings -from messagefoundry.pipeline.alerts import AlertSink, LoggingAlertSink +from messagefoundry.pipeline.alerts import LoggingAlertSink from messagefoundry.pipeline.cluster import build_coordinator from messagefoundry.pipeline.connscale_shim import maybe_install_executor_shim from messagefoundry.pipeline.dr import DrActivationError @@ -289,7 +288,6 @@ make_spec, ) from messagefoundry.store.metadata import user_metadata -from messagefoundry.store.store import _secure_file from messagefoundry.transports.ai_broker import AiBrokerError, ai_broker_from_settings from messagefoundry.transports.base import ( DeliveryError, @@ -5129,54 +5127,6 @@ def _oidc_authorization_host(endpoint: str) -> str: return app -def _emit_bootstrap_admin(bootstrap: BootstrapAdmin, store_settings: StoreSettings) -> None: - """Persist the one-time bootstrap password to a restricted file — never the rotating log. - - Until rotated it is a standing Administrator credential, so it must not land in NSSM's broadly - readable stdout capture. Write it to an owner-only file the operator consumes and deletes; log - only the location. Paired with server-side must_change_password enforcement, it dies at first login. - """ - base = Path(store_settings.path or ".").resolve() - secret_file = base.parent / "bootstrap-admin.txt" - body = f"username: {bootstrap.username}\npassword: {bootstrap.password}\n" - # ASVS 6.4.5: state the renewal deadline WITH the credential — an unclaimed bootstrap is - # auto-disabled at this instant, so the "sign in and change it before then" instruction ships - # alongside the secret rather than being an out-of-band assumption. None when expiry is off. - deadline = ( - datetime.datetime.fromtimestamp(bootstrap.expires_at, tz=datetime.UTC).isoformat() - if bootstrap.expires_at is not None - else None - ) - if deadline is not None: - body += ( - f"expires: {deadline} — sign in and change this password before then, " - "or the unclaimed credential is disabled.\n" - ) - # Create the file owner-only from the instant it exists, closing the POSIX create-then-chmod TOCTOU - # (SEC-020): O_EXCL + 0o600 means the secret is never group/world-readable even momentarily, and - # O_EXCL also refuses to follow a pre-planted symlink/file at that path. A second service start - # before the operator deletes the prior file would hit FileExistsError — remove the stale file we - # own, then re-create exclusively. - flags = os.O_CREAT | os.O_WRONLY | os.O_EXCL | os.O_TRUNC - try: - fd = os.open(str(secret_file), flags, 0o600) - except FileExistsError: - secret_file.unlink() # the prior owner-only file we wrote; replace it under the same mode - fd = os.open(str(secret_file), flags, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(body) - # On Windows os.open's mode is minimal, so still apply the icacls owner-only DACL (the store's - # platform-correct primitive: chmod on POSIX is a no-op here since O_EXCL already set 0o600). - _secure_file(secret_file) - _log.warning( - "Created bootstrap admin %r; one-time password written to %s — sign in, change it, then " - "delete that file%s.", - bootstrap.username, - secret_file, - f" (expires {deadline} unless claimed)" if deadline is not None else "", - ) - - _SESSION_REAP_INTERVAL = 3600.0 # purge expired/idle sessions hourly to bound the sessions table @@ -5217,34 +5167,6 @@ async def _directory_reconciler(auth: AuthService, interval: float) -> None: _log.exception("directory reconcile: pass failed; will retry next interval") -_BOOTSTRAP_EXPIRY_REMINDER_INTERVAL = 3600.0 # re-check the bootstrap warn window hourly - - -async def _bootstrap_expiry_reminder(auth: AuthService, sink: AlertSink) -> None: - """Remind an operator, ONCE, that an UNCLAIMED first-run bootstrap admin is nearing its auto-disable - deadline (ASVS 6.4.5 arm 2). API-lifespan-owned (like :func:`_session_reaper`), NOT engine-owned — it - reaches the :class:`AuthService` directly. ``auth.bootstrap_expiry_warning()`` evaluates the warn - window and latches once-per-process; a non-None result is the fresh reminder to emit as the PHI-free - ``bootstrap_admin_expiring`` alert (the ISO deadline + whole hours remaining — never the password). - - A transient store error must not kill the loop for the process lifetime (that would silently drop the - reminder) — log and retry next interval, the session-reaper precedent.""" - while True: - try: - warning = await auth.bootstrap_expiry_warning() - if warning is not None: - expires_at, hours_remaining = warning - iso = datetime.datetime.fromtimestamp(expires_at, tz=datetime.UTC).isoformat() - sink.bootstrap_admin_expiring( - "bootstrap-admin", expires_at=iso, hours_remaining=hours_remaining - ) - except asyncio.CancelledError: - raise - except Exception: - _log.exception("bootstrap expiry reminder: pass failed; will retry next interval") - await asyncio.sleep(_BOOTSTRAP_EXPIRY_REMINDER_INTERVAL) - - def create_managed_app( *, db_path: str | Path | None = None, @@ -5332,8 +5254,9 @@ def create_managed_app( Pass ``store_settings`` for full backend selection (the service path), or ``db_path`` (+optional ``synchronous``) as a SQLite shortcut. ``config_dir`` loads the code-first Connection/Router/ - Handler graph. ``auth_settings`` (when enabled) attaches an :class:`AuthService`, seeds the - built-in roles, and creates a bootstrap admin on first run. The store is opened via the + Handler graph. ``auth_settings`` (when enabled) attaches an :class:`AuthService` and seeds the + built-in roles; it creates NO account (BACKLOG #1020 — the first Administrator comes from + ``messagefoundry admin-create``, never from a first run). The store is opened via the backend-agnostic :func:`~messagefoundry.store.open_store`. ``api_listener`` is the engine's own ``(host, port)`` (from ``[api]``), reserved so no inbound listener can be wired onto the API's port — the CLI server passes it; in-process/test callers omit it (no separate API socket is bound). @@ -5633,7 +5556,6 @@ async def _audit_upload_prune(meta: UploadedFileMeta) -> None: upload_retention_runner.start() reaper: asyncio.Task[None] | None = None reconciler: asyncio.Task[None] | None = None - bootstrap_reminder: asyncio.Task[None] | None = None security_notifier = None # Back the COMPLETE loosening list on GET /security/posture: [auth] carries posture switches # (ad_session_recheck_seconds) that security_loosenings() must see. Stashed here, OUTSIDE the @@ -5676,10 +5598,8 @@ async def _audit_upload_prune(meta: UploadedFileMeta) -> None: # connector-construction gate, so the clamp is inert unless the posture arrives here). hop_posture=_hop_posture, ) - bootstrap = await auth.initialize() + await auth.initialize() app.state.auth = auth - if bootstrap is not None: - _emit_bootstrap_admin(bootstrap, resolved) if not auth.webauthn_available() and await store.any_webauthn_credentials(): # L5b (ADR 0068 decision 5): enrolled passkeys exist but the [webauthn] extra is # not installed (engine moved/reinstalled, same DB) — affected users stay @@ -5724,14 +5644,6 @@ async def _audit_upload_prune(meta: UploadedFileMeta) -> None: auth_settings.oidc_redirect_path, ) reaper = asyncio.create_task(_session_reaper(store)) - if auth_settings.bootstrap_expiry_hours > 0: - # ASVS 6.4.5 arm 2: nudge an operator BEFORE an unclaimed first-run bootstrap admin is - # auto-disabled. API-lifespan-owned (like the session reaper), NOT engine-owned — it - # reaches the AuthService directly. The warn method latches once-per-window; the sink logs - # (LoggingAlertSink fallback) or notifies. No task when time-expiry is off (byte-identical). - bootstrap_reminder = asyncio.create_task( - _bootstrap_expiry_reminder(auth, notifier or LoggingAlertSink()) - ) if auth.directory_reconcile_enabled: # ADR 0079 mechanism 2: propagate an AD disable/delete to live engine sessions. # Default OFF (ad_session_recheck_seconds = 0) — no task, no behaviour change. @@ -5760,11 +5672,6 @@ async def _audit_upload_prune(meta: UploadedFileMeta) -> None: # previously-died reaper stored, so it can't propagate here and skip engine.stop() # (review M-33). await asyncio.gather(reaper, return_exceptions=True) - if bootstrap_reminder is not None: - bootstrap_reminder.cancel() - # gather(return_exceptions): absorb our cancellation + any stored exception so it can't - # propagate here and skip engine.stop() (the reaper precedent). - await asyncio.gather(bootstrap_reminder, return_exceptions=True) await engine.stop() # B11: shut down the harness-only instrumented executor (None in production / other tests). # The engine is stopped (no more to_thread work), so a non-blocking shutdown is clean. diff --git a/messagefoundry/auth/service.py b/messagefoundry/auth/service.py index 7f69702a..b8717e4e 100644 --- a/messagefoundry/auth/service.py +++ b/messagefoundry/auth/service.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 MessageFoundry Organization and contributors -"""AuthService — orchestrates authentication, sessions, role resolution, and first-run bootstrap. +"""AuthService — orchestrates authentication, sessions, and role resolution. Pure engine-side code (no FastAPI): the API layer composes it. It ties together the store (users, roles, sessions, audit), password hashing/policy, opaque session tokens, and the LDAP/Kerberos @@ -67,9 +67,6 @@ _log = logging.getLogger(__name__) -#: The account created on first run when the store has no users (HIPAA unique-user bootstrap). -BOOTSTRAP_USERNAME = "admin" - def _warn_if_corpus_unreadable(path: str | None) -> None: """Eagerly load (and cache) an operator breach corpus at startup so a misconfigured path surfaces @@ -169,18 +166,6 @@ class MfaStatus: webauthn_enrolled: bool = False -@dataclass(frozen=True) -class BootstrapAdmin: - """Credentials for the one-time bootstrap admin (printed once, then must be changed).""" - - username: str - password: str - # ASVS 6.4.5: the instant an unclaimed bootstrap credential is auto-disabled - # (created_at + [auth].bootstrap_expiry_hours), or None when expiry is off (hours=0). Surfaced at - # issuance so the renewal instruction ("claim it before ") ships WITH the credential. - expires_at: float | None = None - - @dataclass(frozen=True) class CustomRoleInfo: """An admin-defined custom role and its resolved permission subset (ADR 0045).""" @@ -358,9 +343,6 @@ def __init__( self._reconcile_last_probed: dict[str, float] = {} #: Latched mass-revoke circuit-breaker trip, cleared by the next clean pass. self._reconcile_alert: str | None = None - #: ASVS 6.4.5 arm 2: once-per-process latch so the bootstrap-expiry reminder fires exactly once - #: while the unclaimed bootstrap sits inside its warn window (see :meth:`bootstrap_expiry_warning`). - self._bootstrap_expiry_warned = False # Advisory, NON-STICKY federated-IdP health (ADR 0142 AC-8) — see the oidc_available docstring. self._oidc_unavailable_reason: str | None = None self._oidc_client_secret: str | None = None @@ -510,13 +492,14 @@ async def audit_oidc_reject(self, reason: str) -> None: # --- lifecycle ----------------------------------------------------------- - async def initialize(self) -> BootstrapAdmin | None: - """Seed the built-in roles and, on an empty store, create the bootstrap admin. Also retires an - unclaimed bootstrap that became superseded/expired while the service was down (WP-3).""" + async def initialize(self) -> None: + """Seed the built-in roles. Creates **no account**. + + There is deliberately no implicit first-run administrator (ASVS 6.3.2 — no default accounts, + BACKLOG #1020, which closes as superseded by that verb rather than as built). A fresh store + therefore has zero users until an operator runs ``messagefoundry admin-create`` on the box, + which is the only route that mints the first Administrator and names it themselves.""" await self._seed_roles() - created = await self._ensure_bootstrap_admin() - await self._retire_superseded_bootstrap() - return created async def _seed_roles(self) -> None: for role in Role: @@ -525,36 +508,9 @@ async def _seed_roles(self) -> None: role_id=role.value, display_name=label, description=description, builtin=True ) - async def _ensure_bootstrap_admin(self) -> BootstrapAdmin | None: - if await self._store.count_users() > 0: - return None - password = self._generate_policy_password() - user_id = uuid4().hex - await self._store.create_user( - user_id=user_id, - username=BOOTSTRAP_USERNAME, - auth_provider=AuthProvider.LOCAL.value, - display_name="Bootstrap Administrator", - password_hash=await self._argon2(hash_password, password), - must_change_password=True, - ) - await self._store.set_user_roles( - user_id, [Role.ADMINISTRATOR.value], assigned_by="bootstrap" - ) - await self._audit("auth.bootstrap_admin_created", actor="bootstrap") - # ASVS 6.4.5: derive the expiry from the STORED created_at (the same base - # _retire_superseded_bootstrap uses), so the surfaced deadline is exactly the retirement instant. - expiry_hours = self._settings.bootstrap_expiry_hours - expires_at: float | None = None - if expiry_hours > 0: - created = await self._store.get_user(user_id) - if created is not None: - expires_at = created.created_at + expiry_hours * 3600 - return BootstrapAdmin(username=BOOTSTRAP_USERNAME, password=password, expires_at=expires_at) - def _generate_policy_password(self) -> str: - """A random password that satisfies the active policy — so the printed bootstrap credential - is held to the same bar operators are. ``token_urlsafe(n)`` yields ~1.33·n chars (so length is + """A random password that satisfies the active policy — so a machine-issued credential is held + to the same bar operators are. ``token_urlsafe(n)`` yields ~1.33·n chars (so length is guaranteed ≥ ``min_length``); the loop covers the astronomically-unlikely breach/context hit or an opt-in character-class requirement a given token happens to miss.""" length = max(16, self._policy.min_length) @@ -564,68 +520,6 @@ def _generate_policy_password(self) -> str: return candidate return secrets.token_urlsafe(length) + "aA1!" # defensive: satisfies any class requirement - async def _other_enabled_admin_exists(self, exclude_id: str) -> bool: - """True iff some enabled administrator other than ``exclude_id`` exists.""" - for user in await self._store.list_users(): - if user.disabled or user.id == exclude_id: - continue - if Role.ADMINISTRATOR.value in await self._store.get_user_role_ids(user.id): - return True - return False - - async def _retire_superseded_bootstrap(self, now: float | None = None) -> None: - """Disable the first-run bootstrap admin once it's no longer needed (WP-3): when a **second** - administrator exists, or — while still **unclaimed** (never password-changed) — once its expiry - window lapses. Only ever touches an unclaimed bootstrap (``must_change_password`` still set): if - the operator changed its password it is a normal admin account and is left alone, so this can't - lock out a legitimate single-admin deployment.""" - now = time.time() if now is None else now - boot = await self._store.get_user_by_username(BOOTSTRAP_USERNAME) - if boot is None or boot.disabled or not boot.must_change_password: - return # gone, already disabled, or claimed (a real account now) - expiry_hours = self._settings.bootstrap_expiry_hours - expired = expiry_hours > 0 and now >= boot.created_at + expiry_hours * 3600 - superseded = await self._other_enabled_admin_exists(boot.id) - if not (expired or superseded): - return - await self._store.set_user_disabled(boot.id, disabled=True) - await self._store.revoke_user_sessions(boot.id) - await self._audit( - "auth.bootstrap_admin_retired", - actor="system", - detail=_json({"reason": "superseded" if superseded else "expired"}), - ) - - async def bootstrap_expiry_warning(self, now: float | None = None) -> tuple[float, int] | None: - """ASVS 6.4.5 arm 2: if the first-run bootstrap admin is STILL UNCLAIMED and ``now`` sits inside - its warn window ``[expires_at - bootstrap_warn_hours, expires_at)``, return the retirement instant - + whole hours remaining ONCE — an in-memory latch means a periodic caller emits exactly one - reminder per process. Returns ``None`` when time-expiry is off, the bootstrap is - gone/disabled/claimed, ``now`` is before the window (or already at/past it — retirement itself has - taken over by then), or a reminder already fired this process. Advisory only: the actual - auto-disable is :meth:`_retire_superseded_bootstrap`; this nudges an operator BEFORE it happens. - - ``expires_at`` is derived from the STORED ``created_at`` — the same base - :meth:`_retire_superseded_bootstrap` uses — so the surfaced deadline is exactly the retirement - instant, not a fresh clock. The caller (the API-lifespan reminder) turns it into an ISO string + - the PHI-free ``bootstrap_admin_expiring`` AlertSink event; the password is never surfaced.""" - if self._bootstrap_expiry_warned: - return None - expiry_hours = self._settings.bootstrap_expiry_hours - if expiry_hours <= 0: - return None # no time-expiry configured → nothing to warn about - boot = await self._store.get_user_by_username(BOOTSTRAP_USERNAME) - if boot is None or boot.disabled or not boot.must_change_password: - return None # gone, already disabled, or claimed (a real account now) - now = time.time() if now is None else now - expires_at = boot.created_at + expiry_hours * 3600 - warn_start = expires_at - max(0, self._settings.bootstrap_warn_hours) * 3600 - if not (warn_start <= now < expires_at): - return None # not yet in the window, or already at/past the retirement instant - self._bootstrap_expiry_warned = True # latch: exactly one reminder per process - hours_remaining = max(0, int((expires_at - now) // 3600)) - return (expires_at, hours_remaining) - # --- login --------------------------------------------------------------- async def login( @@ -643,12 +537,6 @@ async def login( async def _login_local( self, username: str, password: str, *, client: str | None ) -> LoginOutcome: - # Enforce bootstrap expiry/supersession before the credential check: an unclaimed bootstrap - # that lapsed (or was superseded) is disabled here, so the disabled-account path below refuses - # it like any other invalid login (WP-3). Scoped to the bootstrap username to keep normal - # logins free of the extra lookups. - if username == BOOTSTRAP_USERNAME: - await self._retire_superseded_bootstrap() user = await self._store.get_user_by_username(username) if user is None or user.auth_provider != AuthProvider.LOCAL.value or user.disabled: # Equalize timing with the real-password path so a missing/disabled/AD account is not @@ -688,13 +576,13 @@ async def _login_local( # ASVS 6.4.1: an admin-issued initial/reset credential that was never claimed EXPIRES — the # password verified, but a `must_change_password` temp that is older than # `initial_password_expiry_hours` is refused like any other invalid login (a generic error, so - # it is indistinguishable from a wrong password) and audited. The bootstrap admin has its own - # expiry path (handled above) and is carved out; a user who set their own password has - # `must_change_password=False` and is never gated here. + # it is indistinguishable from a wrong password) and audited. A user who set their own password + # has `must_change_password=False` and is never gated here. The retired first-run bootstrap + # account used to be carved out of this rule because it had its own expiry path; with no + # implicit account left (BACKLOG #1020), every unclaimed temp is now held to the same deadline. expiry_hours = self._settings.initial_password_expiry_hours if ( - username != BOOTSTRAP_USERNAME - and user.must_change_password + user.must_change_password and expiry_hours > 0 and user.password_changed_at is not None and now - user.password_changed_at > expiry_hours * 3600.0 @@ -2550,7 +2438,15 @@ async def create_local_user( email: str | None, roles: Sequence[str], actor: str, + must_change_password: bool = True, ) -> str: + """Create a local account with ``roles``. + + ``must_change_password`` defaults True because the common caller is an ADMIN setting SOMEONE + ELSE's credential: that is a one-time temp and first login must rotate it (ASVS 6.4.6 / + WP-L3-12). The one caller that passes False is ``messagefoundry admin-create``, where the + operator standing at the box is choosing THEIR OWN password — there is no second party the + rotation would protect, and forcing it would only add a step to first sign-in.""" user_id = uuid4().hex await self._store.create_user( user_id=user_id, @@ -2559,16 +2455,12 @@ async def create_local_user( display_name=display_name, email=email, password_hash=await self._argon2(hash_password, password), - # Admin-set the credential is a one-time temp: force rotation on first login so the - # operator never sets a lasting password the user keeps (ASVS 6.4.6 / WP-L3-12). - must_change_password=True, + must_change_password=must_change_password, ) await self._store.set_user_roles(user_id, roles, assigned_by=actor) await self._audit( "user.created", actor=actor, detail=_json({"username": username, "roles": list(roles)}) ) - # If this created a second administrator, retire the now-redundant bootstrap admin (WP-3). - await self._retire_superseded_bootstrap() return user_id async def update_user( @@ -2784,7 +2676,9 @@ async def is_last_enabled_admin(self, user_id: str) -> bool: """True iff ``user_id`` is an enabled administrator and the only one remaining. Guards the role-removal path so the deployment can never be left with no usable admin - account (the bootstrap admin only regenerates against a fully empty users table). + account. Nothing regenerates one: since BACKLOG #1020 there is no implicit first-run + administrator, and ``messagefoundry admin-create`` is an on-the-box command, not a fallback + the running engine can reach. """ admins: set[str] = set() for user in await self._store.list_users(): diff --git a/messagefoundry/config/settings.py b/messagefoundry/config/settings.py index 672b14ee..91ffefe8 100644 --- a/messagefoundry/config/settings.py +++ b/messagefoundry/config/settings.py @@ -1709,8 +1709,9 @@ class AuthSettings(_Section): # secure default over back-compat. It cannot lock a fresh admin out: a required-but-unenrolled # Administrator can still reach the factor-enrollment routes (they are gated by a fresh PASSWORD # step-up bound to the enroll/confirm action, never by the MFA gate — see - # api/security.py:require_reauth_only_action), so the bootstrap admin enrolls TOTP then satisfies - # it. Set ``require_mfa = false`` (the documented opt-out) to revert to the single-factor default. + # api/security.py:require_reauth_only_action), so the first operator-created Administrator enrolls + # TOTP then satisfies it. Set ``require_mfa = false`` (the documented opt-out) to revert to the + # single-factor default. # An off-loopback bind that serves local accounts MUST keep this on; ``serve`` makes that posture # explicit (sec-mfa-on) — on an exposed (non-loopback) PHI bind with this **explicitly opted out** # it **refuses to start** on a production instance and **warns** on a non-production one, mirroring @@ -1772,20 +1773,12 @@ class AuthSettings(_Section): password_breach_corpus_file: str | None = None lockout_threshold: int = 5 # consecutive failed logins before the account locks lockout_minutes: int = 15 - # First-run bootstrap admin: auto-disabled once a second administrator exists, and (if still - # unclaimed — never password-changed) disabled this many hours after creation. 0 = no time expiry. - bootstrap_expiry_hours: int = 72 - # ASVS 6.4.5 arm 2: how many hours BEFORE that auto-disable to start reminding an operator (via the - # `bootstrap_admin_expiring` AlertSink event) that the unclaimed first-run credential is about to be - # retired. The API-lifespan reminder fires once per process while now sits inside - # [expires_at - bootstrap_warn_hours, expires_at). Only meaningful when bootstrap_expiry_hours > 0. - bootstrap_warn_hours: int = 24 # ASVS 6.4.1: an admin-issued initial/reset credential (a `must_change_password` temp password) that # is never claimed EXPIRES this many hours after it was set. Without it, an unused reset password # grants an authenticated session indefinitely — and the one action it permits is to SET the # password, i.e. account takeover. Keyed on `password_changed_at`; a user who set their own password - # has `must_change_password=False` and is unaffected. The bootstrap admin has its own - # `bootstrap_expiry_hours` path and is exempt. 0 = no expiry (not recommended on a PHI instance). + # has `must_change_password=False` and is unaffected. 0 = no expiry (not recommended on a PHI + # instance). initial_password_expiry_hours: int = 72 # Active Directory / LDAP. The bind password is a secret: MEFOR_AUTH_AD_BIND_PASSWORD. @@ -2556,9 +2549,6 @@ class AlertSeverity(str, Enum): # noqa: UP042 "leadership_acquired", # #145 (ADR 0014 amendment): a node went non-leader→leader (HA failover / election) "dr_activated", # #145 (ADR 0014 amendment, ADR 0048): a third-tier DR standby was promoted "content_match", # #81 (ADR 0133): a code-first Handler ("Action Point") matched message content (PHI-free) - # ASVS 6.4.5 arm 2: an UNCLAIMED first-run bootstrap admin is nearing its auto-disable deadline - # (payload is the ISO deadline + whole hours remaining — never the password; PHI-free) - "bootstrap_admin_expiring", # NOTE: the INVERSE events (leadership_lost / dr_released) are auto-resolve-only (alert_sinks # _AUTO_RESOLVE), NOT rule-targetable alert types — a step-down / fail-back needs no page. } @@ -2669,7 +2659,7 @@ class AlertRule(BaseModel): model_config = ConfigDict(extra="forbid") # --- match (all conditions must hold) --- - event_type: str = "any" # "any" | connection_stopped | queue_buildup | storage_threshold | cert_expiry | secret_rotation | connection_error | message_stall | saturation | integrity_drift | update_available | backup_failed | lane_stuck | rcsi_off_degraded | bootstrap_admin_expiring + event_type: str = "any" # "any" | connection_stopped | queue_buildup | storage_threshold | cert_expiry | secret_rotation | connection_error | message_stall | saturation | integrity_drift | update_available | backup_failed | lane_stuck | rcsi_off_degraded connection: str = "*" # fnmatch glob over the connection name; "*" = all min_depth: int | None = Field(None, ge=1) # queue_buildup: match only at/over this lane depth min_oldest_seconds: float | None = Field( diff --git a/messagefoundry/pipeline/alert_sinks.py b/messagefoundry/pipeline/alert_sinks.py index 79c310c7..0a5fdfee 100644 --- a/messagefoundry/pipeline/alert_sinks.py +++ b/messagefoundry/pipeline/alert_sinks.py @@ -793,21 +793,6 @@ def secret_rotation_due( } ) - def bootstrap_admin_expiring(self, name: str, *, expires_at: str, hours_remaining: int) -> None: - # ASVS 6.4.5 arm 2: the UNCLAIMED first-run bootstrap admin is nearing its auto-disable deadline. - # The fixed label ("bootstrap-admin") stands in for "connection" so the realert throttle + subject - # keying + rule matching work uniformly; the payload carries only the ISO deadline + whole hours - # remaining (never the password or any secret — no PHI). The AuthService latch already collapses - # it to one emit per process; the (type, connection) throttle is a second belt. - self._emit( - { - "type": "bootstrap_admin_expiring", - "connection": name, - "expires_at": expires_at, - "hours_remaining": hours_remaining, - } - ) - def gcm_invocations(self, name: str, *, key_id: str, invocations: int, ceiling: int) -> None: # ASVS 11.3.4: the active DEK is approaching its AES-GCM invocation ceiling. The key label # stands in for "connection" so the realert throttle + subject keying + rule matching work diff --git a/messagefoundry/pipeline/alerts.py b/messagefoundry/pipeline/alerts.py index 1ae8484a..7cdb27cc 100644 --- a/messagefoundry/pipeline/alerts.py +++ b/messagefoundry/pipeline/alerts.py @@ -120,18 +120,6 @@ def secret_rotation_due( :meth:`cert_expiry`) so an operator can route a rotation reminder apart from a cert-expiry alert.""" ... - def bootstrap_admin_expiring(self, name: str, *, expires_at: str, hours_remaining: int) -> None: - """The first-run **bootstrap admin** is still UNCLAIMED (never password-changed) and now sits - inside its retirement warn window — an operator must sign in and change the password (or stand - up a second administrator) before the unclaimed credential is auto-disabled (ASVS 6.4.5). ``name`` - labels the credential (``"bootstrap-admin"``); ``expires_at`` is the ISO instant it is disabled; - ``hours_remaining`` is the whole hours left (``0`` in the final hour). Carries **only** the - deadline + hours — **never** the password or any secret, and no message content (no PHI). Emitted - once per process (an in-memory latch on :class:`~messagefoundry.auth.service.AuthService`) by the - API-lifespan reminder task. Dedicated (not reusing :meth:`secret_rotation_due`) so an operator can - route a first-run-credential reminder apart from a long-lived-secret rotation reminder.""" - ... - def gcm_invocations(self, name: str, *, key_id: str, invocations: int, ceiling: int) -> None: """The active store data-encryption key has crossed the AES-GCM soft invocation threshold (2**31 of the 2**32 birthday ceiling) on its PERSISTED, fleet-wide cumulative count (ASVS @@ -338,15 +326,6 @@ def secret_rotation_due( last_rotated, ) - def bootstrap_admin_expiring(self, name: str, *, expires_at: str, hours_remaining: int) -> None: - log.warning( - "ALERT bootstrap_admin_expiring: %r is UNCLAIMED and is auto-disabled in %d hour(s) " - "(expires %s) — sign in and change the password, or add a second admin, before then", - name, - hours_remaining, - expires_at, - ) - def gcm_invocations(self, name: str, *, key_id: str, invocations: int, ceiling: int) -> None: log.warning( "ALERT gcm_invocations: %r (key_id=%s) has encrypted %d value(s), %.1f%% of the %d " diff --git a/messagefoundry/scaffold.py b/messagefoundry/scaffold.py index d2ead021..85a8378f 100644 --- a/messagefoundry/scaffold.py +++ b/messagefoundry/scaffold.py @@ -231,8 +231,6 @@ def archive(msg): # type: ignore[no-untyped-def] *.db-shm *.db-wal *.log -# the one-time bootstrap admin credential the engine writes next to the store -bootstrap-admin.txt .env .env.* /out/ diff --git a/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py b/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py index 8cc8e593..dbdb594b 100644 --- a/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py +++ b/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py @@ -295,14 +295,23 @@ async def test_must_change_outranks_the_second_factor_on_the_gate_page( ) -> None: """RED when: GET /ui/mfa checks mfa before must_change. - A bootstrap admin is BOTH. Leading with MFA parks it on a page it cannot answer until it has + An admin-ISSUED account is BOTH: create_local_user sets must_change_password, and require_mfa + covers it un-enrolled. Leading with MFA parks it on a page it cannot answer until it has rotated — the cookie-plane twin of the JSON ordering rule. """ service = AuthService(engine.store, AuthSettings(login_rate_limit_enabled=False)) - boot = await service.initialize() # the FIRST initialize is what mints the bootstrap admin - assert boot is not None + await service.initialize() + temp = "an-issued-temp-passphrase" + await service.create_local_user( + username="issued", + password=temp, + display_name=None, + email=None, + roles=[Role.ADMINISTRATOR.value], + actor="test", + ) async with _client(engine, service) as c: - r = await c.post("/ui/login", data={"username": boot.username, "password": boot.password}) + r = await c.post("/ui/login", data={"username": "issued", "password": temp}) assert r.status_code == 303 and r.headers["location"] == "/ui/account/password" r = await c.get("/ui/mfa") assert r.status_code == 303 and r.headers["location"] == "/ui/account/password" diff --git a/tests/_first_admin.py b/tests/_first_admin.py new file mode 100644 index 00000000..24620313 --- /dev/null +++ b/tests/_first_admin.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Stand up the first Administrator the way an operator does, for tests that used to lean on the +implicit first-run bootstrap account. + +That account was retired under BACKLOG #1020 (ASVS 6.3.2 — no default accounts), so +``AuthService.initialize()`` now seeds roles and creates nothing. The replacement route is the +``messagefoundry admin-create`` CLI, whose one privileged step is the ``create_local_user`` call +reproduced here. Tests call this helper rather than the CLI so they exercise the auth service +directly; ``tests/test_admin_create_cli.py`` is what proves the CLI itself drives a fresh store to a +usable administrator, and is therefore the test that must fail if this helper drifts from it. + +``must_change_password=False`` matches the CLI: the operator standing at the box chooses their own +password, so there is no second party a forced rotation would protect. +""" + +from __future__ import annotations + +from messagefoundry.auth.permissions import Role +from messagefoundry.auth.service import AuthService + +#: Satisfies the shipped policy (>=15 chars, no app/vendor terms) so it works under stock settings. +FIRST_ADMIN_PW = "a-strong-test-passphrase" +FIRST_ADMIN = "admin" + + +async def create_first_admin( + service: AuthService, + *, + username: str = FIRST_ADMIN, + password: str = FIRST_ADMIN_PW, + email: str | None = None, +) -> str: + """Seed the built-in roles (idempotent) and create ``username`` as an Administrator.""" + await service.initialize() + return await service.create_local_user( + username=username, + password=password, + display_name="Administrator", + email=email, + roles=[Role.ADMINISTRATOR.value], + actor="cli", + must_change_password=False, + ) diff --git a/tests/test_admin_create_cli.py b/tests/test_admin_create_cli.py new file mode 100644 index 00000000..d1576c19 --- /dev/null +++ b/tests/test_admin_create_cli.py @@ -0,0 +1,294 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""``messagefoundry admin-create`` — the operator route to the FIRST administrator (BACKLOG #1020). + +The implicit first-run bootstrap account was retired because ASVS 6.3.2 wants no default account to +exist, and an engine nobody can sign into is not the stricter end state — it is a broken one. So the +removal and this command are one change, and these are the tests that hold the pair together: + +* the OLD behaviour is gone — a fresh store reaches ``serve`` with zero users and no privileged + account appears by itself; and +* the NEW route works end to end — the command drives that same fresh store to an account that can + authenticate against the real API and reach an authorization-gated route. + +Both directions matter. Either one alone would pass while the product was unusable or unsafe. + +The command is driven in-process through ``main()`` (the CLI-dispatch precedent in +``tests/test_cli_backup_dispatch.py``) rather than as a subprocess: there is then no ambient PATH, +interpreter or venv the result could silently depend on. ``_admin_create`` calls ``asyncio.run``, so +every test here is deliberately SYNC — an async test would already own the session event loop. +""" + +from __future__ import annotations + +import asyncio +import io +import os +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from messagefoundry.__main__ import main +from messagefoundry.api import create_app +from messagefoundry.auth import Role +from messagefoundry.auth.service import AuthService +from messagefoundry.config.settings import AuthSettings +from messagefoundry.pipeline import Engine +from messagefoundry.store.store import MessageStore + +PW = "a-strong-operator-passphrase" + +#: The env keys the CLI's own settings load would honour. Left ambient, `MEFOR_STORE_PATH` sends the +#: command at a DIFFERENT database than the one the test asserts on, and `MEFOR_AUTH_*` moves the +#: password policy out from under it — measured 2026-08-10: with those two set, 7 of these 9 tests +#: fail. They fail loudly rather than passing falsely, but a green that depends on the developer's +#: shell is not a green either way, so the env is pinned and `test_the_env_pin_holds_under_a_hostile_ +#: ambient_value` is the positive control proving the pin is what makes it so. +_ENV_PREFIX = "MEFOR_" +#: Set by tests/conftest.py for per-process test isolation (slot, port base, Qt org) — these are not +#: settings the CLI reads, and clearing them would break the isolation they exist for. +_KEEP = ("MEFOR_TEST_",) + + +@pytest.fixture(autouse=True) +def _pinned_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + for key in list(os.environ): + if key.startswith(_ENV_PREFIX) and not key.startswith(_KEEP): + monkeypatch.delenv(key, raising=False) + yield + + +def _service_toml(tmp_path: Path, db: Path) -> str: + toml = tmp_path / "messagefoundry.toml" + toml.write_text(f'[store]\npath = "{db.as_posix()}"\n', encoding="utf-8") + return str(toml) + + +def _run_admin_create( + monkeypatch: pytest.MonkeyPatch, toml: str, *args: str, password: str = PW +) -> int: + """Invoke the command with ``password`` on stdin. The password is never an argv element — see + ``_read_new_password``: argv is readable by other accounts on the box.""" + monkeypatch.setattr("sys.stdin", io.StringIO(password + "\n")) + return main(["admin-create", "--service-config", toml, "--password-stdin", *args]) + + +async def _users(db: Path) -> list[tuple[str, bool, list[str]]]: + store = await MessageStore.open(str(db)) + try: + out = [] + for user in await store.list_users(): + out.append( + (user.username, user.must_change_password, await store.get_user_role_ids(user.id)) + ) + return out + finally: + await store.close() + + +# --- the OLD behaviour is gone ------------------------------------------------------------------ + + +def test_a_fresh_store_gets_no_account_from_starting_the_engine(tmp_path: Path) -> None: + """RED when an implicit first-run account comes back. + + This is the half that is easy to lose: a reintroduced bootstrap would leave every other test in + the suite green, because a working admin account is what they all want.""" + + async def run() -> tuple[int, Any]: + store = await MessageStore.open(str(tmp_path / "fresh.db")) + try: + service = AuthService(store, AuthSettings()) + await service.initialize() # what the API lifespan calls on every start + await service.initialize() # ...and again on the next start + return await store.count_users(), await store.get_user_by_username("admin") + finally: + await store.close() + + count, admin = asyncio.run(run()) + assert count == 0 + assert admin is None + + +# --- the NEW route works ------------------------------------------------------------------------ + + +def test_admin_create_makes_an_administrator_on_a_fresh_store( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + db = tmp_path / "mf.db" + rc = _run_admin_create(monkeypatch, _service_toml(tmp_path, db), "--username", "alice") + assert rc == 0 + out = capsys.readouterr() + # The resolved store path is named back, so a --db/[store].path typo shows up here rather than as + # a login failure against the engine's real database much later. + assert "alice" in out.out and db.name in out.out + assert PW not in out.out and PW not in out.err # the credential is never echoed + + assert asyncio.run(_users(db)) == [("alice", False, ["administrator"])] + + +def test_admin_create_then_the_api_authenticates_that_account( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The behavioural acceptance test: a FRESH store, the new route, then a real authenticated + session over the engine's own API reaching an authorization-gated route.""" + db = tmp_path / "mf.db" + assert _run_admin_create(monkeypatch, _service_toml(tmp_path, db), "--username", "alice") == 0 + + async def run() -> tuple[int, int, str]: + engine = await Engine.create(db, poll_interval=0.02) + try: + # require_mfa=False keeps this test about PROVISIONING; the second factor an operator + # then enrolls is covered by tests/test_mfa.py. + service = AuthService(engine.store, AuthSettings(require_mfa=False)) + await service.initialize() + transport = httpx.ASGITransport(app=create_app(engine, auth=service)) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + login = await c.post( + "/auth/login", + json={"username": "alice", "password": PW, "provider": "local"}, + ) + token = login.json().get("token", "") + h = {"Authorization": f"Bearer {token}"} + me = await c.get("/auth/me", headers=h) + gated = await c.get("/users", headers=h) + return login.status_code, gated.status_code, me.json().get("username", "") + finally: + await engine.stop() + + login_status, gated_status, username = asyncio.run(run()) + assert login_status == 200 and username == "alice" + # /users is USERS_MANAGE-gated, so a 200 proves the account carries real administrator authority + # — not merely that some session was minted. No password rotation stood in the way. + assert gated_status == 200 + + +# --- refusals ----------------------------------------------------------------------------------- + + +def test_admin_create_refuses_a_duplicate_username( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + toml = _service_toml(tmp_path, tmp_path / "mf.db") + assert _run_admin_create(monkeypatch, toml, "--username", "alice") == 0 + capsys.readouterr() + assert _run_admin_create(monkeypatch, toml, "--username", "alice") == 2 + assert "already exists" in capsys.readouterr().err + + +def test_admin_create_holds_the_password_to_the_deployments_own_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # The policy comes from the resolved [auth] settings, not a CLI-local default: the command must + # not be a second, laxer way into the same account store. + db = tmp_path / "mf.db" + toml = tmp_path / "messagefoundry.toml" + toml.write_text( + f'[store]\npath = "{db.as_posix()}"\n\n[auth]\npassword_min_length = 40\n', encoding="utf-8" + ) + rc = _run_admin_create(monkeypatch, str(toml), "--username", "alice") + assert rc == 2 + assert "password must" in capsys.readouterr().err + assert not db.exists() or asyncio.run(_users(db)) == [] + + +def test_admin_create_refuses_an_empty_password( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + toml = _service_toml(tmp_path, tmp_path / "mf.db") + assert _run_admin_create(monkeypatch, toml, "--username", "alice", password="") == 2 + assert "no password supplied" in capsys.readouterr().err + + +def test_admin_create_warns_when_no_email_is_set( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # BACKLOG #1020's original finding was that the first administrator had no deliverable address, + # so the out-of-band security notices for the most privileged account silently no-opped. The + # account is gone, but the hole it exposed is a property of ANY privileged account with no + # address — so the operator route says so at the moment the account is made, and takes one. + toml = _service_toml(tmp_path, tmp_path / "mf.db") + assert _run_admin_create(monkeypatch, toml, "--username", "alice") == 0 + assert "no --email" in capsys.readouterr().err + + toml2 = _service_toml(tmp_path, tmp_path / "mf2.db") + assert ( + _run_admin_create(monkeypatch, toml2, "--username", "bob", "--email", "bob@example.org") + == 0 + ) + assert "no --email" not in capsys.readouterr().err + + async def email_of(db: Path, username: str) -> str | None: + store = await MessageStore.open(str(db)) + try: + user = await store.get_user_by_username(username) + assert user is not None + return user.email + finally: + await store.close() + + assert asyncio.run(email_of(tmp_path / "mf2.db", "bob")) == "bob@example.org" + + +def test_admin_create_audits_the_account_it_made( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db = tmp_path / "mf.db" + assert _run_admin_create(monkeypatch, _service_toml(tmp_path, db), "--username", "alice") == 0 + + async def actions() -> list[str]: + store = await MessageStore.open(str(db)) + try: + return [row["action"] for row in await store.list_audit()] + finally: + await store.close() + + # The most privileged account on the box must not be creatable without a durable record of it. + assert "user.created" in asyncio.run(actions()) + + +def test_the_env_pin_is_load_bearing_and_the_hazard_is_real( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Two halves, because either alone would be a claim rather than a measurement. + + 1. The pin is OBSERVABLE: inside a pinned test no ``MEFOR_`` setting key survives, whatever the + developer's shell exported. RED when ``_pinned_env`` stops clearing the prefix. + 2. The hazard it pins against is REAL: with ``MEFOR_STORE_PATH`` deliberately set, the command + follows it and writes somewhere the test never named. That is the behaviour every other test + in this file would otherwise be exposed to. + """ + assert not [k for k in os.environ if k.startswith(_ENV_PREFIX) and not k.startswith(_KEEP)], ( + "the autouse env pin did not clear the MEFOR_ settings prefix" + ) + + named = tmp_path / "named.db" + hostile = tmp_path / "hostile.db" + monkeypatch.setenv("MEFOR_STORE_PATH", str(hostile)) + assert ( + _run_admin_create(monkeypatch, _service_toml(tmp_path, named), "--username", "alice") == 0 + ) + assert hostile.exists() and not named.exists() # it went where the ENV said, not the config + + +def test_the_created_account_holds_the_administrator_role_object( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db = tmp_path / "mf.db" + assert _run_admin_create(monkeypatch, _service_toml(tmp_path, db), "--username", "alice") == 0 + + async def roles() -> frozenset[Role]: + store = await MessageStore.open(str(db)) + try: + service = AuthService(store, AuthSettings(require_mfa=False)) + out = await service.login("alice", PW) + assert out.ok and out.identity is not None + return out.identity.roles + finally: + await store.close() + + assert Role.ADMINISTRATOR in asyncio.run(roles()) diff --git a/tests/test_admin_new_ip.py b/tests/test_admin_new_ip.py index e4874a04..ef531c66 100644 --- a/tests/test_admin_new_ip.py +++ b/tests/test_admin_new_ip.py @@ -17,6 +17,7 @@ import httpx import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from _totp_clock import pin_totp_clock from messagefoundry.api import create_app @@ -204,9 +205,8 @@ async def test_verify_mfa_reanchors_session_to_the_new_ip( store = await MessageStore.open(":memory:") try: service = AuthService(store, AuthSettings(admin_new_ip_step_up=True)) - boot = await service.initialize() - assert boot is not None - out = await service.login("admin", boot.password, client="10.1.1.1") + await create_first_admin(service) + out = await service.login(FIRST_ADMIN, FIRST_ADMIN_PW, client="10.1.1.1") assert out.ok and out.identity is not None and out.token is not None identity, token = out.identity, out.token enroll = await service.begin_mfa_enrollment(identity) diff --git a/tests/test_alert_rules.py b/tests/test_alert_rules.py index 6cebc8a4..114b4243 100644 --- a/tests/test_alert_rules.py +++ b/tests/test_alert_rules.py @@ -378,20 +378,6 @@ def test_rule_targets_rcsi_off_degraded_and_routes() -> None: assert d.transports == ("webhook",) # decide() returns the rule's subset as a tuple -def test_rule_targets_bootstrap_admin_expiring_and_routes() -> None: - # ASVS 6.4.5 arm 2: an unclaimed first-run bootstrap admin about to be auto-disabled is page-worthy - # on an exposed instance — an operator must be able to escalate the reminder and route it to a - # dedicated transport, not just take the default (all transports, warning). - rule = AlertRule( - event_type="bootstrap_admin_expiring", severity=AlertSeverity.CRITICAL, transports=["email"] - ) - d = AlertRuleSet([rule]).decide( - {"type": "bootstrap_admin_expiring", "connection": "bootstrap-admin"} - ) - assert d.severity == "critical" - assert d.transports == ("email",) - - async def test_lane_stuck_and_rcsi_off_degraded_emit_and_route_end_to_end() -> None: # End-to-end: a real NotifierAlertSink fans out both new event types through _emit, honoring # per-rule severity escalation and transport routing -- the operator control the config gap denied. diff --git a/tests/test_alert_sinks.py b/tests/test_alert_sinks.py index d37fd158..e41f0889 100644 --- a/tests/test_alert_sinks.py +++ b/tests/test_alert_sinks.py @@ -18,7 +18,6 @@ WebhookTransport, notifier_from_settings, ) -from messagefoundry.pipeline.alerts import LoggingAlertSink class _RecordingTransport: @@ -72,41 +71,6 @@ async def test_realert_throttle_suppresses_repeats() -> None: assert keys == [("OB_X", 1), ("OB_Y", 1)] -async def test_bootstrap_admin_expiring_emits_phi_free() -> None: - # ASVS 6.4.5 arm 2: the reminder rides the standard fan-out; its payload is the ISO deadline + whole - # hours remaining only — never the password or any secret. - t = _RecordingTransport("t") - sink = NotifierAlertSink([t]) - sink.bootstrap_admin_expiring( - "bootstrap-admin", expires_at="2026-07-27T12:00:00+00:00", hours_remaining=24 - ) - await _drain(sink) - assert len(t.events) == 1 - ev = t.events[0] - assert ev["type"] == "bootstrap_admin_expiring" - assert ev["connection"] == "bootstrap-admin" - assert ev["expires_at"] == "2026-07-27T12:00:00+00:00" - assert ev["hours_remaining"] == 24 - # no credential material ever rides the payload - assert not any(k in ev for k in ("password", "secret", "token")) - - -def test_bootstrap_admin_expiring_logging_sink_states_deadline_not_password( - caplog: pytest.LogCaptureFixture, -) -> None: - # The fallback LoggingAlertSink surfaces the deadline + hours at WARNING (so an operator sees it with - # no notifier wired) and never a secret — there is no secret in the signature to leak. - import logging - - with caplog.at_level(logging.WARNING): - LoggingAlertSink().bootstrap_admin_expiring( - "bootstrap-admin", expires_at="2026-07-27T12:00:00+00:00", hours_remaining=24 - ) - assert "bootstrap_admin_expiring" in caplog.text - assert "2026-07-27T12:00:00+00:00" in caplog.text - assert "24 hour" in caplog.text - - async def test_suspend_gate_mutes_notification() -> None: # #143: a suspended (type, connection) is muted at the notification enqueue while the window is # active; a different, un-suspended key still delivers. NOTIFICATION-only. diff --git a/tests/test_asvs_phase0.py b/tests/test_asvs_phase0.py index a9ff3bde..428a61c8 100644 --- a/tests/test_asvs_phase0.py +++ b/tests/test_asvs_phase0.py @@ -15,6 +15,7 @@ import httpx import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from pydantic import ValidationError from messagefoundry.api import create_app @@ -135,11 +136,10 @@ async def test_logout_emits_audit_event() -> None: store = await MessageStore.open(":memory:") try: service = AuthService(store, AuthSettings()) - boot = await service.initialize() - assert boot is not None - out = await service.login("admin", boot.password) + await create_first_admin(service) + out = await service.login(FIRST_ADMIN, FIRST_ADMIN_PW) assert out.ok and out.token is not None - await service.logout(out.token, actor="admin") + await service.logout(out.token, actor=FIRST_ADMIN) actions = [row["action"] for row in await store.list_audit()] assert "auth.logout" in actions finally: diff --git a/tests/test_auth_hardening.py b/tests/test_auth_hardening.py index 5ed26e06..f02d372d 100644 --- a/tests/test_auth_hardening.py +++ b/tests/test_auth_hardening.py @@ -6,7 +6,6 @@ H1 PHI summaries are redacted for callers lacking messages:view_summary H2 AD requires LDAPS unless an explicit insecure override is set M2 must_change_password is enforced server-side (not merely advisory) - M3 the bootstrap one-time password goes to a restricted file, never the log M4 an AD login cannot adopt/overwrite a like-named local account M5 the last enabled administrator cannot be stripped of the admin role M6 /me/password requires the current password (defeats session-only takeover) @@ -23,15 +22,16 @@ import httpx import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from _totp_clock import fresh_totp from pydantic import ValidationError from messagefoundry.api import create_app -from messagefoundry.api.app import _emit_bootstrap_admin, _session_reaper +from messagefoundry.api.app import _session_reaper from messagefoundry.auth import Role, hash_password from messagefoundry.auth.ldap import AdPrincipal, LdapAuthenticator, LdapError -from messagefoundry.auth.service import AuthService, BootstrapAdmin -from messagefoundry.config.settings import AuthSettings, StoreSettings +from messagefoundry.auth.service import AuthService +from messagefoundry.config.settings import AuthSettings from messagefoundry.pipeline import Engine from messagefoundry.store import MessageStatus @@ -158,16 +158,27 @@ def test_ad_requires_ldaps_unless_overridden() -> None: async def test_must_change_password_blocks_until_rotated(engine: Engine) -> None: - # M2 + ASVS 6.3.3. A bootstrap admin is must_change AND (since 6.3.3) mfa_pending at the same - # instant, so this pins BOTH the refusal ORDER and the fact that the pair is escapable — the + # M2 + ASVS 6.3.3. An admin-ISSUED account is must_change AND (since 6.3.3) mfa_pending at the + # same instant, so this pins BOTH the refusal ORDER and the fact that the pair is escapable — the # bricked-fresh-account regression. Order is load-bearing: GET /me/mfa is MFA-exempt but NOT # must-change-exempt, so leading with MFA would send this account to /auth/mfa-verify, which it # cannot satisfy before rotating. The account must be told to rotate FIRST. + # (It used to be the implicit first-run bootstrap admin; BACKLOG #1020 retired that account, so + # the same pair of conditions is now produced the way it is actually reachable in the product — + # an existing administrator issuing a temp credential through create_local_user.) service = AuthService(engine.store, AuthSettings(login_rate_limit_enabled=False)) - boot = await service.initialize() - assert boot is not None + await service.initialize() + issued = "an-issued-temp-passphrase" + await service.create_local_user( + username="admin", + password=issued, + display_name=None, + email=None, + roles=[Role.ADMINISTRATOR.value], + actor="test", + ) async with _client(engine, service) as c: - login = await _login(c, boot.username, boot.password) + login = await _login(c, "admin", issued) assert login.status_code == 200 and login.json()["must_change_password"] is True h = _auth(login.json()["token"]) # a rotation-required session may not reach protected routes... @@ -181,7 +192,7 @@ async def test_must_change_password_blocks_until_rotated(engine: Engine) -> None rotated = await c.post( "/me/password", headers=h, - json={"current_password": boot.password, "new_password": "a-rotated-passphrase-99"}, + json={"current_password": issued, "new_password": "a-rotated-passphrase-99"}, ) assert rotated.status_code == 200 @@ -208,60 +219,12 @@ async def test_must_change_password_blocks_until_rotated(engine: Engine) -> None assert (await c.get("/users", headers=_auth(tok))).status_code == 200 -# --- M3: bootstrap one-time password goes to a file, not the log ------------- - - -def test_bootstrap_password_written_to_file_not_log( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - store_settings = StoreSettings(path=str(tmp_path / "mf.db")) - boot = BootstrapAdmin(username="admin", password="S3cret-One-Time-Value") - with caplog.at_level(logging.WARNING): - _emit_bootstrap_admin(boot, store_settings) - secret_file = tmp_path / "bootstrap-admin.txt" - assert secret_file.exists() - assert "S3cret-One-Time-Value" in secret_file.read_text() - # the credential must never appear in the (NSSM-captured) log - assert "S3cret-One-Time-Value" not in caplog.text - assert "bootstrap-admin.txt" in caplog.text - - -def test_bootstrap_file_and_log_state_the_expiry_deadline( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - # ASVS 6.4.5 arm 1: the renewal deadline ships WITH the credential — the file body and the log line - # both carry the ISO instant, so "claim it before " is not an out-of-band assumption. - import datetime - - exp = 1_800_000_000.0 - iso = datetime.datetime.fromtimestamp(exp, tz=datetime.UTC).isoformat() - store_settings = StoreSettings(path=str(tmp_path / "mf.db")) - boot = BootstrapAdmin(username="admin", password="one-time-value", expires_at=exp) - with caplog.at_level(logging.WARNING): - _emit_bootstrap_admin(boot, store_settings) - body = (tmp_path / "bootstrap-admin.txt").read_text() - assert iso in body and "expires" in body - assert iso in caplog.text # the deadline is not a secret — safe to log - assert "one-time-value" not in caplog.text # ...the password still is not - - -def test_bootstrap_states_no_deadline_when_expiry_is_off( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - # expires_at=None (bootstrap_expiry_hours=0) → no deadline line; byte-compatible with pre-6.4.5. - store_settings = StoreSettings(path=str(tmp_path / "mf.db")) - boot = BootstrapAdmin(username="admin", password="one-time-value", expires_at=None) - with caplog.at_level(logging.WARNING): - _emit_bootstrap_admin(boot, store_settings) - assert "expires" not in (tmp_path / "bootstrap-admin.txt").read_text() - - # --- M4: AD login cannot adopt a like-named local account -------------------- async def test_ad_login_conflicting_with_local_account_is_rejected(engine: Engine) -> None: principal = AdPrincipal( - username="admin", # collides with the LOCAL bootstrap admin + username="admin", # collides with a LOCAL account of the same name display_name=None, email=None, dn="CN=admin,DC=x", @@ -283,7 +246,7 @@ def resolve_principal(self, username: str) -> AdPrincipal | None: ad_bind_password="x", ) service = AuthService(engine.store, settings, ldap=_FakeLdap()) # type: ignore[arg-type] - await service.initialize() # creates the LOCAL 'admin' + await create_first_admin(service) # the LOCAL 'admin' the AD principal collides with async with _client(engine, service) as c: r = await _login(c, "admin", "pw", provider="ad") assert r.status_code == 401 # the AD bind cannot take over the local account @@ -296,17 +259,9 @@ async def test_cannot_remove_last_administrator(engine: Engine) -> None: # Last-admin guard test (step-up admin CRUD), not an MFA test: pin require_mfa=False so the # BACKLOG #187 secure default (require_mfa now ON) doesn't 403 the roles/CRUD ops first. service = AuthService(engine.store, AuthSettings(require_mfa=False)) - boot = await service.initialize() - assert boot is not None + await create_first_admin(service) async with _client(engine, service) as c: - h = _auth((await _login(c, "admin", boot.password)).json()["token"]) - # clear the must-change flag so the admin can operate - await c.post( - "/me/password", - headers=h, - json={"current_password": boot.password, "new_password": "a-rotated-passphrase-99"}, - ) - h = _auth((await _login(c, "admin", "a-rotated-passphrase-99")).json()["token"]) + h = _auth((await _login(c, FIRST_ADMIN, FIRST_ADMIN_PW)).json()["token"]) my_id = (await c.get("/auth/me", headers=h)).json()["user_id"] # stripping admin from the only administrator is refused assert ( @@ -569,11 +524,19 @@ async def test_must_change_password_blocks_websocket(engine: Engine) -> None: from messagefoundry.auth import Permission service = AuthService(engine.store, AuthSettings(require_mfa=False)) - boot = await service.initialize() - assert boot is not None - boot_token = (await service.login("admin", boot.password)).token - # the not-yet-rotated bootstrap admin (holds monitoring:read) is denied the WS - denied = await authorize_ws(_FakeWS(service, boot_token), Permission.MONITORING_READ) # type: ignore[arg-type] + await service.initialize() + issued = "an-issued-temp-passphrase" + await service.create_local_user( + username="tempadm", + password=issued, + display_name=None, + email=None, + roles=[Role.ADMINISTRATOR.value], + actor="test", + ) + temp_token = (await service.login("tempadm", issued)).token + # a not-yet-rotated admin-issued account (holds monitoring:read) is denied the WS + denied = await authorize_ws(_FakeWS(service, temp_token), Permission.MONITORING_READ) # type: ignore[arg-type] assert denied is None # a normal user with the permission is allowed through await _add(service, "vw", Role.VIEWER) @@ -588,7 +551,7 @@ async def test_ws_permission_denied_is_audited(engine: Engine) -> None: from messagefoundry.auth import Permission service = AuthService(engine.store, AuthSettings(require_mfa=False)) - assert await service.initialize() is not None + await service.initialize() await _add(service, "vw", Role.VIEWER) vw_token = (await service.login("vw", PW)).token # VIEWER holds monitoring:read but not config:deploy → requesting it on the WS is denied + audited. @@ -606,7 +569,7 @@ async def test_ws_permission_granted_is_audited_for_sensitive_only(engine: Engin from messagefoundry.auth import Permission service = AuthService(engine.store, AuthSettings(require_mfa=False)) - assert await service.initialize() is not None + await service.initialize() await _add(service, "adm", Role.ADMINISTRATOR) await _add(service, "vw", Role.VIEWER) adm_token = (await service.login("adm", PW)).token @@ -667,7 +630,7 @@ async def _assert_http_grant_deny_precision(store: object) -> None: # sits ABOVE the permission loop — leaving it on would refuse every request with auth.mfa_denied # before any grant/deny row could be written, testing the wrong guard. service = AuthService(store, AuthSettings(require_mfa=False)) # type: ignore[arg-type] - assert await service.initialize() is not None + await service.initialize() await _add(service, "adm", Role.ADMINISTRATOR) # holds approvals:approve + messages:purge await _add(service, "op", Role.OPERATOR) # holds messages:purge, NOT approvals:approve await _add(service, "vw", Role.VIEWER) # holds neither @@ -739,7 +702,7 @@ async def test_audit_all_authz_audits_every_grant_but_never_phi_view(engine: Eng from messagefoundry.auth import Permission service = AuthService(engine.store, AuthSettings(require_mfa=False)) - assert await service.initialize() is not None + await service.initialize() await _add( service, "adm", Role.ADMINISTRATOR ) # holds read + view_summary + purge + monitoring:read diff --git a/tests/test_auth_service.py b/tests/test_auth_service.py index d42d21ae..bc87a1da 100644 --- a/tests/test_auth_service.py +++ b/tests/test_auth_service.py @@ -1,12 +1,13 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 MessageFoundry Organization and contributors -"""AuthService unit tests: bootstrap, local login + lockout, sessions, AD group->role mapping.""" +"""AuthService unit tests: first-admin provisioning, local login + lockout, sessions, AD group->role mapping.""" from __future__ import annotations import time import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from messagefoundry.auth import Role, hash_password, hash_token from messagefoundry.auth.identity import AuthProvider @@ -43,236 +44,39 @@ async def _store() -> MessageStore: return await MessageStore.open(":memory:") -async def test_bootstrap_admin_created_once_and_can_log_in() -> None: - store = await _store() - try: - service = AuthService(store, AuthSettings()) - boot = await service.initialize() - assert boot is not None and boot.username == "admin" and len(boot.password) >= 15 - out = await service.login("admin", boot.password) - assert out.ok and out.must_change_password is True - assert out.identity is not None and Role.ADMINISTRATOR in out.identity.roles - # a second service over the same (now non-empty) store does not re-bootstrap - assert await AuthService(store, AuthSettings()).initialize() is None - finally: - await store.close() - - -async def test_bootstrap_password_satisfies_active_policy() -> None: - # The printed bootstrap credential is generated *through* the active policy (WP-3), even a strict one. - store = await _store() - try: - service = AuthService( - store, AuthSettings(password_min_length=20, password_require_symbol=True) - ) - boot = await service.initialize() - assert boot is not None - assert service.policy.violations(boot.password) == [] and len(boot.password) >= 20 - finally: - await store.close() - +async def test_initialize_seeds_roles_and_creates_no_account() -> None: + """BACKLOG #1020 / ASVS 6.3.2: a fresh store has NO users. The implicit first-run Administrator + was retired rather than given an email address, because the verb wants that account not to exist; + the replacement is the operator-invoked `messagefoundry admin-create` (tests/test_admin_create_cli.py). -async def test_bootstrap_auto_disabled_when_second_admin_created() -> None: + RED when initialize() mints an account again — the exact regression this item closed.""" store = await _store() try: service = AuthService(store, AuthSettings()) - boot = await service.initialize() - assert boot is not None - await service.create_local_user( - username="alice", - password="a-long-unguessable-passphrase", - display_name=None, - email=None, - roles=[Role.ADMINISTRATOR.value], - actor="admin", - ) - # the unclaimed bootstrap admin is retired the moment a real second admin exists - assert not (await service.login("admin", boot.password)).ok - retired = await store.get_user_by_username("admin") - assert retired is not None and retired.disabled - finally: - await store.close() - - -async def test_bootstrap_expires_when_left_unclaimed() -> None: - store = await _store() - try: - service = AuthService(store, AuthSettings(bootstrap_expiry_hours=72)) - boot = await service.initialize() - assert boot is not None - assert (await service.login("admin", boot.password)).ok # within the window: usable - # age the account past the expiry window - admin = await store.get_user_by_username("admin") - assert admin is not None - await store._db.execute( - "UPDATE users SET created_at=? WHERE id=?", (time.time() - 73 * 3600, admin.id) - ) - await store._db.commit() - assert not (await service.login("admin", boot.password)).ok # expired → refused - expired = await store.get_user_by_username("admin") - assert expired is not None and expired.disabled - finally: - await store.close() - - -async def test_claimed_bootstrap_is_not_retired() -> None: - # Once the operator changes the bootstrap password (must_change → False) it's a normal admin - # account; neither supersession nor expiry may disable it (no single-admin lockout). - store = await _store() - try: - service = AuthService(store, AuthSettings(bootstrap_expiry_hours=72)) - await service.initialize() - admin = await store.get_user_by_username("admin") - assert admin is not None - await store.set_password( - admin.id, - password_hash=hash_password("a-claimed-real-passphrase"), - must_change_password=False, - ) - # age it past expiry AND add a second admin — still must not be disabled - await store._db.execute( - "UPDATE users SET created_at=? WHERE id=?", (time.time() - 99 * 3600, admin.id) - ) - await store._db.commit() - await service.create_local_user( - username="alice", - password="another-long-passphrase", - display_name=None, - email=None, - roles=[Role.ADMINISTRATOR.value], - actor="admin", - ) - still = await store.get_user_by_username("admin") - assert still is not None and not still.disabled - finally: - await store.close() - - -# --- ASVS 6.4.5 arm 1: the bootstrap credential carries its own expiry deadline ------------------ - - -async def test_bootstrap_admin_carries_its_expiry_deadline() -> None: - store = await _store() - try: - service = AuthService(store, AuthSettings(bootstrap_expiry_hours=72)) - boot = await service.initialize() - assert boot is not None and boot.expires_at is not None - admin = await store.get_user_by_username("admin") - # exactly created_at + window (same base _retire_superseded_bootstrap uses) — not a fresh clock - assert abs(boot.expires_at - (admin.created_at + 72 * 3600)) < 1.0 - finally: - await store.close() - - -async def test_bootstrap_admin_deadline_is_none_when_expiry_off() -> None: - store = await _store() - try: - service = AuthService(store, AuthSettings(bootstrap_expiry_hours=0)) - boot = await service.initialize() - assert boot is not None and boot.expires_at is None - finally: - await store.close() - - -# --- ASVS 6.4.5 arm 2: an unclaimed bootstrap admin is reminded BEFORE it is auto-disabled -------- - - -async def test_bootstrap_expiry_warning_fires_once_in_window() -> None: - # An unclaimed bootstrap 24h from auto-disable draws exactly ONE reminder: the in-memory latch - # collapses a periodic caller to a single emit per process (the runner re-checks hourly). - store = await _store() - try: - service = AuthService( - store, AuthSettings(bootstrap_expiry_hours=72, bootstrap_warn_hours=24) - ) - boot = await service.initialize() - assert boot is not None - admin = await store.get_user_by_username("admin") - assert admin is not None - expires_at = admin.created_at + 72 * 3600 - at_t_minus_24h = expires_at - 24 * 3600 # exactly the window's leading edge - first = await service.bootstrap_expiry_warning(now=at_t_minus_24h) - assert first is not None - surfaced_expires, hours_remaining = first - assert ( - abs(surfaced_expires - expires_at) < 1.0 - ) # the exact retirement instant, not a fresh clock - assert hours_remaining == 24 - # a second pass anywhere in the window is latched → no second reminder - assert await service.bootstrap_expiry_warning(now=at_t_minus_24h + 3600) is None - finally: - await store.close() - - -async def test_bootstrap_expiry_warning_silent_before_the_window() -> None: - # Before [expires_at - warn_hours, expires_at): nothing — and a pre-window pass does NOT consume the - # latch, so the real window still fires afterwards. - store = await _store() - try: - service = AuthService( - store, AuthSettings(bootstrap_expiry_hours=72, bootstrap_warn_hours=24) - ) await service.initialize() - admin = await store.get_user_by_username("admin") - assert admin is not None - expires_at = admin.created_at + 72 * 3600 - assert await service.bootstrap_expiry_warning(now=expires_at - 25 * 3600) is None # before - assert ( - await service.bootstrap_expiry_warning(now=expires_at - 12 * 3600) is not None - ) # inside + assert await store.count_users() == 0 + assert await store.get_user_by_username("admin") is None + # Roles ARE seeded, so the operator route has something to assign. + assert {r["id"] for r in await store.list_roles()} >= {"administrator", "viewer"} + # No account means no credential to guess: every sign-in attempt fails, including the name + # the retired account used to carry. + assert not (await service.login("admin", "a-strong-test-passphrase")).ok finally: await store.close() -async def test_bootstrap_expiry_warning_silent_when_claimed() -> None: - # "claimed fires nothing": once the operator changes the password (must_change → False) it is a - # normal admin account and draws no retirement reminder, even inside what would be the window. +async def test_first_admin_from_the_operator_route_can_log_in() -> None: + # The replacement path, exercised at the service layer the CLI drives (the CLI's own end-to-end + # test is tests/test_admin_create_cli.py). Its password is the operator's, so no forced rotation. store = await _store() try: - service = AuthService( - store, AuthSettings(bootstrap_expiry_hours=72, bootstrap_warn_hours=24) - ) - await service.initialize() - admin = await store.get_user_by_username("admin") - assert admin is not None - await store.set_password( - admin.id, - password_hash=hash_password("a-claimed-real-passphrase"), - must_change_password=False, - ) - expires_at = admin.created_at + 72 * 3600 - assert await service.bootstrap_expiry_warning(now=expires_at - 1 * 3600) is None - finally: - await store.close() - - -async def test_bootstrap_expiry_warning_silent_when_expiry_off() -> None: - # No time-expiry configured → the credential is never auto-disabled, so there is nothing to warn of, - # however far past the (non-existent) deadline the clock is pushed. - store = await _store() - try: - service = AuthService( - store, AuthSettings(bootstrap_expiry_hours=0, bootstrap_warn_hours=24) - ) - await service.initialize() - assert await service.bootstrap_expiry_warning(now=time.time() + 999 * 3600) is None - finally: - await store.close() - - -async def test_bootstrap_expiry_warning_silent_after_the_deadline() -> None: - # At/after expires_at, retirement itself takes over (_retire_superseded_bootstrap); the pre-warning - # does not fire past the deadline. - store = await _store() - try: - service = AuthService( - store, AuthSettings(bootstrap_expiry_hours=72, bootstrap_warn_hours=24) - ) - await service.initialize() - admin = await store.get_user_by_username("admin") - assert admin is not None - expires_at = admin.created_at + 72 * 3600 - assert await service.bootstrap_expiry_warning(now=expires_at + 1) is None + service = AuthService(store, AuthSettings()) + await create_first_admin(service, username="alice") + out = await service.login("alice", FIRST_ADMIN_PW) + assert out.ok and out.identity is not None + assert Role.ADMINISTRATOR in out.identity.roles + user = await store.get_user_by_username("alice") + assert user is not None and user.must_change_password is False finally: await store.close() @@ -314,7 +118,7 @@ async def test_reset_temp_password_expires_when_unclaimed() -> None: out = await service.login("alice", temp) assert not out.ok # expired → refused, even with the CORRECT temp password assert out.error == "invalid credentials" # generic — not distinguishable from a wrong pw - # the account is NOT disabled (unlike bootstrap) — an admin can re-issue a fresh temp + # the account is NOT disabled — an admin can re-issue a fresh temp assert (await store.get_user_by_username("alice")).disabled is False finally: await store.close() @@ -361,24 +165,23 @@ async def test_initial_password_expiry_zero_disables_the_gate() -> None: await store.close() -async def test_bootstrap_admin_is_not_gated_by_initial_password_expiry() -> None: - # The bootstrap admin is must_change + carries password_changed_at, but is CARVED OUT of the - # 6.4.1 gate (it has its own bootstrap_expiry_hours path). With bootstrap expiry off, an aged, - # unclaimed bootstrap still logs in — the initial-password gate must not catch it. +async def test_operator_created_admin_is_not_gated_by_initial_password_expiry() -> None: + # ASVS 6.4.1 gates an admin-ISSUED temp (must_change_password). The operator route sets the + # operator's OWN password with must_change_password clear, so an aged first-admin credential is + # not caught by that gate. This replaces the carve-out the retired bootstrap account used to need + # (BACKLOG #1020): the exemption is now a property of the account, not a username special case. store = await _store() try: - service = AuthService( - store, AuthSettings(initial_password_expiry_hours=1, bootstrap_expiry_hours=0) - ) - boot = await service.initialize() - assert boot is not None - admin = await store.get_user_by_username("admin") + service = AuthService(store, AuthSettings(initial_password_expiry_hours=1)) + await create_first_admin(service) + admin = await store.get_user_by_username(FIRST_ADMIN) + assert admin is not None await store._db.execute( "UPDATE users SET password_changed_at=? WHERE id=?", (time.time() - 500 * 3600, admin.id), ) await store._db.commit() - assert (await service.login("admin", boot.password)).ok # not gated by the 6.4.1 expiry + assert (await service.login(FIRST_ADMIN, FIRST_ADMIN_PW)).ok finally: await store.close() diff --git a/tests/test_bootstrap_admin_perms.py b/tests/test_bootstrap_admin_perms.py deleted file mode 100644 index 240798a8..00000000 --- a/tests/test_bootstrap_admin_perms.py +++ /dev/null @@ -1,77 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (C) 2026 MessageFoundry Organization and contributors -"""SEC-020 — the one-time bootstrap-admin password file is owner-only from the instant it exists. - -``_emit_bootstrap_admin`` must create ``bootstrap-admin.txt`` via an exclusive 0o600 ``os.open`` so a -co-located local user can never read the standing admin credential in a create-then-chmod window -(POSIX TOCTOU), and ``O_EXCL`` must refuse to follow a pre-planted symlink/file at that path.""" - -from __future__ import annotations - -import os -import sys -from pathlib import Path - -import pytest - -from messagefoundry.api.app import _emit_bootstrap_admin -from messagefoundry.auth.service import BootstrapAdmin -from messagefoundry.store import sqlite_settings - - -def _settings(tmp_path: Path) -> object: - # path → tmp_path/db.sqlite; the secret lands beside it as bootstrap-admin.txt - return sqlite_settings(tmp_path / "db.sqlite") - - -def _secret_path(tmp_path: Path) -> Path: - return (tmp_path / "db.sqlite").resolve().parent / "bootstrap-admin.txt" - - -def test_creates_file_with_expected_content(tmp_path: Path) -> None: - boot = BootstrapAdmin(username="admin", password="s3cr3t-one-time") - _emit_bootstrap_admin(boot, _settings(tmp_path)) # type: ignore[arg-type] - secret = _secret_path(tmp_path) - assert secret.is_file() - text = secret.read_text(encoding="utf-8") - assert "username: admin" in text - assert "password: s3cr3t-one-time" in text - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX chmod semantics (Windows uses an ACL)") -def test_file_is_owner_only_0600(tmp_path: Path) -> None: - boot = BootstrapAdmin(username="admin", password="s3cr3t") - _emit_bootstrap_admin(boot, _settings(tmp_path)) # type: ignore[arg-type] - mode = os.stat(_secret_path(tmp_path)).st_mode & 0o777 - assert mode == 0o600 - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only TOCTOU regression") -def test_does_not_leave_world_readable_or_follow_planted_file(tmp_path: Path) -> None: - # Pre-plant a world-readable file at the target path: the O_EXCL create must NOT inherit its - # permissions nor leave a 0o644 file — the secret only ever lands in a fresh 0o600 file we own. - secret = _secret_path(tmp_path) - secret.write_text("attacker-seeded\n", encoding="utf-8") - os.chmod(secret, 0o644) - boot = BootstrapAdmin(username="admin", password="s3cr3t") - _emit_bootstrap_admin(boot, _settings(tmp_path)) # type: ignore[arg-type] - mode = os.stat(secret).st_mode & 0o777 - assert mode == 0o600 - assert "attacker-seeded" not in secret.read_text(encoding="utf-8") - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") -def test_refuses_to_follow_planted_symlink(tmp_path: Path) -> None: - # A pre-planted symlink to a victim file: O_EXCL must refuse to follow it, so the victim is never - # overwritten with the secret and the secret never lands in an attacker-controlled location. - victim = tmp_path / "victim.txt" - victim.write_text("untouched\n", encoding="utf-8") - secret = _secret_path(tmp_path) - secret.symlink_to(victim) - boot = BootstrapAdmin(username="admin", password="s3cr3t") - _emit_bootstrap_admin(boot, _settings(tmp_path)) # type: ignore[arg-type] - # the symlink target was NOT overwritten with the credential - assert victim.read_text(encoding="utf-8") == "untouched\n" - # and the real secret file is a regular 0o600 file we created (not a symlink) - assert not secret.is_symlink() - assert os.stat(secret).st_mode & 0o777 == 0o600 diff --git a/tests/test_last_admin_guard.py b/tests/test_last_admin_guard.py index b4eaf975..b3dcd201 100644 --- a/tests/test_last_admin_guard.py +++ b/tests/test_last_admin_guard.py @@ -22,6 +22,7 @@ import httpx import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from messagefoundry.api import create_app from messagefoundry.auth.service import AuthService @@ -54,16 +55,13 @@ async def _login(c: httpx.AsyncClient, username: str, password: str) -> httpx.Re async def _admin_session(c: httpx.AsyncClient, service: AuthService) -> tuple[dict[str, str], str]: - """Bootstrap the first admin, clear its must-change flag; return (auth-headers, admin-user-id).""" - boot = await service.initialize() - assert boot is not None - h = _auth((await _login(c, "admin", boot.password)).json()["token"]) - await c.post( - "/me/password", - headers=h, - json={"current_password": boot.password, "new_password": "a-rotated-passphrase-99"}, - ) - h = _auth((await _login(c, "admin", "a-rotated-passphrase-99")).json()["token"]) + """Create the first admin and sign it in; return (auth-headers, admin-user-id). + + BACKLOG #1020 retired the implicit first-run account, so there is no machine-issued password to + rotate here — the operator route (`admin-create`, mirrored by `create_first_admin`) sets the + password the operator chose, with must_change_password already clear.""" + await create_first_admin(service) + h = _auth((await _login(c, FIRST_ADMIN, FIRST_ADMIN_PW)).json()["token"]) my_id = (await c.get("/auth/me", headers=h)).json()["user_id"] return h, my_id @@ -75,7 +73,7 @@ async def _reauth_update(c: httpx.AsyncClient, h: dict[str, str]) -> None: r = await c.post( "/me/reauth", headers=h, - json={"password": "a-rotated-passphrase-99", "purpose": "admin_user_update"}, + json={"password": FIRST_ADMIN_PW, "purpose": "admin_user_update"}, ) assert r.status_code == 200, r.text diff --git a/tests/test_mfa.py b/tests/test_mfa.py index c3278981..f93d3ca7 100644 --- a/tests/test_mfa.py +++ b/tests/test_mfa.py @@ -13,6 +13,7 @@ import asyncio import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from _totp_clock import fresh_totp, pin_totp_clock from messagefoundry.auth import totp @@ -39,12 +40,14 @@ async def _store() -> MessageStore: async def _bootstrap_login(service: AuthService) -> tuple[Identity, str, str]: - """Bootstrap the admin and log it in; return (identity, token, password) for the MFA flows.""" - boot = await service.initialize() - assert boot is not None - out = await service.login("admin", boot.password) + """Create the first Administrator and log it in; return (identity, token, password). + + BACKLOG #1020 retired the implicit first-run account, so this stands one up the way the + ``admin-create`` CLI does before signing in.""" + await create_first_admin(service) + out = await service.login(FIRST_ADMIN, FIRST_ADMIN_PW) assert out.ok and out.identity is not None and out.token is not None - return out.identity, out.token, boot.password + return out.identity, out.token, FIRST_ADMIN_PW async def test_enroll_confirm_status_and_recovery_codes() -> None: @@ -139,9 +142,8 @@ async def test_require_mfa_forces_admin_even_unenrolled() -> None: store = await _store() try: service = AuthService(store, AuthSettings(require_mfa=True)) - boot = await service.initialize() - assert boot is not None - out = await service.login("admin", boot.password) + await create_first_admin(service) + out = await service.login(FIRST_ADMIN, FIRST_ADMIN_PW) # Admin must MFA even though not enrolled — they can log in but can't satisfy step-up until # they enroll a TOTP authenticator. assert out.ok and out.mfa_required is True and out.token is not None diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index 5270161a..3c53bc0b 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -46,10 +46,6 @@ def test_scaffold_writes_the_skeleton(tmp_path: Path) -> None: # the service settings template carries the new posture model, not the retired enum toml = (repo / "messagefoundry.toml").read_text() assert 'environment = "dev"' in toml and "data_class" in toml and "production" in toml - # D11: the .gitignore must ignore the one-time bootstrap admin credential the engine writes next - # to the store, so it is never committed - gitignore = (repo / ".gitignore").read_text() - assert "bootstrap-admin.txt" in gitignore # the template + README teach WS-1's env-anchor so a config repo run under a service (CWD != repo # root) still resolves environments/.toml (ADR 0017): base_dir in the toml, --project-root in docs assert "base_dir" in toml diff --git a/tests/test_security_doc_drift.py b/tests/test_security_doc_drift.py index 650f8794..b482e025 100644 --- a/tests/test_security_doc_drift.py +++ b/tests/test_security_doc_drift.py @@ -304,8 +304,6 @@ "oidc_required_acr_values", "oidc_allowed_username_domains", "oidc_username_strip_domain", - # a time attribute x admin-population state that DENIES (disable + revoke + audit) - "bootstrap_expiry_hours", # DATA PLANE — the binding correction: these are pre-auth, IP-keyed ALLOW/DENY decisions too "source_ip_allowlist", "calling_ae_allowlist", @@ -373,7 +371,6 @@ ("auth", "ad_session_recheck_max_users", 200, "200 users"), ("auth", "ad_session_revoke_max", 5, "**5**"), ("auth", "ad_session_revoke_max_fraction", 0.34, "**0.34**"), - ("auth", "bootstrap_expiry_hours", 72, "72 h"), ("auth", "oidc_require_mfa_claim", True, "on"), # These three were documented but unpinned — precisely the defaults the trailing lanes plan to # move (#297's 8.3.2 route proposes an ADR-0080-style derived ad_session_recheck_seconds), so a @@ -397,7 +394,6 @@ "step_up", "session_", "lockout_", - "bootstrap_", "_allowlist", "_networks", "_origins", @@ -435,11 +431,6 @@ # WP #285 (ASVS 6.7.1): the optional SHA-256 integrity pin over the OIDC CA anchor above — # an integrity control on trust material, not a consumer/environment access-decision input. "oidc_tls_ca_cert_pin", - # ASVS 6.4.5 arm 2: how long BEFORE the bootstrap deadline to start reminding an operator that - # the unclaimed first-run credential is about to be retired. Purely the timing of an advisory - # ALERT — no login, session or authorization outcome turns on it (contrast its sibling - # `bootstrap_expiry_hours`, which DISABLES the account and is therefore an inventoried input). - "bootstrap_warn_hours", # ASVS 3.7.3: destinations exempted from the "you are leaving this site" interstitial. It # decides whether the operator is SHOWN A NOTIFICATION before an outbound navigation — not # whether any request is authorized. No login, session, permission or authorization outcome @@ -468,7 +459,10 @@ #: Body-row counts of the two decision tables. Row-scoping alone cannot catch the deletion of a row #: whose tokens are shared with a sibling row (Sec-Fetch, bind/exposure, the DICOM construction #: gate), so the counts are pinned too: removing ANY row reds CI. -_CONTEXT_TABLE_A_ROWS = 35 +#: 35 -> 34 on 2026-08-10 (BACKLOG #1020): the "Bootstrap-admin age x admin population" row went with +#: the account it inventoried. This gate did its job — the row was deleted deliberately, and the count +#: is moved in the same change rather than after a red. +_CONTEXT_TABLE_A_ROWS = 34 _CONTEXT_TABLE_B_ROWS = 9 #: The closed action vocabulary the section declares. Every Action cell in BOTH tables must OPEN with diff --git a/tests/test_security_doc_rate_limits.py b/tests/test_security_doc_rate_limits.py index 1d5758a9..0f2feb2f 100644 --- a/tests/test_security_doc_rate_limits.py +++ b/tests/test_security_doc_rate_limits.py @@ -414,8 +414,8 @@ def test_business_logic_limit_table_states_both_dimensions_for_every_row() -> No #: Enforcement-scope vocabulary the Scope cell must declare, per row. The blanket claim this #: replaces ("All are in-process, per API process — N engine shards multiply every budget by N") was -#: false for three of the table's own rows: two API processes share ONE lockout counter, ONE session -#: cap and ONE bootstrap timer, because all three are written through the store. +#: false for rows of the table's own: two API processes share ONE lockout counter and ONE session +#: cap, because both are written through the store. _SCOPE_TOKENS = ("**in-process**", "**store-backed**", "**stateless**", "**n/a**") #: Limits whose state lives in the STORE, mapped to the ``self._store`` method that proves it. If a @@ -423,7 +423,6 @@ def test_business_logic_limit_table_states_both_dimensions_for_every_row() -> No _STORE_BACKED_LIMITS: dict[str, str] = { "lockout_threshold": "record_login_failure", "max_sessions_per_user": "enforce_session_cap", - "bootstrap_expiry_hours": "set_user_disabled", } #: Reject-when-full caches that are plain in-process objects, mapped to the class ``_bounded_caches`` @@ -472,9 +471,9 @@ def test_business_logic_limit_scope_is_stated_per_row() -> None: RULE (2.1.3): "N engine shards multiply every budget by N" is a claim an assessor tests. It is true of the sliding-window limiters and the two pending-flow caches and FALSE of the account - lockout, the session cap and the bootstrap timer, which are written through the one unified - store. Blanket-scoping the table inverts exactly the distinction that separates a per-process - limiter from a durable one. + lockout and the session cap, which are written through the one unified store. Blanket-scoping + the table inverts exactly the distinction that separates a per-process limiter from a durable + one. """ table = next(t for t in _tables(_section(_H_LIMITS)) if t[0][0] == "Limit") scope = table[0].index("Scope") @@ -1323,7 +1322,6 @@ def test_lockout_is_fed_by_two_legs_but_enforced_on_the_assertion_leg_too() -> N ("admin_write_rate_limit_per_actor", 12), ("admin_write_rate_limit_window_seconds", 1.0), ("max_sessions_per_user", 5), - ("bootstrap_expiry_hours", 72), ], ) def test_limit_defaults_quoted_in_the_table_match_the_code(field: str, pinned: object) -> None: diff --git a/tests/test_settings.py b/tests/test_settings.py index ba8edd84..f8647981 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -212,7 +212,6 @@ def test_auth_password_policy_defaults_are_asvs_aligned() -> None: assert a.password_check_breached and a.password_check_context assert a.password_check_username # v2: own-username rejection on by default (6.2.11) assert a.password_breach_corpus_file is None # opt-in larger offline corpus (6.2.12) - assert a.bootstrap_expiry_hours == 72 def test_auth_breach_corpus_and_username_check_from_env() -> None: diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index 9b94e1d7..636412f0 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -24,6 +24,11 @@ from messagefoundry.auth.service import AuthService # noqa: E402 from messagefoundry.config.settings import AuthSettings # noqa: E402 from messagefoundry.store.store import MessageStore # noqa: E402 +from tests._first_admin import ( # noqa: E402 + FIRST_ADMIN, + FIRST_ADMIN_PW, + create_first_admin, +) from tests._soft_webauthn import SoftAuthenticator # noqa: E402 RP = "t" @@ -51,11 +56,11 @@ async def _service( async def _bootstrap_login(service: AuthService) -> tuple[Identity, str, str]: - boot = await service.initialize() - assert boot is not None - out = await service.login("admin", boot.password) + # BACKLOG #1020: no implicit first-run account — stand one up as `admin-create` does. + await create_first_admin(service) + out = await service.login(FIRST_ADMIN, FIRST_ADMIN_PW) assert out.ok and out.identity is not None and out.token is not None - return out.identity, out.token, boot.password + return out.identity, out.token, FIRST_ADMIN_PW async def _enroll( From 126e9cbbbafa156075bab89ccd8e8248504d5557 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 18:10:20 -0500 Subject: [PATCH 3/4] test(dr): the server-seed fixtures synthesise an audit signature a first run can still write (BACKLOG #1020) Both live-backend fixtures reproduce "a fresh, engine-started, NEVER-restored database" by writing two audit rows directly, and one of them was `auth.bootstrap_admin_created` -- an action the engine no longer emits now that the implicit first-run Administrator is retired. The gate under test keys on "audit_log non-empty AND no dr_backup row", so the assertions were unaffected either way; what was wrong is the fixture's own claim to be "the exact real-path state", which it no longer was. They now write `user.created` (what `messagefoundry admin-create` audits) plus `auth.login_success`, which is the signature a first run actually leaves. These two legs SKIP locally -- there is no live SQL Server or Postgres here -- so this is verified by construct and by ruff/mypy only; the Windows CI `store-sqlserver` and `store-postgres` legs are what execute them. --- tests/test_dr_server_seed_gate_postgres.py | 14 ++++++++------ tests/test_dr_server_seed_gate_sqlserver.py | 14 ++++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/test_dr_server_seed_gate_postgres.py b/tests/test_dr_server_seed_gate_postgres.py index 6a261c45..db694384 100644 --- a/tests/test_dr_server_seed_gate_postgres.py +++ b/tests/test_dr_server_seed_gate_postgres.py @@ -2,7 +2,7 @@ # Copyright (C) 2026 MessageFoundry Organization and contributors """Live Postgres proof of the #102 server-DB DR seed gate (ADR 0048), reproducing the REAL deployment path. On a genuinely fresh/unrestored 'mefor' store whose audit_log is NON-EMPTY-but-has-no-dr_backup-row -(the bootstrap+login signature) + attestation → REFUSED (the data-loss case the config-only archive and +(the first-admin-creation+login signature) + attestation → REFUSED (the data-loss case the config-only archive and the refuted count>0 probe both miss). A store carrying a dr_backup row (restored-primary signature) + attestation → PASS. No attestation → REFUSED. run_restore_verify is stubbed to PASS so the test isolates the LIVE restore-provenance probe (has_prior_backup_history against a real backend). @@ -69,13 +69,15 @@ async def deact() -> None: async def _reset_to_fresh_bootstrapped(store: Any) -> None: - # Reproduce a FRESH/UNRESTORED but engine-started DB: audit_log NON-EMPTY (bootstrap + login) yet with - # NO dr_backup row. This is the exact real-path state the refuted count>0 probe would have PASSED. + # Reproduce a FRESH/UNRESTORED but engine-started DB: audit_log NON-EMPTY (first-admin creation + + # login) yet with NO dr_backup row. This is the exact real-path state the refuted count>0 probe + # would have PASSED. The two actions are the ones a first run actually writes: since BACKLOG #1020 + # that is `user.created` from `messagefoundry admin-create`, not the retired + # `auth.bootstrap_admin_created` — a fixture naming an action the engine can no longer emit is a + # fixture describing a state the product cannot reach. async with store._pool.acquire() as conn: await conn.execute("DELETE FROM audit_log") - await store.record_audit( - "auth.bootstrap_admin_created", actor="bootstrap", detail="{}", now=1.0 - ) + await store.record_audit("user.created", actor="cli", detail="{}", now=1.0) await store.record_audit("auth.login_success", actor="alice", detail="{}", now=2.0) diff --git a/tests/test_dr_server_seed_gate_sqlserver.py b/tests/test_dr_server_seed_gate_sqlserver.py index b0ccd60a..fcb74ac6 100644 --- a/tests/test_dr_server_seed_gate_sqlserver.py +++ b/tests/test_dr_server_seed_gate_sqlserver.py @@ -2,7 +2,7 @@ # Copyright (C) 2026 MessageFoundry Organization and contributors """Live SQL Server proof of the #102 server-DB DR seed gate (ADR 0048), reproducing the REAL deployment path. On a genuinely fresh/unrestored 'mefor' store whose audit_log is NON-EMPTY-but-has-no-dr_backup-row -(the bootstrap+login signature) + attestation → REFUSED (the data-loss case the config-only archive and +(the first-admin-creation+login signature) + attestation → REFUSED (the data-loss case the config-only archive and the refuted count>0 probe both miss). A store carrying a dr_backup row (restored-primary signature) + attestation → PASS. No attestation → REFUSED. run_restore_verify is stubbed to PASS so the test isolates the LIVE restore-provenance probe (has_prior_backup_history against a real backend). @@ -69,15 +69,17 @@ async def deact() -> None: async def _reset_to_fresh_bootstrapped(store: Any) -> None: - # Reproduce a FRESH/UNRESTORED but engine-started DB: audit_log NON-EMPTY (bootstrap + login) yet with - # NO dr_backup row. This is the exact real-path state the refuted count>0 probe would have PASSED. + # Reproduce a FRESH/UNRESTORED but engine-started DB: audit_log NON-EMPTY (first-admin creation + + # login) yet with NO dr_backup row. This is the exact real-path state the refuted count>0 probe + # would have PASSED. The two actions are the ones a first run actually writes: since BACKLOG #1020 + # that is `user.created` from `messagefoundry admin-create`, not the retired + # `auth.bootstrap_admin_created` — a fixture naming an action the engine can no longer emit is a + # fixture describing a state the product cannot reach. async with store._pool.acquire() as conn: cur = await conn.cursor() await cur.execute("DELETE FROM audit_log") await conn.commit() - await store.record_audit( - "auth.bootstrap_admin_created", actor="bootstrap", detail="{}", now=1.0 - ) + await store.record_audit("user.created", actor="cli", detail="{}", now=1.0) await store.record_audit("auth.login_success", actor="alice", detail="{}", now=2.0) From a46f7a83bf4c4ff17aca3b19b9e20eb8c477efde Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 18:11:16 -0500 Subject: [PATCH 4/4] style(cli): drop a duplicated comment in _admin_create The store-locator rationale was written twice when the print was made backend-aware. SDS-3.5: state a load-bearing fact once. No behaviour change. --- messagefoundry/__main__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index bb0f1154..b7a6d81d 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -4057,11 +4057,9 @@ async def run() -> tuple[int, str]: if rc != 0: print(message, file=sys.stderr) return rc - # Name the store that was actually written: an --db/[store].path typo otherwise shows up only as a - # login failure against the engine's real database, long after the cause. - store = settings.store # Name the store that was actually written. A --db / [store].path / MEFOR_STORE_* typo otherwise # surfaces only as a login failure against the engine's REAL database, long after the cause. + store = settings.store where = ( repr(store.path) if store.backend is StoreBackend.SQLITE