From bb9ee0e04405363e5a077565be680d0573edb6d3 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 13:19:12 -0500 Subject: [PATCH 01/17] docs(security): correct three statements #1149's own build left false (BACKLOG #1149) #1149 landed the session-terminate action binding: both JSON routes and both console twins now take the reauth-only action factory, so a login-seeded window no longer unlocks a terminate. Three shipped statements were written before that gate existed and still describe its absence. pages/account.py said revoking one's own sessions is "cookie-authenticated self-service (no step-up)". That is now false. A stale absence claim sitting beside a control is worse than silence: it reads as a licence to remove the gate for consistency. SECURITY.md's session-inventory section enumerated all four routes and never mentioned the password re-proof gate, and asserted the current session is "only revocable via Sign out". The second is a property of the console PAGE, not of the endpoint: revoke_own_session checks ownership and nothing else, so on a first deployment DELETE /me/sessions/{id} would accept the caller's own current session id and revoke it. That last sentence is derived rather than asserted. The new test drives the real route; a mutation adding the current-session guard the prose implied makes the route answer 404, which reds its 200 assertion. The sibling ownership test is the control. Co-Authored-By: Claude Opus 5 --- docs/SECURITY.md | 15 ++++++++--- messagefoundry_webconsole/pages/account.py | 18 +++++++++++--- tests/test_api_auth.py | 29 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 86a78d4f2..72ff80281 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1465,15 +1465,24 @@ Users and admins can see and revoke individual sessions (ASVS 7.5.2 / 7.4.5): flagged). The session `id` is the session's `token_hash` (a one-way hash of the opaque token, safe to expose). - **`DELETE /me/sessions/{id}`** — revoke one of **your own** sessions (ownership-checked: another - user's id returns 404, never revealing or touching it). + user's id returns 404, never revealing or touching it). **Gated on a fresh password re-proof bound + to the `session_terminate` action** (ASVS 7.5.2 — see the route table above): the sign-in you + already hold does not unlock a terminate, and the grant is single-use. - **`DELETE /me/sessions`** — "sign out everywhere else": revoke all your sessions except the current. + Same `session_terminate` re-proof gate. - **`DELETE /users/{id}/sessions`** (`users:manage`) — admin force-sign-out of a user (offboarding / suspected compromise). +The two self-service terminates are **password-only** step-ups deliberately: a second-factor gate +would deadlock an MFA-required-but-unenrolled operator out of revoking their own sessions. + Every targeted revoke is audited (`auth.session_revoked`, with scope + actor). The **web console** surfaces this: an **Active sessions…** view in the account menu lists your sessions and offers per-session -revoke + "sign out everywhere else" (the current session is shown but only revocable via *Sign out*), -and the **Users** page has a **Revoke sessions** action for admin force-sign-out. +revoke + "sign out everywhere else". The console renders **no Revoke button on the current session**, +so the list cannot leave the operator mid-request; *Sign out* is the console's way to end it. That is +a property of the **page**, not of the API — `DELETE /me/sessions/{id}` checks ownership only, so it +accepts the caller's own current session id and revokes it. The **Users** page has a **Revoke +sessions** action for admin force-sign-out. ### Security-event notifications (WP-L3-05, ASVS 6.3.5 / 6.3.7) diff --git a/messagefoundry_webconsole/pages/account.py b/messagefoundry_webconsole/pages/account.py index 2573310b7..920552b2c 100644 --- a/messagefoundry_webconsole/pages/account.py +++ b/messagefoundry_webconsole/pages/account.py @@ -498,9 +498,21 @@ def sessions_page(sessions: Sequence[Mapping[str, object]], *, notice: str | Non every live session for the caller with its own **Revoke**, plus **Sign out everywhere else**. ``sessions`` are plain row mappings built by the route (id / created_at / last_used_at / - expires_at / client / current) — this module never touches the store. Revoking one's OWN - sessions is cookie-authenticated self-service (no step-up); the current session shows no Revoke - button (use the header Sign out to end it) so the list can't leave the user mid-request.""" + expires_at / client / current) — this module never touches the store. + + **Revoking one's OWN sessions is step-up gated** (ASVS 7.5.2, BACKLOG #1149): both terminate + POSTs take ``require_ui_reauth_only_action(STEP_UP_ACTION_SESSION_TERMINATE)``, so the cookie + alone does not carry them — ``/ui/reauth`` mints a single-use grant bound to that action and each + terminate consumes one. It is the password-only family on purpose: the full step-up would + deadlock an MFA-required-but-unenrolled operator out of revoking their own sessions. + + *This docstring previously said "no step-up", which stopped being true when that gate landed. + A stale absence claim beside a control is worse than silence: it reads as a licence to remove + the gate for consistency.* + + The current session shows no Revoke button (use the header Sign out to end it) so the list can't + leave the user mid-request. That is a property of this PAGE — ``DELETE /me/sessions/{id}`` checks + ownership only and would accept the caller's own current session id.""" note = el("p", notice, class_="muted") if notice else Markup("") rows: list[Markup] = [] others = 0 diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 4c67231aa..cb6ae6fcd 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -1006,6 +1006,35 @@ async def test_revoke_other_sessions_keeps_current(engine: Engine) -> None: assert (await c.get("/auth/me", headers=_auth(t2))).status_code == 200 +async def test_the_api_accepts_a_revoke_of_the_callers_own_current_session(engine: Engine) -> None: + """`DELETE /me/sessions/{id}` checks OWNERSHIP only, so the caller's CURRENT session id is a + valid target and revoking it ends the caller's own session. + + RED when: a current-session guard is added to the route or to ``revoke_own_session`` without + the documentation changing with it. This test exists because `docs/SECURITY.md` and + `messagefoundry_webconsole/pages/account.py` both describe the console's missing Revoke button + on the current row, and a reader would reasonably carry that over to the API. It is a property + of the PAGE, not of the endpoint, and this pins which. Derived, not asserted. + """ + service = await _service(engine) + await _add(service, "u", Role.VIEWER) + async with _client(engine, service) as c: + token = (await _login(c, "u")).json()["token"] + sessions = (await c.get("/me/sessions", headers=_auth(token))).json()["sessions"] + current = next(s for s in sessions if s["current"]) + re = await _reauth(c, token, purpose="session_terminate") + assert re.status_code == 200 + # Adopt a rotated token if the re-auth handed one back. ASVS 7.2.4 (BACKLOG #1146) wires + # rotation into this leg, and this test must pin the ownership rule either side of that. + token = re.json().get("token") or token + assert ( + await c.delete(f"/me/sessions/{current['id']}", headers=_auth(token)) + ).status_code == 200 + # The caller signed THEMSELVES out — the whole point, and the negative control for the + # ownership rule is the sibling test above, where another user's id answers 404. + assert (await c.get("/auth/me", headers=_auth(token))).status_code == 401 + + async def test_cannot_revoke_another_users_session(engine: Engine) -> None: service = await _service(engine) await _add(service, "a", Role.VIEWER) From 74511b6b0c5b881cb364a49003d82d922af16d94 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 13:22:03 -0500 Subject: [PATCH 02/17] fix(apiclient): handle the dual-control 202 hold on all three gated calls (BACKLOG #1113) The engine answers 202 with a PendingApprovalResponse when [approvals].enabled holds an operation for a second approver. The shared client raised only at 400 and above, so the hold arrived as success and each of the three gated calls then mishandled it a different way. replay_dead_letters and reload_config parsed it bare, bypassing the module's own _decode. Both result models require every field and the hold body supplies none, so on a first deployment with the gate on a correctly-working hold would raise an unhandled pydantic ValidationError out of the client -- breaking the contract that file's docstring says _decode exists to preserve. purge_connection did use _decode and would have reported the hold as engine version skew. Three behaviours for one wire state was the underlying defect. All three now return ` | PendingApprovalResponse`, discriminated on the status code and decoded through _decode, so a malformed body of either shape is still an ApiError and never a bare ValidationError. That mirrors the engine's own route signatures and the console's existing isinstance narrowing. Callers: harness/monitor.py reports a hold as a status line rather than an error. probe.py's time_reload would have timed a held reload as a fast one, reading as the O(connections) cost getting cheaper; it returns None on a hold. Mutated both ways. Reverting one call to the bare model_validate reds three tests; reporting every 2xx as held reds exactly the three negative controls. The 2.3.5 cell is an owner-ruled permanent partial. This is the row's "fix them regardless of what the verdict does" half and claims nothing about the verdict. Co-Authored-By: Claude Opus 5 --- harness/load/connscale/probe.py | 13 +- harness/monitor.py | 20 ++- messagefoundry/apiclient/client.py | 70 ++++++-- tests/test_apiclient_approval_hold.py | 234 ++++++++++++++++++++++++++ 4 files changed, 323 insertions(+), 14 deletions(-) create mode 100644 tests/test_apiclient_approval_hold.py diff --git a/harness/load/connscale/probe.py b/harness/load/connscale/probe.py index 6af82e317..1cea6e741 100644 --- a/harness/load/connscale/probe.py +++ b/harness/load/connscale/probe.py @@ -46,6 +46,7 @@ from enum import StrEnum from pathlib import Path +from messagefoundry.api.models import PendingApprovalResponse from messagefoundry.apiclient import ApiError, EngineClient _WINDOWS = sys.platform == "win32" @@ -679,11 +680,17 @@ def _as_int(text: str) -> int | None: def time_reload(client: EngineClient, config_dir: str | None) -> float | None: """Time one ``reload_config(config_dir)`` round-trip in seconds (wall #5), or ``None`` if the - reload errors. Synchronous — the runner calls it in ``run_in_executor`` (off the event loop, like - the rest of the engine polling). ``config_dir=None`` reloads the server's startup --config dir.""" + reload errors or was held. Synchronous — the runner calls it in ``run_in_executor`` (off the + event loop, like the rest of the engine polling). ``config_dir=None`` reloads the server's + startup --config dir.""" t0 = time.perf_counter() try: - client.reload_config(config_dir) + result = client.reload_config(config_dir) except ApiError: return None + if isinstance(result, PendingApprovalResponse): + # Dual-control held the reload (ASVS 2.3.5): no graph was swapped, so the elapsed time is + # the cost of parking an approval, not the O(connections) reload this wall measures. Report + # no sample rather than a fast one that would read as a reload getting cheaper. + return None return time.perf_counter() - t0 diff --git a/harness/monitor.py b/harness/monitor.py index 925c9ad69..81a7d6398 100644 --- a/harness/monitor.py +++ b/harness/monitor.py @@ -43,7 +43,7 @@ fmt_ts, ) from harness._login import LoginDialog -from messagefoundry.api.models import ConnectionRow, DeadLetterRow +from messagefoundry.api.models import ConnectionRow, DeadLetterRow, PendingApprovalResponse from messagefoundry.apiclient import ApiError, EngineClient _DEFAULT_URL = "http://127.0.0.1:8765" @@ -486,6 +486,9 @@ def _purge_outbound(self) -> None: except ApiError as exc: self._set_status(str(exc), error=True) return + if isinstance(result, PendingApprovalResponse): + self._set_held_status(result) + return self._set_status(f"purged {result.cancelled} queued delivery(ies) from {key[2]}") def _replay_selected_dead(self) -> None: @@ -525,6 +528,9 @@ def _do_replay(self, *, channel_id: str | None, destination_name: str | None) -> except ApiError as exc: self._set_status(str(exc), error=True) return + if isinstance(result, PendingApprovalResponse): + self._set_held_status(result) + return self._set_status(f"re-queued {result.requeued} dead-lettered delivery(ies)") def _reload_config(self) -> None: @@ -534,6 +540,9 @@ def _reload_config(self) -> None: except ApiError as exc: self._set_status(str(exc), error=True) return + if isinstance(result, PendingApprovalResponse): + self._set_held_status(result) + return self._set_status( f"reloaded: {result.inbound} inbound · {result.outbound} outbound · " f"{result.routers} routers · {result.handlers} handlers" @@ -541,6 +550,15 @@ def _reload_config(self) -> None: # --- status -------------------------------------------------------------- + def _set_held_status(self, held: PendingApprovalResponse) -> None: + """Report a dual-control hold (ASVS 2.3.5): the engine accepted the request and did NOT run + it, so this is neither a failure nor a completed action. A distinct second approver must + release it, and the requester cannot release their own.""" + self._set_status( + f"{held.operation} held for a second approver (approval {held.approval_id}): " + f"{held.detail}" + ) + def _set_status(self, message: str, *, error: bool = False) -> None: self._status.setStyleSheet("color: #c62828;" if error else "") self._status.setText(message) diff --git a/messagefoundry/apiclient/client.py b/messagefoundry/apiclient/client.py index 5ef63e436..5ae7fb5b5 100644 --- a/messagefoundry/apiclient/client.py +++ b/messagefoundry/apiclient/client.py @@ -59,6 +59,7 @@ MessageDetail, MessageList, MessageSearchResults, + PendingApprovalResponse, PurgeResult, ReloadResult, ReplayResult, @@ -85,6 +86,11 @@ MAX_REQUEST_URL_LEN = 8192 MAX_REQUEST_HEADER_VALUE_LEN = 8192 +# ASVS 2.3.5 (BACKLOG #1113): the status the engine answers when dual-control holds a gated +# operation for a second approver instead of running it. Pinned against the engine's own route +# handlers by test_apiclient_approval_hold.py, which reads the code back rather than trusting 202. +_HTTP_PENDING_APPROVAL = 202 + class ApiError(RuntimeError): """An API call failed (transport error, a non-2xx response, or an undecodable 2xx body).""" @@ -117,6 +123,27 @@ def _decode_list(response: httpx.Response, model: type[_Model]) -> list[_Model]: raise ApiError(f"invalid response from engine: {exc}") from exc +def _decode_approvable( # noqa: UP047 + response: httpx.Response, model: type[_Model] +) -> _Model | PendingApprovalResponse: + """Decode a 2xx body from a route that dual-control may hold (ASVS 2.3.5, BACKLOG #1113). + + A held operation is **not** an error and **not** a completed one. The engine answers + :data:`_HTTP_PENDING_APPROVAL` with a :class:`PendingApprovalResponse` instead of executing + inline, and its route signatures say so (``response_model=X | PendingApprovalResponse`` on + ``/connections/{name}/purge``, ``/dead-letters/replay`` and ``/config/reload``). This decoder is + the client half of that contract, so the three gated methods answer a hold identically. + + The status code is the discriminator, not the body shape. It is what the engine actually + varies, and a pydantic union over two models whose fields are disjoint-but-all-optional-looking + would guess. ``_decode`` still does the validating, so a malformed body of either shape stays an + :class:`ApiError` rather than a bare ``ValidationError`` escaping into a caller's event loop. + """ + if response.status_code == _HTTP_PENDING_APPROVAL: + return _decode(response, PendingApprovalResponse) + return _decode(response, model) + + def _seg(value: str | int) -> str: """Percent-encode ``value`` for use as ONE URL path segment (ASVS 1.2.2, BACKLOG #1107). @@ -528,8 +555,16 @@ def stop_connection(self, name: str) -> None: def restart_connection(self, name: str) -> None: self._request("POST", f"/connections/{_seg(name)}/restart") - def purge_connection(self, name: str, scope: str = "all") -> PurgeResult: - return _decode( + def purge_connection( + self, name: str, scope: str = "all" + ) -> PurgeResult | PendingApprovalResponse: + """Soft-cancel queued deliveries to an outbound connection. + + Returns a :class:`PurgeResult` when the purge ran, or a :class:`PendingApprovalResponse` + when dual-control held it for a second approver (ASVS 2.3.5). Narrow with + ``isinstance(result, PendingApprovalResponse)``. The two models share no field, so mypy + refuses ``result.cancelled`` until the hold is handled.""" + return _decode_approvable( self._request("POST", f"/connections/{_seg(name)}/purge", params={"scope": scope}), PurgeResult, ) @@ -659,23 +694,38 @@ def list_connection_events( def replay_dead_letters( self, *, channel_id: str | None = None, destination_name: str | None = None - ) -> DeadLetterReplayResult: + ) -> DeadLetterReplayResult | PendingApprovalResponse: """Re-queue dead-lettered deliveries (``None`` scope = all; a channel-scoped user must - name their channel — an unscoped replay-all is denied server-side).""" - return DeadLetterReplayResult.model_validate( + name their channel — an unscoped replay-all is denied server-side). + + Returns a :class:`DeadLetterReplayResult` when the replay ran, or a + :class:`PendingApprovalResponse` when dual-control held it for a second approver (ASVS + 2.3.5). Narrow with ``isinstance(result, PendingApprovalResponse)``. The two models share no + field, so mypy refuses ``result.requeued`` until the hold is handled.""" + return _decode_approvable( self._request( "POST", "/dead-letters/replay", json={"channel_id": channel_id, "destination_name": destination_name}, - ).json() + ), + DeadLetterReplayResult, ) # --- config -------------------------------------------------------------- - def reload_config(self, config_dir: str | None = None) -> ReloadResult: - """Apply code-first config atomically (``None`` = the server's startup --config dir).""" - return ReloadResult.model_validate( - self._request("POST", "/config/reload", json={"config_dir": config_dir}).json() + def reload_config( + self, config_dir: str | None = None + ) -> ReloadResult | PendingApprovalResponse: + """Apply code-first config atomically (``None`` = the server's startup --config dir). + + Returns a :class:`ReloadResult` when the graph was swapped, or a + :class:`PendingApprovalResponse` when dual-control held the reload for a second approver + (ASVS 2.3.5). Narrow with ``isinstance(result, PendingApprovalResponse)``. The two models + share no field, so mypy refuses ``result.inbound`` until the hold is handled. A held reload + has changed nothing yet; the captured ``config_dir`` is replayed on release.""" + return _decode_approvable( + self._request("POST", "/config/reload", json={"config_dir": config_dir}), + ReloadResult, ) def stats(self) -> StatsResponse: diff --git a/tests/test_apiclient_approval_hold.py b/tests/test_apiclient_approval_hold.py new file mode 100644 index 000000000..1b27a9799 --- /dev/null +++ b/tests/test_apiclient_approval_hold.py @@ -0,0 +1,234 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""The engine client answers a dual-control hold as a hold (ASVS 2.3.5, BACKLOG #1113). + +Three approval-gated operations reach the engine through +:class:`~messagefoundry.apiclient.EngineClient`: ``purge_connection``, ``replay_dead_letters`` and +``reload_config``. When ``[approvals]`` gates one, the engine answers **202 with a +``PendingApprovalResponse``** instead of running it -- the operation has NOT happened, and a +distinct second approver must release it. + +Before this file, the client answered that 202 three different ways, which was the defect: two +methods validated the hold body against a result model whose every field is required and let +pydantic's ``ValidationError`` escape (breaking the contract ``_decode``'s own docstring states), +and the third reported the hold as an engine version-skew ``ApiError``. A deploying site that +turned the gate on would have seen all three. + +The negative control is the load-bearing half: without it, a client that reported EVERY response as +held would pass the hold assertions. +""" + +from __future__ import annotations + +import pathlib +import re +from collections.abc import Callable + +import httpx +import pytest +from pydantic import BaseModel, ValidationError + +from messagefoundry.api.models import ( + DeadLetterReplayResult, + PendingApprovalResponse, + PurgeResult, + ReloadResult, +) +from messagefoundry.apiclient import ApiError, EngineClient +from messagefoundry.apiclient.client import _HTTP_PENDING_APPROVAL + +# One row per approval-gated client method: (label, call, the engine's operation key, the result +# model instance a COMPLETED call returns). The success bodies are real model instances, not hand +# typed dicts, so a field added to one of these models reaches this test rather than drifting past it. +_GATED_CALLS: list[tuple[str, Callable[[EngineClient], object], str, BaseModel]] = [ + ( + "purge_connection", + lambda c: c.purge_connection("OB_ACME_ADT"), + "connection_purge", + PurgeResult(cancelled=3), + ), + ( + "replay_dead_letters", + lambda c: c.replay_dead_letters(), + "dead_letter_replay", + DeadLetterReplayResult(requeued=7), + ), + ( + "reload_config", + lambda c: c.reload_config(), + "config_reload", + ReloadResult(inbound=1, outbound=2, routers=3, handlers=4, running=True), + ), +] + +# The routes those three methods POST to, each declared on the engine as +# ``response_model= | PendingApprovalResponse``. +_GATED_ROUTES = ("/connections/{name}/purge", "/dead-letters/replay", "/config/reload") + + +def _hold_body(operation: str) -> object: + """The 202 body, serialized from the ENGINE's own :class:`PendingApprovalResponse`. + + Built from the model rather than typed out here so the test cannot drift away from the wire + shape: a renamed or added field changes this body automatically, and a field the client stops + reading is caught by the assertions below rather than by a stale literal that still matches.""" + return PendingApprovalResponse( + approval_id="0f1e2d3c4b5a69788796a5b4c3d2e1f0", + operation=operation, + detail="held for a second approver (dual-control)", + ).model_dump(mode="json") + + +def _client_answering(status: int, body: object) -> EngineClient: + """An ``EngineClient`` whose transport answers every request with ``status`` and ``body``. + + ``_request`` builds the request then dispatches it through ``self._http.send``, so ``send`` is + the seam a stub replaces (the same seam ``tests/test_apiclient.py`` uses).""" + client = EngineClient("http://127.0.0.1:8765") + + def _send(request: httpx.Request, *args: object, **kwargs: object) -> httpx.Response: + return httpx.Response(status, json=body, request=request) + + client._http.send = _send # type: ignore[method-assign] + return client + + +@pytest.mark.parametrize( + ("label", "call", "operation", "_completed"), + _GATED_CALLS, + ids=[row[0] for row in _GATED_CALLS], +) +def test_a_held_operation_returns_the_hold_and_never_a_bare_validation_error( + label: str, + call: Callable[[EngineClient], object], + operation: str, + _completed: BaseModel, +) -> None: + """A 202 + ``PendingApprovalResponse`` decodes to the hold on all three methods, identically.""" + client = _client_answering(_HTTP_PENDING_APPROVAL, _hold_body(operation)) + try: + result = call(client) + except ValidationError as exc: # the shipped defect on replay_dead_letters / reload_config + pytest.fail(f"{label} leaked a bare pydantic ValidationError on a hold: {exc}") + except ApiError as exc: # the shipped defect on purge_connection (a hold read as version skew) + pytest.fail(f"{label} reported a hold as an API error: {exc}") + finally: + client.close() + assert isinstance(result, PendingApprovalResponse), ( + f"{label} decoded a 202 hold as {type(result).__name__}, so a caller cannot tell a held " + "operation from a completed one" + ) + assert result.operation == operation + assert result.approval_id == "0f1e2d3c4b5a69788796a5b4c3d2e1f0" + assert result.status == "pending_approval" + + +@pytest.mark.parametrize( + ("label", "call", "_operation", "completed"), + _GATED_CALLS, + ids=[row[0] for row in _GATED_CALLS], +) +def test_a_completed_operation_still_returns_its_own_result_unchanged( + label: str, + call: Callable[[EngineClient], object], + _operation: str, + completed: BaseModel, +) -> None: + """NEGATIVE CONTROL. Without this, a client that reported everything as held would pass the + test above. A 200 + the real result body must still decode to that result, untouched.""" + client = _client_answering(200, completed.model_dump(mode="json")) + try: + result = call(client) + finally: + client.close() + assert not isinstance(result, PendingApprovalResponse), ( + f"{label} reported a COMPLETED operation as held for approval" + ) + assert result == completed + + +@pytest.mark.parametrize( + ("label", "call", "operation", "_completed"), + _GATED_CALLS, + ids=[row[0] for row in _GATED_CALLS], +) +def test_a_malformed_hold_body_is_still_an_apierror( + label: str, + call: Callable[[EngineClient], object], + operation: str, + _completed: BaseModel, +) -> None: + """The hold path keeps the module's decoder contract: a 202 whose body does not match the model + (an engine skew) raises ``ApiError``, never a bare ``ValidationError`` out of the client.""" + client = _client_answering(_HTTP_PENDING_APPROVAL, {"unexpected": "shape"}) + try: + with pytest.raises(ApiError, match="invalid response from engine"): + call(client) + finally: + client.close() + + +def test_the_hold_body_carries_the_fields_the_client_reads() -> None: + """Pin the wire shape against the engine's own model, not a literal. + + ``_hold_body`` serializes :class:`PendingApprovalResponse`; this asserts the three fields a + caller acts on survive that serialization, so dropping or renaming one reds here.""" + body = _hold_body("config_reload") + assert isinstance(body, dict) + assert set(body) >= {"approval_id", "operation", "status", "detail"} + assert body["status"] == "pending_approval", ( + "the engine's own default; a caller keying on it would silently stop matching" + ) + + +def test_the_engine_answers_a_hold_with_the_status_the_client_discriminates_on() -> None: + """``_HTTP_PENDING_APPROVAL`` is a claim about the ENGINE, so read it back from the engine. + + The client picks the hold branch on the status code. Scanning ``api/app.py`` for every + ``response.status_code = N`` immediately preceding a ``return PendingApprovalResponse(...)`` + answers the question the client asks -- what does the engine actually set -- rather than + re-asserting 202 against itself. + + The route list is the positive control: a scan that found nothing would otherwise be + indistinguishable from an engine that never holds anything.""" + from messagefoundry.api import app as app_module + + source = pathlib.Path(app_module.__file__).read_text(encoding="utf-8") + holds = re.findall( + r"response\.status_code = (\d+)\s*\r?\n\s*return PendingApprovalResponse\(", source + ) + assert len(holds) == len(_GATED_ROUTES), ( + f"expected one hold site per gated route ({len(_GATED_ROUTES)}), found {len(holds)}: " + "either a gated route was added/removed, or the scan stopped matching the code" + ) + assert set(holds) == {str(_HTTP_PENDING_APPROVAL)}, ( + f"the engine holds with {sorted(set(holds))} but the client discriminates on " + f"{_HTTP_PENDING_APPROVAL}" + ) + for route in _GATED_ROUTES: + declaration = re.search( + rf'"{re.escape(route)}",\s*response_model=[^)]*PendingApprovalResponse', source + ) + assert declaration is not None, ( + f"{route} no longer declares PendingApprovalResponse in its response_model, so the " + "engine's own signature has stopped saying it can hold" + ) + + +def test_every_gated_client_method_routes_through_the_shared_hold_decoder() -> None: + """The three methods must answer a hold the SAME way -- three behaviours was the defect. + + Reads each method's own source (via ``inspect``, so it cannot drift onto the wrong lines) and + requires it to decode through ``_decode_approvable``. A revert to a bare ``model_validate`` + reds here instead of quietly reintroducing a third behaviour.""" + import inspect + + for label, _call, _operation, _completed in _GATED_CALLS: + body = inspect.getsource(getattr(EngineClient, label)) + assert "_decode_approvable(" in body, ( + f"{label} does not decode through _decode_approvable, so it answers a dual-control " + "hold differently from its two siblings" + ) + assert "model_validate(" not in body, ( + f"{label} validates a response body directly, bypassing the module's decoder contract" + ) From 1351a5ce565ed150c3633f6e4078b23d0a6448c9 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 13:34:17 -0500 Subject: [PATCH 03/17] fix(api): invert the attachment MIME classifier to an allow-list (BACKLOG #1350) ASVS 1.3.4. An attacker-influenced image/svg+xml OBX-5.2 attachment is accepted, stored and served, so the prior not-applicable rationale -- that the engine neither accepts nor renders SVG -- was false on the accepts conjunct. The control was a four-token deny-list (html, xml, script, svg) plus a multipart rule. Its completeness was an unprovable negative, and application/hta is the counterexample: it carries none of those tokens, so it was declared verbatim. _safe_attachment_content_type now returns the CANONICAL key from a ten-entry exact-match allow-list, so no attacker-influenced byte reaches the Content-Type header at all. Everything else is application/octet-stream. Completeness is now a property of a short reviewable list. The download-name extension came from mimetypes.guess_extension, which reads the HOST registry on Windows -- measured here, guess_extension("application/hta") returns ".hta". A served filename would then be a property of the machine the engine happens to run on rather than of the product. The extension now comes from the same table, defaulting to .bin, and app.py no longer imports mimetypes. The allow-list decides what is DECLARED, never whether a file is served: an unrecognized type downloads exactly as a refused one does. The CSP, the unconditional Content-Disposition, nosniff and the middleware are untouched. application/pdf STAYS on the list, decided in writing above the table. The clause this control answers is about executing in the APPLICATION ORIGIN; PDF script runs in the viewer against the document, so a downgrade narrows nothing while costing the type hint on the commonest clinical attachment. What would narrow the local-open threat is content scanning, which this route does not do. Differential control: restoring the deny-list behind the new names reds 19 of 94. Still unmeasured, and no comment claims otherwise: no browser was exercised. That Content-Disposition suppresses inline rendering rests on specification alone. Co-Authored-By: Claude Opus 5 --- docs/CONNECTIONS.md | 30 ++- docs/PHI.md | 15 +- messagefoundry/api/app.py | 137 ++++++++---- tests/test_attachment_download_api.py | 297 ++++++++++++++++++++++---- 4 files changed, 384 insertions(+), 95 deletions(-) diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index 832e9eb77..5a973b21f 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -880,13 +880,29 @@ upload chokepoint enforces a fixed policy independent of the directory-source po **Downloads are made safe at serve (ASVS 1.3.4).** The attachment download route (GET `/messages/{message_id}/attachments/{attachment_id}`, and its `/ui` delegate) serves the stored bytes **verbatim** (the preserve-the-original invariant forbids rewriting a clinical payload) but neutralizes -them at the response: the sender-influenced OBX-5.2 MIME is forced through `_safe_attachment_content_type` -to `application/octet-stream` on any non-clean value **and** on any **browser-active** type (`html`, -`xml`, `script`, `svg` subtypes + `multipart`, matched case-folded, length-bounded); the response carries -`Content-Disposition: attachment` (a download, never an inline render), `X-Content-Type-Options: nosniff` -(no MIME re-sniff), and `Content-Security-Policy: default-src 'none'; sandbox` (an opaque origin with -scripts/forms disabled), re-asserted on the `/ui` delegate from **outside** the console's own CSP writers -so a browser-active representation can never execute in the application origin. +them at the response. The sender-influenced OBX-5.2 MIME goes through `_safe_attachment_content_type`, +which is an **allow-list**: it declares the stored label only when the label exactly names one of a short, +reviewable set of inert types (`application/pdf`, `application/dicom`, `application/json`, `text/plain`, +`text/csv`, and the raster image types), matched case-folded and length-bounded. Everything else is served +as `application/octet-stream` -- every **browser-active** type (`text/html`, `image/svg+xml`, +`application/hta`), every type nobody listed, and every non-clean or over-long value. The direction +matters: the earlier control listed the browser-active subtypes to refuse, which asked a reviewer to prove +no further executable type existed, and `application/hta` showed that negative could not be proved. The +same table supplies the download-name extension, defaulting to `.bin`, so the served filename is a +property of the product rather than of the host's MIME registry. + +The allow-list decides what is **declared**, never whether the file is served: an unrecognized type +downloads exactly as a refused one does. The response carries `Content-Disposition: attachment` (a +download, never an inline render), `X-Content-Type-Options: nosniff` (no MIME re-sniff), and +`Content-Security-Policy: default-src 'none'; sandbox` (an opaque origin with scripts/forms disabled), +re-asserted on the `/ui` delegate from **outside** the console's own CSP writers, so a browser-active +representation can never execute in the application origin. + +`application/pdf` is allow-listed by a decision recorded beside the table in `api/app.py`, not by +oversight: a PDF can carry script that runs in a viewer once a saved file is opened, but that script runs +against the document rather than against the serving origin, and the declared type stops governing the +moment the file is on disk. The instrument for the local-open threat would be content scanning, which this +route does not do. ### Remote file — `Sftp(...)` / `Ftp(...)` diff --git a/docs/PHI.md b/docs/PHI.md index 7eb9be316..66c2151c2 100644 --- a/docs/PHI.md +++ b/docs/PHI.md @@ -815,12 +815,15 @@ control unchanged (`messages:view_raw`/`view_summary` RBAC, field-level redactio - **Attachments are neutralized at serve, never rewritten.** A detached document (ADR 0105) is a verbatim clinical payload carrying its own attacker-influenced `OBX-5.2` MIME label, and the preserve-the-original invariant forbids editing the stored bytes — so the browser-safety control runs - at *serve* time, not on the stored document: a browser-active label (`html`/`xml`/`script`/`svg`, - case-folded) is downgraded to `application/octet-stream`, which also strips a `.svg`/`.html` download - name, and the response carries `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff` - and `Content-Security-Policy: default-src 'none'; sandbox` on both the JSON route and the `/ui` - delegate. No served representation can execute in the application origin. Trade-off: `svg`/`html` - attachments no longer preview in the browser; the bytes are unchanged and still downloadable. + at *serve* time, not on the stored document. The served `Content-Type` comes from an **allow-list** of + inert types matched case-folded and exactly (`api/app.py`); a browser-active label such as `text/html`, + `image/svg+xml` or `application/hta` is simply not on it, so it is declared `application/octet-stream`, + and the same table gives the download name a `.bin` extension instead of `.svg`/`.html`/`.hta`. The + response carries `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff` and + `Content-Security-Policy: default-src 'none'; sandbox` on both the JSON route and the `/ui` delegate. + No served representation can execute in the application origin. Trade-off: `svg`/`html` attachments no + longer preview in the browser; the bytes are unchanged and still downloadable, since the allow-list + governs the declared type and never whether the file is served. - **XSS-safe rendering.** All HL7/message content is escaped by an autoescape-by-default renderer and a strict CSP (`script-src 'self'`, no `unsafe-*`); attacker-influenced HL7 cannot execute in the DOM. - **Residual (documented, not a claimed control):** a shared clinical workstation, browser devtools, or a diff --git a/messagefoundry/api/app.py b/messagefoundry/api/app.py index e6648d36d..5d69c2e01 100644 --- a/messagefoundry/api/app.py +++ b/messagefoundry/api/app.py @@ -33,7 +33,6 @@ import datetime import json import logging -import mimetypes import os import re import shutil @@ -643,8 +642,8 @@ def _export_ndjson_line(row: Row) -> bytes: #: (``;``/space/CR/LF/``"``) that could inject or split the ``Content-Type`` header. An attachment's #: ``content_type`` originates from an attacker-influenced OBX-5.2 label, so a value failing this is #: served as the generic binary type below rather than trusted into the response header. This is a -#: shape screen ONLY — it admits ``image/svg+xml``/``text/html``; the browser-active downgrade below is -#: what makes the served type inert (ASVS 1.3.4). +#: shape screen ONLY — it admits ``image/svg+xml``/``text/html``/``application/hta``; the allow-list +#: below is what makes the served type inert (ASVS 1.3.4). _SAFE_MIME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.+-]*/[A-Za-z0-9][A-Za-z0-9.+-]*$") #: Length bound on the served ``Content-Type``. The token grammar above is unbounded and the stored #: label has no column check, so an arbitrarily long attacker string would otherwise be echoed into a @@ -652,16 +651,66 @@ def _export_ndjson_line(row: Row) -> bytes: _MAX_ATTACHMENT_MIME_LEN = 255 _DEFAULT_ATTACHMENT_MIME = "application/octet-stream" -#: Case-folded subtype tokens that make a media type **browser-active** — a representation a browser may -#: execute, or render as markup, rather than treat as opaque bytes. Matched as SUBSTRINGS of the -#: case-folded subtype, deliberately wider than exact-subtype equality or a ``+xml``-suffix test: +#: The **inert** media types an attachment download may DECLARE, each mapped to the extension its download +#: name carries. This is an ALLOW-LIST, and the direction is the point. The control it replaced listed +#: the **browser-active** subtypes to refuse (``html``/``xml``/``script``/``svg``, plus a ``multipart`` +#: top-type), which asks a reviewer to prove a negative — that no further executable type exists. That +#: cannot be proved, and it was not true: ``application/hta`` names a scriptable HTML application and +#: passes every one of those tokens. Enumerating what is SAFE makes completeness a property of a list a +#: reviewer can read, and puts every unforeseen type on the safe side of the default. +#: +#: **Exact match on the case-folded type — never a substring or suffix test, in either direction.** The +#: refusal list matched substrings deliberately, and its reasoning about the threat was right: #: ``application/x-javascript``, ``text/x-html``, ``image/svg`` (no ``+xml``) and ``application/xml-dtd`` -#: are all browser-active and every one of them slips past an equality/suffix check. ``script`` also -#: catches ``ecmascript``/``vbscript``/``jscript``; the only benign type it sweeps up is -#: ``application/postscript``, which no browser renders and which is not a pass-through requirement. -_BROWSER_ACTIVE_SUBTYPE_TOKENS = ("html", "xml", "script", "svg") -#: Top-level types that are browser-active whatever the subtype (``multipart/x-mixed-replace`` renders). -_BROWSER_ACTIVE_TYPES = ("multipart",) +#: are all browser-active, and every one slips past an equality or ``+xml``-suffix check. That density of +#: near-miss spellings is why refusal cannot be enumerated — and it is also why an allow-list has to be +#: exact: a substring match in the *allow* direction would hand the same near-miss family a pass, since +#: ``application/pdf-javascript`` contains ``application/pdf``. The fold stays because browsers match +#: media types case-insensitively, so ``Image/PNG`` and ``image/png`` name one type. +#: +#: What is served is the CANONICAL key from this table, not the stored label, so no attacker-influenced +#: byte reaches the ``Content-Type`` header at all. The shape screen above becomes a first cut rather +#: than the last line of defence. +#: +#: **``application/pdf`` is on this list deliberately, and it is the entry that is not inert.** A genuine +#: PDF passes the shape screen and the magic check, and a PDF may carry ``/JavaScript`` that runs when a +#: saved file is opened in a viewer. It stays, for three reasons. +#: +#: 1. The clause this control answers is about a representation executing **in the application origin** — +#: reading the console's DOM, its session, its cookies. PDF script runs inside the viewer, against the +#: document, not against the origin that served it. Declaring ``application/octet-stream`` here would +#: not narrow that clause by anything. +#: 2. The declared type stops governing the moment the file is on disk. From there the file extension and +#: the operator's application association decide what opens it, and an operator who wants to read a +#: downloaded report will supply ``.pdf`` whatever this header said. So the downgrade would buy nothing +#: against the local-open threat, while costing the operator a usable type hint on the commonest +#: OBX-5 attachment in a clinical feed. +#: 3. What WOULD narrow the local-open threat is content scanning, which this download route does not do +#: (the ``ScanRejected`` pre-ingest seam covers the ``File(...)``/remote directory sources, not this +#: route). A serve-time MIME choice is the wrong instrument for it. A scan seam on this path is +#: unfiled work, named by subject rather than by a number. +#: +#: Anyone reversing this decision should reverse it on evidence about the **origin**, because the origin +#: is the only thing this header controls. +_INERT_ATTACHMENT_TYPES: dict[str, str] = { + "application/dicom": ".dcm", + "application/json": ".json", + "application/pdf": ".pdf", + "image/bmp": ".bmp", + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/tiff": ".tif", + "text/csv": ".csv", + "text/plain": ".txt", +} +#: The download-name extension for every type the allow-list does not name — the partner of +#: :data:`_DEFAULT_ATTACHMENT_MIME`. Both the served type and the served extension now come from this +#: module's own tables, never from :mod:`mimetypes`: ``mimetypes.guess_extension`` consults the HOST +#: registry on Windows, which would make the served filename a property of the machine the engine happens +#: to run on rather than of the product. Measured on a Windows host: +#: ``mimetypes.guess_extension("application/hta")`` returns ``.hta``. +_DEFAULT_ATTACHMENT_EXT = ".bin" #: The attachment download's Content-Security-Policy (ASVS 1.3.4). ``default-src 'none'`` denies every #: subresource and fetch; ``sandbox`` with NO ``allow-*`` token drops the response into a unique opaque @@ -674,47 +723,50 @@ def _export_ndjson_line(row: Row) -> bytes: _ATTACHMENT_PATH_RE = re.compile(r"^(?:/ui)?/messages/[^/]+/attachments/[^/]+$") -def _is_browser_active_mime(mime: str) -> bool: - """True when ``mime`` (already shape-screened ``type/subtype``) names a representation a browser may - execute or render as markup. - - **Case-folded, deliberately.** The token grammar admits uppercase and browsers match media types - case-insensitively, so ``Image/SVG+XML`` is exactly the threat ``image/svg+xml`` is (``mimetypes`` - lower-cases internally too, so the mixed-case form even yields a ``.svg`` download name).""" - top, _, subtype = mime.casefold().partition("/") - if top in _BROWSER_ACTIVE_TYPES: - return True - return any(token in subtype for token in _BROWSER_ACTIVE_SUBTYPE_TOKENS) - - def _safe_attachment_content_type(content_type: str | None) -> str: - """The download ``Content-Type``: the stored ``content_type`` when it is a clean, bounded - ``type/subtype`` MIME that is **not browser-active**, else ``application/octet-stream``. + """The download ``Content-Type``: the canonical spelling from :data:`_INERT_ATTACHMENT_TYPES` when the + stored ``content_type`` is a clean, bounded ``type/subtype`` naming one of them, else + ``application/octet-stream``. The stored value is a verbatim, attacker-influenced OBX-5.2 label, so it is never trusted into the - response header (header-splitting shapes) and never trusted into the *browser* either: an - ``image/svg+xml`` or ``text/html`` label is downgraded to the inert binary type, which also stops - :func:`_attachment_filename` from deriving a ``.svg``/``.html`` download name (ASVS 1.3.4). + response header (header-splitting shapes) and never trusted into the *browser* either. An + ``image/svg+xml``, ``text/html`` or ``application/hta`` label is simply not on the list, so it is + declared as the inert binary type — which also makes :func:`_attachment_filename` produce ``.bin`` + instead of a ``.svg``/``.html``/``.hta`` download name (ASVS 1.3.4). + + **This decides what is DECLARED, never whether the file is served.** An unrecognized type downloads + exactly as a refused one does, under the generic type. Neither the route's availability nor the + count-and-log invariant depends on this function. **Why downgrade rather than sanitize.** Attachment bytes are verbatim clinical payloads — ADR 0105 Approach B stores the OBX-5.5 value untouched and the preserve-the-original invariant forbids rewriting them — so the control is *neutralize at serve* (inert MIME + attachment disposition + - nosniff + the sandbox CSP), never a sanitizing rewrite of the stored document. Inert types - (``application/pdf``, ``image/png``, …) still pass through under their own type.""" + nosniff + the sandbox CSP), never a sanitizing rewrite of the stored document.""" ct = (content_type or "").strip() if len(ct) > _MAX_ATTACHMENT_MIME_LEN or not _SAFE_MIME_RE.match(ct): return _DEFAULT_ATTACHMENT_MIME - return _DEFAULT_ATTACHMENT_MIME if _is_browser_active_mime(ct) else ct + # The shape screen has already excluded parameters (``; charset=…``) and inner whitespace, so the + # case-fold is the only normalization an exact lookup still needs. + key = ct.casefold() + return key if key in _INERT_ATTACHMENT_TYPES else _DEFAULT_ATTACHMENT_MIME + + +def _attachment_extension(content_type: str) -> str: + """The download-name extension for an ALREADY-downgraded served type: the allow-list's own value, or + :data:`_DEFAULT_ATTACHMENT_EXT` for anything else (``application/octet-stream`` included).""" + return _INERT_ATTACHMENT_TYPES.get(content_type.casefold(), _DEFAULT_ATTACHMENT_EXT) def _attachment_filename(attachment_id: str, content_type: str) -> str: """A header-safe download filename. ``attachment_id`` is a 64-hex sha256 (safe by construction); a - short prefix keeps it readable and a ``mimetypes`` extension (when the MIME is known) hints the type. - No user/attacker text reaches the ``Content-Disposition`` header. Callers pass the ALREADY-downgraded - :func:`_safe_attachment_content_type` result, so a browser-active label can never source the + short prefix keeps it readable and the allow-list's own extension hints the type. + + No user/attacker text reaches the ``Content-Disposition`` header, and no HOST state reaches it either: + the extension comes from :func:`_attachment_extension`, never from :func:`mimetypes.guess_extension`, + which reads the Windows registry. Callers pass the ALREADY-downgraded + :func:`_safe_attachment_content_type` result, so only a type on the allow-list can source an extension.""" - ext = mimetypes.guess_extension(content_type) or "" - return f"attachment-{attachment_id[:16]}{ext}" + return f"attachment-{attachment_id[:16]}{_attachment_extension(content_type)}" def _is_attachment_download_path(path: str) -> bool: @@ -3558,10 +3610,11 @@ async def download_attachment( detail=json.dumps({"message_id": message_id, "attachment_id": attachment_id}), client=client_ip(request), ) - # Neutralize at serve (ASVS 1.3.4): a browser-active OBX-5.2 label (svg/html/xml/script) is - # downgraded to the inert binary type, which also keeps the .svg/.html extension out of the - # download name, and the response carries a sandbox CSP so no served representation can execute - # in the application origin. The stored bytes are NEVER rewritten (ADR 0105 Approach B keeps the + # Neutralize at serve (ASVS 1.3.4): the sender-influenced OBX-5.2 label is declared only when it + # names one of the inert types on the _INERT_ATTACHMENT_TYPES allow-list, so a browser-active + # label (svg/html/hta/script and every type nobody listed) is declared as the inert binary type, + # which also keeps a .svg/.html extension out of the download name; the response carries a + # sandbox CSP so no served representation can execute in the application origin. The stored bytes are NEVER rewritten (ADR 0105 Approach B keeps the # OBX-5.5 value verbatim). AttachmentSecurityHeadersMiddleware re-asserts the CSP from outside # the /ui CSP writers so the console delegate serves it too. content_type = _safe_attachment_content_type(match["content_type"]) diff --git a/tests/test_attachment_download_api.py b/tests/test_attachment_download_api.py index c89aa82e0..cbba90091 100644 --- a/tests/test_attachment_download_api.py +++ b/tests/test_attachment_download_api.py @@ -9,14 +9,21 @@ crux: never pull a shared content-addressed blob unlinked to an in-scope message), the audit chain (``record_view`` + ``attachment_download`` with NO bytes), and the Content-Type / Content-Disposition. -**ASVS 1.3.4 (browser-active downgrade + sandbox CSP).** The stored ``content_type`` is a verbatim, +**ASVS 1.3.4 (inert-type allow-list + sandbox CSP).** The stored ``content_type`` is a verbatim, attacker-influenced OBX-5.2 label. The serve-time control is *neutralize at serve*, never a sanitizing -rewrite of the stored clinical bytes (ADR 0105 Approach B keeps the OBX-5.5 value verbatim): a -browser-active label is downgraded to ``application/octet-stream`` — case-folded, so ``Image/SVG+XML`` -is treated exactly like ``image/svg+xml`` — which also keeps a ``.svg``/``.html`` extension out of the -download name, and every download response carries ``Content-Security-Policy: default-src 'none'; -sandbox``, **including the console's ``/ui`` delegate**, where two ``/ui``-scoped middlewares would -otherwise overwrite a route-level CSP with a console policy that has no ``sandbox``. +rewrite of the stored clinical bytes (ADR 0105 Approach B keeps the OBX-5.5 value verbatim): the label is +DECLARED only when it exactly names one of the inert types on ``_INERT_ATTACHMENT_TYPES``, and everything +else — browser-active, unknown or malformed — is declared ``application/octet-stream``. The match is +case-folded, so ``Image/SVG+XML`` is treated exactly like ``image/svg+xml``. The same table supplies the +download-name extension (default ``.bin``), so no ``.svg``/``.html``/``.hta`` name is produced and the +served filename no longer depends on ``mimetypes``, which reads the Windows registry. Every download +response carries ``Content-Security-Policy: default-src 'none'; sandbox``, **including the console's +``/ui`` delegate**, where two ``/ui``-scoped middlewares would otherwise overwrite a route-level CSP with +a console policy that has no ``sandbox``. + +The allow-list is what makes these tests meaningful in BOTH directions. A table where every input +downgrades would pass against a function that returns the constant, so ``_PASS_THROUGH_LABELS`` is the +negative control and is asserted just as hard. """ from __future__ import annotations @@ -31,7 +38,12 @@ import pytest from messagefoundry.api import create_app -from messagefoundry.api.app import _ATTACHMENT_CSP +from messagefoundry.api.app import ( + _ATTACHMENT_CSP, + _DEFAULT_ATTACHMENT_EXT, + _INERT_ATTACHMENT_TYPES, + _safe_attachment_content_type, +) from messagefoundry.auth import Role from messagefoundry.auth.service import AuthService from messagefoundry.config.settings import AuthSettings @@ -45,13 +57,21 @@ DOC = b"%PDF-1.4\nsynthetic document body \x00\x01\x02 not real PHI\n%%EOF\n" DOC_B64 = base64.b64encode(DOC).decode("ascii") -#: Labels a browser may EXECUTE or render as markup — every one of them must serve as the inert binary -#: type. Beyond the four subtypes and the ``+xml`` family the assessor named, this pins the vectors an -#: exact-subtype / suffix test misses: ``application/x-javascript`` (browsers honour it as script), -#: ``image/svg`` (no ``+xml``), ``application/xml-dtd``, ``multipart/x-mixed-replace`` (browser-rendered) -#: and ``text/x-html`` — plus the MIXED-CASE vectors, which the token grammar admits verbatim today and -#: which ``mimetypes`` still resolves to ``.svg``/``.html``. +#: Labels whose specifications describe an executable or markup representation — none of them is on the +#: inert allow-list, so every one must serve as the generic binary type. The first block is the family the +#: retired four-token refusal list caught (``html``/``xml``/``script``/``svg`` + ``multipart``), including +#: the vectors an exact-subtype or ``+xml``-suffix test misses (``application/x-javascript``, ``image/svg`` +#: with no ``+xml``, ``application/xml-dtd``, ``text/x-html``) and the MIXED-CASE spellings the token +#: grammar admits verbatim. +#: +#: The second block is what the refusal list DID NOT catch, and is the reason the classifier was inverted: +#: each of these passes all four tokens and the ``multipart`` rule. ``application/hta`` is the decisive +#: one — a scriptable HTML Application whose registry-derived extension is ``.hta``. Their presence here +#: is a specification claim about the types, NOT a browser measurement: nobody has exercised a browser. +#: The allow-list is what makes that distinction stop mattering, because a type nobody thought of is +#: refused for the same reason a listed one is — it is simply not on the list. _BROWSER_ACTIVE_LABELS = ( + # caught by the retired four-token refusal list "image/svg+xml", "text/html", "Image/SVG+XML", @@ -69,18 +89,73 @@ "application/xml-dtd", "multipart/x-mixed-replace", "text/x-html", + # MISSED by the retired four-token refusal list + "application/hta", + "text/x-component", + "application/x-xpinstall", + "application/x-shockwave-flash", + "application/x-msdownload", +) + +#: Shapes that never reach the allow-list at all because the MIME *shape* screen rejects them first: a +#: parameterized type (the screen admits no ``;``), and a header-splitting attempt. Both must land on the +#: same generic type, so the two screens compose rather than leaving a gap between them. +_MALFORMED_LABELS = ( + "image/svg+xml; charset=utf-8", + "text/plain; charset=utf-8", + "text/html\r\nX-Evil: 1", ) #: Inert labels that must keep passing through under their own type — the operator still gets a usable -#: download hint, and browser PDF/image viewers are themselves sandboxed. -_PASS_THROUGH_LABELS = ("application/pdf", "image/png", "application/dicom", "text/plain") +#: download hint. **This is the negative control.** A downgrade table alone would pass against a +#: ``_safe_attachment_content_type`` that returned ``application/octet-stream`` unconditionally; these +#: cases are what force the allow-list to actually allow. +_PASS_THROUGH_LABELS = ( + "application/pdf", + "image/png", + "image/jpeg", + "image/gif", + "application/dicom", + "application/json", + "text/plain", + "text/csv", +) #: Leading magic so a CORRECTLY-labelled inert attachment agrees with its declared MIME (ASVS 5.2.2): #: the download-side MIME-vs-magic check downgrades a sniffable label whose bytes contradict it. -#: dicom/text carry no leading signature, so they need none. +#: dicom/text/bmp carry no leading signature in that table, so they need none. _PASS_THROUGH_MAGIC: dict[str, bytes] = { "application/pdf": b"%PDF-", "image/png": bytes.fromhex("89504e470d0a1a0a"), # PNG signature + "image/jpeg": bytes.fromhex("ffd8ff"), + "image/gif": b"GIF89a", + "application/json": b"{", # leading-brace sniff, not a magic-byte family +} + +#: The extension the SHIPPED allow-list gives each served type. Pinned as LITERALS on purpose. The old +#: assertions computed the expectation with ``mimetypes.guess_extension`` — the very call the endpoint +#: made — so they agreed with the endpoint by construction and would have agreed with a wrong endpoint +#: too. The extension is now a property of the product rather than of the host, so there is nothing +#: machine-local left to compute and a literal is the honest expectation. +_SERVED_EXT: dict[str, str] = { + "application/dicom": ".dcm", + "application/json": ".json", + "application/pdf": ".pdf", + "image/bmp": ".bmp", + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/tiff": ".tif", + "text/csv": ".csv", + "text/plain": ".txt", } +#: What every non-allow-listed type gets, refused or merely unknown. +_OCTET = "application/octet-stream" +_OCTET_EXT = ".bin" + + +def _disposition(ref: str, ext: str) -> str: + """The exact ``Content-Disposition`` the route must serve for ``ref`` at ``ext``.""" + return f'attachment; filename="attachment-{ref[:16]}{ext}"' @pytest.fixture @@ -184,12 +259,11 @@ async def test_download_round_trips_to_original_bytes( assert r.content == DOC assert r.headers["content-type"].startswith("application/pdf") # 5.4.1 re-score: pin the FULL served Content-Disposition, not just a substring — the fixed - # 'attachment; filename="attachment-' prefix + the sha256 content address cut to 16 hex + a - # mimetypes extension hint, quoted; no user/attacker text reaches the header + # 'attachment; filename="attachment-' prefix + the sha256 content address cut to 16 hex + the + # allow-list's extension, quoted; no user/attacker text reaches the header # (api/app.py:_attachment_filename). Seeded straight through the store, so this holds WITHOUT - # enabling the opt-in stream_threshold_bytes. Compute ext the same way the endpoint does. - ext = mimetypes.guess_extension("application/pdf") or "" - assert r.headers["content-disposition"] == f'attachment; filename="attachment-{ref[:16]}{ext}"' + # enabling the opt-in stream_threshold_bytes. The extension is a literal now, not a mimetypes call. + assert r.headers["content-disposition"] == _disposition(ref, ".pdf") async def test_download_audits_view_and_download_before_returning( @@ -235,10 +309,9 @@ async def test_download_content_type_defaults_when_not_clean_mime( # header survives. assert r.headers["content-type"] == "application/octet-stream" assert "X-Evil" not in r.headers - # The served-filename control still holds on a rejected MIME: the extension hint then derives - # from the octet-stream default, never the attacker text. - ext = mimetypes.guess_extension("application/octet-stream") or "" - assert r.headers["content-disposition"] == f'attachment; filename="attachment-{ref[:16]}{ext}"' + # The served-filename control still holds on a rejected MIME: the extension is the allow-list's + # default, never the attacker text and never a host-registry lookup. + assert r.headers["content-disposition"] == _disposition(ref, _OCTET_EXT) async def test_download_downgrades_mislabelled_active_mime_to_octet_stream( @@ -266,35 +339,38 @@ async def test_browser_active_label_is_downgraded_to_octet_stream( """A label a browser would execute or render as markup is NEVER served verbatim. Mechanically: the served ``Content-Type`` is exactly ``application/octet-stream``, and the served - filename is exactly the one derived from that inert type — so no ``.svg``/``.html``/``.js`` name is + filename carries the allow-list's ``.bin`` default — so no ``.svg``/``.html``/``.hta``/``.js`` name is produced either. Mixed-case vectors are in the table because the token grammar admits uppercase and - ``mimetypes`` lower-cases internally, so ``Image/SVG+XML`` yielded a ``.svg`` name before the fix.""" + the allow-list lookup is case-folded, so ``Image/SVG+XML`` must resolve exactly as ``image/svg+xml`` + does.""" mid, ref = await _seed_labelled(engine, label, marker=label) r = await client.get(f"/messages/{mid}/attachments/{ref}") assert r.status_code == 200 - assert _base_media_type(r) == "application/octet-stream" - # Compute the extension the way the endpoint does (mimetypes is machine-local — never pin a - # literal): the served name must be the octet-stream one, never a scriptable extension. - ext = mimetypes.guess_extension("application/octet-stream") or "" - assert r.headers["content-disposition"] == f'attachment; filename="attachment-{ref[:16]}{ext}"' - assert not r.headers["content-disposition"].rstrip('"').endswith((".svg", ".html", ".xml")) + assert _base_media_type(r) == _OCTET + assert r.headers["content-disposition"] == _disposition(ref, _OCTET_EXT) + assert ( + not r.headers["content-disposition"].rstrip('"').endswith((".svg", ".html", ".xml", ".hta")) + ) @pytest.mark.parametrize("label", _PASS_THROUGH_LABELS) async def test_inert_label_passes_through_unchanged( engine: Engine, client: httpx.AsyncClient, label: str ) -> None: - """The downgrade is targeted, not a blanket octet-stream: a correctly-labelled inert type still - serves as itself (and still supplies the download-name extension), so operators keep a usable hint. - Sniffable families (pdf/png) are seeded with matching magic so the 5.2.2 MIME-vs-magic check agrees.""" + """THE NEGATIVE CONTROL. The downgrade is targeted, not a blanket octet-stream: a correctly-labelled + inert type still serves as itself and still supplies the download-name extension, so operators keep a + usable hint. Without these cases the downgrade table above would pass against a + ``_safe_attachment_content_type`` that returned the constant. + + Sniffable families (pdf/png/jpeg/gif/json) are seeded with matching magic so the 5.2.2 MIME-vs-magic + check agrees; dicom/text carry no signature in that table.""" mid, ref = await _seed_labelled( engine, label, marker=label, prefix=_PASS_THROUGH_MAGIC.get(label, b"") ) r = await client.get(f"/messages/{mid}/attachments/{ref}") assert r.status_code == 200 assert _base_media_type(r) == label - ext = mimetypes.guess_extension(label) or "" - assert r.headers["content-disposition"] == f'attachment; filename="attachment-{ref[:16]}{ext}"' + assert r.headers["content-disposition"] == _disposition(ref, _SERVED_EXT[label]) async def test_overlong_label_is_downgraded(engine: Engine, client: httpx.AsyncClient) -> None: @@ -303,7 +379,148 @@ async def test_overlong_label_is_downgraded(engine: Engine, client: httpx.AsyncC mid, ref = await _seed_labelled(engine, "application/" + "a" * 300, marker="overlong") r = await client.get(f"/messages/{mid}/attachments/{ref}") assert r.status_code == 200 - assert _base_media_type(r) == "application/octet-stream" + assert _base_media_type(r) == _OCTET + assert r.headers["content-disposition"] == _disposition(ref, _OCTET_EXT) + + +# --- ASVS 1.3.4: the classifier is an ALLOW-LIST, and the extension is ours --------------------- + + +@pytest.mark.parametrize("label", _BROWSER_ACTIVE_LABELS + _MALFORMED_LABELS) +def test_only_allowlisted_types_are_declared(label: str) -> None: + """Unit-level twin of the download parametrization, at the function the whole control rests on. + + Every label here is refused for ONE reason: it is not on ``_INERT_ATTACHMENT_TYPES``. That is the + inversion. The retired control listed what to refuse, which asked review to prove no further + executable type existed; ``application/hta`` in the table above is the counterexample that shows the + negative could not be proved. Adding ``hta`` to a refusal list would have closed one vector and left + the shape of the defect intact.""" + assert _safe_attachment_content_type(label) == _OCTET + + +@pytest.mark.parametrize("label", sorted(_INERT_ATTACHMENT_TYPES)) +def test_allowlisted_types_are_declared_verbatim(label: str) -> None: + """The negative control at unit level, over the WHOLE shipped allow-list: every listed type is + declared as itself. Driven off ``_INERT_ATTACHMENT_TYPES`` rather than ``_PASS_THROUGH_LABELS`` so + entries the HTTP table cannot exercise (``image/tiff`` and ``image/bmp`` need magic bytes the seeded + document does not carry) are still covered here.""" + assert _safe_attachment_content_type(label) == label + + +@pytest.mark.parametrize( + "label", + [ + "application/pdf-javascript", # CONTAINS an allow-listed type + "xapplication/pdf", + "application/pdf+xml", + "text/plain-html", + "image/png2", + ], +) +def test_allowlist_match_is_exact_not_substring(label: str) -> None: + """The allow-list is matched EXACTLY, never as a substring or a prefix. + + The refusal list it replaced matched substrings on purpose, and that reasoning was right for a + refusal list: near-miss spellings of active types are dense. Turned around, the same density is a + hazard — a substring match in the allow direction would hand ``application/pdf-javascript`` a pass + because it contains ``application/pdf``. This pins the direction of the match, not just its result.""" + assert _safe_attachment_content_type(label) == _OCTET + + +@pytest.mark.parametrize("label", ["Text/Plain", "IMAGE/PNG", "aPPlicaTion/PDF"]) +def test_allowlist_lookup_is_case_folded(label: str) -> None: + """Browsers match media types case-insensitively, so the lookup folds case — and what is SERVED is + the canonical key from the table, not the stored spelling, so no attacker-influenced byte reaches the + ``Content-Type`` header at all.""" + served = _safe_attachment_content_type(label) + assert served == label.casefold() + assert served in _INERT_ATTACHMENT_TYPES + + +def test_none_and_blank_content_type_are_declared_generic() -> None: + """A missing OBX-5.2 label declares nothing, so it gets the generic type like any other non-match.""" + assert _safe_attachment_content_type(None) == _OCTET + assert _safe_attachment_content_type("") == _OCTET + assert _safe_attachment_content_type(" ") == _OCTET + + +def test_served_extension_table_covers_the_shipped_allowlist() -> None: + """Drift guard on the literals above: a lane that adds a type to ``_INERT_ATTACHMENT_TYPES`` has to + pin its extension here too, so ``_SERVED_EXT`` cannot quietly stop covering the shipped list.""" + assert set(_SERVED_EXT) == set(_INERT_ATTACHMENT_TYPES) + assert _SERVED_EXT == _INERT_ATTACHMENT_TYPES + assert _DEFAULT_ATTACHMENT_EXT == _OCTET_EXT + + +def test_app_module_no_longer_imports_mimetypes() -> None: + """The served filename must not be a property of the HOST. + + ``mimetypes.guess_extension`` reads the Windows registry, so the extension the engine served was + whatever the machine happened to have registered — measured on a Windows host, + ``mimetypes.guess_extension("application/hta")`` returns ``.hta``. The module no longer imports the + library at all, which is the strongest form of the assertion.""" + from messagefoundry.api import app as app_module + + assert not hasattr(app_module, "mimetypes") + + +async def test_served_extension_does_not_depend_on_mimetypes( + engine: Engine, client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """Differential test: force ``mimetypes.guess_extension`` to answer ``.hta`` for EVERY type and show + the served filename is unmoved. + + This is the assertion that separates the shipped code from the code it replaced. An expectation + written only as an output value would have passed on the old endpoint for most inputs, because the + host registry usually agrees with the intent. Under this patch the old endpoint would have served + ``attachment-.hta`` for both cases below.""" + monkeypatch.setattr(mimetypes, "guess_extension", lambda *a, **k: ".hta") + + # An allow-listed type keeps the allow-list's own extension. + mid, ref = await _seed_labelled(engine, "application/pdf", marker="mt-pdf", prefix=b"%PDF-") + r = await client.get(f"/messages/{mid}/attachments/{ref}") + assert r.status_code == 200 + assert r.headers["content-disposition"] == _disposition(ref, ".pdf") + + # A refused type keeps the allow-list's default extension. + mid2, ref2 = await _seed_labelled(engine, "application/hta", marker="mt-hta") + r2 = await client.get(f"/messages/{mid2}/attachments/{ref2}") + assert r2.status_code == 200 + assert r2.headers["content-disposition"] == _disposition(ref2, _OCTET_EXT) + + +async def test_unrecognized_type_still_downloads(engine: Engine, client: httpx.AsyncClient) -> None: + """The allow-list decides what is DECLARED, never whether the file is served. + + An inert-but-unlisted type (``audio/wav``) and an executable one (``application/hta``) take the same + path: 200, bytes byte-for-byte, generic type, ``.bin`` name. Nothing about the route's availability + or the count-and-log invariant moves — a stricter classifier that started refusing downloads would + fail here.""" + for label, marker in (("audio/wav", "unlisted-audio"), ("application/hta", "unlisted-hta")): + mid, ref = await _seed_labelled(engine, label, marker=marker) + r = await client.get(f"/messages/{mid}/attachments/{ref}") + assert r.status_code == 200 + assert r.content == f"synthetic document {marker} not real PHI".encode() + assert _base_media_type(r) == _OCTET + assert r.headers["content-disposition"] == _disposition(ref, _OCTET_EXT) + + +def test_pdf_stays_on_the_allowlist_by_recorded_decision() -> None: + """``application/pdf`` is allow-listed on purpose, and the reasoning lives beside the table. + + PDF is the one entry that is not inert: a PDF may carry ``/JavaScript`` that runs when a saved file + is opened in a viewer. It stays because the header this control sets governs rendering in the + APPLICATION ORIGIN, and viewer script does not run there; because the declared type stops governing + once the file is on disk, where the operator's own extension and file association take over; and + because the instrument for the local-open threat is content scanning, which this route does not do. + + This test pins the decision so a later lane removing PDF has to confront the argument rather than + silently reverse it. What it does NOT assert is anything about browser behaviour: no browser has been + exercised by anyone, and the inline-rendering claim for ``Content-Disposition: attachment`` rests on + specification alone.""" + assert _INERT_ATTACHMENT_TYPES["application/pdf"] == ".pdf" + doc = _safe_attachment_content_type.__doc__ or "" + assert "never whether the file is served" in doc async def test_download_carries_sandbox_csp(engine: Engine, client: httpx.AsyncClient) -> None: From ee0887952deefd506e354b1dfa842ca64dfde0d9 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 13:34:44 -0500 Subject: [PATCH 04/17] refactor(parsing): delete the unenforced conformance profile parameter (BACKLOG #1109) parsing/validate.py took `profile: object | None = None` and its own docstring said it was "accepted but not yet enforced". config/models.py carried the twin, `Validation.profile: str | None`, whose comment promised an operator that naming a conformance profile would do something. Both are false affordances. A Handler author passing a profile would reasonably believe conformance was being checked, and nothing said otherwise at runtime. That is the shape CLAUDE.md section 11 forbids: a control-shaped thing that is not a control. `object | None` also accepts anything, so mypy strict could not help. Measured before removing: `profile=` reaches validate() ZERO times, against a positive control of 13 for the sibling `expected_version=` in the same run. All 41 `profile=` hits in the tree are harness load profiles and DR callbacks. The parameter was keyword-only, so no positional call could reach it either. The model field was reachable from no authoring path -- inbound() never passed it, so connections.toml could not set it -- and nothing read it. ENFORCE was ruled out on evidence, not by default: no conformance-profile type ships anywhere, no ADR covers it, HL7-VALIDATION.md never mentions it, and the feature is BACKLOG #78, re-scored twice to demand-gate. RAISE was ruled out too -- with zero callers the branch is dead by construction, and it would keep advertising a keyword that does nothing. Removing the model field changes no construction: Validation takes Pydantic's default extra="ignore", so Validation(profile=...) was silently ignored before and is silently ignored now. Also corrected the Validation docstring, which said `strict` runs "hl7apy profile validation". hl7apy does STRUCTURAL validation and takes no profile -- that sentence was the source of the confusion. The tolerant default is untouched. validation.strict still ships False. Co-Authored-By: Claude Opus 5 --- messagefoundry/config/models.py | 14 ++- messagefoundry/parsing/validate.py | 18 ++- tests/test_validate_profile_param.py | 171 +++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 tests/test_validate_profile_param.py diff --git a/messagefoundry/config/models.py b/messagefoundry/config/models.py index 00e057471..8e50a6519 100644 --- a/messagefoundry/config/models.py +++ b/messagefoundry/config/models.py @@ -676,11 +676,21 @@ def _validate_hop_attestation(self) -> Destination: class Validation(BaseModel): """Parse/validate behaviour. Tolerant by default — non-conformant messages - still route; ``strict`` runs full hl7apy profile validation and NACKs on failure.""" + still route; ``strict`` runs full hl7apy structural validation and NACKs on failure. + + "Structural", not "profile": hl7apy checks a message against the official HL7 structure + for its version. It does not take a conformance profile, and neither does this model.""" hl7_version: str | None = None # e.g. "2.5.1"; None = infer from MSH-12 strict: bool = False - profile: str | None = None # path to a conformance profile, optional + # No ``profile`` field, deliberately. It read ``profile: str | None = None # path to a + # conformance profile, optional`` until 2026-09-06 and was removed alongside the sibling + # ``profile`` parameter on messagefoundry/parsing/validate.py, whose comment carries the full + # argument. Two facts specific to this field: no authoring path could set it (``inbound()`` + # never passed it, so neither could connections.toml), and nothing anywhere read it. Removing + # it changes no construction, because this model takes Pydantic's default ``extra="ignore"`` + # -- ``Validation(profile=...)`` was silently ignored before and is silently ignored now. + # Wall-clock seconds a strict hl7apy validate may run before the message dead-letters (#89, DoS # backstop against a pathological body that makes hl7apy's structure/cardinality parse spin). A # slow-parse input can otherwise pin the listener; the timeout bounds it. ``None`` inherits the diff --git a/messagefoundry/parsing/validate.py b/messagefoundry/parsing/validate.py index fb484721f..b40a3035f 100644 --- a/messagefoundry/parsing/validate.py +++ b/messagefoundry/parsing/validate.py @@ -41,11 +41,22 @@ def __bool__(self) -> bool: return self.ok +# There is deliberately NO ``profile`` parameter here, and a new one must not be added until +# something reads it. One sat in this signature until 2026-09-06, typed ``object | None`` and +# documented as "reserved for a conformance-profile object (Phase 2+); passing one today is +# accepted but not yet enforced" -- accepted by every call and read by none. That is a +# control-shaped parameter that is not a control: a Handler author who passed a conformance +# profile would reasonably believe conformance was being checked, and nothing at runtime would +# have told them otherwise. ``object | None`` accepts anything, so strict mypy could not warn +# them either. Measured across the tree on 2026-09-06: ZERO call sites passed it, against a +# positive control of 13 sites passing the sibling ``expected_version=``, so deleting it broke +# no caller. The roadmap commitment is not lost -- a persisted message-definition model plus a +# conformance validator is BACKLOG #78, demand-gated -- and when that lands it should add a +# TYPED parameter rather than restore an untyped placeholder. def validate( raw: str | bytes, *, expected_version: str | None = None, - profile: object | None = None, max_bytes: int | None = DEFAULT_MAX_MESSAGE_BYTES, max_segments: int | None = DEFAULT_MAX_SEGMENTS, ) -> ValidationResult: @@ -53,9 +64,8 @@ def validate( ``expected_version`` cross-checks MSH-12: if the message declares a different version that is reported as an error (a feed sending the wrong version is a misconfiguration - a strict channel should reject). ``profile`` is reserved for a conformance-profile - object (Phase 2+); passing one today is accepted but not yet enforced. ``max_bytes`` / - ``max_segments`` reject an oversized message before the (slow) strict parse. + a strict channel should reject). ``max_bytes`` / ``max_segments`` reject an oversized + message before the (slow) strict parse. """ from hl7apy.exceptions import HL7apyException from hl7apy.parser import parse_message diff --git a/tests/test_validate_profile_param.py b/tests/test_validate_profile_param.py new file mode 100644 index 000000000..25dd80c3c --- /dev/null +++ b/tests/test_validate_profile_param.py @@ -0,0 +1,171 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""The deleted conformance ``profile`` affordance stays deleted, and validation still works. + +``messagefoundry.parsing.validate.validate`` used to take ``profile: object | None = None`` and +``messagefoundry.config.models.Validation`` used to carry a matching ``profile`` field. Both were +accepted, documented and read nowhere -- a control-shaped surface that was not a control. They were +removed on 2026-09-06 (BACKLOG #1109, ASVS 2.2.1). + +This file lives apart from ``tests/test_parsing.py``, which owns the pure ``validate()`` surface, +because the deletion spans three layers: the function signature, the config model, and the pipeline +call path that joins them. Each half of that needs a different fixture, and only the join proves the +deletion broke no caller. + +Every assertion here is paired with a NEGATIVE CONTROL that would fail if the deletion had broken +validation outright rather than merely removed a no-op. A test that only checks "profile is gone" +passes just as happily on a ``validate()`` that has stopped validating anything. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from pathlib import Path + +import pytest + +from messagefoundry.config.models import ConnectorType, Validation +from messagefoundry.config.wiring import ( + ConnectionSpec, + InboundConnection, + OutboundConnection, + Registry, + Send, +) +from messagefoundry.parsing import validate +from messagefoundry.parsing.message import Message +from messagefoundry.pipeline.dryrun import dry_run +from messagefoundry.store import MessageStatus + +SAMPLES = Path(__file__).resolve().parents[1] / "samples" / "messages" +ADT = (SAMPLES / "adt_a01.hl7").read_text(encoding="utf-8") + +# ADT_A01 requires a PID segment; this one has none, so hl7apy rejects it. +NON_CONFORMANT = "MSH|^~\\&|A|B|C|D|20260101||ADT^A01|N1|P|2.5.1\rEVN|A01|20260101\r" + + +# --- the parameter is gone, and stays gone ----------------------------------- + + +def test_validate_signature_has_no_profile_keyword() -> None: + params = inspect.signature(validate).parameters + assert "profile" not in params + # Positive control on the instrument: the sibling keyword the pipeline really passes IS + # here, so an empty/rebound `params` cannot make the assertion above pass vacuously. + assert "expected_version" in params + # No ``**kwargs`` either -- one would swallow ``profile=`` and re-create the silent no-op. + assert not any(p.kind is p.VAR_KEYWORD for p in params.values()) + + +def test_passing_profile_now_raises_instead_of_being_silently_ignored() -> None: + """The whole point of the deletion: a caller who passes one is told, not ignored.""" + with pytest.raises(TypeError, match="profile"): + validate(ADT, profile="some/conformance/profile.xml") # type: ignore[call-arg] + + +def test_validation_config_model_has_no_profile_field() -> None: + assert "profile" not in Validation.model_fields + # Positive control: the fields that ARE read by the pipeline are still declared. + assert {"strict", "hl7_version", "strict_timeout_s"} <= set(Validation.model_fields) + + +def test_removing_the_config_field_changed_no_construction() -> None: + """``Validation`` takes Pydantic's default ``extra='ignore'``, as it did before. + + So a caller that passed ``profile=`` got a silently-ignored value before the deletion and + gets a silently-ignored value after it. Nothing that used to construct stopped constructing. + """ + cfg = Validation(strict=True, hl7_version="2.5.1", profile="ignored") # type: ignore[call-arg] + assert cfg.strict is True + assert cfg.hl7_version == "2.5.1" + assert not hasattr(cfg, "profile") + + +# --- negative controls: validation itself is unchanged ----------------------- + + +def test_conformant_message_still_validates_ok() -> None: + result = validate(ADT) + assert result.ok + assert result.version == "2.5.1" + assert result.errors == [] + + +def test_non_conformant_message_still_reports_errors() -> None: + result = validate(NON_CONFORMANT) + assert not result.ok + assert result.errors + + +def test_expected_version_cross_check_still_fires() -> None: + result = validate(ADT, expected_version="2.3") + assert not result.ok + assert any("version mismatch" in e for e in result.errors) + + +def test_tolerant_default_is_untouched_by_the_deletion() -> None: + """A non-conformant message must still be VALIDATED as bad, not rejected earlier. + + Guards the constraint that mattered most: nothing here may make the engine refuse traffic it + exists to tolerate. ``validation.strict`` ships False, so this message only reaches ``validate`` + at all because this test calls it directly -- and when it does, it comes back as a result + object, never an exception. + """ + assert validate(NON_CONFORMANT).errors # a result, not a raise + assert validate(" ").errors # empty input is a result too + + +# --- the real pipeline strict-validation call path still works --------------- + + +def _registry(*, strict: bool, handler: Callable[[Message], Send] | None = None) -> Registry: + """The shipped strict path: ``dryrun`` reads ``ic.validation`` and calls ``validate``.""" + reg = Registry() + reg.add_inbound( + InboundConnection( + "in", + ConnectionSpec(ConnectorType.MLLP, {"host": "127.0.0.1", "port": 2575}), + router="r", + validation=Validation(strict=strict, hl7_version="2.5.1"), + ) + ) + reg.add_outbound( + OutboundConnection("out", ConnectionSpec(ConnectorType.FILE, {"directory": "./out"})) + ) + reg.add_router("r", lambda m: ["h"]) + reg.add_handler("h", handler or (lambda m: Send("out", m))) + return reg + + +def test_strict_pipeline_path_still_passes_a_conformant_message() -> None: + result = dry_run(_registry(strict=True), ADT) + assert result.disposition is not MessageStatus.ERROR + assert result.error is None + assert result.handlers == ["h"] + + +def test_strict_pipeline_path_still_rejects_a_non_conformant_message() -> None: + result = dry_run(_registry(strict=True), NON_CONFORMANT) + assert result.disposition is MessageStatus.ERROR + assert result.error + + +def test_tolerant_pipeline_path_still_accepts_a_non_conformant_message() -> None: + """The default posture: ``strict=False`` routes an off-spec message rather than erroring.""" + result = dry_run(_registry(strict=False), NON_CONFORMANT) + assert result.disposition is not MessageStatus.ERROR + assert result.handlers == ["h"] + + +def test_handler_receives_a_parsed_message_on_the_strict_path() -> None: + """End to end, not just a disposition: the strict path really produced a delivery.""" + seen: list[str] = [] + + def handle(msg: Message) -> Send: + seen.append(msg["MSH-10"] or "") + return Send("out", msg) + + result = dry_run(_registry(strict=True, handler=handle), ADT) + assert seen and seen[0] + assert [d.to for d in result.deliveries] == ["out"] From f5bbbca7d8ce8981aef59953fb463feca9eeca2f Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 13:38:17 -0500 Subject: [PATCH 05/17] fix(pipeline): report what a config reload actually did (BACKLOG #1111) Engine.reload swapped the live graph and then ran three steps that could raise: the reference-set reconcile, the provenance fingerprint, and the cluster version bump. The fingerprint was wrapped but caught only OSError. So on a first deployment a raise in any of them would surface to the caller as a FAILED reload while the new graph was already live -- and at the bump, possibly after other nodes had been told to converge. Each step was asked whether it could move BEFORE the swap, rather than assuming none could: reference sync NO. _make_reference_runner reads its specs through a lambda closing over the live registry, so pre-swap it would materialize the OLD graph's reference sets and a reload that ADDS a set would leave it unarmed. Moving it changes what it means. fingerprint YES, and it moved. config_fingerprint_detail is a pure offline fold over the directory bytes and never reads the live graph, so computing it early is meaning-preserving. It also narrows the gap between the bytes load_config read and the bytes this reload is credited with, and puts the local import's ImportError -- which `except OSError` never covered -- on the honest side of the swap. Only the assignment stays after. cluster bump NO. The bump TELLS other nodes to converge. Bumping first would announce a config this node had not applied. What cannot move is now reported instead of swallowed. reload_detail returns a ReloadOutcome with three discriminable states: a raise means nothing was applied; applied with no failures is clean; applied WITH named failures means the graph is live and a step did not finish. Engine.reload keeps its exact signature and Registry return, so no caller moves. Both catches log and name the step, never `pass`. CancelledError derives from BaseException, so cooperative cancellation still propagates. Mutated three ways: reverting the reference-sync guard, reporting the swap itself as degraded (the dishonest direction -- caught by the pre-swap negative control), and moving the fingerprint back. Each reds a different test. Follow-on NOT built, because a live peer holds api/app.py: POST /config/reload still calls reload() and reports a degraded apply as plain success. It needs reload_detail, a degraded flag on ReloadResult, and the step names in the audit detail. The dual-control executor needs the same switch. This does not move ASVS 2.3.3 -- the approvals executor replay-safety, the store transaction boundary and DR recovery limbs are untouched. Co-Authored-By: Claude Opus 5 --- messagefoundry/pipeline/__init__.py | 15 +- messagefoundry/pipeline/engine.py | 150 +++++++-- tests/test_config_reload_outcome.py | 481 ++++++++++++++++++++++++++++ 3 files changed, 624 insertions(+), 22 deletions(-) create mode 100644 tests/test_config_reload_outcome.py diff --git a/messagefoundry/pipeline/__init__.py b/messagefoundry/pipeline/__init__.py index 523dd2de0..2009005b5 100644 --- a/messagefoundry/pipeline/__init__.py +++ b/messagefoundry/pipeline/__init__.py @@ -15,7 +15,18 @@ from __future__ import annotations -from messagefoundry.pipeline.engine import ConfigReloadDenied, Engine +from messagefoundry.pipeline.engine import ( + ConfigReloadDenied, + Engine, + ReloadOutcome, + ReloadStepFailure, +) from messagefoundry.pipeline.wiring_runner import RegistryRunner -__all__ = ["Engine", "ConfigReloadDenied", "RegistryRunner"] +__all__ = [ + "Engine", + "ConfigReloadDenied", + "RegistryRunner", + "ReloadOutcome", + "ReloadStepFailure", +] diff --git a/messagefoundry/pipeline/engine.py b/messagefoundry/pipeline/engine.py index 93731b2cc..67e4b9f78 100644 --- a/messagefoundry/pipeline/engine.py +++ b/messagefoundry/pipeline/engine.py @@ -13,7 +13,7 @@ import logging import time from collections.abc import Callable, Mapping, Sequence -from dataclasses import replace +from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Protocol, runtime_checkable @@ -91,7 +91,7 @@ ResendOutcome, ) -__all__ = ["Engine", "ConfigReloadDenied"] +__all__ = ["Engine", "ConfigReloadDenied", "ReloadOutcome", "ReloadStepFailure"] log = logging.getLogger(__name__) @@ -104,6 +104,46 @@ class ConfigReloadDenied(Exception): ``config_reload_roots`` entry — never an arbitrary client-supplied path.""" +@dataclass(frozen=True, slots=True) +class ReloadStepFailure: + """One named step of a config reload that did not complete, with a PHI-free reason. + + ``step`` is a stable machine-readable label (``config_fingerprint``, ``reference_sync``, + ``cluster_propagate``); ``detail`` is a :func:`~messagefoundry.redaction.safe_exc` rendering, so + it carries the exception type and a redacted message and never a message body.""" + + step: str + detail: str + + +@dataclass(frozen=True, slots=True) +class ReloadOutcome: + """What a :meth:`Engine.reload_detail` call actually did, so the report matches the engine. + + Three situations a caller must be able to tell apart, because they call for different operator + action: + + * **The reload did not happen.** The call RAISES (``ConfigReloadDenied``, ``FileNotFoundError``, + ``WiringError``, or a connector build failure) and returns no outcome at all. The previously + live graph is untouched, so the operator fixes the config and retries. + * **The reload happened cleanly.** ``applied`` is True and ``failures`` is empty. + * **The reload happened and a follow-on step failed.** ``applied`` is True and ``failures`` + names each step. The NEW graph is live. Reporting outright failure here would describe an + engine that does not exist, and reporting plain success would hide a step an operator has to + go finish by hand -- so the partial outcome is its own answer (ASVS 2.3.3, BACKLOG #1111). + + A dry run applies nothing, so it reports ``applied`` False with no failures.""" + + registry: Registry + applied: bool + failures: tuple[ReloadStepFailure, ...] = () + + @property + def degraded(self) -> bool: + """True when the graph swapped but at least one follow-on step did not complete.""" + return self.applied and bool(self.failures) + + def _within(path: Path, root: Path) -> bool: """True if ``path`` is ``root`` itself or nested under it (both already resolved).""" return path == root or root in path.parents @@ -1483,8 +1523,29 @@ async def reload( dry_run: bool = False, propagate: bool = False, ) -> Registry: + """The graph now live -- or, for a dry run, the graph that *would* go live. + + A thin projection of :meth:`reload_detail` for callers that only need the Registry. It + DISCARDS the partial-outcome report, so a clean apply and an apply whose follow-on step + failed both come back as a plain return here. A caller that has to tell those apart -- + anything that reports the result to an operator -- calls :meth:`reload_detail` instead.""" + outcome = await self.reload_detail(config_dir, dry_run=dry_run, propagate=propagate) + return outcome.registry + + async def reload_detail( + self, + config_dir: str | Path | None = None, + *, + dry_run: bool = False, + propagate: bool = False, + ) -> ReloadOutcome: """Load the code-first graph from ``config_dir`` and apply it to the running engine. + Returns a :class:`ReloadOutcome` saying what actually happened: whether the graph swapped, + and which follow-on steps did not complete. Read that class for the three situations a + caller must tell apart; the short version is that a raise means nothing was applied and a + return with a non-empty ``failures`` means the new graph IS live. + ``config_dir`` defaults to the server's startup ``--config`` dir. Any explicit value must resolve **within** an allowed reload root (the startup dir + ``config_reload_roots``); otherwise :class:`ConfigReloadDenied` is raised — the loader executes Python, so an @@ -1493,7 +1554,13 @@ async def reload( Validates first (a bad config raises before anything is swapped, so the running graph is left untouched), then atomically swaps via the runner's quiesce-and-swap reload. If the - engine was started without a graph, this loads and starts one. Returns the new Registry. + engine was started without a graph, this loads and starts one. + + Everything that CAN be done before the swap is done before the swap, because a step that + raises there fails honestly: nothing was applied. Only two steps cannot move -- reference-set + reconciliation reads its specs off the LIVE registry, and the cluster version bump announces + a config this node has already taken -- so those two run after the swap and report as + ``failures`` rather than as a failed reload. ``dry_run`` performs the full validation **against this instance's environment** — it loads the graph and build-checks every connector, which resolves the graph's ``env()`` references @@ -1511,8 +1578,10 @@ async def reload( Raises ``ConfigReloadDenied`` (path outside the allowed roots), ``FileNotFoundError`` (missing dir) or ``WiringError`` (invalid / empty config / unresolved env value) — the - caller maps these to HTTP errors. + caller maps these to HTTP errors. Every one of them is raised BEFORE the swap, so a raise + from this method always means the live graph is the one that was already running. """ + failures: list[ReloadStepFailure] = [] path = self._resolve_reload_target(config_dir) self.last_reload_dir = path if not path.is_dir(): @@ -1583,7 +1652,26 @@ async def reload( coordinator=self._coordinator, ) checker.build_check(registry) - return registry + return ReloadOutcome(registry=registry, applied=False) + # ADR 0041 D1 (config provenance, item C): fingerprint the bundle BEFORE the swap. The digest + # is a pure, offline fold over the directory's file BYTES (config/fingerprint.py). It never + # reads the live graph, so computing it here is meaning-preserving, and it narrows the window + # between the bytes load_config just read and the bytes attributed to this reload. Doing it + # here also means a failure the OSError guard never covered (an ImportError on the local + # import, say) aborts the reload while the OLD graph is still live, instead of surfacing as a + # failed reload with the NEW graph already serving. The value is ASSIGNED only after the swap. + fingerprint: dict[str, object] | None + try: + from messagefoundry.config.fingerprint import config_fingerprint_detail + + fingerprint = await asyncio.to_thread(config_fingerprint_detail, path) + except OSError as exc: + # Still best-effort: an unreadable bundle leaves provenance unknown rather than refusing + # an otherwise-applicable config. Recorded as a step failure so the caller is not told the + # reload was clean when GET /config/provenance will report nothing. + log.warning("config fingerprint before reload failed for %s: %s", path, exc) + fingerprint = None + failures.append(ReloadStepFailure("config_fingerprint", safe_exc(exc))) if runner is None: runner = self.add_registry(registry) try: @@ -1596,31 +1684,53 @@ async def reload( raise else: await runner.reload(registry) + # ---- THE NEW GRAPH IS LIVE FROM HERE (BACKLOG #1111, ASVS 2.3.3) -------------------------- + # Nothing below can put the old graph back: the runner owns the new registry and the old one + # is gone, and the swap is not a store transaction that could roll back. So a failure below is + # reported as a PARTIAL outcome, never as a failed reload. Telling the caller the reload + # failed while the new graph serves traffic would be a false report, and reporting plain + # success would hide a step an operator still has to finish by hand. + # Provenance is now the NEW bundle's (the digest was taken above, before the swap). + self.loaded_config_fingerprint = fingerprint # Reference sets (ADR 0006): re-arm + materialize after the swap, so a reference set added by # this reload syncs immediately (resolves on the next message, not only after the refresh # interval) and a 0->N change actually starts the loop. Idempotent when nothing changed. - await self._reconcile_reference_sync(startup=False) - # ADR 0041 D1 (config provenance, item C): remember the fingerprint + git commit of the graph - # now live, so GET /config/provenance can report the running commit and detect on-disk DRIFT. - # Best-effort + off the event loop (like load_config); a failure leaves provenance unknown - # rather than failing an otherwise-successful reload. + # CANNOT move before the swap: the runner reads its specs LIVE off _registry_runner.registry + # (see _make_reference_runner), so pre-swap it would materialize the OLD graph's sets and a + # reload that ADDS a set would leave it unarmed. try: - from messagefoundry.config.fingerprint import config_fingerprint_detail - - self.loaded_config_fingerprint = await asyncio.to_thread( - config_fingerprint_detail, path - ) - except OSError as exc: - log.warning("config fingerprint after reload failed for %s: %s", path, exc) - self.loaded_config_fingerprint = None + await self._reconcile_reference_sync(startup=False) + except Exception as exc: + # Broad on purpose: this reaches a reference source (network/DB) and the coordinator, so + # the failure surface is open-ended. Logged and reported, never swallowed. Per-set source + # failures are already isolated inside sync_all (last-good kept); what lands here is the + # arm/converge machinery, which leaves the sets stale but the graph correct. + log.warning("reference sync after reload failed for %s: %s", path, safe_exc(exc)) + failures.append(ReloadStepFailure("reference_sync", safe_exc(exc))) # Config-reload convergence (Track B Step 6): only the OPERATOR-initiated path propagates. Bump # the shared version so other nodes converge, and advance THIS node's applied version to the new # value so its own convergence loop sees no change (feedback-avoidance — the initiator does not # re-reload). A no-op on single-node (is_clustered() False). The per-node convergence reload # passes propagate=False and so never bumps (it would otherwise make nodes chase each other). + # CANNOT move before the swap either: the bump TELLS every other node to converge, so bumping + # first would announce a config this node had not applied, and a swap that then failed would + # leave the cluster converging on a graph the initiator never took. if propagate and self._coordinator.is_clustered(): - self._applied_config_version = await self._coordinator.bump_config_version() - return registry + try: + self._applied_config_version = await self._coordinator.bump_config_version() + except Exception as exc: + # This node IS running the new graph; only the cluster-wide announcement is missing, so + # the other nodes stay on the old config until the next bump or an operator reload + # there. That is a partial apply across the cluster, not a failed one here. + log.warning("cluster config-version bump after reload failed: %s", safe_exc(exc)) + failures.append(ReloadStepFailure("cluster_propagate", safe_exc(exc))) + if failures: + log.warning( + "config reload APPLIED with %d incomplete follow-on step(s): %s", + len(failures), + ", ".join(f.step for f in failures), + ) + return ReloadOutcome(registry=registry, applied=True, failures=tuple(failures)) def _resolve_reload_target(self, config_dir: str | Path | None) -> Path: """Resolve the reload target and enforce the allow-list (see :class:`ConfigReloadDenied`).""" diff --git a/tests/test_config_reload_outcome.py b/tests/test_config_reload_outcome.py new file mode 100644 index 000000000..bff6f92b7 --- /dev/null +++ b/tests/test_config_reload_outcome.py @@ -0,0 +1,481 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""The reported outcome of a config reload matches what the engine actually did (BACKLOG #1111). + +``Engine.reload`` swaps the live graph, and until this landed three steps that could raise ran +AFTER the swap. A raise in any of them would surface to ``POST /config/reload`` as a FAILED reload +while the NEW graph was already live -- and, on the propagate step, possibly after other nodes had +been told to converge onto it. An operator reading that failure would go on believing the old graph +was still serving. + +The fix is two-sided, and both sides need holding down: + +* the fingerprint step MOVED to before the swap, where a raise is honest (nothing was applied); +* the two steps that cannot move -- reference-set reconciliation reads its specs off the LIVE + registry, and the cluster version bump announces a config this node has already taken -- report a + PARTIAL outcome (``applied`` True with a named failure) instead of a failed reload. + +The negative controls below are the load-bearing half. A change that simply reported success for +everything would satisfy every "degraded" assertion here; only the pre-swap arms, which still demand +an outright raise and the OLD graph still delivering, can tell that apart. + +This fixes ONE named reporting defect. It does not move ASVS cell 2.3.3, whose row covers other +subjects entirely. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +import pytest + +from messagefoundry.config.wiring import WiringError, load_config +from messagefoundry.pipeline import Engine +from messagefoundry.pipeline.cluster import NullCoordinator + +ADT = ( + "MSH|^~\\&|SENDINGAPP|SENDINGFAC|RECV|RFAC|20260604||ADT^A01|MSG1|P|2.5.1\r" + "EVN|A01|20260604\r" + "PID|1||100^^^H^MR||DOE^JANE\r" +) + + +# --- helpers ----------------------------------------------------------------- + + +def _write_config(cfg: Path, *, inbound_name: str, outbound_name: str, inbox: Path, outdir: Path): + """A minimal valid graph: one file inbound -> one router -> one handler -> one file outbound. + + The inbound/outbound NAMES differ between the two configs a test loads, so "which graph is live" + is answered by a name that exists in exactly one of them rather than by counting.""" + cfg.mkdir(parents=True, exist_ok=True) + inbox.mkdir(parents=True, exist_ok=True) + outdir.mkdir(parents=True, exist_ok=True) + (cfg / "cfg.py").write_text( + "from messagefoundry import inbound, outbound, router, handler, Send, File\n" + f"inbound({inbound_name!r}, File(directory={str(inbox)!r}, pattern='*.hl7', " + "poll_seconds=0.02), router='r')\n" + f"outbound({outbound_name!r}, File(directory={str(outdir)!r}, filename='{{MSH-10}}.hl7'))\n" + "@router('r')\n" + "def route(msg):\n" + " return ['h']\n" + "@handler('h')\n" + "def handle(msg):\n" + f" return Send({outbound_name!r}, msg)\n", + encoding="utf-8", + ) + + +def _write_bad_connector_config(cfg: Path, inbox: Path) -> None: + """Valid wiring (the router resolves) but an outbound connector that cannot build (no directory). + + This is the PRE-swap failure the runner's own ``build_check`` raises on, before it touches the + running graph -- the negative control's failure mode.""" + cfg.mkdir(parents=True, exist_ok=True) + inbox.mkdir(parents=True, exist_ok=True) + (cfg / "cfg.py").write_text( + "from messagefoundry import inbound, outbound, router, handler, Send, File\n" + "from messagefoundry.config.wiring import ConnectionSpec\n" + "from messagefoundry.config.models import ConnectorType\n" + f"inbound('IB_BAD', File(directory={str(inbox)!r}, pattern='*.hl7', " + "poll_seconds=0.02), router='r')\n" + "outbound('OUT_BAD', ConnectionSpec(ConnectorType.FILE, {}))\n" + "@router('r')\n" + "def route(msg):\n" + " return ['h']\n" + "@handler('h')\n" + "def handle(msg):\n" + " return Send('OUT_BAD', msg)\n", + encoding="utf-8", + ) + + +async def _delivers(inbox: Path, outdir: Path, *, name: str, timeout: float = 5.0) -> bool: + """True once a message dropped in ``inbox`` lands in ``outdir`` -- the graph is really RUNNING. + + Asserting on ``registry_runner.registry`` alone would pass against an engine that swapped the + object and left the listeners bound to the old one, so every "which graph is live" claim here is + settled by an end-to-end delivery.""" + (inbox / f"{name}.hl7").write_bytes(ADT.replace("MSG1", name).encode("utf-8")) + deadline = asyncio.get_running_loop().time() + timeout + target = outdir / f"{name}.hl7" + while asyncio.get_running_loop().time() < deadline: + if target.exists(): + return True + await asyncio.sleep(0.02) + return False + + +class _BumpFailsCoordinator(NullCoordinator): + """A clustered stand-in whose cluster-wide config-version bump fails. + + ``is_clustered`` True is what puts the propagate step on the reload path at all; the bump then + raises, which is the post-swap failure this arm needs.""" + + def is_clustered(self) -> bool: + return True + + async def bump_config_version(self) -> int: + raise RuntimeError("cluster_config table is unreachable") + + +# --- the post-swap arms: applied, with the failed step named ------------------ + + +async def test_post_swap_reference_sync_failure_reports_degraded_and_new_graph_is_live( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A reference-sync failure AFTER the swap reports applied-with-a-named-failure, and the NEW + graph is delivering. + + Falsified by reverting the ``try``/``except`` around ``_reconcile_reference_sync`` to the bare + ``await``: the call raises out of ``reload_detail``, ``pytest.raises`` would be needed instead, + and the reload reports total failure while the new feed below is demonstrably serving. Falsified + separately by dropping the ``_delivers`` assertion: the test would then pass on a change that + reports a degraded outcome without the swap ever having happened.""" + old_in, old_out = tmp_path / "old-in", tmp_path / "old-out" + new_in, new_out = tmp_path / "new-in", tmp_path / "new-out" + old_cfg, new_cfg = tmp_path / "old", tmp_path / "new" + _write_config( + old_cfg, inbound_name="IB_OLD", outbound_name="OUT_OLD", inbox=old_in, outdir=old_out + ) + _write_config( + new_cfg, inbound_name="IB_NEW", outbound_name="OUT_NEW", inbox=new_in, outdir=new_out + ) + + eng = await Engine.create(tmp_path / "e.db", poll_interval=0.02) + eng.add_registry(load_config(old_cfg)) + await eng.start() + try: + + async def _boom(self: Engine, *, startup: bool) -> None: + raise RuntimeError("reference source refused the connection") + + monkeypatch.setattr(Engine, "_reconcile_reference_sync", _boom) + + outcome = await eng.reload_detail(new_cfg) + + assert outcome.applied is True + assert outcome.degraded is True + assert [f.step for f in outcome.failures] == ["reference_sync"] + # safe_exc keeps the type and redacts the message -- no body, and no bare "an error occurred". + assert outcome.failures[0].detail.startswith("RuntimeError") + + # The reported outcome is only honest if the new graph really is live. Prove it end to end. + assert eng.registry_runner is not None + assert "IB_NEW" in eng.registry_runner.registry.inbound + assert "IB_OLD" not in eng.registry_runner.registry.inbound + assert await _delivers(new_in, new_out, name="AFTERSWAP") + finally: + await eng.stop() + + +async def test_post_swap_cluster_propagate_failure_reports_degraded_and_new_graph_is_live( + tmp_path: Path, +) -> None: + """A failed cluster config-version bump AFTER the swap reports applied-with-a-named-failure, and + the NEW graph is delivering. + + This is the worst arm of the defect: the bump is what tells sibling nodes to converge, so a raise + here used to report a failed reload from the one node that had definitely applied the config. + + Falsified by reverting the ``try``/``except`` around ``bump_config_version`` to the bare + ``await``: ``RuntimeError`` escapes and no outcome is returned at all.""" + old_in, old_out = tmp_path / "old-in", tmp_path / "old-out" + new_in, new_out = tmp_path / "new-in", tmp_path / "new-out" + old_cfg, new_cfg = tmp_path / "old", tmp_path / "new" + _write_config( + old_cfg, inbound_name="IB_OLD", outbound_name="OUT_OLD", inbox=old_in, outdir=old_out + ) + _write_config( + new_cfg, inbound_name="IB_NEW", outbound_name="OUT_NEW", inbox=new_in, outdir=new_out + ) + + eng = await Engine.create( + tmp_path / "e.db", poll_interval=0.02, coordinator=_BumpFailsCoordinator() + ) + eng.add_registry(load_config(old_cfg)) + await eng.start() + try: + outcome = await eng.reload_detail(new_cfg, propagate=True) + + assert outcome.applied is True + assert outcome.degraded is True + assert [f.step for f in outcome.failures] == ["cluster_propagate"] + + assert eng.registry_runner is not None + assert "IB_NEW" in eng.registry_runner.registry.inbound + assert await _delivers(new_in, new_out, name="BUMPFAIL") + finally: + await eng.stop() + + +async def test_unreadable_bundle_applies_and_names_the_fingerprint_step( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An unreadable config bundle still applies (provenance is best-effort) but is NOT reported as a + clean reload -- the caller is told which step left ``GET /config/provenance`` with nothing. + + Falsified by dropping the ``failures.append`` in the ``OSError`` branch: ``degraded`` goes False + and the reload reports plain success while provenance is blank.""" + old_in, old_out = tmp_path / "old-in", tmp_path / "old-out" + new_in, new_out = tmp_path / "new-in", tmp_path / "new-out" + old_cfg, new_cfg = tmp_path / "old", tmp_path / "new" + _write_config( + old_cfg, inbound_name="IB_OLD", outbound_name="OUT_OLD", inbox=old_in, outdir=old_out + ) + _write_config( + new_cfg, inbound_name="IB_NEW", outbound_name="OUT_NEW", inbox=new_in, outdir=new_out + ) + + eng = await Engine.create(tmp_path / "e.db", poll_interval=0.02) + eng.add_registry(load_config(old_cfg)) + await eng.start() + try: + + def _unreadable(directory: Any) -> dict[str, object]: + raise OSError("config dir vanished mid-reload") + + monkeypatch.setattr( + "messagefoundry.config.fingerprint.config_fingerprint_detail", _unreadable + ) + + outcome = await eng.reload_detail(new_cfg) + + assert outcome.applied is True + assert [f.step for f in outcome.failures] == ["config_fingerprint"] + assert eng.loaded_config_fingerprint is None # provenance unknown, and the caller was told + assert eng.registry_runner is not None + assert "IB_NEW" in eng.registry_runner.registry.inbound + finally: + await eng.stop() + + +async def test_fingerprint_is_taken_before_the_swap( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The provenance fingerprint is computed BEFORE the graph swap, not after. + + Order is the whole point: taken after the swap, a raise the ``OSError`` guard does not cover (an + ``ImportError`` on the local import, say) reports a failed reload with the new graph already + live. Taken before, the same raise aborts while the old graph still serves. + + Falsified by moving the fingerprint block back below ``runner.reload``: ``order`` becomes + ``['swap', 'fingerprint']``.""" + old_in, old_out = tmp_path / "old-in", tmp_path / "old-out" + new_in, new_out = tmp_path / "new-in", tmp_path / "new-out" + old_cfg, new_cfg = tmp_path / "old", tmp_path / "new" + _write_config( + old_cfg, inbound_name="IB_OLD", outbound_name="OUT_OLD", inbox=old_in, outdir=old_out + ) + _write_config( + new_cfg, inbound_name="IB_NEW", outbound_name="OUT_NEW", inbox=new_in, outdir=new_out + ) + + eng = await Engine.create(tmp_path / "e.db", poll_interval=0.02) + eng.add_registry(load_config(old_cfg)) + await eng.start() + try: + order: list[str] = [] + runner = eng.registry_runner + assert runner is not None + original_reload = runner.reload + + def _recording_fingerprint(directory: Any) -> dict[str, object]: + order.append("fingerprint") + return {"fingerprint": "deadbeef", "files": 1} + + async def _recording_swap(new_registry: Any) -> None: + order.append("swap") + await original_reload(new_registry) + + monkeypatch.setattr( + "messagefoundry.config.fingerprint.config_fingerprint_detail", _recording_fingerprint + ) + monkeypatch.setattr(runner, "reload", _recording_swap) + + outcome = await eng.reload_detail(new_cfg) + + assert order == ["fingerprint", "swap"] + assert outcome.failures == () + assert eng.loaded_config_fingerprint == {"fingerprint": "deadbeef", "files": 1} + finally: + await eng.stop() + + +# --- the negative controls: a PRE-swap failure still fails outright ----------- + + +async def test_pre_swap_wiring_error_still_fails_and_leaves_the_old_graph_live( + tmp_path: Path, +) -> None: + """A config the loader rejects raises out of the reload and leaves the OLD graph delivering. + + THE control for the arms above. A change that reported a degraded success for everything would + keep every "degraded" assertion in this file green; only this one goes red, because the reload + genuinely did not happen and saying otherwise would send an operator looking at a graph that + never loaded. + + Falsified by catching ``WiringError`` and returning an applied outcome: ``pytest.raises`` goes + red. Falsified separately by dropping the ``_delivers`` assertion: the test would pass against a + reload that raised AFTER wrecking the running graph.""" + old_in, old_out = tmp_path / "old-in", tmp_path / "old-out" + old_cfg = tmp_path / "old" + _write_config( + old_cfg, inbound_name="IB_OLD", outbound_name="OUT_OLD", inbox=old_in, outdir=old_out + ) + + eng = await Engine.create(tmp_path / "e.db", poll_interval=0.02) + eng.add_registry(load_config(old_cfg)) + await eng.start() + try: + bad = tmp_path / "bad" + bad.mkdir() + (bad / "bad.py").write_text( + "from messagefoundry import inbound, File\n" + "inbound('IB_NEW', File(directory='.', pattern='*.hl7'), router='missing')\n", + encoding="utf-8", + ) + with pytest.raises(WiringError): + await eng.reload_detail(bad) + + assert eng.registry_runner is not None + assert "IB_OLD" in eng.registry_runner.registry.inbound + assert "IB_NEW" not in eng.registry_runner.registry.inbound + assert await _delivers(old_in, old_out, name="OLDSTILLRUNS") + finally: + await eng.stop() + + +async def test_pre_swap_connector_build_failure_still_fails_and_leaves_the_old_graph_live( + tmp_path: Path, +) -> None: + """A config that loads but whose connector cannot BUILD raises out of the reload, with the OLD + graph still delivering. + + The second control, one step later than the loader: this failure comes from the runner's own + ``build_check`` at the top of its quiesce-and-swap, so it exercises the boundary the partial + outcome must never creep across. + + Falsified by widening the post-swap ``except Exception`` to cover the swap call itself: this + would report an applied outcome for a graph that never went live.""" + old_in, old_out = tmp_path / "old-in", tmp_path / "old-out" + old_cfg = tmp_path / "old" + _write_config( + old_cfg, inbound_name="IB_OLD", outbound_name="OUT_OLD", inbox=old_in, outdir=old_out + ) + + eng = await Engine.create(tmp_path / "e.db", poll_interval=0.02) + eng.add_registry(load_config(old_cfg)) + await eng.start() + try: + bad = tmp_path / "badconn" + _write_bad_connector_config(bad, tmp_path / "bad-in") + with pytest.raises(ValueError): + await eng.reload_detail(bad) + + assert eng.registry_runner is not None + assert "IB_OLD" in eng.registry_runner.registry.inbound + assert "IB_BAD" not in eng.registry_runner.registry.inbound + assert await _delivers(old_in, old_out, name="BUILDFAILOLD") + finally: + await eng.stop() + + +# --- the clean paths: unchanged behaviour ------------------------------------ + + +async def test_clean_reload_reports_plain_success(tmp_path: Path) -> None: + """A reload with nothing wrong reports applied with NO failures, and the new graph delivers. + + Falsified by appending an unconditional entry to ``failures``: ``degraded`` goes True and a + healthy reload would start reading as partial, which is the same defect pointed the other way.""" + old_in, old_out = tmp_path / "old-in", tmp_path / "old-out" + new_in, new_out = tmp_path / "new-in", tmp_path / "new-out" + old_cfg, new_cfg = tmp_path / "old", tmp_path / "new" + _write_config( + old_cfg, inbound_name="IB_OLD", outbound_name="OUT_OLD", inbox=old_in, outdir=old_out + ) + _write_config( + new_cfg, inbound_name="IB_NEW", outbound_name="OUT_NEW", inbox=new_in, outdir=new_out + ) + + eng = await Engine.create(tmp_path / "e.db", poll_interval=0.02) + eng.add_registry(load_config(old_cfg)) + await eng.start() + try: + outcome = await eng.reload_detail(new_cfg) + + assert outcome.applied is True + assert outcome.failures == () + assert outcome.degraded is False + assert outcome.registry.inbound.keys() == {"IB_NEW"} + # Provenance was still recorded, from the digest taken before the swap. + assert eng.loaded_config_fingerprint is not None + assert eng.loaded_config_fingerprint["files"] == 1 + assert await _delivers(new_in, new_out, name="CLEAN") + finally: + await eng.stop() + + +async def test_reload_wrapper_still_returns_the_registry(tmp_path: Path) -> None: + """``reload()`` keeps its Registry return, so every existing caller is untouched by the outcome + type -- including ``POST /config/reload``, which counts the returned graph. + + Falsified by changing ``reload`` to return the ``ReloadOutcome``: the ``.inbound`` lookup below + raises ``AttributeError``.""" + old_in, old_out = tmp_path / "old-in", tmp_path / "old-out" + new_in, new_out = tmp_path / "new-in", tmp_path / "new-out" + old_cfg, new_cfg = tmp_path / "old", tmp_path / "new" + _write_config( + old_cfg, inbound_name="IB_OLD", outbound_name="OUT_OLD", inbox=old_in, outdir=old_out + ) + _write_config( + new_cfg, inbound_name="IB_NEW", outbound_name="OUT_NEW", inbox=new_in, outdir=new_out + ) + + eng = await Engine.create(tmp_path / "e.db", poll_interval=0.02) + eng.add_registry(load_config(old_cfg)) + await eng.start() + try: + registry = await eng.reload(new_cfg) + assert registry.inbound.keys() == {"IB_NEW"} + assert registry.outbound.keys() == {"OUT_NEW"} + finally: + await eng.stop() + + +async def test_dry_run_reports_not_applied_and_swaps_nothing(tmp_path: Path) -> None: + """A dry run reports ``applied`` False with no failures, and the OLD graph is still live. + + ``applied`` False here is what keeps "validated" separate from "applied cleanly" -- both return + an empty ``failures``, so a caller reading only ``failures`` could not tell them apart. + + Falsified by returning ``applied=True`` from the dry-run branch: the assertion below goes red + while the old graph, correctly, keeps running.""" + old_in, old_out = tmp_path / "old-in", tmp_path / "old-out" + new_in, new_out = tmp_path / "new-in", tmp_path / "new-out" + old_cfg, new_cfg = tmp_path / "old", tmp_path / "new" + _write_config( + old_cfg, inbound_name="IB_OLD", outbound_name="OUT_OLD", inbox=old_in, outdir=old_out + ) + _write_config( + new_cfg, inbound_name="IB_NEW", outbound_name="OUT_NEW", inbox=new_in, outdir=new_out + ) + + eng = await Engine.create(tmp_path / "e.db", poll_interval=0.02) + eng.add_registry(load_config(old_cfg)) + await eng.start() + try: + outcome = await eng.reload_detail(new_cfg, dry_run=True) + + assert outcome.applied is False + assert outcome.failures == () + assert outcome.degraded is False + assert outcome.registry.inbound.keys() == {"IB_NEW"} # the graph that WOULD go live + assert eng.registry_runner is not None + assert "IB_OLD" in eng.registry_runner.registry.inbound # still the running one + finally: + await eng.stop() From a13332b049b0adf3c9c088f0fe5bd2d68c8ae44e Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 13:42:02 -0500 Subject: [PATCH 06/17] fix(transports): refuse a missing or blank outbound host on MLLP, TCP and X12 (BACKLOG #1110) The three dialing destinations defaulted a missing host to 127.0.0.1. A defaulted peer is one the operator never chose: on a first deployment the delivery would dial THIS machine, and where the same engine runs a listener on that port it would SUCCEED into its own intake rather than failing -- surfacing as a misdelivered feed rather than a connection error. All three now refuse at construction, matching EmailDestination, DirectDestination and DicomScuDestination. build_check_registry builds every deployed outbound, so the refusal fires at check and dry-run. THE ROW'S OWN JUSTIFICATION IS FALSE AND IS CORRECTED HERE. #1110 says the inbound half of this rule is enforced while the outbound half is not, and that asymmetry is what makes it a defect. Both halves are enforced, 350 lines apart in the same file: config/wiring.py:4461 already refuses an absent host for MLLP, TCP and X12 outbound, through the shared build_outbound_connection core, so both the code-first surface and connections.toml refuse. Measured through the TOML loader, which is the GUI's own save target. WHAT IS GENUINELY UNGUARDED AT EVERY LAYER IS A BLANK HOST, and nobody had named it. Wiring tests `settings.get("host") is None`, so `host=""` passes it and the connector kept the empty string. That is not loopback: measured on this machine, getaddrinfo("") resolves to the host's own LAN interfaces (192.168.4.27, 192.168.9.1), so a blank host would dial the engine's own box OFF-LOOPBACK. X12 was worse -- its str() turned a None host into the literal hostname "None". The connector is the layer that owns the peer address, so it is the layer that must not fabricate one. wiring.py's `is None` test is left alone deliberately: tightening it to a falsy test would need to stay falsy rather than isinstance, because host=env(...) puts a truthy EnvRef there, and that file is large and contended. Destination-versus-source was established per site from the enclosing class and its register_* call, not from line numbers. The `s.get("host") or "127.0.0.1"` lines in MLLPSource, TcpSource and X12Source are untouched -- loopback is the correct default for a LISTENER bind, and a source guard test pins that it still is. docs/CONNECTIONS.md already documented host as required for all three, so the code contradicted the shipped documentation. Nothing here claims to move ASVS 2.2.3. The row is explicit that re-scoring on this would be the same trap wearing a config-plane hat. Co-Authored-By: Claude Opus 5 --- messagefoundry/transports/mllp.py | 10 +- messagefoundry/transports/tcp.py | 14 ++- messagefoundry/transports/x12.py | 9 +- tests/test_outbound_host_required.py | 150 +++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 tests/test_outbound_host_required.py diff --git a/messagefoundry/transports/mllp.py b/messagefoundry/transports/mllp.py index 3567b24be..0b1fb0a33 100644 --- a/messagefoundry/transports/mllp.py +++ b/messagefoundry/transports/mllp.py @@ -637,7 +637,15 @@ class MLLPDestination(DestinationConnector): def __init__(self, config: Destination) -> None: s = config.settings - self.host: str = s.get("host", "127.0.0.1") + # Refuse a missing/blank host rather than invent one, for the reason spelled out in full on + # TcpDestination.__init__: a defaulted peer would dial THIS machine, and where the same engine + # runs a listener on that port the delivery would succeed into its own intake rather than + # failing. `_mllp_ssl_context` below carries its own `s.get("host", "127.0.0.1")` defaults; + # this refusal runs first, so those become unreachable through the destination path. + host = s.get("host") + if not isinstance(host, str) or not host: + raise ValueError("MLLP destination requires a 'host' setting (the downstream peer)") + self.host: str = host self.port: int = int(s["port"]) self.timeout: float = float(s.get("timeout_seconds", 30.0)) self.connect_timeout: float = float(s.get("connect_timeout", 10.0)) diff --git a/messagefoundry/transports/tcp.py b/messagefoundry/transports/tcp.py index 0646f92c1..04ebf4774 100644 --- a/messagefoundry/transports/tcp.py +++ b/messagefoundry/transports/tcp.py @@ -106,7 +106,19 @@ class TcpDestination(DestinationConnector): def __init__(self, config: Destination) -> None: s = config.settings - self.host: str = s.get("host", "127.0.0.1") + # Refuse a missing/blank host rather than invent one. Defaulting to loopback would name a peer + # the operator never chose: on first deployment the delivery would dial THIS machine, and where + # the same engine runs a listener on that port it would SUCCEED into its own intake instead of + # failing, so the fault would surface as a misdelivered feed rather than a connection error. + # Raising at construction fails at `check`/dry-run/start (build_check_registry builds every + # deployed outbound), matching EmailDestination/DirectDestination/DicomScuDestination. + # build_outbound_connection already refuses an ABSENT host on both authoring surfaces, but it + # tests `is None`, so a blank or non-string one still arrives here; this is the layer that owns + # the peer address, so it is the layer that must not fabricate one. + host = s.get("host") + if not isinstance(host, str) or not host: + raise ValueError("TCP destination requires a 'host' setting (the downstream peer)") + self.host: str = host self.port: int = int(s["port"]) self.codec = _codec_from_settings(s) self.timeout: float = float(s.get("timeout_seconds", 30.0)) diff --git a/messagefoundry/transports/x12.py b/messagefoundry/transports/x12.py index 88553f15e..3bfb21432 100644 --- a/messagefoundry/transports/x12.py +++ b/messagefoundry/transports/x12.py @@ -81,7 +81,14 @@ class X12Destination(DestinationConnector): def __init__(self, config: Destination) -> None: s = config.settings - self.host: str = str(s.get("host", "127.0.0.1")) + # Refuse a missing/blank host rather than invent one, for the reason spelled out on + # TcpDestination.__init__ (a defaulted peer would dial this machine and can land in the + # engine's own listener). The str() this replaces was the worse half of the same defect: it + # turned a None host into the literal hostname "None" instead of failing. + host = s.get("host") + if not isinstance(host, str) or not host: + raise ValueError("X12 destination requires a 'host' setting (the downstream peer)") + self.host: str = host self.port: int = int(s["port"]) self.encoding: str = str(s.get("encoding", "utf-8")) self.timeout: float = float(s.get("timeout_seconds", 30.0)) diff --git a/tests/test_outbound_host_required.py b/tests/test_outbound_host_required.py new file mode 100644 index 000000000..f91b6017e --- /dev/null +++ b/tests/test_outbound_host_required.py @@ -0,0 +1,150 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""One pre-defined rule, applied the same way by every outbound network connector: a destination that +dials a peer requires a ``host``, and refuses to invent one. + +The MLLP/TCP/X12 destinations used to default a missing ``host`` to ``127.0.0.1`` while their +EMAIL/DIRECT/DIMSE siblings raised, so the same rule had two answers depending on the connector. The +defaulted address is the part that would bite on a first deployment: a delivery would dial the engine's +own machine, and where that engine also runs a listener on the port it would SUCCEED into its own +intake rather than failing, so the fault would present as a misrouted feed instead of a connection +error. ``build_outbound_connection`` refuses an ABSENT host at both authoring surfaces already, but it +tests ``is None``, so a blank host reached the connector untested by either layer. + +Everything is built through :func:`build_destination` / :func:`build_source`, the seam the loader and +``build_check_registry`` use, so the tests exercise the real construction path rather than the classes. + +Two halves of the suite are load-bearing and must not be trimmed: + + * the **positive controls** (each destination builds with a host; each sibling still raises without + one) -- without them a registry that simply failed to build anything would pass every refusal + assertion here; + * the **source guard** -- MLLP/TCP/X12 *listeners* legitimately default a missing host to loopback, + because their bind interface comes from the service's ``[inbound].bind_host`` and binding all + interfaces must stay an admin decision. The destination fix must not touch that, and this is the + test that tells the two apart. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from messagefoundry.config.models import ConnectorType, Destination, Source +from messagefoundry.transports import build_destination, build_source + +#: The outbound network connectors under test, with the minimum settings each needs *besides* a host. +_DIALING_DESTINATIONS: list[tuple[ConnectorType, dict[str, Any]]] = [ + (ConnectorType.MLLP, {"port": 2575}), + (ConnectorType.TCP, {"port": 2575, "framing": "mllp"}), + (ConnectorType.X12, {"port": 2575}), +] + +#: The connectors that already refused a missing host, kept here as the control that the refusal being +#: asserted is this rule and not a broken registry. Each names settings that are otherwise valid, so a +#: raise can only come from the host check. +_SIBLING_DESTINATIONS: list[tuple[ConnectorType, dict[str, Any]]] = [ + ( + ConnectorType.EMAIL, + {"port": 587, "sender": "feed@example.org", "recipients": ["a@b.example"]}, + ), + ( + ConnectorType.DIRECT, + {"port": 587, "sender": "feed@example.org", "recipients": ["a@b.example"]}, + ), + (ConnectorType.DIMSE, {"port": 104, "ae_title": "MEFOR_SCU", "called_ae_title": "PACS"}), +] + +_LISTENING_SOURCES: list[tuple[ConnectorType, dict[str, Any]]] = [ + (ConnectorType.MLLP, {"port": 2575}), + (ConnectorType.TCP, {"port": 2575, "framing": "mllp"}), + (ConnectorType.X12, {"port": 2575}), +] + + +def _ids(rows: list[tuple[ConnectorType, dict[str, Any]]]) -> list[str]: + return [kind.value for kind, _ in rows] + + +@pytest.mark.parametrize( + ("kind", "settings"), _DIALING_DESTINATIONS, ids=_ids(_DIALING_DESTINATIONS) +) +def test_dialing_destination_refuses_absent_host( + kind: ConnectorType, settings: dict[str, Any] +) -> None: + """No ``host`` key at all: refuse, rather than substitute a loopback peer nobody configured.""" + config = Destination(name=f"OB_{kind.value.upper()}", type=kind, settings=dict(settings)) + with pytest.raises(ValueError, match="requires a 'host' setting"): + build_destination(config) + + +@pytest.mark.parametrize( + ("kind", "settings"), _DIALING_DESTINATIONS, ids=_ids(_DIALING_DESTINATIONS) +) +@pytest.mark.parametrize("blank", ["", None], ids=["empty-string", "explicit-none"]) +def test_dialing_destination_refuses_blank_host( + kind: ConnectorType, settings: dict[str, Any], blank: str | None +) -> None: + """A present-but-empty host is the case neither layer caught: ``build_outbound_connection`` tests + ``is None`` so ``host=""`` passes wiring, and the connector then kept the blank string. An empty + host is not loopback -- ``getaddrinfo("")`` resolves to this machine's own interfaces -- so it is + the same misdelivery hazard wearing a different value.""" + config = Destination( + name=f"OB_{kind.value.upper()}", type=kind, settings={**settings, "host": blank} + ) + with pytest.raises(ValueError, match="requires a 'host' setting"): + build_destination(config) + + +@pytest.mark.parametrize( + ("kind", "settings"), _DIALING_DESTINATIONS, ids=_ids(_DIALING_DESTINATIONS) +) +def test_dialing_destination_builds_with_a_host( + kind: ConnectorType, settings: dict[str, Any] +) -> None: + """Positive control: the refusal is the missing host and nothing else. Without this row a + connector (or a registry) that raised unconditionally would satisfy every test above.""" + config = Destination( + name=f"OB_{kind.value.upper()}", + type=kind, + settings={**settings, "host": "downstream.example.org"}, + ) + connector = build_destination(config) + assert connector.host == "downstream.example.org" # type: ignore[attr-defined] + + +@pytest.mark.parametrize( + ("kind", "settings"), _SIBLING_DESTINATIONS, ids=_ids(_SIBLING_DESTINATIONS) +) +def test_sibling_destinations_still_refuse_absent_host( + kind: ConnectorType, settings: dict[str, Any] +) -> None: + """Control on the other side: the three connectors that already enforced the rule still do, so a + green suite means MLLP/TCP/X12 joined them rather than everyone quietly stopping.""" + config = Destination(name=f"OB_{kind.value.upper()}", type=kind, settings=dict(settings)) + with pytest.raises(ValueError, match="(?i)requires a 'host' setting"): + build_destination(config) + + +@pytest.mark.parametrize(("kind", "settings"), _LISTENING_SOURCES, ids=_ids(_LISTENING_SOURCES)) +def test_listening_source_still_defaults_to_loopback( + kind: ConnectorType, settings: dict[str, Any] +) -> None: + """The regression guard. An inbound MLLP/TCP/X12 connection takes no author-supplied host: the bind + interface is injected from the service's ``[inbound].bind_host``, and a missing/None value must fall + back to loopback so an unauthenticated raw listener is never bound to every interface by accident. + Requiring a host here instead of on the destination would be a security regression, so the rule + being tightened above is asserted NOT to have reached this side.""" + connector = build_source(Source(type=kind, settings=dict(settings))) + assert connector.host == "127.0.0.1" # type: ignore[attr-defined] + + +@pytest.mark.parametrize(("kind", "settings"), _LISTENING_SOURCES, ids=_ids(_LISTENING_SOURCES)) +def test_listening_source_keeps_an_injected_bind_host( + kind: ConnectorType, settings: dict[str, Any] +) -> None: + """The other half of the guard: the loopback fallback is a *fallback*, so a bind host the service + injected still reaches the listener. A change that hard-coded loopback would pass the test above.""" + connector = build_source(Source(type=kind, settings={**settings, "host": "10.0.0.5"})) + assert connector.host == "10.0.0.5" # type: ignore[attr-defined] From c5d1e9a3fdbc96b2d3bf04ae1b27ca9012e399f6 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 13:56:45 -0500 Subject: [PATCH 07/17] fix(cli): reach the unified-store guard from serve --shard (BACKLOG #1112) require_unified_store refuses a >1-engine-shard config on a non-server store (ADR 0063). It had exactly two call sites, both the supervisor path, which checks before spawning. The direct `serve --shard` entrypoint filtered the registry and called nothing, so two hand-run shard processes over one SQLite file would bypass the guard the supported path enforces. The check goes in the registry_filter closure because that is the only place on the serve path that sees the UNFILTERED registry, so it knows the whole engine-shard universe; it also fires on every reload, not just startup. A pre-flight load was rejected on measurement: load_config calls _exec_module unconditionally with no sys.modules reuse, so it would execute the operator's config modules TWICE on every sharded start. The ValueError is re-raised as WiringError because /config/reload catches that specifically for a 422, where a bare ValueError would be a 500. THIS NARROWS; IT DOES NOT CLOSE. Route (A) and a store-open single-writer lock are NOT substitutes -- neither subsumes the other, which is why this is the right arm to build rather than the cheap one: a lock cannot see a LONE `serve --shard a` against a >1-shard config. One writer, so nothing trips -- but filter_registry_for_shard arms ADR 0073 lane ownership whenever the config declares >1 shard, so lanes owned by shards nobody started would get no delivery consumer at all: ACKed at ingress, never delivered, nothing reporting it. This guard refuses that. this guard cannot see two plain `serve` processes on one SQLite file. There is no engine-shard universe to refuse. A WORSE UNGUARDED PATH, FOUND WHILE ENUMERATING AND NOT CLOSED HERE. Engine._owned_ lanes returns None when registry.shard_id is None, so an UNSHARDED second serve calls reset_stale_inflight(owned=None) -- "every inflight row at startup is this node's own crash residue". On a first deployment a second plain serve against the same --db would re-pend every in-flight row store-wide, including a live sibling's. Recorded in the source and the test module so a green run cannot imply closure. The enumeration ran with both controls in the same pass: the plain-serve store open was found (api/app.py:5967) and the supervisor correctly returned zero, so a 12-site result is a measurement rather than a pattern that matches one spelling. ONE PREMISE THIS ROW CARRIES IS FALSE AND IS CORRECTED. "No advisory-locking precedent exists in the engine" rests on a six-token grep that still returns zero -- but messagefoundry/tray/instance.py is a single-instance guard on a Local\ named mutex via ctypes CreateMutexW (ADR 0113), which none of those tokens match. It is Windows-only and outside the engine packages, so the store-open lock still needs a POSIX arm and a stale-lock policy for the six admin CLIs that legitimately open the store. It is ADR-shaped and belongs in its own item. Mutation-tested: deleting the call reds both positives; refusing every sharded start reds all three negative controls, which is what tells a guard from a wall. Nothing here claims ASVS 2.3.4 moves; its remaining limb is a server-DB rig. Co-Authored-By: Claude Opus 5 --- messagefoundry/__main__.py | 45 ++- tests/test_serve_shard_unified_store_guard.py | 270 ++++++++++++++++++ 2 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 tests/test_serve_shard_unified_store_guard.py diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index 4879cc76d..c2bc6a241 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -100,7 +100,9 @@ def main(argv: list[str] | None = None) -> int: default=None, help="run only the inbound connections tagged with this shard id (L3 multi-process " "sharding). Outbound/routers/handlers are shared; only intake is partitioned. Omit to run " - "the whole graph. `messagefoundry supervise` sets this per subprocess.", + "the whole graph. `messagefoundry supervise` sets this per subprocess. A config declaring " + "more than one shard requires a server-DB store (Postgres or SQL Server) so every shard " + "shares ONE unified database (ADR 0063); serve refuses it on a single-file backend.", ) serve.add_argument( "--allow-insecure-bind", @@ -2982,8 +2984,12 @@ def env_values() -> dict[str, Any]: # exactly as before. The supervisor spawns one such process per shard with its own --db and --port. registry_filter = None if args.shard is not None: - from messagefoundry.config.wiring import Registry - from messagefoundry.pipeline.sharding import filter_registry_for_shard + from messagefoundry.config.wiring import Registry, WiringError + from messagefoundry.pipeline.sharding import ( + filter_registry_for_shard, + require_unified_store, + shard_ids, + ) # ADR 0073: engine sharding and [cluster] active-passive are mutually exclusive, fail-closed. # The cluster leadership lease is store-wide, so leadership would transfer ACROSS shard ids — @@ -3002,8 +3008,41 @@ def env_values() -> dict[str, Any]: return 2 shard_id: str = args.shard + shard_store_backend = settings.store.backend def registry_filter(reg: Registry) -> Registry: # noqa: F811 (local shard-bound closure) + # ADR 0063 no-split-store guard, ON THE DIRECT ENTRYPOINT (BACKLOG #1112). `supervise` + # calls require_unified_store before it spawns anything; a hand-run `serve --shard` + # reached NO call site of it, so the supported path refused a config the direct one ran. + # + # Sited HERE because this closure is the only place on the serve path that sees the + # UNFILTERED registry — it alone knows the whole engine-shard universe — at startup AND + # on every reload, with no second load_config. A pre-flight load would be the obvious + # alternative and is rejected: load_config EXECUTES the operator's config modules, so it + # would run arbitrary config code twice on every sharded start. + # + # This NARROWS the defect, it does not close it. Two plain `serve` processes over one + # SQLite file are still unguarded, and are worse: an unsharded registry yields + # owned=None, so the second process's startup reset_stale_inflight re-pends EVERY + # in-flight row store-wide, including the live sibling's. Closing that needs a + # single-writer guard at store open, which is a different mechanism (a new dependency or + # a per-platform primitive) and a separate subject. Neither guard subsumes the other: + # a store-open lock cannot see a LONE `serve --shard a` against a >1-shard config (one + # writer, no lock tripped) whose non-owned outbound lanes would have no delivery + # consumer at all under ADR 0073 rendezvous ownership. + try: + require_unified_store(shard_store_backend, shard_ids(reg)) + except ValueError as exc: + # WiringError, not the raw ValueError: it is the type the engine already raises for + # "this config cannot run in this process" (the ADR 0073 shard-set reload refusal in + # Engine.reload), and /config/reload maps it to a clean 422 instead of a 500. + raise WiringError( + f"{exc} This process was started as `serve --shard {shard_id}` directly; " + "`messagefoundry supervise` refuses this same config before it spawns anything. " + "Starting just ONE shard of a multi-shard config is not a workaround: outbound-" + "lane ownership (ADR 0073) is pinned to the whole shard universe, so the lanes " + "owned by the shards you did not start would have no delivery consumer at all." + ) from exc return filter_registry_for_shard(reg, shard_id) # ADR 0118: reflect the serve-gate EFFECTIVE flips (egress deny-by-default, retention auto-bound) back diff --git a/tests/test_serve_shard_unified_store_guard.py b/tests/test_serve_shard_unified_store_guard.py new file mode 100644 index 000000000..543d91192 --- /dev/null +++ b/tests/test_serve_shard_unified_store_guard.py @@ -0,0 +1,270 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""The ADR 0063 no-split-store guard is reachable from the `serve --shard` entrypoint (BACKLOG #1112). + +``require_unified_store`` refuses a >1-engine-shard config on a single-file store. It had exactly two +call sites, both on the supervisor path (``discover_shard_specs``), so ``supervise`` refused a config +that a hand-run ``serve --shard a`` + ``serve --shard b`` over one SQLite file ran happily. This file +pins the entrypoint arm. + +NOT in ``tests/test_sharding.py``: that file owns ``messagefoundry/pipeline/sharding.py``, the pure +module (shard tag, filter, discovery, and the guard's own unit behaviour). The subject here is the +CLI's WIRING of that guard into the registry filter it hands the engine, which is ``__main__.py`` +behaviour. ``test_sharding.py`` imports no ``main``. + +**Scope, stated so nobody reads more into a green run than is here.** This arm narrows the defect; it +does not close it. Two plain ``serve`` processes over one SQLite file remain unguarded (and are +worse: an unsharded registry yields ``owned=None``, so the second process's startup +``reset_stale_inflight`` re-pends every in-flight row store-wide). Closing that needs a single-writer +guard at store open, a different mechanism and a separate subject. + +Every test names its red mutation, because the negative controls are what carry this file: a change +that refuses EVERY sharded start passes every positive assertion here on its own. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from messagefoundry.config.settings import StoreBackend +from messagefoundry.config.wiring import ( + MLLP, + File, + Registry, + WiringError, + build_inbound_connection, + build_outbound_connection, +) +from messagefoundry.pipeline.sharding import require_unified_store, shard_ids +from messagefoundry.pipeline.supervisor import discover_shard_specs + +_SAMPLES_CONFIG = Path(__file__).resolve().parents[1] / "samples" / "config" + +# `[cluster]` is not involved here, but a server-DB backend needs its connection essentials to pass +# settings validation. Nothing is dialed: create_managed_app is stubbed, so no store is ever opened. +_SQLITE_TOML = "security.handles_real_patient_data = false\n" +_POSTGRES_TOML = ( + "security.handles_real_patient_data = false\n" + '[store]\nbackend = "postgres"\nserver = "127.0.0.1"\ndatabase = "mf"\nusername = "mf"\n' +) + + +def _inb(name: str, port: int, *, shard: str | None = None) -> Any: + return build_inbound_connection(name, MLLP(port=port), router="r", shard=shard) + + +def _registry(*shards: str | None) -> Registry: + """A registry whose inbounds carry ``shards`` (``None`` = untagged -> the default shard).""" + reg = Registry() + for index, shard in enumerate(shards): + reg.add_inbound(_inb(f"ib_{index}", 2575 + index, shard=shard)) + reg.add_outbound(build_outbound_connection("ob", File(directory="."))) + reg.add_router("r", lambda m: ["h"]) + reg.add_handler("h", lambda m: None) + return reg + + +def _serve_registry_filter( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + toml_body: str, + shard: str | None, +) -> Callable[[Registry], Registry] | None: + """Run the real `serve` gate and return the ``registry_filter`` it hands ``create_managed_app``. + + Capturing the production object is the point: a reimplementation of the closure here would pass + while the entrypoint stayed unguarded, which is exactly the bug under test. + """ + from messagefoundry.__main__ import main + + monkeypatch.chdir(tmp_path) + (tmp_path / "messagefoundry.toml").write_text(toml_body, encoding="utf-8") + captured: dict[str, Any] = {} + + def _fake_create_managed_app(**kwargs: Any) -> object: + captured["registry_filter"] = kwargs.get("registry_filter") + return object() + + monkeypatch.setattr("messagefoundry.api.create_managed_app", _fake_create_managed_app) + monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) + + argv = ["serve", "--config", str(_SAMPLES_CONFIG), "--env", "dev"] + if shard is not None: + argv += ["--shard", shard] + assert main(argv) == 0, "the serve gate must reach create_managed_app for this fixture" + assert "registry_filter" in captured, "create_managed_app was never called" + result: Callable[[Registry], Registry] | None = captured["registry_filter"] + return result + + +# --- the entrypoint arm ------------------------------------------------------ + + +def test_serve_shard_refuses_multi_engine_shard_config_on_sqlite( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # THE ROW. Two hand-run `serve --shard` processes over one SQLite file would be two writers on a + # store ADR 0063 forbids splitting, and the direct entrypoint reached no call site of the guard + # that exists to refuse exactly that. Now it does, and it fails CLOSED (the process cannot build + # its graph). + # RED MUTATION: drop the require_unified_store call from the __main__ --shard closure. The filter + # then returns a filtered Registry and this raises nothing. + filt = _serve_registry_filter(tmp_path, monkeypatch, toml_body=_SQLITE_TOML, shard="a") + assert filt is not None + with pytest.raises(WiringError) as excinfo: + filt(_registry("a", "b")) + message = str(excinfo.value) + # Names what is wrong and both ways out — the register require_unified_store already sets. + assert "requires a server-DB" in message + assert "postgres" in message and "sqlserver" in message + assert "run a single un-sharded engine" in message + # And the entrypoint-specific half: why one shard of a multi-shard config is not a workaround. + assert "serve --shard a" in message + assert "no delivery consumer" in message + + +def test_serve_shard_refusal_is_a_wiring_error_not_a_bare_value_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # WiringError is the type the engine already raises for "this config cannot run in this process" + # (Engine.reload's ADR 0073 shard-set refusal), and /config/reload catches WiringError + # SPECIFICALLY to return a clean 422. The filter is re-applied on EVERY reload, so a bare + # ValueError escaping here would fall through that handler and surface as a 500. + # + # The catch is deliberately the WIDE type. `WiringError` subclasses `ValueError` + # (config/wiring.py), so `pytest.raises(WiringError)` alone would still discriminate, but + # catching ValueError and asserting the narrow type states the contract the route depends on: + # what is raised must be the subclass, not merely the base. + # RED MUTATION: drop the `raise WiringError(...) from exc` wrapper and let require_unified_store's + # ValueError escape. `pytest.raises(ValueError)` still passes; the isinstance assertion reds. + filt = _serve_registry_filter(tmp_path, monkeypatch, toml_body=_SQLITE_TOML, shard="a") + assert filt is not None + with pytest.raises(ValueError) as excinfo: # noqa: PT011 (narrowed by the assertion below) + filt(_registry("a", "b")) + assert isinstance(excinfo.value, WiringError), ( + "the reload route catches WiringError specifically; a bare ValueError would be a 500" + ) + # The original guard message is preserved as the cause, not swallowed. + assert isinstance(excinfo.value.__cause__, ValueError) + assert "requires a server-DB" in str(excinfo.value.__cause__) + + +# --- negative controls: these carry the file --------------------------------- +# +# Without both, a closure that refused EVERY sharded start would pass every assertion above. + + +def test_serve_shard_still_starts_a_single_engine_shard_on_sqlite( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # NEGATIVE CONTROL 1. One engine shard on SQLite is one writer on one store — the guard's own + # allowed case, and byte-identical to plain serve: filter_registry_for_shard attaches NO shard + # identity below two shards, so none of the ADR 0073 sharded behaviours arm. + # RED MUTATION: make the closure refuse on len(shard_ids) >= 1 (or drop the distinct-count test + # inside require_unified_store). This reds; the positive test above stays green. + filt = _serve_registry_filter(tmp_path, monkeypatch, toml_body=_SQLITE_TOML, shard="a") + assert filt is not None + filtered = filt(_registry("a", "a")) # one DISTINCT shard, two inbounds on it + assert sorted(filtered.inbound) == ["ib_0", "ib_1"] + assert filtered.shard_id is None + assert filtered.all_shard_ids is None + + +def test_serve_shard_still_starts_an_untagged_config_on_sqlite( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # NEGATIVE CONTROL 1b. An UNTAGGED config resolves to the implicit DEFAULT_SHARD, so + # `serve --shard default` against it is a single engine shard and must start unchanged. This is + # the shape `supervise` spawns for an unsharded config, and the shape a shard-count-blind guard + # would break first. + # RED MUTATION: have the closure key on args.shard being set rather than on the config's distinct + # shard count. This reds immediately. + filt = _serve_registry_filter(tmp_path, monkeypatch, toml_body=_SQLITE_TOML, shard="default") + assert filt is not None + filtered = filt(_registry(None, None)) + assert sorted(filtered.inbound) == ["ib_0", "ib_1"] + assert filtered.shard_id is None + + +def test_serve_shard_still_starts_a_multi_engine_shard_config_on_a_server_db( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # NEGATIVE CONTROL 2. The SUPPORTED multi-engine-shard deployment: every shard on ONE unified + # server-DB store. It must start, and it must arm the ADR 0073 shard identity (the guard is about + # the store backend, never about disabling sharding). + # RED MUTATION: make the closure refuse whenever more than one shard is declared, ignoring the + # backend. This reds; the SQLite positive test stays green. + filt = _serve_registry_filter(tmp_path, monkeypatch, toml_body=_POSTGRES_TOML, shard="a") + assert filt is not None + filtered = filt(_registry("a", "b")) + assert sorted(filtered.inbound) == ["ib_0"] + assert filtered.shard_id == "a" + assert filtered.all_shard_ids == ("a", "b") + + +def test_plain_serve_installs_no_registry_filter( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # NEGATIVE CONTROL 3. Without --shard there is no closure at all, so the unsharded engine is + # untouched by this change — no guard, no filter, the whole graph. + # RED MUTATION: hoist the guard out of the --shard branch into the unconditional serve path. The + # filter stops being None and this reds. + assert _serve_registry_filter(tmp_path, monkeypatch, toml_body=_SQLITE_TOML, shard=None) is None + + +# --- the supervisor path must not have moved --------------------------------- + + +def test_supervisor_path_still_refuses_multi_engine_shard_on_sqlite(tmp_path: Path) -> None: + # REGRESSION PIN. The entrypoint arm must not have changed the path that already worked: + # discover_shard_specs still refuses before it builds a single ShardSpec. + # RED MUTATION: remove the require_unified_store call from discover_shard_specs. + config = tmp_path / "config" + config.mkdir() + (config / "graph.py").write_text( + "from messagefoundry import MLLP, Send, handler, inbound, outbound, router\n" + "\n" + 'inbound("IB_A", MLLP(port=2575), router="r", shard="a")\n' + 'inbound("IB_B", MLLP(port=2576), router="r", shard="b")\n' + 'outbound("OB", MLLP(host="127.0.0.1", port=2600))\n' + "\n" + '@router("r")\n' + "def route(msg):\n" + ' return ["h"]\n' + "\n" + '@handler("h")\n' + "def handle(msg):\n" + ' return Send("OB", msg)\n', + encoding="utf-8", + ) + with pytest.raises(ValueError, match="requires a server-DB"): + discover_shard_specs( + str(config), + store_backend=StoreBackend.SQLITE, + db_base=str(tmp_path / "mf.db"), + base_port=8765, + ) + # And the same config on a server DB still builds both specs (the supervisor's own control). + specs = discover_shard_specs( + str(config), + store_backend=StoreBackend.POSTGRES, + db_base=str(tmp_path / "mf.db"), + base_port=8765, + ) + assert sorted(s.shard for s in specs) == ["a", "b"] + + +def test_guard_and_entrypoint_agree_on_the_same_inputs() -> None: + # The entrypoint passes require_unified_store the WHOLE config's shard ids, not the filtered + # slice — a filtered registry names one shard, which would silently never refuse. + # RED MUTATION: pass shard_ids(filter_registry_for_shard(reg, shard)) instead. The distinct count + # drops to 1 and the guard becomes inert; this test states the input contract it would break. + reg = _registry("a", "b") + assert shard_ids(reg) == ["a", "b"] + with pytest.raises(ValueError, match="2 shards"): + require_unified_store(StoreBackend.SQLITE, shard_ids(reg)) From 84b3d504cad2615d29d3358621a3b4fee19a0996 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 14:16:38 -0500 Subject: [PATCH 08/17] fix(ide): stop the live-status poll carrying a bearer on a timer (BACKLOG #1145) Two files in ide/src gave opposite answers to one question. statusBar.ts opens with a load-bearing block whose first item says its poll sends NO TOKEN, because "a bearer on a 15s timer would keep refreshing the engine's session idle clock and make its 30-minute idle timeout unreachable forever (CWE-613)". liveStatus.ts did exactly that, on a 5-to-10-second timer. The chain, read rather than assumed: GET /connections is gated by plain require(Permission.MONITORING_READ); require() resolves the bearer with identity_for_token(bearer_token(request)) at the activity=True default, which calls touch_session. So on a first deployment, with liveStatus.enabled turned on, the idle cap would not bind while a VS Code window stayed open -- only the 12-hour absolute cap would. Keeping the bearer safe is not available to a client. There are four identity_for_token call sites in api/: three take the activity=True default and one is a hardcoded activity=False WebSocket keepalive. No header, query parameter or route lets a CALLER ask for activity=False -- it is a per-route server-side decision. That surface is an engine-side change and is named, not built. So the bearer is dropped. Tokenlessness is now DATA -- LIVE_STATUS_PLAN carries authenticated: false and CI asserts it -- rather than a comment that a later edit can contradict, which is how the two files diverged in the first place. Accepted cost, stated where it bites: against an auth-enabled engine the rows stay undecorated, so decorations land only where /connections answers tokenless. The setting ships OFF by default, what is given up is a status word and a count on a tree row, and the full monitor remains the web console, which reads the same data under activity=False. A second defect fell out. The 401 branch called clearToken. With no bearer sent, a 401 means the route demands auth, not that the session died -- clearing on it would sign the operator out from a timer over a request their session took no part in. Removed; auth.withAuth still clears on a 401 that DID carry the token. Two documents asserted the defect was fine and are corrected. ADR 0091's shipped-status bullet called this "auth is passive"; passive there meant never prompts, and it was not passive about the idle clock. docs/SECURITY.md enumerated the PHI-scoped token holders in a closed list omitting the extension, which is the only holder putting the token in durable OS-managed storage that outlives the process -- now an "at least" form per SDS-3.6. SECURITY.md's claim that the idle clock is refreshed only by user-driven requests needed no edit: it was false of this poll before and is true after. The code change restores the documented claim. Unverified by execution, and both the comment and the ADR say so: the idle-clock claim rests on reading identity_for_token's signature and the route's dependency chain, not on running an engine. ide checks: tsc --noEmit clean, esbuild clean, 643 mocha tests passing. Both new controls mutation-tested. ASVS 7.1.3 does not move -- the Kerberos ticket-lifetime limb is untouched and the cell carries its signed acceptance to 2027-01-14. Co-Authored-By: Claude Opus 5 --- docs/SECURITY.md | 26 +++++-- .../0091-element-centric-connections-view.md | 1 + ide/src/liveStatus.ts | 63 +++++++++++----- ide/src/liveStatusModel.ts | 23 ++++++ ide/src/test/suite/live-status.test.ts | 72 ++++++++++++++++++- 5 files changed, 159 insertions(+), 26 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 72ff80281..1482abe8b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1407,12 +1407,26 @@ session alive. `[auth].max_sessions_per_user` caps concurrent sessions (default the cap revokes the user's oldest — ASVS 7.1.2; `0` = unlimited). Clients send the token as `Authorization: Bearer ` (the WebSocket prefers the header; the legacy `?token=` query param is deprecated because it leaks into proxy/access logs). The token is a **PHI-scoped** credential (the -user's full RBAC for the session lifetime): the web console holds it in the browser session and the -`apiclient` (test harness / automation) keeps it in memory, each re-validating it against `/auth/me` -before use (discarding a stale/revoked one); `apiclient` also **refuses to send credentials over -plaintext `http` to a non-loopback host** (no TLS yet) unless explicitly run with `--insecure` for -trusted-network dev. (The retired PySide6 desktop console's OS-keyring token cache is an accepted -retirement loss — BACKLOG #103.) +user's full RBAC for the session lifetime), so where each client keeps it matters. **At least** these +three shipped clients hold one: + +| Client | Where the token lives | Outlives the process that got it? | +|---|---|---| +| Web console | the browser session | no | +| `apiclient` (test harness / automation) | process memory | no | +| VS Code extension (`ide/src/auth.ts`) | VS Code **SecretStorage**, keyed by engine URL | **yes** | + +The console and `apiclient` each re-validate against `/auth/me` before use, discarding a stale or +revoked token; `apiclient` also **refuses to send credentials over plaintext `http` to a non-loopback +host** (no TLS yet) unless explicitly run with `--insecure` for trusted-network dev. The extension is +the one holder that puts the credential in **durable, OS-managed** storage. It persists across VS Code +restarts, so on a deploying site the token would outlive the editor window that acquired it and stay +usable until the session's own idle or absolute timeout retires it server-side. The extension clears +its copy on sign-out (revoking the session on the engine first, where the engine is reachable) and on +a 401 from a request that carried the token; a background timer never clears it, because a request the +session took no part in is not evidence about the session. (The retired PySide6 desktop console's +OS-keyring token cache is an accepted retirement loss — BACKLOG #103. That retired one *instance* of +durable token storage, not the shape: the extension's SecretStorage cache is a live one.) ### Directory session reconciliation — propagating an AD disable (ADR 0079 mechanism 2) diff --git a/docs/adr/0091-element-centric-connections-view.md b/docs/adr/0091-element-centric-connections-view.md index b586cc7cd..c69907136 100644 --- a/docs/adr/0091-element-centric-connections-view.md +++ b/docs/adr/0091-element-centric-connections-view.md @@ -131,6 +131,7 @@ A **read-only** graph canvas of the estate as an **on-demand editor-area webview ## Deferred follow-ups (all closed 2026-07-12) - [x] **Live decorations** — shipped 2026-07-12 (owner authorized 2026-07-12). Opt-in (`messagefoundry.liveStatus.enabled`, default off; `intervalSeconds` ≥ 5) poll of the engine's `GET /connections` (`Permission.MONITORING_READ` — the lowest read tier, same as the Console dashboard; no new engine route or permission) feeding status + message counts as description suffixes on inbound/outbound rows (`ide/src/liveStatus.ts` poller, pure `liveStatusModel.ts` row aggregation, `graphModel.ts` suffix rendering, `GraphProvider.setRuntime`). Destination rows (one per inbound→outbound edge) aggregate per outbound: counts sum, worst-severity status wins. Auth is **passive**: the poll reuses the SecretStorage session Stage → Promote signed in with (`auth.peekToken` — never prompts from a timer) behind the SEC-005 host gate; 401 clears the dead session, 401/403/unreachable all degrade silently to undecorated rows (a dev engine embedded `allow_no_auth` serves it tokenless). Counts + status words only — never message content. *Residual gap, honest by design:* router/handler rows stay undecorated — the engine keys its stage metrics by connection, so no per-router/per-handler runtime counter exists to show. + - **Amendment (2026-09-06, BACKLOG #1145) — the poll is now TOKENLESS, and the "auth is passive" sentence above describes only what shipped in July.** Passive there meant *never prompts*; it was not passive about the session's idle clock. `GET /connections` is gated by plain `require(Permission.MONITORING_READ)`, and `require()` resolves the bearer with `identity_for_token(bearer_token(request))` — the default `activity=True`, which refreshes the session's idle clock. Every other bearer-gated `require*` factory in `api/security.py` delegates to `require()`; `require_service_cert` is the one that does not, and it is cert-only, never a bearer. So a bearer on a 5-to-10-second timer refreshed the idle clock on every tick and would make the engine's 30-minute idle timeout unreachable for as long as a VS Code window stayed open (CWE-613). That is the rule `ide/src/statusBar.ts` already stated for its own 15s poll, so the two files gave opposite answers to one question. The bearer is now driven by `liveStatusModel.LIVE_STATUS_PLAN` (`authenticated: false`, asserted in CI) instead of an unconditional `peekToken`, and the 401 branch no longer clears the cached session — a tokenless 401 is the route demanding auth, not evidence the session died. **Accepted cost:** against an auth-enabled engine the rows stay undecorated, so decorations now land only where `/connections` answers tokenless (a dev or embedded engine under `allow_no_auth`). The setting ships OFF by default; the full monitor remains the web console at `/ui`, which reads the same data under `activity=False`. Making a bearer safe here would need an engine-side passive-read surface — no header, query parameter or route lets a *client* ask for `activity=False` today — which is a separate change. - [x] **Refresh** — shipped 2026-07-12: a config-dir `FileSystemWatcher` (`**/*.py`, `connections.toml`, `codesets/**/*.csv`; `ide/src/configWatcher.ts`) and the save handler both funnel into one debounced (750 ms, `configRefresh.ts` `RefreshCoalescer`) validate + graph + code-sets refresh, so an external edit (git pull, another tool) refreshes without double-firing against the save path; exec-gated (ADR 0035) exactly like the save handler. A `configDir` resolving outside the workspace folder is not watched (graceful: manual refresh still works); the watcher rebuilds when the setting changes. - [x] **D3 go gate:** resolved — **owner go 2026-07-12**. v1 scope: a **focus-first** (always opened focused on one element — tree selection, context menu, or QuickPick), **hop-bounded** (1–3, default 2, BFS both directions), **node-capped** (150, farthest hop dropped deterministically, truncation surfaced), strictly **read-only** editor-area webview (`ide/src/wiringMap.ts` over the pure `ide/src/wiringMapModel.ts`); dynamic elements render a synthetic "?" stub, edges carry their D1 provenance (solid = declared/literal, dashed = heuristic). A **whole-estate render is deliberately absent** (the Dagster ~200-node degradation vs the ADR 0052 target) — an unfocused build is legal only under the node cap. No drag-drop, no editing (#26 untouched). diff --git a/ide/src/liveStatus.ts b/ide/src/liveStatus.ts index 73d6b2c7b..8931f7337 100644 --- a/ide/src/liveStatus.ts +++ b/ide/src/liveStatus.ts @@ -3,19 +3,39 @@ // Live per-element decorations for the CONNECTIONS view (ADR 0091 "live decorations"): an opt-in // (`messagefoundry.liveStatus.enabled`, default OFF) poll of the engine's `GET /connections` // (Permission.MONITORING_READ) that feeds status + message counts onto inbound/outbound rows via -// GraphProvider.setRuntime. Deliberately PASSIVE about auth: it reuses the session Stage → Promote -// cached in SecretStorage (auth.peekToken — never prompts), and treats 401/403/unreachable as -// "no live data" (undecorated rows), never a toast or a login popup from a background timer. A dev -// engine embedded with allow_no_auth serves /connections tokenless, so the local loop needs no -// sign-in at all. The row aggregation is the pure liveStatusModel; this is the Extension-Host shell. +// GraphProvider.setRuntime. It never prompts and never toasts: 401/403/unreachable all degrade to +// "no live data" (undecorated rows), because a background timer must not interrupt anyone. The row +// aggregation is the pure liveStatusModel; this is the Extension-Host shell. +// +// TWO THINGS HERE ARE LOAD-BEARING AND EASY TO "TIDY" INTO BUGS: +// 1. The poll sends NO TOKEN — the same rule statusBar.ts states as its item 1, for the same +// reason. `GET /connections` is gated by plain `require(Permission.MONITORING_READ)`, and +// `require()` resolves the bearer with `identity_for_token(bearer_token(request))` — the +// default `activity=True`, which refreshes the session's idle clock. So a bearer on +// this timer would refresh the session's idle clock on every tick and make the engine's +// 30-minute idle timeout unreachable while a VS Code window is open (CWE-613). No client-side +// opt-out exists: nothing in the engine API reads a header, query parameter, or route that lets +// a caller ask for `activity=False` — that flag is a server-side per-route decision only. This +// file DID send a bearer here, which is the defect this block exists to stop coming back. The +// tokenlessness is DATA (`LIVE_STATUS_PLAN`, asserted in CI), not this comment. Read against +// the engine source, not a running engine: the claim rests on `identity_for_token`'s signature +// and the route's dependency chain. +// 2. What that costs, accepted on purpose. Against an engine with auth ENABLED the poll gets a 401 +// and the rows stay undecorated, so decorations now appear only where `/connections` answers +// tokenless — a dev or embedded engine run with `allow_no_auth`. The setting ships OFF by +// default, so no one who has not asked for it loses anything; what is given up is a status word +// and a count on a tree row, and what is kept is an automatic-logoff control on the one client +// that stays open all day. The full monitor is the web console at /ui, which reads the same +// data under `activity=False` and is the surface built for it. Making a bearer safe instead +// needs an engine-side passive-read surface, which is its own change, not a tidy-up here. import * as vscode from "vscode"; -import { clearToken, peekToken } from "./auth"; +import { peekToken } from "./auth"; import { engineUrl, environments } from "./cli"; -import { getJson, HttpError } from "./engineClient"; +import { getJson } from "./engineClient"; import { resolveEngineStatusTarget } from "./engineStatusModel"; import { assertTargetAllowed } from "./engineTarget"; import type { GraphProvider } from "./graphTree"; -import { buildRuntimeMap, type ConnectionRowLite } from "./liveStatusModel"; +import { buildRuntimeMap, LIVE_STATUS_PLAN, type ConnectionRowLite } from "./liveStatusModel"; import type { RuntimeMap } from "./graphModel"; /** Floor for the poll interval (seconds) — the settings schema declares the same minimum; this @@ -78,20 +98,25 @@ export class LiveStatusPoller implements vscode.Disposable { try { // Same target the engine status bar reflects (first named environment, else engineUrl). const url = resolveEngineStatusTarget(engineUrl(), environments()).url; - // SEC-005: never send a bearer token in clear to a non-loopback http:// host. + // The SEC-005 host gate (ADR 0035). Kept even though the poll is now tokenless: it is about + // the TARGET, and a background timer should not reach an arbitrary non-loopback plaintext host + // either. It is also the guard that would still hold if LIVE_STATUS_PLAN ever gained a bearer. if (assertTargetAllowed(url).ok) { - const bearer = await peekToken(this.ctx, url); + const entry = LIVE_STATUS_PLAN[0]; + // The bearer is attached IFF the plan says so — which is what makes `authenticated: false` + // an actual control rather than a comment (same shape as statusBar.runProbe). No entry says + // so today, so the token is never even read and the request carries no Authorization header. + const bearer = entry.authenticated ? await peekToken(this.ctx, url) : undefined; try { - const rows = await getJson(url, "/connections", bearer); + const rows = await getJson(url, entry.route, bearer); map = Array.isArray(rows) ? buildRuntimeMap(rows) : undefined; - } catch (e) { - if (e instanceof HttpError && e.status === 401 && bearer) { - // The cached session is dead — clear it so the next interactive action (promote) - // re-authenticates cleanly. A 403 is NOT cleared: the session is valid, the account - // just lacks MONITORING_READ; clearing would only churn the promote sign-in. - await clearToken(this.ctx, url); - } - map = undefined; // unauthorized / unreachable / non-JSON → undecorated rows, silently + } catch { + // Unauthorized / unreachable / non-JSON → undecorated rows, silently. Nothing is cleared + // here: the poll sends no bearer, so a 401 is the engine saying "this route needs auth", + // NOT evidence that the cached session died. Clearing on it would sign the user out from a + // timer over a request their session never took part in. `auth.withAuth` still clears on a + // 401 from a request that DID carry the token — the only place that inference is sound. + map = undefined; } } } finally { diff --git a/ide/src/liveStatusModel.ts b/ide/src/liveStatusModel.ts index 6fee7abe5..52d880308 100644 --- a/ide/src/liveStatusModel.ts +++ b/ide/src/liveStatusModel.ts @@ -8,6 +8,29 @@ // never hidden behind a healthy sibling. Node-side unit-tested (no Extension Host). import { runtimeKey, type RuntimeInfo, type RuntimeMap } from "./graphModel"; +import type { ProbePlanEntry } from "./engineStatusModel"; + +/** The one engine route this feature reads. Deliberately NOT added to engineStatusModel's + * PROBE_ENDPOINTS: that is the engine-link DOCTOR's frozen allowlist, and a test keeps + * `/connections` out of it because probing workload is how an indicator becomes a monitor. Live + * decorations are the sanctioned exception (ADR 0091), so the route is named here instead. */ +export const CONNECTIONS_ROUTE = "/connections"; + +/** + * What the live-decorations TIMER is allowed to do. The single entry is `authenticated: false`, and + * a test asserts exactly that — the same `ProbePlanEntry` vocabulary, and the same rule, as + * engineStatusModel's `POLL_PLAN`. + * + * Why it may not carry a bearer: `GET /connections` is gated by plain `require(...)`, and + * `require()` resolves the token with `identity_for_token(bearer_token(request))` — the default + * `activity=True`, which refreshes the session's idle clock. So a bearer on a 5-to-10-second timer + * from an open VS Code window would keep refreshing that clock and make the engine's 30-minute idle + * timeout unreachable for as long as the window stays open (CWE-613) — on a client that sits open + * all day. A comment saying "do not add a token here" is not a control; this list is. + */ +export const LIVE_STATUS_PLAN: readonly ProbePlanEntry[] = [ + { route: CONNECTIONS_ROUTE, authenticated: false }, +]; /** The subset of the engine's ConnectionRow (api/models.py) the IDE consumes. Everything else in * the payload (peers, ports, backlog, shard ownership …) is deliberately ignored — the tree shows diff --git a/ide/src/test/suite/live-status.test.ts b/ide/src/test/suite/live-status.test.ts index 97913e4b2..6bd3847b4 100644 --- a/ide/src/test/suite/live-status.test.ts +++ b/ide/src/test/suite/live-status.test.ts @@ -14,7 +14,12 @@ import { type RuntimeInfo, type VmNode, } from "../../graphModel"; -import { buildRuntimeMap, type ConnectionRowLite } from "../../liveStatusModel"; +import { + buildRuntimeMap, + CONNECTIONS_ROUTE, + LIVE_STATUS_PLAN, + type ConnectionRowLite, +} from "../../liveStatusModel"; // Live decorations for the CONNECTIONS view (ADR 0091 "live decorations"), exercised vscode-free: // the pure reduction of the engine's `GET /connections` rows into a RuntimeMap, and the @@ -160,6 +165,71 @@ suite("graphModel — elements view runtime enrichment", () => { }); }); +suite("liveStatus poll — the timer may not carry a bearer (AUTH-IDLE / CWE-613)", () => { + const LIVE_STATUS_TS = path.join(__dirname, "..", "..", "..", "src", "liveStatus.ts"); + + test("LIVE_STATUS_PLAN is tokenless, and names the one route this feature reads", () => { + // This is the control, not the file header above it. `GET /connections` is gated by plain + // `require(Permission.MONITORING_READ)`, and `require()` resolves the bearer with + // `identity_for_token(bearer_token(request))` — the default `activity=True`, which refreshes + // the session's idle clock. So a bearer on this 5-to-10-second + // timer would keep the session alive for as long as a VS Code window stays open and make the + // engine's 30-minute idle timeout unreachable, on the exact client the automatic-logoff control + // exists for. liveStatus.poll attaches the token IFF the plan entry says `authenticated`, so + // this assertion is what actually holds the line. Same rule, same shape, as POLL_PLAN. + assert.ok(LIVE_STATUS_PLAN.length > 0); + for (const entry of LIVE_STATUS_PLAN) { + assert.strictEqual( + entry.authenticated, + false, + `the live-status poll must not authenticate (${entry.route}) — it would defeat AUTH-IDLE`, + ); + } + assert.deepStrictEqual( + LIVE_STATUS_PLAN.map((e) => e.route), + [CONNECTIONS_ROUTE], + ); + assert.strictEqual(CONNECTIONS_ROUTE, "/connections"); + }); + + test("the poll's call site reads the token ONLY through the plan", () => { + // The plan is a control only if the shell obeys it, so read the shell. A source scan that + // silently matches nothing is indistinguishable from a clean file, hence the guards below. + const text = fs.readFileSync(LIVE_STATUS_TS, "utf8"); + assert.ok( + text.includes("getJson("), + "the poll's call site moved — re-point this scan before trusting it", + ); + const sites = text.split(/\r?\n/).filter((l) => l.includes("peekToken(")); + assert.strictEqual(sites.length, 1, `expected one peekToken call site, found ${sites.length}`); + assert.ok( + /entry\.authenticated \?/.test(sites[0]), + `the poll must resolve its bearer through the plan, not unconditionally: ${sites[0].trim()}`, + ); + + // The check must be able to give a DIFFERENT answer. This is the line the file actually carried + // before the fix; if the predicate accepts it, the predicate is not testing anything. + const defect = " const bearer = await peekToken(this.ctx, url);"; + assert.ok(defect.includes("peekToken("), "the control line does not even reach the predicate"); + assert.ok( + !/entry\.authenticated \?/.test(defect), + "the predicate passes the defect it exists to catch", + ); + }); + + test("a tokenless 401 does not clear the cached session", () => { + // The poll sends no bearer, so a 401 means "this route needs auth" — never "your session died". + // Clearing on it would sign the user out from a timer, over a request their session had no part + // in. auth.withAuth still clears on a 401 from a request that DID carry the token. + const text = fs.readFileSync(LIVE_STATUS_TS, "utf8"); + assert.ok(text.includes("peekToken"), "vacuity guard: this file should still name auth at all"); + assert.ok( + !text.includes("clearToken"), + "liveStatus must not clear a token from a background timer", + ); + }); +}); + suite("liveStatus contributions", () => { interface Pkg { version: string; From 4b4200fab60cebbe818d4103d6aa218de42d1ab5 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 14:32:53 -0500 Subject: [PATCH 09/17] feat(transports): shipped-on per-tick ceilings for the three poll sources (BACKLOG #1114) FileSource scanned every glob candidate with no cap, RemoteFileSource did the same over its listing, and DatabaseSource materialised the whole poll result set with fetchall. On a first deployment a long-unattended drop directory or table would be taken whole in one tick. All three now take at most 500 items per tick, SHIPPED ON, operator-overridable; 0 or None disables, and a negative value is refused at build (accepted, it would make the ceiling true on the first candidate of every tick, so the connection would report running and ingest nothing for ever). WHY THESE MAY DEFAULT ON WHEN THE NETWORK-LISTENER PACER MAY NOT. Deferral on a poll source is not a drop: a file the scan does not reach is still in the drop directory, a row the poll does not fetch is still in the table and unmarked, and the next tick takes it. Nothing is quarantined, errored or accepted-and-dropped, so the count-and-log invariant is untouched -- an item that was never received has no disposition to record. That is the whole difference from the MLLP pacer, which ships off by deliberate ruling. Stated in the code and the docs, because a later reader will otherwise "fix" the inconsistency the wrong way. THE NUMBER IS ANCHORED ON THE REPO'S OWN MEASUREMENTS, not picked. docs/THROUGHPUT records ~450 msg/s at intake and ~60 end-to-end; SYSTEM-REQUIREMENTS records ~97 sustained and ~107 burst as the highest ever measured from one engine process. At the shipped poll intervals 500 per tick is 500/s on File and 100/s on the other two, so the ceiling sits at or above every rate this engine has been measured achieving and cannot throttle a feed it could otherwise have kept up with. Ingesting faster than the engine drains delivers nothing sooner; it moves the backlog from the source system into the store. capture_max_rows=100 was deliberately NOT reused: it bounds a captured response body, a different axis, and 100 rows per 5-second poll is 20/s, below the engine's own measured sustained rate. The name shape and construction idiom were matched; the value was not. FAIR PROGRESS. Only an item the tick FINISHED with charges the budget -- handed to the pipeline, or quarantined. Every arm that leaves an item in place for a later retry (locked or vanished file, scan-hook malfunction, handler failure, and the unsafe-listing-name refusal) deliberately does not charge, because charging them would let one permanently stuck item that sorts early eat the whole ceiling every tick and starve the healthy items behind it. Two tests pin this and both red when those arms are made to charge. The ceiling bounds the INGEST, not the listing: _candidates still globs and sorts the whole directory, because taking the first N in order requires seeing them all. Two anchors in the row point at the CAPTURE path, not the poll path, and are corrected: the poll fetchall was at database.py:1181, and the capture row ceiling is at :899/:952. The defect itself reproduced exactly. Every new test proved load-bearing by mutation, each restored: removing the break reds the File and RemoteFile volume tests, deleting the untouched candidates reds the second-scan test alone, restoring fetchall reds two DB tests, and charging either transient arm reds its fairness test. Nothing here claims ASVS 2.4.1 moves. The row is explicit that a builder should not build to move it; the two acts that would are the owner's. Co-Authored-By: Claude Opus 5 --- docs/CONNECTIONS.md | 50 +- messagefoundry/config/wiring.py | 35 +- messagefoundry/transports/base.py | 53 +++ messagefoundry/transports/database.py | 44 +- messagefoundry/transports/file.py | 56 ++- messagefoundry/transports/remotefile.py | 51 +- tests/test_database_cursor_close.py | 6 + tests/test_database_transport.py | 15 +- tests/test_poll_source_tick_ceilings.py | 591 ++++++++++++++++++++++++ 9 files changed, 888 insertions(+), 13 deletions(-) create mode 100644 tests/test_poll_source_tick_ceilings.py diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index 5a973b21f..d4ccf0d07 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -675,6 +675,7 @@ def route(msg): | `sort` | in | `name` | process order: `name` or `mtime` | | `recursive` | in | `false` | also scan subdirectories | | `max_file_bytes` | in | `16 MiB` | route files larger than this to the error dir instead of reading them into memory (OOM guard). `None`/`0` = unlimited. | +| `poll_max_files` | in | `500` | most files one scan will take. The rest stay in the drop directory and the next scan takes them — a **deferral, not a drop**: nothing is quarantined, errored, or left unaccounted for. See [*Per-tick poll ceilings*](#per-tick-poll-ceilings) for the number and when to raise it. `None`/`0` = unlimited. | | `validate_directory` | both | `false` | validate the directory **at startup** (#114): a missing/unusable dir reports the connection **`failed`** (ADR 0031) instead of the default deferral to run time. **No mkdir** — a merely-missing dir fails. **In:** a `leave` source validates read-only (a read-only share passes); `move`/`delete` also require write. **Out:** the target must already exist and accept a write, and is then **never created** — not at start, not on write (a delivery into a vanished dir fails retryably instead), and not by `POST /connections/{name}/test`. Left off (the default) the outbound target is still created on first write, but the creation is now logged as a `WARNING`. | | `processed_subdir` / `error_subdir` | in | `.processed` / `.error` | where read/failed files go | | `filename` | out | `{MSH-10}.hl7` | output name (supports `{HL7-path}` placeholders). Resolved values are sanitized to a **single safe filename** — path separators/unsafe chars stripped, leading dots removed, and `.`/`..`/reserved device names fall back — so a message field can never write outside the directory. | @@ -942,6 +943,7 @@ poll/write shape against a remote server, selected by an internal `protocol` set | `min_age_seconds` | in | `0.0` | **accepted but not honoured on a remote source today** — the connector never reads it (a remote directory listing carries no reliable mtime). Only `File(...)` implements it; use `after_read`/the partner's own write-then-rename to avoid partial reads. | | `after_read` | in | `move` | `move` (→ `processed_subdir`), `delete`, or `leave` (process **in place**, #142 — a durable dedup ledger keyed on a hash of the **full remote path** + size ensures a left file is ingested once) | | `max_file_bytes` | in | `16 MiB` | **charged twice, and the second charge is the one that binds.** Before the retrieve, against the size the **server reported** in its own directory listing — an over-size entry is moved to `error_subdir` without being read. Then **during** the retrieve, against the **bytes actually read**: the download streams in 1 MiB chunks and is cut off at the first byte past the budget, so a share that lists a small file and then delivers an arbitrarily large body is refused mid-transfer rather than buffered whole (BACKLOG #1191). Either refusal quarantines the file to `error_subdir` and logs it — never a silent drop, and never left in place to be re-pulled every poll. `None`/`0` = unlimited, in both charges. | +| `poll_max_files` | in | `500` | most files one poll will take. The rest stay on the share and the next poll takes them — a **deferral, not a drop**. Identical in shape and reasoning to the `File(...)` row; see [*Per-tick poll ceilings*](#per-tick-poll-ceilings). `None`/`0` = unlimited. | | `validate_directory` | both | `false` | validate `remote_dir` **at startup** (#114): unreachable/unusable reports the connection **`failed`** (ADR 0031) instead of deferring to run time. The probe is a **listing** — it never creates. **Out:** the upload dir is then never `ensure_dir`ed either, on send or by `POST /connections/{name}/test`; an upload into a vanished dir fails **retryably** rather than dead-lettering on the partner's permanent no-such-dir. Left off (the default) the upload dir is still created on first send, but the creation is now logged as a `WARNING`. | | `processed_subdir` / `error_subdir` | in | `.processed` / `.error` | where read / failed files go | | `filename` | out | `{MSH-10}.hl7` | upload name (supports `{HL7-path}` placeholders, sanitized to a **single safe filename** exactly as `File(...)`) | @@ -1230,6 +1232,7 @@ handler returns** — runs `mark_statement` (bound from the row's columns) so th | `mark_statement` | — | run **per row after** the handler succeeds, with `:name` params bound from the row, e.g. `UPDATE mf_inbox SET status='DONE' WHERE id=:id`. Omit only for a genuinely read-only/idempotent feed. | | `body_column` | — | unset → the **whole row** as a JSON object `{column: value}` (pair with `content_type=json`); set → that **one column's value verbatim** (e.g. a column holding an HL7 message → `content_type=hl7v2`) | | `poll_seconds` | `5.0` | interval between polls | +| `poll_max_rows` | `500` | most rows one poll will **fetch** from `poll_statement`'s result set. The rest are left in the table — not read, not marked, not errored — and the next poll selects them again. Charged at the fetch, so a long-unattended table is no longer materialised whole into memory. Progress needs `mark_statement` to take a handled row out of the `poll_statement` predicate, which is the shape this connector already requires. See [*Per-tick poll ceilings*](#per-tick-poll-ceilings). `None`/`0` = unlimited. | | `encoding` | `utf-8` | charset for the body bytes handed to the pipeline | | `dialect` / `odbc_driver` / `odbc_params` / `odbc_user_key` / `odbc_password_key` | `sqlserver` / … | same as `Database(...)` — `dialect="generic"` polls any OS-installed ODBC driver (PostgreSQL / Oracle / MySQL); see [*Generic ODBC*](#generic-odbc-postgresql--oracle--mysql) | | `auth` / `username` / `password` / `port` / `encrypt` / `trust_server_certificate` / `connect_timeout` / `app_name` / `pool_max` | — | identical to the `Database(...)` destination above | @@ -2496,6 +2499,47 @@ mechanism differs per class: a timeout for the bounded hops, the 5 s strict-vali `[store].command_timeout` for the store hop, cooperative cancellation on stop for the workers, and — for the Router/Handler and the SMB worker — nothing but a restart. +### Per-tick poll ceilings + +The three **poll** sources — `File(...)`, `Sftp(...)`/`Ftp(...)` and `DatabasePoll(...)` — each take at +most **500 items per tick** (`poll_max_files`, `poll_max_rows`). The ceiling **ships on**, and a falsy +value (`None`/`0`) turns it off. + +**It is a deferral, not a drop.** A file the scan does not reach is still in the drop directory; a row +the poll does not fetch is still in the table, unmarked. The next tick takes it. Nothing is quarantined, +errored, or accepted-and-dropped, so the count-and-log invariant is untouched: an item that was never +read was never received, and there is no disposition to record. + +**Why these three default on when the MLLP message pacer ships off.** On a listen socket a rate bound +has to refuse or stall a sender mid-conversation, and the right number comes from a real feed profile +the project does not have — so that one stays opt-in ([`transports/mllp.py`](../messagefoundry/transports/mllp.py), +ruled 2026-08-11). A poll source has no sender to refuse. The two cases differ in what a bound does to +the partner, not in appetite for risk. + +**Why 500.** At the shipped poll intervals it allows 500 items/s on `File(...)` (`poll_seconds` 1.0) and +100/s on the remote and database sources (`poll_seconds` 5.0). The published measurements are ~450 msg/s +at intake and ~97 msg/s sustained end-to-end from one engine process +([`docs/THROUGHPUT.md`](THROUGHPUT.md), [`docs/SYSTEM-REQUIREMENTS.md`](SYSTEM-REQUIREMENTS.md)), so the +ceiling sits at or above every rate this engine has been measured achieving. It cannot be the thing that +throttles a feed the engine could otherwise have kept up with, and ingesting faster than the engine +drains would only move the backlog from the source system into this engine's store. + +**When to raise it.** The drain rate is `poll_max_files ÷ poll_seconds`, so a long interval shrinks it: a +30,000-file nightly drop on a 60-second poll needs 60 ticks at the default. Raise the ceiling, shorten +the interval, or set the knob to `0` for that connection. + +**What it bounds, and what it does not.** It bounds the ingest — the read, the pre-ingest scan, the +pipeline hand-off and the durable commit. The `File(...)` source still lists and sorts the whole +directory each scan, because taking the first N in name or mtime order requires seeing all of them. On +the database source the ceiling is charged at the **fetch**, so the rest of the result set is never +pulled out of the driver. + +**Files left for a retry do not spend the budget.** A locked or vanished file, a malfunctioning +pre-ingest scan hook, a handler failure, and a listing entry refused as an unsafe name all leave the +item where it is. Charging those would let one permanently stuck item consume the whole ceiling on every +tick and starve the healthy items behind it. Only an item the tick finished with — handed off, or +quarantined to the error directory — charges. + ### Table A — concurrency limits & behaviour at the limit (ASVS 13.1.2 / 13.2.6) | Service/hop | Concurrency bound (setting + default) | Behaviour when the limit is reached | Fallback / recovery | @@ -2506,9 +2550,9 @@ for the Router/Handler and the SMB worker — nothing but a restart. | X12 listener (inbound) | `max_connections` default 256 concurrent clients | connection accepted, then immediately refused and closed at the application layer; the active-client counter is not incremented. **No ADR 0021 connection_event is emitted** — `transports/x12.py` emits none at all; an allow-list refusal is a logged warning only, and the at-capacity path emits **no log line either** | as MLLP | | Raw TCP / X12 destination | as MLLP destination — one delivery per outbound lane | a lane waits for a processing slot; a fresh connection is dialled per delivery | transient failure re-queues into the retry path | | HTTP web-service listener (inbound) | `max_connections` default 256; `max_header_bytes` 64 KiB and `max_body_bytes` 16 MiB bound one request | at capacity the connection is accepted then refused and closed (`at_capacity`); an over-declared `Content-Length` is refused before buffering; a slow read gets a synchronous `408` | the partner retries; slots free on completion or `receive_timeout` | -| File endpoint — local filesystem | one poll worker per inbound connection; one delivery lane per outbound | no connection limit exists — the bound is the poll interval `poll_seconds` (default 1.0) and `max_file_bytes` (16 MiB) | an oversize or unreadable file is skipped/errored and left for the operator; the next poll continues | +| File endpoint — local filesystem | one poll worker per inbound connection; one delivery lane per outbound | no connection limit exists — the bounds are the poll interval `poll_seconds` (default 1.0), `max_file_bytes` (16 MiB) and `poll_max_files` (500 files per scan, [deferring the rest to the next scan](#per-tick-poll-ceilings)) | an oversize or unreadable file is skipped/errored and left for the operator; the next poll continues | | File endpoint — UNC / SMB share | as local File, plus one dedicated impersonation worker thread per endpoint | the OS redirector queues; no engine-side cap | an SMB failure surfaces as a transient delivery/poll error and re-queues | -| SFTP (remote-file) | one session per poll or per delivery — no session pool | sessions are serialized by the lane budget; there is no server-side connection cap the engine enforces | a refused/limited server surfaces as a transient error and re-queues per `RetryPolicy` | +| SFTP (remote-file) | one session per poll or per delivery — no session pool | sessions are serialized by the lane budget; there is no server-side connection cap the engine enforces; one poll takes at most `poll_max_files` (500) files and [defers the rest](#per-tick-poll-ceilings) | a refused/limited server surfaces as a transient error and re-queues per `RetryPolicy` | | FTP / FTPS (remote-file) | one session per poll or per delivery — no session pool | as SFTP | as SFTP | | Reference-set sync (`FileRef`) | one read per set per `refresh_seconds` pass (default 3600); no concurrency knob — the OS / SMB redirector queues on a UNC path | a slow or unreachable path stretches that set's sync; the sync is isolated per reference set | the previous encrypted snapshot keeps serving reads | | REST destination | no per-connection HTTP connection cap exists; the indirect bound is `[pipeline].pooled_max_processing_lanes` (default 256) | requests queue behind the lane budget; the backend's own 429/503 is classified transient | transient → `RetryPolicy` with backoff; permanent → dead-letter | @@ -2519,7 +2563,7 @@ for the Router/Handler and the SMB worker — nothing but a restart. | DICOM C-STORE SCU / C-ECHO | one association per delivery, bounded by the lane budget | the association request fails on `connect_timeout` | out-of-resources status → retry; a hard refusal → dead-letter | | EMAIL (SMTP) destination | one SMTP connection per send, bounded by the lane budget | the relay's own limit surfaces as an SMTP error | transient → retry; permanent → dead-letter | | DIRECT (S/MIME over SMTP) | one SMTP connection per send, bounded by the lane budget | as EMAIL | as EMAIL | -| DATABASE destination / poll source / `db_lookup` | `pool_max` default 5 connections per connection definition | a borrow that cannot be satisfied within `acquire_timeout` (default 30 s) fails **transiently** with a PHI-free "pool exhausted or DB unresponsive" error | the row re-queues into the `RetryPolicy` path; the pool self-heals as borrows return | +| DATABASE destination / poll source / `db_lookup` | `pool_max` default 5 connections per connection definition; the poll source additionally fetches at most `poll_max_rows` (500) rows per poll, [deferring the rest](#per-tick-poll-ceilings) | a borrow that cannot be satisfied within `acquire_timeout` (default 30 s) fails **transiently** with a PHI-free "pool exhausted or DB unresponsive" error | the row re-queues into the `RetryPolicy` path; the pool self-heals as borrows return | | Reference-set sync (`DatabaseRef`) | `pool_max` default 5, in a **throwaway pool built per sync** | a borrow that cannot be satisfied within `DatabaseRef(acquire_timeout=…)` (default 30 s) raises `StoreAcquireTimeout`, failing that set's sync | the sync task is isolated per reference set; the previous snapshot keeps serving reads and the AlertSink fires. The bound also keeps one wedged source from stalling the sequential pass over the other sets | | Internal sources — Timer / Loopback / PassThrough | n/a — they open no socket and reach no external system | n/a | n/a | | Engine API + `/ui` + `/ws/stats` (`[api].port`) | uvicorn's own defaults (no `limit_concurrency` / `timeout_keep_alive` is passed); per-actor 429 throttles bound abuse: login 10 per IP and 60 global per 60 s, PHI reads 120 per actor per 60 s, admin writes 12 per actor per second | over a throttle the request gets `429` and an audit row; the connection stays usable | the caller backs off; the window rolls | diff --git a/messagefoundry/config/wiring.py b/messagefoundry/config/wiring.py index 0f4a124d3..83ffc1870 100644 --- a/messagefoundry/config/wiring.py +++ b/messagefoundry/config/wiring.py @@ -1779,6 +1779,8 @@ def File( sort: str = "name", # inbound: process order — "name" | "mtime" recursive: bool = False, # inbound: also scan subdirectories max_file_bytes: int | None = 16 * 1024 * 1024, # inbound: skip files over this (OOM guard) + poll_max_files: int + | None = 500, # inbound: files ONE scan may take; the rest wait for the next scan (0/None = unlimited) validate_directory: bool = False, # both directions (#114): fail-fast at start on a missing/unusable dir, and never create it; default defers to run time overwrite: bool = False, # outbound: overwrite vs. uniquify a name collision processed_subdir: str = ".processed", @@ -1800,6 +1802,12 @@ def File( (atomically). ``encoding`` is the file charset (outbound). ``max_file_bytes`` mirrors transports.file.DEFAULT_MAX_FILE_BYTES (pass None/0 to disable). + ``poll_max_files`` (inbound) mirrors transports.base.DEFAULT_MAX_ITEMS_PER_POLL and **ships on**: one + scan takes at most this many files and the rest wait in the drop directory for the next scan. A + deferral, not a drop — nothing is quarantined, errored or unaccounted for. Raise it (or pass None/0 + for unlimited) if a site's periodic drop is larger than the ceiling and its ``poll_seconds`` is long + enough that the backlog would take too many scans to clear. + ``after_read`` (inbound) chooses the source-file disposition: ``move`` (→ ``processed_subdir``, the default), ``delete``, or ``leave`` — **process in place** for a read-only share / a directory another system owns (#142; a HASHED per-file ledger dedups so a left file is ingested once). @@ -1836,6 +1844,7 @@ def File( "sort": sort, "recursive": recursive, "max_file_bytes": max_file_bytes, + "poll_max_files": poll_max_files, "validate_directory": validate_directory, "overwrite": overwrite, "processed_subdir": processed_subdir, @@ -2505,6 +2514,8 @@ def DatabasePoll( | None = None, # UPDATE/DELETE run per row after the handler succeeds (:name) body_column: str | None = None, # None → whole row as JSON; set → that column's value verbatim poll_seconds: float = 5.0, + poll_max_rows: int + | None = 500, # rows ONE poll may take; the rest wait for the next poll (0/None = unlimited) auth: Literal[ "sql", "integrated", "entra" ] = "sql", # sql | integrated | entra (SQL Server preset only) @@ -2538,6 +2549,13 @@ def DatabasePoll( ``env()``; TLS is on by default (weakening needs ``MEFOR_ALLOW_INSECURE_TLS``); the polled ``server`` is gated by ``[egress].allowed_db``. + ``poll_max_rows`` mirrors transports.base.DEFAULT_MAX_ITEMS_PER_POLL and **ships on**: one poll + fetches at most this many rows and leaves the rest of the result set in the table for the next poll. + A deferral, not a drop — an unfetched row is not read, not marked and not errored. It bounds the + fetch itself, so a long-unattended table is no longer materialised whole into memory. Progress needs + ``mark_statement`` to take a handled row out of ``poll_statement``'s predicate, which is the shape + this connector already requires. Pass None/0 for the unbounded fetch. + ``dialect='generic'`` (#66) polls any OS-installed ODBC driver (PostgreSQL / Oracle / MySQL) — name it in ``odbc_driver``, pass driver keywords via ``odbc_params``, and configure TLS through the driver's own keyword (the SQL-Server weakened-TLS refusal does not apply on that path). Credentials stay in @@ -2553,6 +2571,7 @@ def DatabasePoll( "mark_statement": mark_statement, "body_column": body_column, "poll_seconds": poll_seconds, + "poll_max_rows": poll_max_rows, "auth": auth, "username": username, "password": password, @@ -2766,6 +2785,8 @@ def Sftp( ] = "move", # inbound: "move" (to processed_subdir) | "delete" | "leave" (process in place, #142) min_age_seconds: float = 0.0, # inbound: skip files modified within this window (partial writes) max_file_bytes: int | None = 16 * 1024 * 1024, # inbound: skip files over this (OOM guard) + poll_max_files: int + | None = 500, # inbound: files ONE poll may take; the rest wait for the next poll (0/None = unlimited) validate_directory: bool = False, # both directions (#114): fail-fast at start on an unreachable remote dir, and never create it overwrite: bool = False, # outbound: overwrite vs. uniquify a name collision processed_subdir: str = ".processed", @@ -2786,7 +2807,11 @@ def Sftp( ``validate_directory`` (#114, both directions) makes an unreachable/missing ``remote_dir`` **fail startup** — the connection is reported ``failed`` — instead of the default deferral to run time; on an outbound it additionally stops the upload directory from ever being created (on send, or by the - on-demand test probe). Off by default: an intermittently-available remote dir must still start.""" + on-demand test probe). Off by default: an intermittently-available remote dir must still start. + + ``poll_max_files`` (inbound) mirrors transports.base.DEFAULT_MAX_ITEMS_PER_POLL and **ships on**: one + poll takes at most this many files and the rest stay on the share for the next poll. A deferral, not + a drop. Pass None/0 for unlimited.""" return ConnectionSpec( ConnectorType.REMOTEFILE, { @@ -2805,6 +2830,7 @@ def Sftp( "after_read": after_read, "min_age_seconds": min_age_seconds, "max_file_bytes": max_file_bytes, + "poll_max_files": poll_max_files, "validate_directory": validate_directory, "overwrite": overwrite, "processed_subdir": processed_subdir, @@ -2831,6 +2857,8 @@ def Ftp( ] = "move", # inbound: "move" (to processed_subdir) | "delete" | "leave" (process in place, #142) min_age_seconds: float = 0.0, # inbound: skip files modified within this window (partial writes) max_file_bytes: int | None = 16 * 1024 * 1024, # inbound: skip files over this (OOM guard) + poll_max_files: int + | None = 500, # inbound: files ONE poll may take; the rest wait for the next poll (0/None = unlimited) validate_directory: bool = False, # both directions (#114): fail-fast at start on an unreachable remote dir, and never create it overwrite: bool = False, # outbound: overwrite vs. uniquify a name collision processed_subdir: str = ".processed", @@ -2844,8 +2872,8 @@ def Ftp( plain ``ftp`` is **refused** unless ``MEFOR_ALLOW_INSECURE_TLS`` is set (use ``tls=True`` for FTPS, or :func:`Sftp`). FTPS encrypts the control + data channels, so credentials are fine there. Put secrets (``password``) in ``env()``. The host is gated by ``[egress].allowed_remote`` (both - directions). At-least-once → downstreams **must be idempotent**. ``validate_directory`` behaves - exactly as it does on :func:`Sftp`.""" + directions). At-least-once → downstreams **must be idempotent**. ``validate_directory`` and + ``poll_max_files`` behave exactly as they do on :func:`Sftp`.""" return ConnectionSpec( ConnectorType.REMOTEFILE, { @@ -2862,6 +2890,7 @@ def Ftp( "after_read": after_read, "min_age_seconds": min_age_seconds, "max_file_bytes": max_file_bytes, + "poll_max_files": poll_max_files, "validate_directory": validate_directory, "overwrite": overwrite, "processed_subdir": processed_subdir, diff --git a/messagefoundry/transports/base.py b/messagefoundry/transports/base.py index 79683ded4..59dce7065 100644 --- a/messagefoundry/transports/base.py +++ b/messagefoundry/transports/base.py @@ -55,8 +55,61 @@ "ECH_UNSUPPORTED_SOURCE_MSG", "peer_ip_allowed", "probe_tcp_reachable", + "DEFAULT_MAX_ITEMS_PER_POLL", + "resolve_poll_ceiling", ] +#: Per-tick intake ceiling for the three POLL sources — FILE, REMOTEFILE and DATABASE, which are +#: exactly the sources that set :attr:`SourceConnector.polls_shared_resource`. **It ships ON.** One +#: tick hands at most this many items to the pipeline and leaves the rest where they are; the next +#: tick takes the next batch. Each connector exposes it as its own setting (``poll_max_files`` on the +#: two file sources, ``poll_max_rows`` on the database poll), and a falsy value (None/0) disables it. +#: +#: **Why a poll source may default this ON while the MLLP message pacer deliberately ships OFF** +#: (``transports/mllp.py`` ``DEFAULT_MAX_MESSAGES_PER_SECOND``, ruled 2026-08-11). Here a ceiling is a +#: DEFERRAL, not a drop: an unread file stays in the drop directory and an unselected row stays in the +#: table, so the next tick picks it up. Nothing is refused, no disposition changes, and the +#: count-and-log invariant is untouched — a deferred item was never received, so there is nothing to +#: count. On a listen socket the same bound has to refuse or stall a sender mid-conversation, which is +#: why that one waits for the site's own number. **Do not "fix" the inconsistency by defaulting these +#: three off**; the two cases differ in what a bound does to the sender, not in taste. +#: +#: **Why 500.** The published measurements are the anchor: ``docs/THROUGHPUT.md`` records ~450 msg/s at +#: intake (ACK-on-receipt) and ~60 msg/s end-to-end on one ordered interface, and +#: ``docs/SYSTEM-REQUIREMENTS.md`` puts the highest rate ever measured from one engine process at ~97 +#: msg/s sustained (~107 as a burst). At the shipped poll intervals 500 items per tick allows 500/s on +#: the FILE source (``poll_seconds`` 1.0) and 100/s on REMOTEFILE and DATABASE (``poll_seconds`` 5.0) — +#: at or above every one of those figures, so the ceiling cannot be the constraint that throttles a +#: feed the engine could otherwise have kept up with. It is also low enough to bind the pathological +#: tick this exists for: a partner dropping a hundred thousand files at once, or a queue table that has +#: gone unattended for a week. Ingesting faster than the engine drains would not deliver anything +#: sooner anyway — it moves the backlog from the source system, where it is visible and its owner +#: controls it, into this engine's store. +DEFAULT_MAX_ITEMS_PER_POLL = 500 + + +def resolve_poll_ceiling(value: object, *, knob: str, transport: str) -> int | None: + """Read one poll source's per-tick ceiling from its settings: a positive count, or ``None`` for the + documented unlimited opt-out (a falsy ``0``/``None``). + + A **negative** value is refused at construction rather than clamped or accepted. Accepted, it would + stop the source ingesting anything at all while the connection still reported running — the worst + outcome this control can produce, and one an operator would have no reason to expect from a typo. + Clamping it to 1 would instead silently ingest at a rate nobody asked for. The connector already + raises a :class:`ValueError` for a bad ``after_read``, so a bad number surfaces the same way: at + wiring / ``messagefoundry check``, before the connection ever starts.""" + if not value: + return None + # A non-numeric setting raises here, which is the same build-time refusal a bad value gets below. + ceiling: int = int(value) # type: ignore[call-overload] + if ceiling < 1: + raise ValueError( + f"{transport} {knob}={value!r} must be a positive number of items per poll " + f"(or 0 for unlimited)" + ) + return ceiling + + # A source hands each inbound message (raw bytes, MLLP framing already stripped) to this # callback and sends whatever it returns back to the sender. Return ``None`` for # fire-and-forget transports (e.g. file) that have no reply channel. diff --git a/messagefoundry/transports/database.py b/messagefoundry/transports/database.py index 6cb52621d..49c93449d 100644 --- a/messagefoundry/transports/database.py +++ b/messagefoundry/transports/database.py @@ -60,6 +60,7 @@ ) from messagefoundry.config.tls_policy import InsecureHopRefused, current_hop_posture from messagefoundry.transports.base import ( + DEFAULT_MAX_ITEMS_PER_POLL, DeliveryError, DeliveryResponse, DestinationConnector, @@ -68,6 +69,7 @@ SourceConnector, register_destination, register_source, + resolve_poll_ceiling, ) from messagefoundry.transports.mllp import InsecureHopGuard @@ -1059,6 +1061,15 @@ def __init__(self, config: Source) -> None: self._mark_sql, self._mark_names = _parse_named_params(str(mark)) if mark else (None, []) self._body_column: str | None = s.get("body_column") or None self._poll_seconds = float(s.get("poll_seconds", 5.0)) + # Per-tick row ceiling, SHIPPED ON (DEFAULT_MAX_ITEMS_PER_POLL — the number and the reason a + # poll source may default this on are stated once, in transports/base.py). Caps how many rows + # ONE poll takes from poll_statement's result set; the rest stay in the table and the next poll + # takes them. A falsy value (None/0) disables the cap, matching the file sources' knobs. + self._poll_max_rows: int | None = resolve_poll_ceiling( + s.get("poll_max_rows", DEFAULT_MAX_ITEMS_PER_POLL), + knob="poll_max_rows", + transport="DATABASE source", + ) self._encoding: str = s.get("encoding", "utf-8") self._pool_max = int(s.get("pool_max", 5)) self._acquire_timeout = float(s.get("acquire_timeout", _DEFAULT_DB_ACQUIRE_TIMEOUT)) @@ -1168,9 +1179,21 @@ async def _poll_once(self) -> None: ) async def _select(self) -> tuple[list[str], list[Any]]: - """Run ``poll_statement`` and return ``(column_names, rows)``. The connection is released before - the rows are handed to the (possibly slow) handler, so a batch never holds a pool connection - hostage to downstream store I/O.""" + """Run ``poll_statement`` and return ``(column_names, rows)``, at most ``poll_max_rows`` of them. + The connection is released before the rows are handed to the (possibly slow) handler, so a batch + never holds a pool connection hostage to downstream store I/O. + + **The ceiling is charged at the FETCH, not after it.** ``fetchmany`` leaves the rest of the + result set in the driver and the cursor is closed on the way out, so a poll of a table holding a + million rows pulls the ceiling (plus one probe row, see below) into memory rather than all of + them — the ``fetchall`` this replaced materialised the whole set before anything could bound it. + The rows not taken are untouched in the table, so the next poll re-runs ``poll_statement`` and + takes the next batch; nothing is dropped, errored or marked. Progress depends on the + ``mark_statement`` removing a handled row from ``poll_statement``'s own predicate, which is the + shape this connector already documents and requires — without a mark the same rows re-emit every + poll, ceiling or no ceiling. + + A falsy ``poll_max_rows`` disables the ceiling and restores the unbounded ``fetchall``.""" pool = await self._get_pool() conn = await _acquire(pool, self._acquire_timeout) cur: Any = None @@ -1178,7 +1201,20 @@ async def _select(self) -> tuple[list[str], list[Any]]: cur = await conn.cursor() await cur.execute(self._poll_sql) columns = [d[0] for d in cur.description] - rows = list(await cur.fetchall()) + if self._poll_max_rows is None: + rows = list(await cur.fetchall()) + else: + # limit + 1 (the same probe auth/oidc uses on a bounded read): one row past the + # ceiling is enough to know a backlog is waiting, and it is dropped from the batch — + # never handed to the handler, never marked, so the next poll selects it again. + rows = list(await cur.fetchmany(self._poll_max_rows + 1)) + if len(rows) > self._poll_max_rows: + rows = rows[: self._poll_max_rows] + logger.info( + "DATABASE source reached poll_max_rows (%s) this poll; the rest of the result " + "set is left for the next poll (deferred, not dropped)", + self._poll_max_rows, + ) finally: await _close_cursor(cur) await pool.release(conn) diff --git a/messagefoundry/transports/file.py b/messagefoundry/transports/file.py index d7db17430..bf89844c7 100644 --- a/messagefoundry/transports/file.py +++ b/messagefoundry/transports/file.py @@ -41,6 +41,7 @@ from messagefoundry.parsing.split import split_batch from messagefoundry.transports import wincred from messagefoundry.transports.base import ( + DEFAULT_MAX_ITEMS_PER_POLL, DeliveryError, DestinationConnector, DestinationStartupError, @@ -50,6 +51,7 @@ encode_wire_body, register_destination, register_source, + resolve_poll_ceiling, ) __all__ = [ @@ -57,6 +59,7 @@ "FileSource", "render_filename", "DEFAULT_MAX_FILE_BYTES", + "DEFAULT_MAX_ITEMS_PER_POLL", "LEAVE_SEEN_CACHE_MAX", # Re-exported from parsing.sniff (ASVS 5.2.2) so remotefile.py + existing tests import them here. "_content_matches_declared", @@ -382,6 +385,15 @@ def __init__(self, config: Source) -> None: self.encoding: str = s.get("encoding", "utf-8") mfb = s.get("max_file_bytes", DEFAULT_MAX_FILE_BYTES) self.max_file_bytes: int | None = int(mfb) if mfb else None + # Per-tick intake ceiling, SHIPPED ON (DEFAULT_MAX_ITEMS_PER_POLL — the number and the reason a + # poll source may default this on are stated once, in transports/base.py). Caps how many files + # ONE scan disposes of; the rest stay in the drop directory and the next scan takes them. A + # falsy value (None/0) disables the cap, matching max_file_bytes above. + self.poll_max_files: int | None = resolve_poll_ceiling( + s.get("poll_max_files", DEFAULT_MAX_ITEMS_PER_POLL), + knob="poll_max_files", + transport="file source", + ) # Optional inbound decompression (ADR 0123): "gzip" gunzips each file's bytes BEFORE the sniff / # AV scan / batch split (they must see the real HL7). None (default) is byte-identical to before. self.decompress: str | None = _validate_compression(s.get("decompress"), "decompress") @@ -529,7 +541,11 @@ async def _scan_once(self) -> None: newly_recorded = ( 0 # #142: files marked processed THIS tick — gates a single end-of-tick prune ) - for path in await self._run_fs(self._candidates): + candidates = await self._run_fs(self._candidates) + disposed = 0 # files this tick finished with — the per-tick ceiling's budget (_at_ceiling) + for position, path in enumerate(candidates): + if self._at_ceiling(disposed, len(candidates) - position): + break # #142 leave-in-place dedup: skip a file this connection already ingested. In-memory set # first (no I/O), then the durable ledger (survives restart / a fresh process). Keyed on a # HASHED file id (name+mtime+size) — never a cleartext filename, never logged at INFO+. @@ -551,6 +567,7 @@ async def _scan_once(self) -> None: self.max_file_bytes, ) await self._run_fs(self._move, path, self.error_dir) + disposed += 1 continue try: raw = await self._run_fs(path.read_bytes) @@ -576,6 +593,7 @@ async def _scan_once(self) -> None: "file %s failed to gunzip (%s); routing to error dir", path.name, exc ) await self._run_fs(self._move, path, self.error_dir) + disposed += 1 continue if not _content_matches_declared(self.content_type, raw): # Content doesn't match the declared content_type (a PDF on a json inbound, a non-ISA @@ -595,6 +613,7 @@ async def _scan_once(self) -> None: (self.content_type or ContentType.HL7V2).value, ) await self._run_fs(self._move, path, self.error_dir) + disposed += 1 continue try: # The scan hook operates on already-read bytes (it may itself dial an AV/ICAP service), @@ -611,6 +630,7 @@ async def _scan_once(self) -> None: exc, ) await self._run_fs(self._move, path, self.error_dir) + disposed += 1 continue except Exception as exc: # noqa: BLE001 - operator scan hook: any failure fails closed # The scan hook MALFUNCTIONED (AV/ICAP unreachable, a plugin bug) — NOT a content @@ -643,6 +663,7 @@ async def _scan_once(self) -> None: logger.warning("handler failed for %s (will retry next scan): %s", path.name, exc) continue await self._run_fs(self._after_processing, path) + disposed += 1 if self.after_read == "leave" and file_key is not None: # Record AFTER emit success (the FILE — not each split message — is the dedup unit), so a # partial-emit crash re-reads and re-emits the whole file (at-least-once), never dropping. @@ -653,6 +674,39 @@ async def _scan_once(self) -> None: # read-only share (nothing new) never churns the store. await self.processed_ledger.prune() + def _at_ceiling(self, disposed: int, remaining: int) -> bool: + """True when this scan has spent its per-tick budget (``poll_max_files``) and must stop, leaving + ``remaining`` candidates for the next scan. + + **Nothing is dropped.** A file this scan does not reach is still in the drop directory, so the + next scan takes it — the same at-least-once deferral a transient read failure already produces. + No message was received, so there is no disposition to record and the count-and-log invariant is + untouched. + + **What charges the budget, and why the exceptions are not an oversight.** Only a file this scan + FINISHED with charges: one handed to the pipeline, or one quarantined to ``.error`` (oversize, + a failed gunzip, a content-vs-type mismatch, a scanner rejection). Each of those leaves the + candidate set, so the next scan starts on new work. The arms that leave a file **in place** to be + retried — a locked/vanished file, a malfunctioning scan hook, a handler failure — deliberately do + NOT charge. If they did, a permanently stuck file that sorts early would eat the whole budget on + every scan and the healthy files behind it would never be ingested. A budget can only be charged + by something that makes progress. + + This bounds the INGEST, not the listing: ``_candidates`` still globs and sorts the whole + directory, because picking the first N in name/mtime order requires seeing all of them. The + per-file cost the ceiling removes is the read, the scan hook, the pipeline hand-off and the + durable commit — not the stat.""" + if self.poll_max_files is None or disposed < self.poll_max_files: + return False + logger.info( + "file source %s reached poll_max_files (%s) this scan; %d candidate(s) left for the next " + "poll (deferred, not dropped)", + self.directory, + self.poll_max_files, + remaining, + ) + return True + def _file_key(self, path: Path) -> str: """A stable, HASHED identity for a source file, for the leave-in-place dedup ledger (#142). diff --git a/messagefoundry/transports/remotefile.py b/messagefoundry/transports/remotefile.py index 150f8d69b..bb749faa7 100644 --- a/messagefoundry/transports/remotefile.py +++ b/messagefoundry/transports/remotefile.py @@ -77,6 +77,7 @@ ) from messagefoundry.controlchars import has_control_char from messagefoundry.transports.base import ( + DEFAULT_MAX_ITEMS_PER_POLL, DeliveryError, DestinationConnector, DestinationStartupError, @@ -86,6 +87,7 @@ SourceStartupError, register_destination, register_source, + resolve_poll_ceiling, ) from messagefoundry.transports.file import ( DEFAULT_MAX_FILE_BYTES, @@ -1008,6 +1010,15 @@ def __init__(self, config: Source) -> None: self._validate_directory: bool = bool(s.get("validate_directory", False)) mfb = s.get("max_file_bytes", DEFAULT_MAX_FILE_BYTES) self._max_file_bytes: int | None = int(mfb) if mfb else None + # Per-tick intake ceiling, SHIPPED ON (DEFAULT_MAX_ITEMS_PER_POLL — the number and the reason a + # poll source may default this on are stated once, in transports/base.py). Caps how many files + # ONE poll disposes of; the rest stay on the remote share and the next poll takes them. A falsy + # value (None/0) disables the cap, matching max_file_bytes above. + self._poll_max_files: int | None = resolve_poll_ceiling( + s.get("poll_max_files", DEFAULT_MAX_ITEMS_PER_POLL), + knob="poll_max_files", + transport="REMOTEFILE source", + ) self._processed_dir = posixpath.join( self._remote_dir, s.get("processed_subdir", ".processed") ) @@ -1119,9 +1130,13 @@ async def _poll_once(self) -> None: await asyncio.to_thread(self._client.ensure_dir, self._error_dir) entries = await asyncio.to_thread(self._client.list_dir, self._remote_dir) newly_recorded = 0 # #142: files marked processed THIS poll — gates one end-of-poll prune - for name, size in sorted(entries): + listing = sorted(entries) + disposed = 0 # files this poll finished with — the per-tick ceiling's budget (_at_ceiling) + for position, (name, size) in enumerate(listing): if self._stop.is_set(): break # shutting down — leave the rest for the next start (at-least-once) + if self._at_ceiling(disposed, len(listing) - position): + break if not _is_contained_name(name): # #1238 (ASVS 5.3.2): the server chose this name. Refuse it HERE — at the source, # before the pattern filter — because the raw name reaches at least four consumers @@ -1160,6 +1175,7 @@ async def _poll_once(self) -> None: self._max_file_bytes, ) await self._move(path, self._error_dir, name) + disposed += 1 continue try: raw = await asyncio.to_thread( @@ -1177,6 +1193,7 @@ async def _poll_once(self) -> None: self._max_file_bytes, ) await self._move(path, self._error_dir, name) + disposed += 1 continue except _RemoteError as exc: # Transient (locked / vanished mid-poll): leave it in place to retry next poll rather @@ -1203,6 +1220,7 @@ async def _poll_once(self) -> None: (self.content_type or ContentType.HL7V2).value, ) await self._move(path, self._error_dir, name) + disposed += 1 continue try: await asyncio.to_thread(scan_inbound_file, raw, name) @@ -1217,6 +1235,7 @@ async def _poll_once(self) -> None: exc, ) await self._move(path, self._error_dir, name) + disposed += 1 continue except Exception as exc: # noqa: BLE001 - operator scan hook: any failure fails closed # The scan hook MALFUNCTIONED (AV/ICAP unreachable, a plugin bug) — NOT a content @@ -1243,6 +1262,7 @@ async def _poll_once(self) -> None: ) continue await self._after_processing(path, name) + disposed += 1 if file_key is not None: # Record AFTER emit success (the FILE — not each split message — is the dedup unit). await self._leave_record(file_key) @@ -1252,6 +1272,35 @@ async def _poll_once(self) -> None: self.processed_ledger.prune() ) # bound growth (age + count), only when something new + def _at_ceiling(self, disposed: int, remaining: int) -> bool: + """True when this poll has spent its per-tick budget (``poll_max_files``) and must stop, leaving + ``remaining`` listing entries for the next poll. + + **Nothing is dropped.** A file this poll does not reach is still on the share, so the next poll + takes it — the same at-least-once deferral a transient retrieve failure already produces. No + message was received, so there is no disposition to record. + + **What charges the budget.** Only a file this poll FINISHED with: one handed to the pipeline, or + one quarantined to ``error_subdir`` (over the listed size, over the retrieved size, a + content-vs-type mismatch, a scanner rejection). Those leave the poll directory, so the next poll + starts on new work. The arms that leave a file **in place** for a later retry — a refused unsafe + listing name, a transient retrieve failure, a malfunctioning scan hook, a handler failure — do + NOT charge, because a stuck file that sorts early would otherwise eat the whole budget every + poll and starve the healthy files behind it. This mirrors + :meth:`~messagefoundry.transports.file.FileSource._at_ceiling`, which states the rule in full. + + PHI-safe: the log names the redacted host/dir and two counts, never a filename.""" + if self._poll_max_files is None or disposed < self._poll_max_files: + return False + logger.info( + "REMOTEFILE source %s reached poll_max_files (%s) this poll; %d listing entr(ies) left for " + "the next poll (deferred, not dropped)", + _redact(self._host, self._remote_dir), + self._poll_max_files, + remaining, + ) + return True + def _file_key(self, name: str, size: int) -> str: """A stable, HASHED identity for a remote source file, for the leave-in-place dedup ledger (#142). SHA-256 over the file's FULL REMOTE PATH (``remote_dir``/``name``) + size — folding the diff --git a/tests/test_database_cursor_close.py b/tests/test_database_cursor_close.py index 7f050807b..01fc7f79f 100644 --- a/tests/test_database_cursor_close.py +++ b/tests/test_database_cursor_close.py @@ -44,6 +44,11 @@ async def execute(self, *_a: Any, **_k: Any) -> None: async def fetchall(self) -> list[Any]: return [] + async def fetchmany(self, _size: int) -> list[Any]: + # The poll path fetches through here whenever `poll_max_rows` is on, which is the shipped + # default; the close-before-release invariant has to hold on the path the engine takes. + return [] + async def close(self) -> None: self._log.append(f"close:{self._tag}") @@ -95,6 +100,7 @@ async def test_source_select_closes_its_cursor_before_release() -> None: src._get_pool = lambda: _pool_coro(pool) # type: ignore[method-assign,assignment] src._acquire_timeout = 5.0 # type: ignore[attr-defined] src._poll_sql = "SELECT 1" # type: ignore[attr-defined] + src._poll_max_rows = db.DEFAULT_MAX_ITEMS_PER_POLL # type: ignore[attr-defined] await src._select() assert "close:select" in log, f"the poll cursor was never closed; log={log}" diff --git a/tests/test_database_transport.py b/tests/test_database_transport.py index e7803172a..e9aee9f23 100644 --- a/tests/test_database_transport.py +++ b/tests/test_database_transport.py @@ -627,6 +627,7 @@ def __init__( self._rows = rows self._poll_exc = poll_exc self._mark_exc = mark_exc + self._position = 0 # DB-API cursor position, so fetchmany/fetchall consume the same set self.marks: list[tuple[str, tuple[Any, ...]]] = [] async def execute(self, sql: str, params: tuple[Any, ...] | None = None) -> None: @@ -639,7 +640,19 @@ async def execute(self, sql: str, params: tuple[Any, ...] | None = None) -> None raise self._mark_exc async def fetchall(self) -> list[tuple[Any, ...]]: - return list(self._rows) + rows = self._rows[self._position :] + self._position = len(self._rows) + return list(rows) + + async def fetchmany(self, size: int) -> list[tuple[Any, ...]]: + """DB-API 2.0 ``fetchmany``: at most ``size`` rows from the current position, advancing it. + + The source's poll path fetches through here whenever ``poll_max_rows`` is on (the shipped + default), so a fake carrying only ``fetchall`` would model a cursor the connector no longer + uses.""" + rows = self._rows[self._position : self._position + size] + self._position += len(rows) + return list(rows) class _SrcConn: diff --git a/tests/test_poll_source_tick_ceilings.py b/tests/test_poll_source_tick_ceilings.py new file mode 100644 index 000000000..a6002e186 --- /dev/null +++ b/tests/test_poll_source_tick_ceilings.py @@ -0,0 +1,591 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Per-tick intake ceilings on the three POLL sources: FILE, REMOTEFILE and DATABASE. + +Each source now takes at most ``DEFAULT_MAX_ITEMS_PER_POLL`` items per tick and leaves the rest where +they are. The ceiling **ships on**, which is safe here and only here: on a poll source it is a +**deferral**, not a drop — an unread file stays in the drop directory and an unfetched row stays in the +table, so the next tick takes it. Nothing is quarantined, errored or unaccounted for, so the +count-and-log invariant is untouched. + +**Every volume test asserts on the LEFTOVERS, not only on the count.** A ceiling that discarded its +overflow would satisfy "exactly N were handled this tick" and be far worse than no ceiling at all, so +each source is also polled a second time and the remainder must arrive intact. + +This file does not claim any ASVS cell moves. The three ceilings are buildable on their own merits; +the acts that could re-grade 2.4.1 are the owner's. +""" + +from __future__ import annotations + +import logging +import posixpath +from pathlib import Path +from typing import Any + +import pytest + +from messagefoundry.config.models import ConnectorType, Source +from messagefoundry.config.wiring import DatabasePoll, File, Ftp, Sftp +from messagefoundry.transports import build_source, remotefile +from messagefoundry.transports.base import DEFAULT_MAX_ITEMS_PER_POLL +from messagefoundry.transports.database import DatabaseSource +from messagefoundry.transports.file import FileSource +from messagefoundry.transports.remotefile import RemoteFileSource, _RemoteClient, _RemoteError + +_FILE_LOGGER = "messagefoundry.transports.file" +_REMOTE_LOGGER = "messagefoundry.transports.remotefile" +_DB_LOGGER = "messagefoundry.transports.database" + +#: A minimal conformant message: the sources sniff the leading bytes against the declared +#: content_type (None → hl7v2), so a body without an MSH would be quarantined before the ceiling +#: could be measured. +_ADT = "MSH|^~\\&|SEND|FAC|RECV|FAC|20260101||ADT^A01|{n}|P|2.5" + + +class _RecordingHandler: + def __init__(self) -> None: + self.bodies: list[bytes] = [] + + async def __call__(self, raw: bytes) -> str | None: + self.bodies.append(raw) + return None + + +# === FILE ===================================================================== + + +def _file_source(directory: Path, **over: Any) -> FileSource: + settings: dict[str, Any] = {"directory": str(directory)} + settings.update(over) + src = build_source(Source(type=ConnectorType.FILE, settings=settings)) + assert isinstance(src, FileSource) + src._prepare_subdirs() # .processed/.error, which start() would otherwise create + return src + + +def _drop(directory: Path, count: int, *, first: int = 0) -> None: + """Write ``count`` numbered HL7 files. Zero-padded so name order is numeric order, which is the + source's default ``sort`` — a test that could not predict the order could not name the leftovers.""" + for n in range(first, first + count): + (directory / f"m{n:03d}.hl7").write_text(_ADT.format(n=n), encoding="utf-8") + + +def _pending(directory: Path) -> list[str]: + """Names still waiting in the poll directory (the archive/quarantine subdirs are not candidates).""" + return sorted(p.name for p in directory.iterdir() if p.is_file()) + + +async def test_file_scan_stops_at_the_ceiling_and_leaves_the_rest_in_place( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """One scan hands off exactly the ceiling and the remaining files are STILL in the drop directory. + + Red mutation: delete the ``_at_ceiling`` break in ``_scan_once`` — all five files are emitted in one + scan and the two leftovers are gone from the poll directory. Asserting only on the hand-off count + would also pass a ceiling that deleted or quarantined the overflow, which is why the leftovers are + named here.""" + from messagefoundry.transports import file as file_mod + + monkeypatch.setattr(file_mod, "DEFAULT_MAX_ITEMS_PER_POLL", 3) + inbox = tmp_path / "in" + inbox.mkdir() + _drop(inbox, 5) + src = _file_source(inbox) # no operator configuration at all — the shipped default applies + handler = _RecordingHandler() + src._handler = handler + with caplog.at_level(logging.INFO, logger=_FILE_LOGGER): + await src._scan_once() + assert len(handler.bodies) == 3 + assert _pending(inbox) == ["m003.hl7", "m004.hl7"] # deferred, still on disk, untouched + assert sorted(p.name for p in (inbox / ".processed").iterdir()) == [ + "m000.hl7", + "m001.hl7", + "m002.hl7", + ] + assert list((inbox / ".error").iterdir()) == [] # a deferral is not a quarantine + assert "reached poll_max_files" in caplog.text + assert "2 candidate(s) left" in caplog.text + + +async def test_file_second_scan_drains_the_deferred_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The next scan picks up what the first one left, so every file is ingested exactly once. + + Red mutation: make the ceiling drop its overflow (quarantine or unlink the untouched candidates at + the break). The first scan still reports three hand-offs, and only this test goes red — the second + scan finds nothing and the last two messages never arrive.""" + from messagefoundry.transports import file as file_mod + + monkeypatch.setattr(file_mod, "DEFAULT_MAX_ITEMS_PER_POLL", 3) + inbox = tmp_path / "in" + inbox.mkdir() + _drop(inbox, 5) + src = _file_source(inbox) + handler = _RecordingHandler() + src._handler = handler + await src._scan_once() + await src._scan_once() + assert len(handler.bodies) == 5 # every file, once — nothing dropped, nothing duplicated + assert [b.decode() for b in handler.bodies] == [_ADT.format(n=n) for n in range(5)] + assert _pending(inbox) == [] # fully drained by the second scan + + +async def test_file_scan_below_the_ceiling_is_unchanged( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Negative control: a scan whose work fits under the ceiling behaves exactly as before it existed. + + Three files against a ceiling of three is the boundary, so an off-by-one (``>`` for ``>=``, or a + budget charged on candidates examined rather than files disposed of) stops the scan early and reds + this test. No ceiling log line is emitted when nothing was deferred.""" + from messagefoundry.transports import file as file_mod + + monkeypatch.setattr(file_mod, "DEFAULT_MAX_ITEMS_PER_POLL", 3) + inbox = tmp_path / "in" + inbox.mkdir() + _drop(inbox, 3) + src = _file_source(inbox) + handler = _RecordingHandler() + src._handler = handler + with caplog.at_level(logging.INFO, logger=_FILE_LOGGER): + await src._scan_once() + assert len(handler.bodies) == 3 + assert _pending(inbox) == [] + assert "poll_max_files" not in caplog.text + + +async def test_file_ceiling_is_on_by_default_and_operator_overridable(tmp_path: Path) -> None: + """The shipped default is ON at ``DEFAULT_MAX_ITEMS_PER_POLL``, with no setting anywhere; a falsy + ``poll_max_files`` is the documented opt-out. + + Red mutation: default the knob to ``None`` (off) — the first assertion reds. This is the assertion + that would catch the ceiling quietly becoming opt-in, which is the failure mode the shipped-on + argument exists to prevent. The ``File()`` assertion is a drift guard: the factory has to repeat the + number as a literal (``config/`` cannot import ``transports/``), so the two can diverge silently.""" + inbox = tmp_path / "in" + inbox.mkdir() + assert DEFAULT_MAX_ITEMS_PER_POLL == 500 + assert File(directory=str(inbox)).settings["poll_max_files"] == DEFAULT_MAX_ITEMS_PER_POLL + assert _file_source(inbox).poll_max_files == DEFAULT_MAX_ITEMS_PER_POLL + assert _file_source(inbox, poll_max_files=25).poll_max_files == 25 + assert _file_source(inbox, poll_max_files=0).poll_max_files is None # explicit unlimited + + +async def test_file_unlimited_scan_takes_everything( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``poll_max_files=0`` restores the unbounded scan, so the opt-out is a real opt-out. + + Red mutation: treat a falsy value as "use the default" — five files against a ceiling of three + leaves two behind and this test reds.""" + from messagefoundry.transports import file as file_mod + + monkeypatch.setattr(file_mod, "DEFAULT_MAX_ITEMS_PER_POLL", 3) + inbox = tmp_path / "in" + inbox.mkdir() + _drop(inbox, 5) + src = _file_source(inbox, poll_max_files=0) + handler = _RecordingHandler() + src._handler = handler + await src._scan_once() + assert len(handler.bodies) == 5 + assert _pending(inbox) == [] + + +async def test_file_stuck_files_do_not_charge_the_ceiling( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Fair progress: a file left in place for a later retry must not spend the budget. + + Two unreadable files sort ahead of two healthy ones. The unreadable arm leaves them in the + directory, so if it charged the budget it would spend the whole ceiling on the same two files every + scan and the healthy ones behind them would never be ingested. + + Red mutation: add ``disposed += 1`` to the transient-read arm of ``_scan_once`` — the ceiling of two + is spent on the locked files and ``handler.bodies`` is empty.""" + from messagefoundry.transports import file as file_mod + + monkeypatch.setattr(file_mod, "DEFAULT_MAX_ITEMS_PER_POLL", 2) + inbox = tmp_path / "in" + inbox.mkdir() + (inbox / "a_locked1.hl7").write_text(_ADT.format(n=1), encoding="utf-8") + (inbox / "a_locked2.hl7").write_text(_ADT.format(n=2), encoding="utf-8") + (inbox / "b_good1.hl7").write_text(_ADT.format(n=3), encoding="utf-8") + (inbox / "b_good2.hl7").write_text(_ADT.format(n=4), encoding="utf-8") + real_read = Path.read_bytes + + def read_bytes(self: Path) -> bytes: + if self.name.startswith("a_locked"): + raise OSError("locked by another process") + return real_read(self) + + monkeypatch.setattr(Path, "read_bytes", read_bytes) + src = _file_source(inbox) + handler = _RecordingHandler() + src._handler = handler + await src._scan_once() + assert [b.decode() for b in handler.bodies] == [_ADT.format(n=3), _ADT.format(n=4)] + assert _pending(inbox) == ["a_locked1.hl7", "a_locked2.hl7"] # still there, still retryable + + +# === REMOTEFILE =============================================================== + + +class _FakeRemoteClient(_RemoteClient): + """In-memory SFTP/FTP stand-in: enough of the client contract for the poll path (list, retrieve, + rename, remove, ensure_dir). Files live in one flat ``{path: bytes}`` map, so a moved file is + visible under its new directory and a leftover is visible under the poll directory.""" + + def __init__(self, files: dict[str, bytes]) -> None: + self.files = dict(files) + + def list_dir(self, remote_dir: str) -> list[tuple[str, int]]: + return [ + (posixpath.basename(path), len(data)) + for path, data in self.files.items() + if posixpath.dirname(path) == remote_dir + ] + + def retrieve(self, path: str, *, max_bytes: int | None = None) -> bytes: + try: + return self.files[path] + except KeyError: + raise _RemoteError(f"no such file: {path}", permanent=True) from None + + def store(self, path: str, data: bytes) -> None: + self.files[path] = data + + def rename(self, src: str, dst: str) -> None: + self.files[dst] = self.files.pop(src) + + def remove(self, path: str) -> None: + self.files.pop(path, None) + + def ensure_dir(self, remote_dir: str) -> bool: + return False + + +def _remote_source( + monkeypatch: pytest.MonkeyPatch, client: _FakeRemoteClient, **over: Any +) -> RemoteFileSource: + monkeypatch.setattr(remotefile, "_make_client", lambda settings, **_: client) + base: dict[str, Any] = {"host": "sftp.example.com", "remote_dir": "/in", "pattern": "*.hl7"} + base.update(over) + settings = dict(Sftp(**base).settings) + if "poll_max_files" not in over: + # The factory writes its OWN default into every settings dict, so leaving the key in place + # would test the wiring literal rather than the connector's shipped default. Dropping it is + # what a connections.toml table that never mentions the knob looks like — and the two defaults + # are pinned equal by test_remote_ceiling_is_on_by_default_and_operator_overridable. + settings.pop("poll_max_files", None) + src = build_source(Source(type=ConnectorType.REMOTEFILE, settings=settings)) + assert isinstance(src, RemoteFileSource) + return src + + +def _remote_files(count: int) -> dict[str, bytes]: + return {f"/in/m{n:03d}.hl7": _ADT.format(n=n).encode() for n in range(count)} + + +def _remote_pending(client: _FakeRemoteClient) -> list[str]: + return sorted(posixpath.basename(p) for p in client.files if posixpath.dirname(p) == "/in") + + +async def test_remote_poll_stops_at_the_ceiling_and_leaves_the_rest_on_the_share( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """One poll retrieves exactly the ceiling; the rest are still on the remote share afterwards. + + Red mutation: delete the ``_at_ceiling`` break in ``_poll_once`` — all five are retrieved and the + two leftovers move into ``.processed``, so both the count and the share listing change.""" + monkeypatch.setattr(remotefile, "DEFAULT_MAX_ITEMS_PER_POLL", 3) + client = _FakeRemoteClient(_remote_files(5)) + src = _remote_source(monkeypatch, client) # no operator configuration — the shipped default + handler = _RecordingHandler() + src._handler = handler + with caplog.at_level(logging.INFO, logger=_REMOTE_LOGGER): + await src._poll_once() + assert len(handler.bodies) == 3 + assert _remote_pending(client) == ["m003.hl7", "m004.hl7"] # deferred, untouched on the share + assert "/in/.processed/m000.hl7" in client.files + assert not [p for p in client.files if p.startswith("/in/.error/")] + assert "reached poll_max_files" in caplog.text + + +async def test_remote_second_poll_drains_the_deferred_files( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The next poll takes what the first left, so every remote file is ingested exactly once. + + Red mutation: quarantine or remove the unreached listing entries at the break — the first poll's + count is unchanged and only this test reds.""" + monkeypatch.setattr(remotefile, "DEFAULT_MAX_ITEMS_PER_POLL", 3) + client = _FakeRemoteClient(_remote_files(5)) + src = _remote_source(monkeypatch, client) + handler = _RecordingHandler() + src._handler = handler + await src._poll_once() + await src._poll_once() + assert [b.decode() for b in handler.bodies] == [_ADT.format(n=n) for n in range(5)] + assert _remote_pending(client) == [] + + +async def test_remote_poll_below_the_ceiling_is_unchanged( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Negative control at the boundary: three files against a ceiling of three are all ingested and + nothing is logged as deferred. + + Red mutation: an off-by-one at the comparison (``disposed >= ceiling - 1``, or charging the budget + before the file is disposed of) stops the poll one file short.""" + monkeypatch.setattr(remotefile, "DEFAULT_MAX_ITEMS_PER_POLL", 3) + client = _FakeRemoteClient(_remote_files(3)) + src = _remote_source(monkeypatch, client) + handler = _RecordingHandler() + src._handler = handler + with caplog.at_level(logging.INFO, logger=_REMOTE_LOGGER): + await src._poll_once() + assert len(handler.bodies) == 3 + assert _remote_pending(client) == [] + assert "poll_max_files" not in caplog.text + + +async def test_remote_ceiling_is_on_by_default_and_operator_overridable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Shipped on at ``DEFAULT_MAX_ITEMS_PER_POLL`` with no setting; a falsy value opts out. + + Red mutation: default the knob to ``None`` — the first assertion reds. Both remote factories are + pinned against the constant because each repeats the number as a literal.""" + for factory in (Sftp, Ftp): + settings = factory(host="sftp.example.com", remote_dir="/in").settings + assert settings["poll_max_files"] == DEFAULT_MAX_ITEMS_PER_POLL + client = _FakeRemoteClient({}) + assert _remote_source(monkeypatch, client)._poll_max_files == DEFAULT_MAX_ITEMS_PER_POLL + assert _remote_source(monkeypatch, client, poll_max_files=25)._poll_max_files == 25 + assert _remote_source(monkeypatch, client, poll_max_files=0)._poll_max_files is None + + +async def test_remote_refused_listing_name_does_not_charge_the_ceiling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Fair progress: an entry refused as an unsafe path component is left in place forever, so it must + never spend the budget. + + Red mutation: charge the budget on the ``_is_contained_name`` refusal — the two refused entries eat + a ceiling of two on every poll and the healthy files behind them are never ingested.""" + monkeypatch.setattr(remotefile, "DEFAULT_MAX_ITEMS_PER_POLL", 2) + files = _remote_files(2) + client = _FakeRemoteClient(files) + # Two hostile listing entries that sort ahead of the healthy ones. They are refused at the source + # and deliberately NOT quarantined (joining a hostile name onto a directory is the refused act). + monkeypatch.setattr( + client, + "list_dir", + lambda remote_dir: [ + ("../escape.hl7", 4), + ("also/bad.hl7", 4), + *_FakeRemoteClient.list_dir(client, remote_dir), + ], + ) + src = _remote_source(monkeypatch, client) + handler = _RecordingHandler() + src._handler = handler + await src._poll_once() + assert [b.decode() for b in handler.bodies] == [_ADT.format(n=0), _ADT.format(n=1)] + + +# === DATABASE ================================================================= + + +class _FakeTable: + """A poll table. ``mark`` deletes a row, which is the shape ``mark_statement`` is documented to + have, and it is what makes a deferral drain: an unmarked row is still selected by the next poll.""" + + def __init__(self, count: int) -> None: + self.rows: list[tuple[int, str]] = [(n, _ADT.format(n=n)) for n in range(count)] + + def mark(self, row_id: int) -> None: + self.rows = [row for row in self.rows if row[0] != row_id] + + +class _FakeCursor: + description = [("id",), ("payload",)] + + def __init__(self, table: _FakeTable, fetches: list[tuple[str, int | None]]) -> None: + self._table = table + self._buffer: list[tuple[int, str]] = [] + self._position = 0 + self._fetches = fetches + + async def execute(self, sql: str, params: tuple[Any, ...] | None = None) -> None: + if params is None: # the poll SELECT + self._buffer = list(self._table.rows) + self._position = 0 + else: # a per-row mark + self._table.mark(params[0]) + + async def fetchall(self) -> list[tuple[int, str]]: + self._fetches.append(("fetchall", None)) + rows = self._buffer[self._position :] + self._position = len(self._buffer) + return rows + + async def fetchmany(self, size: int) -> list[tuple[int, str]]: + self._fetches.append(("fetchmany", size)) + rows = self._buffer[self._position : self._position + size] + self._position += len(rows) + return rows + + async def close(self) -> None: + return None + + +class _FakeConn: + def __init__(self, table: _FakeTable, fetches: list[tuple[str, int | None]]) -> None: + self._table = table + self._fetches = fetches + + async def cursor(self) -> _FakeCursor: + return _FakeCursor(self._table, self._fetches) + + +class _FakePool: + def __init__(self, conn: _FakeConn) -> None: + self._conn = conn + + async def acquire(self) -> _FakeConn: + return self._conn + + async def release(self, conn: _FakeConn) -> None: + return None + + +def _db_source(**over: Any) -> DatabaseSource: + base: dict[str, Any] = { + "server": "sql.example.com", + "database": "MFDB", + "poll_statement": "SELECT id, payload FROM mf_inbox WHERE status='NEW' ORDER BY id", + "mark_statement": "UPDATE mf_inbox SET status='DONE' WHERE id=:id", + "body_column": "payload", + } + base.update(over) + src = build_source(Source(type=ConnectorType.DATABASE, settings=DatabasePoll(**base).settings)) + assert isinstance(src, DatabaseSource) + return src + + +def _attach(src: DatabaseSource, table: _FakeTable) -> list[tuple[str, int | None]]: + fetches: list[tuple[str, int | None]] = [] + src._pool = _FakePool(_FakeConn(table, fetches)) + return fetches + + +async def test_db_poll_stops_at_the_shipped_ceiling_and_leaves_the_rest_in_the_table( + caplog: pytest.LogCaptureFixture, +) -> None: + """One poll takes exactly the SHIPPED 500 rows and the surplus row is still in the table, unmarked. + + This one runs at the real default rather than a patched one, so the number the engine ships is the + number under test on at least one source. + + Red mutation: restore the unbounded ``fetchall`` in ``_select`` — all 501 rows are handed off and + the table empties, so both the count and the leftover assertion red.""" + table = _FakeTable(DEFAULT_MAX_ITEMS_PER_POLL + 1) + src = _db_source() # no operator configuration at all — the shipped default applies + fetches = _attach(src, table) + handler = _RecordingHandler() + src._handler = handler + with caplog.at_level(logging.INFO, logger=_DB_LOGGER): + await src._poll_once() + assert len(handler.bodies) == DEFAULT_MAX_ITEMS_PER_POLL + assert table.rows == [(500, _ADT.format(n=500))] # deferred, unmarked, still selectable + # The ceiling is charged at the FETCH: the driver is asked for the ceiling plus one probe row, + # never for the whole result set. + assert fetches == [("fetchmany", DEFAULT_MAX_ITEMS_PER_POLL + 1)] + assert "reached poll_max_rows" in caplog.text + + +async def test_db_second_poll_drains_the_deferred_rows() -> None: + """The next poll takes the rows the first one left, so every row is handed off exactly once. + + Red mutation: mark or delete the rows past the ceiling at the fetch — the first poll's count is + unchanged and only this test reds, with the surplus row's body never arriving.""" + table = _FakeTable(DEFAULT_MAX_ITEMS_PER_POLL + 1) + src = _db_source() + _attach(src, table) + handler = _RecordingHandler() + src._handler = handler + await src._poll_once() + await src._poll_once() + assert len(handler.bodies) == DEFAULT_MAX_ITEMS_PER_POLL + 1 + assert handler.bodies[-1].decode() == _ADT.format(n=500) + assert table.rows == [] + + +async def test_db_poll_below_the_ceiling_is_unchanged(caplog: pytest.LogCaptureFixture) -> None: + """Negative control: a result set under the ceiling is handled exactly as before, with no deferral + log line. + + Red mutation: fetch ``poll_max_rows - 1``, or log the ceiling unconditionally — either reds here + while the over-ceiling tests stay green.""" + table = _FakeTable(3) + src = _db_source(poll_max_rows=3) + fetches = _attach(src, table) + handler = _RecordingHandler() + src._handler = handler + with caplog.at_level(logging.INFO, logger=_DB_LOGGER): + await src._poll_once() + assert [b.decode() for b in handler.bodies] == [_ADT.format(n=n) for n in range(3)] + assert table.rows == [] # all three marked + assert fetches == [("fetchmany", 4)] + assert "poll_max_rows" not in caplog.text + + +async def test_db_ceiling_is_on_by_default_and_the_opt_out_restores_fetchall() -> None: + """Shipped on at ``DEFAULT_MAX_ITEMS_PER_POLL`` with no setting; ``poll_max_rows=0`` restores the + unbounded ``fetchall``. + + Red mutation: default the knob to ``None`` — the first assertion reds. Second red mutation: keep + using ``fetchmany`` when the knob is falsy, and the recorded fetch call names it.""" + factory_default = DatabasePoll( + server="sql.example.com", database="MFDB", poll_statement="SELECT 1" + ).settings["poll_max_rows"] + assert factory_default == DEFAULT_MAX_ITEMS_PER_POLL # the factory repeats it as a literal + assert _db_source()._poll_max_rows == DEFAULT_MAX_ITEMS_PER_POLL + assert _db_source(poll_max_rows=25)._poll_max_rows == 25 + src = _db_source(poll_max_rows=0) + assert src._poll_max_rows is None + table = _FakeTable(7) + fetches = _attach(src, table) + handler = _RecordingHandler() + src._handler = handler + await src._poll_once() + assert len(handler.bodies) == 7 + assert fetches == [("fetchall", None)] + + +# === all three poll sources =================================================== + + +async def test_a_negative_ceiling_is_refused_at_build_on_every_poll_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A negative ceiling is a build error, not a running connection that ingests nothing. + + Accepted, ``poll_max_files=-1`` would make ``_at_ceiling`` true on the first candidate of every + tick: the source would report running and take nothing, for ever. That is the worst outcome this + control can produce, so a typo is refused where a bad ``after_read`` is — at wiring, before start. + + Red mutation: replace ``resolve_poll_ceiling`` with ``int(value) if value else None`` — no build + raises, and this test reds three times.""" + inbox = tmp_path / "in" + inbox.mkdir() + with pytest.raises(ValueError, match="positive number of items per poll"): + _file_source(inbox, poll_max_files=-1) + with pytest.raises(ValueError, match="positive number of items per poll"): + _remote_source(monkeypatch, _FakeRemoteClient({}), poll_max_files=-1) + with pytest.raises(ValueError, match="positive number of items per poll"): + _db_source(poll_max_rows=-1) From 02b33cb5b31ed47de58d8ff6739e0e785c7a31e7 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 14:41:40 -0500 Subject: [PATCH 10/17] feat(auth): rotate the session token at all five elevation sites (BACKLOG #1146) ASVS 7.2.4 asks for a new session token on authentication INCLUDING re-authentication, with the current one terminated. Initial authentication minted fresh; re-authentication did not. Five sites stamped elevation onto the SAME token hash -- reauth, verify_mfa, confirm_mfa_enrollment, finish_webauthn_registration, finish_webauthn_assertion -- so on a first deployment a pre-MFA token captured before the second factor would be elevated in place to a fully authenticated session. The primitive existed and had zero production callers. It is not a toolkit wired for effect: rotate_session updates the row keyed on the old hash and returns its rowcount, so the old hash stops resolving in the same transaction that mints the new one and the verb's terminate limb comes free. THE ORDERING IS THE WHOLE CONTROL, and it lives in ONE place. Every session UPDATE except revoke_session and rotate_session is rowcount-blind, so a stamp issued after a rotation writes nothing and reports success. One private _elevated() is the sole caller of _rotate_session_token, so the rule is stated once rather than in nine route handlers. Both site-specific traps are handled: in reauth, _factor_binding_is_blocked resolves by the OLD token and fails closed, so it is decided before the rotation while the action grant is minted after against the new hash; in verify_mfa the whole three-write group lands first. Fails closed. A rotation on a vanished or revoked session returns session_lost, which the routes map to 401 rather than the 403 a wrong proof gets -- a correct password must not be reported as incorrect because the session died mid-ceremony. Both outcomes are audited; the failure row is the more interesting one. THE CONSOLE WAS THE SHARP SURFACE. /ui/reauth can rotate TWICE in one request, so the local token is rebound between them and the cookie is stamped through a helper taking the token as an argument, precisely because the variable moves mid-handler. The cookie is re-set on every post-elevation path INCLUDING the error exits -- a correct code followed by a wrong password rotates once, and without that the browser would be stranded on a dead cookie mid-ceremony. The apiclient now shares a token cell by reference instead of copying it into for_polling's clone, and that docstring's justification is rewritten rather than left asserting something rotation made false. A response with no usable token leaves the current one alone: a version skew must not become a sign-out. NOT DONE ON THE CHEAP SUBSET. A 2026-07-25 owner ruling names two JSON routes, and building precisely those would have looked like building to the owner's own words while leaving the three legs that turn an MFA-pending session into an MFA-satisfied one un-rotated -- and for POST /ui/mfa the passkey assertion is the ONLY leg, so the cell would have read "rotates on re-authentication" while missing the passkey path entirely. The pass rests on behaviour, not on the marker. The absence claim keys on the primitive's own call pattern, so wiring it for effect would flip the marker and change nothing. tests/test_session_rotation_wiring.py asserts a pre-elevation token stops authenticating at the moment of elevation, one case per site. The negative control was mutation-checked: making reauth rotate unconditionally reds test_a_failed_elevation_rotates_nothing[reauth] and nothing else, so it can fail. WEBSOCKET, reasoned and recorded in _elevated's docstring rather than left to a report: a rotation would drop an open /ws/stats socket at the next revalidation tick, since the keepalive re-validates the token captured at handshake. That is fail-closed and correct. app.js wires ws.onclose to resume the HTTP poll, which carries the new cookie, so completing MFA would cost the live push for the rest of that page's life -- a liveness regression, not correctness or data loss. That is why the bounded reconnect is deferred rather than built. Also repaired: the test file cited a BACKLOG number that cannot resolve from a public checkout. Replaced with the subject, per docs/LEDGER-GATE.md. No number invented. Deferred, named: IDE sign-in supersession, login supersession on the three console cookie-minting legs, written rationales for both bearer login legs, the console self-session revoke identifier, and the bounded WebSocket reconnect. Co-Authored-By: Claude Opus 5 --- messagefoundry/api/auth_models.py | 19 +- messagefoundry/api/auth_routes.py | 71 +++- messagefoundry/apiclient/client.py | 94 ++++- messagefoundry/auth/service.py | 244 ++++++++++--- messagefoundry_webconsole/routes/account.py | 32 +- messagefoundry_webconsole/routes/core.py | 87 ++++- .../tests/test_ui_mfa_gate.py | 65 +++- tests/test_admin_new_ip.py | 17 +- tests/test_api_auth.py | 148 +++++--- tests/test_apiclient.py | 46 ++- tests/test_mfa.py | 72 ++-- tests/test_mfa_access_gate.py | 21 +- tests/test_session_rotation_primitive.py | 2 +- tests/test_session_rotation_wiring.py | 330 ++++++++++++++++++ tests/test_step_up.py | 64 +++- tests/test_webauthn.py | 104 +++--- 16 files changed, 1165 insertions(+), 251 deletions(-) create mode 100644 tests/test_session_rotation_wiring.py diff --git a/messagefoundry/api/auth_models.py b/messagefoundry/api/auth_models.py index 6712d863d..eb1b6c03f 100644 --- a/messagefoundry/api/auth_models.py +++ b/messagefoundry/api/auth_models.py @@ -163,9 +163,26 @@ class MfaConfirmRequest(RequestModel): class MfaConfirmResponse(BaseModel): """The one-time single-use recovery codes minted on enrollment — shown **once** for the user to - save (lost-authenticator escape hatch).""" + save (lost-authenticator escape hatch), plus the rotated session token. + + ``token`` is the caller's NEW bearer token: confirming an enrolment elevates the session, and + ASVS 7.2.4 re-keys it on every elevation, so the token the client authenticated this very call + with has stopped working. A client that ignores this field has locked itself out.""" recovery_codes: list[str] + token: str + + +class ElevatedResponse(BaseModel): + """A ceremony that RAISED the session's authentication state, and the token it was re-keyed to. + + ``token`` is the caller's NEW bearer token (ASVS 7.2.4). The one the request carried no longer + authenticates, so a client MUST adopt this or it has just ended its own session. The responses + carrying this field are sent ``Cache-Control: no-store`` — a body holding a live session token + must not sit in a shared cache.""" + + detail: str + token: str class MfaStatusResponse(BaseModel): diff --git a/messagefoundry/api/auth_routes.py b/messagefoundry/api/auth_routes.py index b01d3055c..5c473779a 100644 --- a/messagefoundry/api/auth_routes.py +++ b/messagefoundry/api/auth_routes.py @@ -17,7 +17,7 @@ import logging from collections.abc import Iterator -from fastapi import Depends, FastAPI, HTTPException, Query, Request, status +from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, status from fastapi.responses import StreamingResponse # The /ui admin pages moved to the messagefoundry_webconsole package (Option B, ADR 0065); this module @@ -35,6 +35,7 @@ CurrentUser, CustomRoleInfo, CustomRoleRequest, + ElevatedResponse, LoginRequest, LoginResponse, MfaConfirmRequest, @@ -155,6 +156,15 @@ def _client(request: Request) -> str | None: return request.client.host if request.client else None +def _no_store(response: Response) -> None: + """Forbid caching a response whose BODY carries a live session token (ASVS 7.2.4 delivery). + + The rotated token has to reach the client somehow, and the body is the only channel a bearer + client has. That makes these three responses credential-bearing, so they must not sit in a proxy + or browser cache where a later reader could lift a working session out of one.""" + response.headers["Cache-Control"] = "no-store" + + def _current_user(identity: Identity) -> CurrentUser: return CurrentUser( user_id=identity.user_id, @@ -327,22 +337,28 @@ async def change_password( ) return SimpleMessage(detail="password changed; please sign in again") - @app.post("/me/reauth", response_model=SimpleMessage) + @app.post("/me/reauth", response_model=ElevatedResponse) async def reauth( body: ReauthRequest, + response: Response, request: Request, service: AuthService = Depends(_service), identity: Identity = Depends(require()), - ) -> SimpleMessage: + ) -> ElevatedResponse: """Step-up re-verification (ASVS 7.5.3): re-prove the current credential to refresh this session's step-up window so it may perform highly sensitive operations for the configured - period. Rate-limited like the password change; a failure is a 403 and performs nothing.""" + period. Rate-limited like the password change; a failure is a 403 and performs nothing. + + On success the session is RE-KEYED (ASVS 7.2.4) and the response carries the new bearer + token — the one this request authenticated with is dead by the time the client reads it.""" # Post-session ceremony: per-ACTOR budget. Sharing the sign-in global budget let an # unauthenticated flood deny step-up to every signed-in operator. if not service.allow_reauth_attempt(identity.user_id): raise _rate_limited(request, "reauth") token = bearer_token(request) - if token is None or not await service.reauth( + if token is None: + raise HTTPException(status.HTTP_403_FORBIDDEN, "re-verification failed") + elevation = await service.reauth( identity, body.password, token=token, @@ -350,28 +366,46 @@ async def reauth( # ADR 0077: bind the fresh proof to the action the caller named (the value the 403 handed # back in X-Step-Up-Action). None => refresh only the session window, as before. purpose=body.purpose, - ): + ) + if not elevation.ok or elevation.token is None: + # session_lost is a good password on a session revoked mid-ceremony: 401, not the 403 a + # wrong password gets, so the client re-authenticates instead of re-prompting for a + # password that was already correct. + if elevation.session_lost: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session ended; sign in again") raise HTTPException(status.HTTP_403_FORBIDDEN, "re-verification failed") - return SimpleMessage(detail="re-verified") + _no_store(response) + return ElevatedResponse(detail="re-verified", token=elevation.token) # --- MFA: native TOTP second factor (WP-14, ASVS 6.3.3) ------------------ - @app.post("/auth/mfa-verify", response_model=SimpleMessage) + @app.post("/auth/mfa-verify", response_model=ElevatedResponse) async def mfa_verify( body: MfaVerifyRequest, + response: Response, request: Request, service: AuthService = Depends(_service), _: Identity = Depends(require()), - ) -> SimpleMessage: + ) -> ElevatedResponse: """Satisfy the current session's second factor with a TOTP code or a single-use recovery code. Authenticated but **not** step-up/MFA-gated (this is *how* a session becomes MFA-satisfied); - rate-limited like login. A wrong code is a 401 and changes nothing.""" + rate-limited like login. A wrong code is a 401 and changes nothing. + + The session is RE-KEYED on success (ASVS 7.2.4) and the new bearer token is in the body: + this is the exact transition — pre-MFA to MFA-satisfied — that must not happen in place.""" if not service.allow_login_attempt(_client(request)): raise _rate_limited(request, "mfa-verify") token = bearer_token(request) - if token is None or not await service.verify_mfa(token, body.code, client=_client(request)): + if token is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid code") - return SimpleMessage(detail="verified") + elevation = await service.verify_mfa(token, body.code, client=_client(request)) + if not elevation.ok or elevation.token is None: + # A correct code on a session revoked mid-ceremony is already a 401 here, so unlike + # /me/reauth there is no status to split — only the message differs. + detail = "session ended; sign in again" if elevation.session_lost else "invalid code" + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail) + _no_store(response) + return ElevatedResponse(detail="verified", token=elevation.token) @app.get("/me/mfa", response_model=MfaStatusResponse) async def my_mfa( @@ -410,6 +444,7 @@ async def enroll_mfa( @app.post("/me/mfa/confirm", response_model=MfaConfirmResponse) async def confirm_mfa( body: MfaConfirmRequest, + response: Response, request: Request, service: AuthService = Depends(_service), identity: Identity = Depends(require_reauth_only_action(STEP_UP_ACTION_MFA_CONFIRM)), @@ -424,14 +459,20 @@ async def confirm_mfa( if token is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated") try: - codes = await service.confirm_mfa_enrollment( + elevation = await service.confirm_mfa_enrollment( identity, body.code, token=token, client=_client(request) ) except ValueError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc - if codes is None: + if not elevation.ok or elevation.token is None: + if elevation.session_lost: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session ended; sign in again") raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid code") - return MfaConfirmResponse(recovery_codes=codes) + # The body now carries BOTH the one-time recovery codes and a live session token. + _no_store(response) + return MfaConfirmResponse( + recovery_codes=list(elevation.recovery_codes), token=elevation.token + ) @app.delete("/me/mfa", response_model=SimpleMessage) async def disable_my_mfa( diff --git a/messagefoundry/apiclient/client.py b/messagefoundry/apiclient/client.py index 5ae7fb5b5..e895aa25d 100644 --- a/messagefoundry/apiclient/client.py +++ b/messagefoundry/apiclient/client.py @@ -275,6 +275,26 @@ def _build_verify_context( return ctx +class _TokenCell: + """The bearer token, in a cell SHARED by a client and every :meth:`EngineClient.for_polling` + clone it makes. + + A clone used to copy the token by value. That was sound only while a session's token never + changed after sign-in, which stopped being true with session rotation on re-authentication + (ASVS 7.2.4): a poll client cloned BEFORE a re-auth or an MFA verify would hold the retired + token and every background read on it would start failing, while the main-thread client + carried on fine. Sharing the cell means one rotation reaches every clone. + + This does not widen who may WRITE the token: the clones still have no step-up/MFA handlers and + never call an entry point that sets one. The primary remains the only writer. + """ + + __slots__ = ("value",) + + def __init__(self, value: str | None = None) -> None: + self.value = value + + class EngineClient: """Blocking client for the MessageFoundry localhost API. @@ -312,7 +332,7 @@ def __init__( if self.base_url.lower().startswith("https"): verify = _build_verify_context(cacert, tls_client_cert, tls_client_key) self._http = httpx.Client(base_url=self.base_url, timeout=timeout, verify=verify) - self._token: str | None = None + self._token_cell = _TokenCell() self._user: CurrentUser | None = None #: Invoked when the engine demands step-up re-verification (403 + X-Step-Up-Required); the GUI #: prompts, calls reauth(), and returns True iff re-verified — then the request is retried. @@ -325,6 +345,18 @@ def __init__( #: prompts for a TOTP / recovery code, calls verify_mfa(), and returns True iff verified. self._mfa_handler: Callable[[], bool] | None = None + @property + def _token(self) -> str | None: + """The bearer token, read through the shared cell (see :class:`_TokenCell`). + + Kept as an attribute-shaped property so every existing read and write is unchanged; only + WHERE the value lives moved.""" + return self._token_cell.value + + @_token.setter + def _token(self, token: str | None) -> None: + self._token_cell.value = token + def __enter__(self) -> EngineClient: return self @@ -343,17 +375,26 @@ def for_polling(self) -> EngineClient: """A second client dedicated to **background (off-thread) reads** — the nav health poll, the Engine Status refresh, and the per-page auto-refresh. - It shares this client's bearer token but has its **own** ``httpx.Client`` connection pool and - **no step-up/MFA handlers**, so background reader threads never contend on the main-thread + It shares this client's bearer token — by REFERENCE, through the cell described in + :class:`_TokenCell` — but has its **own** ``httpx.Client`` connection pool and **no + step-up/MFA handlers**, so background reader threads never contend on the main-thread client's pool or its mutable auth state. That separation is what makes the console concurrency-safe: the handler-bearing, token-mutating primary client stays **main-thread only** (it serves the modal sign-in/step-up/MFA flows and user actions), while this read-only - client is the only one shared across worker threads — and sharing *it* is safe because its - token is never mutated, its 403→prompt retry branches are inert (no handlers), and + client is the only one shared across worker threads — and sharing *it* is safe because it + never WRITES the token, its 403→prompt retry branches are inert (no handlers), and ``httpx.Client`` is itself thread-safe for concurrent requests. - The token is copied at creation. A mid-session credential change relaunches the console - (sign-out/expiry quits the app), so this snapshot can't drift out from under a live window. + **The token is shared, not snapshotted, and that is the point.** This method previously + copied it and justified the copy with "a mid-session credential change relaunches the + console, so the snapshot can't drift". Session rotation on re-authentication (ASVS 7.2.4) + retired that premise: a re-auth, an MFA verify, or a passkey ceremony now re-keys the session + WITHOUT relaunching anything, so a copy taken before one would be a dead token in every + background reader. Reading through the cell means the rotation reaches them. + + A read racing a rotation can still see either the old or the new token — the cell is a shared + reference, not a lock. That is a plain retry (one background read 401s and the next succeeds), + not the permanent breakage a stale copy would cause. """ poll = EngineClient( self.base_url, @@ -363,7 +404,7 @@ def for_polling(self) -> EngineClient: tls_client_cert=self._tls_client_cert, tls_client_key=self._tls_client_key, ) - poll._token = self._token + poll._token_cell = self._token_cell # shared by reference — see the docstring poll._user = self._user return poll @@ -471,6 +512,26 @@ def _request( raise ApiError(_error_detail(response), status=response.status_code) return response + def _adopt_rotated(self, response: httpx.Response) -> None: + """Adopt the re-keyed session token an elevation route hands back (ASVS 7.2.4). + + The engine rotates the session on every successful elevation, so the token this client + authenticated the call with is already dead when the response arrives. Not adopting the new + one signs the client out at the exact moment it succeeded -- and the retry that follows a + step-up would then fail on a token the ceremony itself retired. + + Writing through the shared cell (see :class:`_TokenCell`) is what carries the rotation to the + background poll clients too. A body without a usable ``token`` leaves the current one in + place rather than clearing it: that is an engine older than this contract, and dropping the + token there would turn a version skew into a sign-out. + """ + try: + token = response.json().get("token") + except (JSONDecodeError, AttributeError): + return + if isinstance(token, str) and token: + self._token = token + def set_step_up_handler(self, handler: Callable[[], bool] | None) -> None: """Register the callback invoked when the engine demands step-up re-verification (403 + ``X-Step-Up-Required``). It must prompt the user, call :meth:`reauth`, and return ``True`` iff @@ -492,7 +553,7 @@ def reauth(self, password: str) -> None: body: dict[str, str] = {"password": password} if action is not None: body["purpose"] = action - self._request("POST", "/me/reauth", json=body, _allow_step_up=False) + self._adopt_rotated(self._request("POST", "/me/reauth", json=body, _allow_step_up=False)) def set_mfa_handler(self, handler: Callable[[], bool] | None) -> None: """Register the callback invoked when the engine demands a second factor (403 + @@ -513,16 +574,23 @@ def enroll_mfa(self) -> MfaEnrollResponse: def confirm_mfa(self, code: str) -> list[str]: """Confirm enrollment with a live TOTP code; activates MFA and returns the one-time recovery - codes (shown **once**). Raises :class:`ApiError` (400) on a wrong code.""" - return _decode( + codes (shown **once**). Raises :class:`ApiError` (400) on a wrong code. + + Confirming an enrolment elevates the session, so it also re-keys it (ASVS 7.2.4) and the new + token is adopted here — the caller keeps getting just the codes.""" + result = _decode( self._request("POST", "/me/mfa/confirm", json={"code": code}), MfaConfirmResponse - ).recovery_codes + ) + self._token = result.token + return result.recovery_codes def verify_mfa(self, code: str) -> None: """Satisfy the current session's second factor with a TOTP or single-use recovery code. Raises :class:`ApiError` (401) on a wrong code. Does not itself trigger the MFA handler.""" self._refuse_credential_on_cleartext("a second factor") - self._request("POST", "/auth/mfa-verify", json={"code": code}, _allow_mfa=False) + self._adopt_rotated( + self._request("POST", "/auth/mfa-verify", json={"code": code}, _allow_mfa=False) + ) def disable_mfa(self) -> None: """Turn off the signed-in user's TOTP MFA (step-up gated).""" diff --git a/messagefoundry/auth/service.py b/messagefoundry/auth/service.py index a425429a9..37264f8f8 100644 --- a/messagefoundry/auth/service.py +++ b/messagefoundry/auth/service.py @@ -163,6 +163,37 @@ class LoginOutcome: reason: str | None = None +@dataclass(frozen=True) +class Elevation: + """The outcome of a session-elevating ceremony, carrying the ROTATED session token (ASVS 7.2.4). + + Every ceremony that raises a session's authentication state -- re-auth, TOTP verify, TOTP + enrollment confirm, passkey registration, passkey assertion -- re-keys the session to a fresh + token instead of stamping the elevation onto the token the caller already holds. One shape for + all five, deliberately: three different shapes is exactly the defect that makes a caller adopt + the new token on some legs and quietly keep the dead one on others. + + Three states, and a caller must be able to tell them apart: + + * ``ok`` -- elevated. ``token`` is the caller's NEW session token and is never ``None`` here; + the token they presented has stopped authenticating. The caller MUST hand it back (response + body, cookie), or it has just made the session it elevated unreachable. + * not ``ok``, ``session_lost`` False -- the proof was wrong (bad password, bad code, bad + assertion). Nothing rotated, and the token the caller presented still authenticates. + * not ``ok``, ``session_lost`` True -- the proof was GOOD but the session was revoked or expired + underneath the ceremony, so there was no row to re-key. Fails CLOSED: no token is handed back + and the caller must sign in again. Held apart from a wrong proof so a route can say which one + happened rather than report a correct credential as incorrect. + + ``recovery_codes`` is populated only by :meth:`AuthService.confirm_mfa_enrollment` (shown once). + """ + + ok: bool + token: str | None = None + session_lost: bool = False + recovery_codes: tuple[str, ...] = () + + @dataclass(frozen=True) class MfaEnrollment: """A staged (not-yet-confirmed) TOTP enrollment: the base32 secret to render as a QR + the @@ -1807,6 +1838,60 @@ async def _rotate_session_token(self, token: str) -> str | None: self._rekey_token_state(old_hash, hash_token(new_token)) return new_token + async def _elevated( + self, + token: str, + *, + ceremony: str, + actor: str | None, + client: str | None = None, + recovery_codes: tuple[str, ...] = (), + ) -> Elevation: + """Rotate the caller's session and package the :class:`Elevation` (ASVS 7.2.4). + + **The single place the five elevation sites rotate.** The ordering invariant that makes + rotation safe (see :meth:`_rotate_session_token`) is subtle and silent when broken, so it is + enforced by having exactly one caller of the primitive rather than nine route handlers each + getting it right. Every store stamp for the elevation must ALREADY be written against the + old hash when this runs; anything purpose-bound is minted after, against the new hash. + + A ``None`` rotate means the session was revoked or expired underneath a ceremony that + otherwise succeeded, so this fails CLOSED -- ``ok=False`` with no token, flagged + ``session_lost`` so the route can say "sign in again" rather than "wrong password". + + Both outcomes are audited. The in-place re-key would otherwise leave no store trace at all: + a session's token changing is exactly the event an operator reconstructing a timeline needs, + and the fail-closed branch is the more interesting of the two (a good proof landing on a + session that just vanished). + + **A rotation drops any open ``/ws/stats`` socket, and that is accepted.** The socket + authenticates once at handshake and its keepalive re-validates the token CAPTURED there, so + on a first deployment a rotation would make that captured token stop resolving and the server + would close the socket at the next revalidation tick -- indistinguishable from a revoke, + which is the fail-closed direction and the right one. The console does not reconnect it; + ``app.js`` wires ``ws.onclose`` to resume the 5-second HTTP poll, and that poll carries the + NEW cookie, so the dashboard would keep updating over the fallback until the next full page + load re-opened a socket. Completing MFA on the dashboard would therefore cost the live push + for the rest of that page's life -- a LIVENESS regression, not a correctness or data-loss + one, which is why a bounded reconnect is filed as follow-up rather than built here. + """ + rotated = await self._rotate_session_token(token) + if rotated is None: + await self._audit( + "auth.session_rotation_failed", + actor=actor, + detail=_json({"ceremony": ceremony, "reason": "session_gone"}), + client=client, + ) + return Elevation(ok=False, session_lost=True) + await self._audit( + "auth.session_rotated", + actor=actor, + detail=_json({"ceremony": ceremony}), + client=client, + ) + return Elevation(ok=True, token=rotated, recovery_codes=recovery_codes) + async def identity_for_token( self, token: str | None, *, activity: bool = True ) -> Identity | None: @@ -1978,7 +2063,7 @@ async def reauth( token: str, client: str | None = None, purpose: str | None = None, - ) -> bool: + ) -> Elevation: """Step-up re-verification (ASVS 7.5.3): re-prove the caller's credential and, on success, refresh the current session's ``reauth_at`` so it may perform highly sensitive operations for the configured window. Local accounts re-verify the password (argon2); **AD accounts do a live @@ -1988,26 +2073,54 @@ async def reauth( named action, so a durable-takeover route (TOTP enroll/confirm, disable-MFA) can require a fresh proof tied to *it* rather than riding the broad session window. It is purely additive — the session-window refresh above is unchanged (the broad admin/replay/config routes - keep using it), and the grant is minted ONLY here, never by login or ``verify_mfa``.""" + keep using it), and the grant is minted ONLY here, never by login or ``verify_mfa``. + + Returns an :class:`Elevation`: on success the session is re-keyed (ASVS 7.2.4) and the NEW + token is in ``Elevation.token``. The three steps below are ORDER-CRITICAL -- see the inline + notes and :meth:`_rotate_session_token`.""" if identity.auth_provider is AuthProvider.AD: ok = await self._reauth_ad(identity.username, password) else: ok = await self.verify_current_password(identity, password) + elevation = Elevation(ok=False) if ok: + # (1) Every stamp for this elevation, against the OLD hash. The rotation carries these + # columns forward; a stamp issued after it would silently write nothing. # Re-anchor the session to the address it re-verified from, so a forced step-up triggered # by a roamed/new client IP (WP-L3-13) clears once the caller re-proves from there. await self._store.mark_session_reauthed(hash_token(token), client=client) - if purpose is not None and not await self._factor_binding_is_blocked(token, purpose): + # `_factor_binding_is_blocked` resolves the session BY THE OLD TOKEN and fails closed when + # it cannot find it, so it is decided here, BEFORE the rotation retires that token -- + # asking after would refuse every factor-binding grant on a session that is perfectly fine. + binding_blocked = purpose is not None and await self._factor_binding_is_blocked( + token, purpose + ) + # (2) Rotate. Past this line `token` no longer authenticates. + elevation = await self._elevated( + token, ceremony="reauth", actor=identity.username, client=client + ) + if purpose is not None and not binding_blocked and elevation.token is not None: + # (3) Purpose-bound grants are minted AFTER, against the NEW hash -- minted against the + # old one they would be stranded on a hash nothing resolves any more. # Bind THIS fresh proof to the single action named by `purpose` (single-use), so a broad # login-seeded window can never authorize a factor-binding action (ASVS 7.5.1 / 8.2.4). - self._grant_action_step_up(hash_token(token), purpose) + self._grant_action_step_up(hash_token(elevation.token), purpose) await self._audit( "auth.reauth", actor=identity.username, - detail=_json({"ok": ok, "provider": identity.auth_provider.value, "purpose": purpose}), + detail=_json( + { + "ok": ok, + "provider": identity.auth_provider.value, + "purpose": purpose, + # A good password on a session that vanished mid-ceremony is neither a success nor + # a credential failure; without this the audit row would read as a clean re-auth. + "session_lost": elevation.session_lost, + } + ), client=client, ) - return ok + return elevation #: The step-up actions that BIND A NEW SECOND FACTOR. Reaching one from an MFA-pending session is #: legitimate only while the account has no factor at all — that is the bootstrap escape the MFA @@ -2296,12 +2409,18 @@ async def begin_mfa_enrollment(self, identity: Identity) -> MfaEnrollment: async def confirm_mfa_enrollment( self, identity: Identity, code: str, *, token: str, client: str | None = None - ) -> list[str] | None: + ) -> Elevation: """Confirm a staged enrollment by proving a live TOTP code. On success: activate MFA, mint the single-use recovery codes (returned **once**, plaintext, for the user to save), mark the - current session MFA-verified, audit + notify. Returns the recovery codes, or ``None`` when the - code was wrong or its time-step was already consumed (single-use, BACKLOG #1021). Raises - :class:`ValueError` if no enrollment is staged / the user isn't local.""" + current session MFA-verified, re-key the session (ASVS 7.2.4), audit + notify. Raises + :class:`ValueError` if no enrollment is staged / the user isn't local. + + Returns an :class:`Elevation` whose ``recovery_codes`` carry the plaintext codes; a wrong code + (or a time-step already consumed -- single-use, BACKLOG #1021) is ``ok=False`` with none. + + This is one of the two legs that turn an MFA-pending session into an MFA-satisfied one for a + FIRST enrolment, so it rotates for the same reason ``verify_mfa`` does: without it a pre-MFA + token captured before the ceremony would be elevated in place on a first deployment.""" user = await self._store.get_user(identity.user_id) if user is None or user.auth_provider != AuthProvider.LOCAL.value: raise ValueError("only local users can enroll a TOTP authenticator") @@ -2330,29 +2449,44 @@ async def confirm_mfa_enrollment( detail=_json({"phase": "enroll"}), client=client, ) - return None + return Elevation(ok=False) plain = totp.generate_recovery_codes(self._settings.mfa_recovery_code_count) hashes = [await self._argon2(hash_password, c) for c in plain] await self._store.enable_totp(identity.user_id, recovery_code_hashes=hashes) + # Stamp against the OLD hash, then rotate — never the other way round. await self._store.mark_session_mfa_verified(hash_token(token)) + elevation = await self._elevated( + token, + ceremony="mfa_enroll_confirm", + actor=identity.username, + client=client, + recovery_codes=tuple(plain), + ) await self._audit("auth.mfa_enrolled", actor=identity.username, client=client) await self._notify_security( MFA_ENABLED, username=user.username, email=user.notify_email, client=client ) - return plain + return elevation - async def verify_mfa(self, token: str | None, code: str, *, client: str | None = None) -> bool: + async def verify_mfa( + self, token: str | None, code: str, *, client: str | None = None + ) -> Elevation: """Validate a TOTP code (or a single-use recovery code) for the caller's session and, on - success, mark the session's second factor satisfied. Always audited; the API gates this behind - the login rate limiter. Returns False (never raises) for any invalid input.""" + success, mark the session's second factor satisfied and re-key the session (ASVS 7.2.4). + Always audited; the API gates this behind the login rate limiter. Returns a not-``ok`` + :class:`Elevation` (never raises) for any invalid input. + + This is the leg the 7.2.4 verb is really about: without the rotation, a pre-MFA token captured + before the second factor would be elevated in place to a fully authenticated session on a + first deployment.""" if not token: - return False + return Elevation(ok=False) session = await self._store.get_session(hash_token(token)) if session is None or session.revoked_at is not None: - return False + return Elevation(ok=False) user = await self._store.get_user(session.user_id) if user is None or user.disabled or not user.totp_enabled: - return False + return Elevation(ok=False) now = time.time() # Per-account lockout covers the SECOND factor too (parity with the password path): a run of # wrong codes locks the account, so MFA guessing isn't bounded only by the shared per-IP login @@ -2364,8 +2498,11 @@ async def verify_mfa(self, token: str | None, code: str, *, client: str | None = detail=_json({"reason": "locked"}), client=client, ) - return False + return Elevation(ok=False) if await self._verify_second_factor(user, code, client=client): + # ORDER-CRITICAL: this whole three-write group lands against the OLD hash, and only then + # does the session rotate. Moving any of them after the rotation writes NOTHING and reports + # success — every session UPDATE but revoke/rotate is rowcount-blind. # The 2nd factor is now satisfied; also seed the step-up window (the session has completed # password + MFA) and clear the failure counter. (Initial enrollment has no factor to verify, # so this never fires there — keeping the enrollment step-up gate honest, WP-14.) @@ -2376,7 +2513,9 @@ async def verify_mfa(self, token: str | None, code: str, *, client: str | None = await self._store.mark_session_reauthed(hash_token(token), client=client) await self._store.record_login_success(user.id, now=now) await self._audit("auth.mfa_verified", actor=user.username, client=client) - return True + return await self._elevated( + token, ceremony="mfa_verify", actor=user.username, client=client + ) # Wrong code: register the failure through the SAME machinery the password path uses, so the # per-account lockout + ACCOUNT_LOCKED notification fire on sustained MFA guessing. attempts, just_locked = await self._register_failure(user, now) @@ -2389,7 +2528,7 @@ async def verify_mfa(self, token: str | None, code: str, *, client: str | None = client=client, detail={"failed_attempts": attempts}, ) - return False + return Elevation(ok=False) async def _verify_second_factor( self, user: UserRecord, code: str, *, client: str | None = None @@ -2632,13 +2771,17 @@ async def finish_webauthn_registration( client: str | None = None, rp_id: str, origin: str, - ) -> bool: - """Verify an attestation response and persist the passkey. Returns ``False`` when the - response fails verification (audited — parity with a wrong TOTP code); raises - :class:`ValueError` for flow errors with safe, renderable messages (AD account, bad label, - expired ceremony, duplicate label/credential). On success the enrolling session is marked - MFA-verified (exact :meth:`confirm_mfa_enrollment` parity) — **no recovery codes are - minted** (ADR 0068 decision 5).""" + ) -> Elevation: + """Verify an attestation response and persist the passkey. Returns a not-``ok`` + :class:`Elevation` when the response fails verification (audited — parity with a wrong TOTP + code); raises :class:`ValueError` for flow errors with safe, renderable messages (AD account, + bad label, expired ceremony, duplicate label/credential). On success the enrolling session is + marked MFA-verified and re-keyed (exact :meth:`confirm_mfa_enrollment` parity, ASVS 7.2.4) — + **no recovery codes are minted** (ADR 0068 decision 5). + + The other first-enrolment promotion leg. For a passkey-only account this and + :meth:`finish_webauthn_assertion` are the ONLY ways a session becomes MFA-satisfied, so a + 7.2.4 build that rotated the TOTP legs alone would miss the passkey path entirely.""" user = await self._store.get_user(identity.user_id) if user is None or user.auth_provider != AuthProvider.LOCAL.value: raise ValueError("only local users can enroll a passkey") @@ -2662,7 +2805,7 @@ async def finish_webauthn_registration( detail=_json({"phase": "enroll"}), client=client, ) - return False + return Elevation(ok=False) credential_id_hash = hash_bytes(result.credential_id) if await self._store.get_webauthn_credential(credential_id_hash) is not None: raise ValueError("this passkey is already enrolled") @@ -2692,8 +2835,12 @@ async def finish_webauthn_registration( raise ValueError("label already in use") from exc raise # Parity with confirm_mfa_enrollment: the enrolling session is now MFA-verified (it just - # proved possession of the freshly-bound authenticator). + # proved possession of the freshly-bound authenticator). Stamped against the OLD hash, then + # rotated — the reverse order writes nothing and still reports success. await self._store.mark_session_mfa_verified(hash_token(token)) + elevation = await self._elevated( + token, ceremony="webauthn_enroll", actor=identity.username, client=client + ) await self._audit( "auth.webauthn_enrolled", actor=identity.username, @@ -2703,7 +2850,7 @@ async def finish_webauthn_registration( await self._notify_security( MFA_ENABLED, username=user.username, email=user.notify_email, client=client ) - return True + return elevation async def begin_webauthn_assertion(self, token: str | None, *, rp_id: str) -> str | None: """Stage an assertion ceremony for the caller's session; returns the browser request-options @@ -2739,23 +2886,26 @@ async def finish_webauthn_assertion( client: str | None = None, rp_id: str, origin: str, - ) -> bool: + ) -> Elevation: """Verify an assertion for the caller's session; on success mark the session's second - factor satisfied — **`mfa_verified` ONLY** (ADR 0068 decision 1: ``reauth_at`` + the - WP-L3-13 client re-anchor come from the password leg of ``POST /ui/reauth``, never from - the assertion — the loop-class defense). Returns ``False`` (never raises) for any invalid - input, always audited. **Deliberate divergence from :meth:`verify_mfa`** (recorded in ADR + factor satisfied and re-key the session (ASVS 7.2.4) — **`mfa_verified` ONLY** (ADR 0068 + decision 1: ``reauth_at`` + the WP-L3-13 client re-anchor come from the password leg of + ``POST /ui/reauth``, never from the assertion — the loop-class defense). Returns a + not-``ok`` :class:`Elevation` (never raises) for any invalid input, always audited. + + For a passkey-only account this is the ONLY leg of ``POST /ui/mfa``, so the rotation here is + what keeps the 7.2.4 claim honest rather than TOTP-shaped. **Deliberate divergence from :meth:`verify_mfa`** (recorded in ADR 0068): assertion failures do NOT feed ``_register_failure`` — signatures are not guessable secrets and a flaky authenticator must not lock the account; abuse is bounded by the route's ``allow_login_attempt`` gate + cookie-holder-only reachability + these audits.""" if not token: - return False + return Elevation(ok=False) session = await self._store.get_session(hash_token(token)) if session is None or session.revoked_at is not None: - return False + return Elevation(ok=False) user = await self._store.get_user(session.user_id) if user is None or user.disabled: - return False + return Elevation(ok=False) now = time.time() # A locked account is refused BEFORE any verify (verify_mfa parity). if user.locked_until is not None and now < user.locked_until: @@ -2765,7 +2915,7 @@ async def finish_webauthn_assertion( detail=_json({"reason": "locked"}), client=client, ) - return False + return Elevation(ok=False) pending = self._webauthn_challenges.pop((hash_token(token), "assert")) if pending is None or pending.user_id != user.id: await self._audit( @@ -2774,7 +2924,7 @@ async def finish_webauthn_assertion( detail=_json({"reason": "expired"}), client=client, ) - return False + return Elevation(ok=False) try: raw_id = webauthn.credential_id_from_response(response_json) except webauthn.WebAuthnVerificationError: @@ -2784,7 +2934,7 @@ async def finish_webauthn_assertion( detail=_json({"reason": "malformed"}), client=client, ) - return False + return Elevation(ok=False) cred = await self._store.get_webauthn_credential(hash_bytes(raw_id)) if cred is None or cred.user_id != user.id or cred.rp_id != rp_id: # Unknown credential, another user's, or minted under a different origin — same @@ -2795,7 +2945,7 @@ async def finish_webauthn_assertion( detail=_json({"reason": "unknown_credential"}), client=client, ) - return False + return Elevation(ok=False) try: new_count = webauthn.verify_assertion( response_json=response_json, @@ -2814,7 +2964,7 @@ async def finish_webauthn_assertion( detail=_json({"label": cred.label}) if clone else None, client=client, ) - return False + return Elevation(ok=False) if not await self._store.update_webauthn_sign_count( cred.credential_id_hash, expected=cred.sign_count, new=new_count, used_at=now ): @@ -2825,10 +2975,12 @@ async def finish_webauthn_assertion( detail=_json({"label": cred.label}), client=client, ) - return False + return Elevation(ok=False) await self._store.mark_session_mfa_verified(hash_token(token)) await self._audit("auth.webauthn_verified", actor=user.username, client=client) - return True + return await self._elevated( + token, ceremony="webauthn_assert", actor=user.username, client=client + ) async def delete_webauthn_credential( self, identity: Identity, credential_id_hash: str, *, client: str | None = None diff --git a/messagefoundry_webconsole/routes/account.py b/messagefoundry_webconsole/routes/account.py index 4c361f00c..edad22299 100644 --- a/messagefoundry_webconsole/routes/account.py +++ b/messagefoundry_webconsole/routes/account.py @@ -34,12 +34,14 @@ allow_reauth_attempt, assert_same_origin, clear_session_cookie, + login_redirect_response, register_ui_action, require_ui, require_ui_reauth_only, require_ui_reauth_only_action, require_ui_step_up_action, session_token, + set_session_cookie, webauthn_rp, ) from .._service import _service @@ -275,16 +277,26 @@ async def ui_mfa_verify( form = dict(await _form_pairs(request)) code = form.get("code", "").strip() try: - codes = await service.confirm_mfa_enrollment(identity, code, token=token, client=client) + elevation = await service.confirm_mfa_enrollment( + identity, code, token=token, client=client + ) except ValueError as exc: # No enrollment staged / not a local account — back to the account page. return await _account_response( service, identity, request, error=str(exc), status_code=400 ) - if codes is None: + if elevation.session_lost: + # A correct code on a session revoked mid-enrolment: MFA IS now on, but this browser's + # cookie is dead, so the recovery codes cannot be shown here. Land on login. + return login_redirect_response() + if not elevation.ok or elevation.token is None: return HTMLResponse(pages.mfa_confirm_page(error="Invalid code."), status_code=400) - # Activated: the recovery codes render ONCE — never re-fetchable. - return HTMLResponse(pages.mfa_recovery_page(codes)) + # Activated: the recovery codes render ONCE — never re-fetchable. The confirm re-keyed the + # session (ASVS 7.2.4), so this response must carry the new cookie or the operator is signed + # out on the very page showing codes they have not written down yet. + resp = HTMLResponse(pages.mfa_recovery_page(list(elevation.recovery_codes))) + set_session_cookie(resp, elevation.token, request=request) + return resp @app.post("/ui/account/mfa/disable") async def ui_mfa_disable( @@ -444,7 +456,7 @@ async def ui_webauthn_verify( except (ValueError, KeyError, TypeError): return JSONResponse({"ok": False, "error": "malformed request"}, status_code=400) try: - ok = await service.finish_webauthn_registration( + elevation = await service.finish_webauthn_registration( identity, response_json, label=label, @@ -457,11 +469,17 @@ async def ui_webauthn_verify( # Service-authored, safe messages only (expired ceremony / duplicate label or # credential / bad label / AD) — never reflected input. return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) - if not ok: + if elevation.session_lost: + return JSONResponse({"ok": False, "error": "session expired"}, status_code=401) + if not elevation.ok or elevation.token is None: return JSONResponse( {"ok": False, "error": "passkey verification failed"}, status_code=400 ) - return JSONResponse({"ok": True, "redirect": "/ui/account?m=passkey_added"}) + # Enrolling a passkey marks the session MFA-satisfied, so it re-keys (ASVS 7.2.4). The page + # follows `redirect` immediately, and that GET must carry the new cookie. + resp = JSONResponse({"ok": True, "redirect": "/ui/account?m=passkey_added"}) + set_session_cookie(resp, elevation.token, request=request) + return resp @app.post("/ui/account/webauthn/{credential_id_hash}/delete") async def ui_webauthn_delete( diff --git a/messagefoundry_webconsole/routes/core.py b/messagefoundry_webconsole/routes/core.py index 73bd7c8f4..848278840 100644 --- a/messagefoundry_webconsole/routes/core.py +++ b/messagefoundry_webconsole/routes/core.py @@ -25,7 +25,7 @@ from messagefoundry.api.security import get_auth from messagefoundry.auth import Identity, Permission from messagefoundry.auth.identity import AuthProvider -from messagefoundry.auth.service import AuthService, MfaStatus +from messagefoundry.auth.service import AuthService, Elevation, MfaStatus from messagefoundry.auth.tokens import hash_token from messagefoundry.parsing import HL7PeekError, parse_tree @@ -785,8 +785,17 @@ async def ui_mfa_submit(request: Request) -> Response: # budget — a different hint for the same limiter would just misreport when it clears. raise HTTPException(429, "too many attempts", headers={"Retry-After": "30"}) form = dict(parse_qsl((await request.body()).decode("utf-8", "replace"))) - if await auth.verify_mfa(token, form.get("code", ""), client=client): - return RedirectResponse("/ui", status_code=303) + elevation = await auth.verify_mfa(token, form.get("code", ""), client=client) + if elevation.ok and elevation.token is not None: + # The session was re-keyed (ASVS 7.2.4), so the cookie this browser holds is now dead. + # Re-set it on the redirect or the operator is signed out by their own correct code. + resp = RedirectResponse("/ui", status_code=303) + set_session_cookie(resp, elevation.token, request=request) + return resp + if elevation.session_lost: + # A correct code on a session revoked underneath it: there is nothing to re-render the + # gate for, and the cookie is dead. Land on login like any other post-termination exit. + return login_redirect_response() mfa = await auth.mfa_status(identity) wa_options, wa_notice = await _reauth_webauthn_state(request, auth, token, mfa, False) # The submitted code is NOT echoed back — it is a bearer credential, and verify_mfa has @@ -895,6 +904,7 @@ async def ui_reauth(request: Request) -> Response: client = request.client.host if request.client else None if not allow_reauth_attempt(auth, identity, client): # per-ACTOR, not the sign-in budget raise HTTPException(429, "too many attempts", headers={"Retry-After": "30"}) + # Satisfy whichever factor is pending — TOTP first (mirrors require_step_up), then # password. The code is only demanded from a user with an ENROLLED authenticator # (decision 1(c): the code branch keys on TOTP enrollment alone — a WebAuthn-only @@ -904,13 +914,37 @@ async def ui_reauth(request: Request) -> Response: # require_reauth_only. Error re-renders re-stage FRESH assertion options (decision # 1(e)): the prior challenge was single-use, and the passkey button must survive a # failed password/code attempt. + # THIS HANDLER CAN ROTATE TWICE IN ONE REQUEST — the code leg below, then the password leg. + # Two consequences, and both are load-bearing: + # 1. `token` is REBOUND after each rotation. The second call must run against the live hash; + # against the retired one it fails closed and a correct password reads as wrong. + # 2. EVERY return path past the first rotation re-sets the cookie, the error exits included. + # A correct code followed by a wrong password rotates once and then renders an error page; + # without the cookie on that response the browser would be left holding a dead cookie in + # the middle of the ceremony, which presents as an unexplained sign-out. + def _keep_session(resp: Response, tok: str) -> Response: + """Carry the session's CURRENT token onto an outgoing response. + + Takes the token as an argument rather than closing over it: a closure would capture the + variable, and the whole point here is that it is rebound mid-handler.""" + set_session_cookie(resp, tok, request=request) + return resp + mfa_enrolled = mfa.enabled if mfa_enrolled and not satisfied: code = form.get("code", "").strip() - if not code or not await auth.verify_mfa(token, code, client=client): + code_elevation = ( + await auth.verify_mfa(token, code, client=client) if code else Elevation(ok=False) + ) + if code_elevation.session_lost: + return login_redirect_response() # session ended under a correct code + if code_elevation.ok and code_elevation.token is not None: + token = code_elevation.token # rotation 1 of 2 + else: wa_options, wa_notice = await _reauth_webauthn_state( request, auth, token, mfa, await auth.mfa_satisfied(token) ) + # Nothing rotated on this leg, so the cookie the browser holds is still live. return HTMLResponse( pages.reauth( next_, @@ -923,30 +957,38 @@ async def ui_reauth(request: Request) -> Response: # 7.5.1 (ADR 0077): mint the single-use grant bound to this continuation's action. action.action # is None for every non-factor continuation (replay/purge/config/create-user), so reauth mints # nothing there and those flows stay byte-identical; the factor-binding lanes tag their action. - if not await auth.reauth( + pw_elevation = await auth.reauth( identity, form.get("password", ""), token=token, client=client, purpose=action.action - ): + ) + if pw_elevation.session_lost: + return login_redirect_response() + if not pw_elevation.ok or pw_elevation.token is None: still_unsatisfied = not await auth.mfa_satisfied(token) wa_options, wa_notice = await _reauth_webauthn_state( request, auth, token, mfa, not still_unsatisfied ) - return HTMLResponse( - pages.reauth( - next_, - mfa_needed=mfa_enrolled and still_unsatisfied, - webauthn_options=wa_options, - webauthn_notice=wa_notice, - error="Incorrect password.", - ) + # The wrong-password exit AFTER a successful code leg — the stranded-cookie case. + return _keep_session( + HTMLResponse( + pages.reauth( + next_, + mfa_needed=mfa_enrolled and still_unsatisfied, + webauthn_options=wa_options, + webauthn_notice=wa_notice, + error="Incorrect password.", + ) + ), + token, ) + token = pw_elevation.token # rotation 2 of 2 # Fully stepped up. Hand control back per the action's continuation style: # - an unlock target is a GET admin form → 303-GET-redirect so it re-opens inside the now # fresh window; the operator then submits the body-carrying POST (incl. a create-user # password) once, never crossing /ui/reauth (the stateless confirm-after-step-up path). # - otherwise it is a body-less POST action → auto-retry it via the same-origin submit form. if is_unlock_action(next_): - return RedirectResponse(next_, status_code=303) - return HTMLResponse(pages.reauth_continue(next_)) + return _keep_session(RedirectResponse(next_, status_code=303), token) + return _keep_session(HTMLResponse(pages.reauth_continue(next_)), token) # ADR 0068 decision 6: the browser passkey leg of step-up. A cookie-authed JSON POST # (the sanctioned /ui carve — the cookie stays confined to /ui deps; bearer_token() @@ -981,14 +1023,21 @@ async def ui_reauth_webauthn(request: Request) -> Response: response_json = json.dumps(body["response"]) except (ValueError, KeyError, TypeError): return JSONResponse({"ok": False, "error": "malformed request"}, status_code=400) - ok = await auth.finish_webauthn_assertion( + elevation = await auth.finish_webauthn_assertion( token, response_json, client=client, rp_id=rp[0], origin=rp[1] ) - if not ok: + if elevation.session_lost: + return JSONResponse({"ok": False, "error": "session expired"}, status_code=401) + if not elevation.ok or elevation.token is None: return JSONResponse( {"ok": False, "error": "passkey verification failed"}, status_code=400 ) - return JSONResponse({"ok": True}) + # The assertion re-keyed the session (ASVS 7.2.4). The new cookie rides this JSON response, + # because the page's next request is the POST /ui/reauth password leg — it would otherwise + # present the retired token and be refused on a correct password. + resp = JSONResponse({"ok": True}) + set_session_cookie(resp, elevation.token, request=request) + return resp # Bulk dead-letter replay (M3): re-queue ALL dead deliveries for one channel. Like message # replay it is require_step_up (→ require_ui_step_up, which 303s to /ui/reauth on a stale diff --git a/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py b/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py index 8cc8e593a..6a0f9ced7 100644 --- a/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py +++ b/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py @@ -94,9 +94,13 @@ async def _enroll_totp(service: AuthService, username: str = "op") -> str: outcome = await service.login(username, PW) assert outcome.ok and outcome.token is not None enrollment = await service.begin_mfa_enrollment(identity) - assert await service.confirm_mfa_enrollment( - identity, totp.totp(enrollment.secret), token=outcome.token - ) + # `.ok`, not the result object: confirm_mfa_enrollment returns an Elevation (ASVS 7.2.4), and a + # frozen dataclass is ALWAYS truthy — a bare assert on it would pass on a failed enrolment. + assert ( + await service.confirm_mfa_enrollment( + identity, totp.totp(enrollment.secret), token=outcome.token + ) + ).ok return enrollment.secret @@ -306,3 +310,58 @@ async def test_must_change_outranks_the_second_factor_on_the_gate_page( 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" + + +# --- ASVS 7.2.4: the cookie plane of session rotation on re-authentication --- + + +async def test_a_correct_code_then_a_wrong_password_leaves_a_working_cookie( + engine: Engine, monkeypatch: pytest.MonkeyPatch +) -> None: + """RED when: POST /ui/reauth stops re-setting the cookie on its ERROR exits. + + ``/ui/reauth`` can rotate TWICE in one request — the code leg, then the password leg. A correct + code followed by a wrong password rotates ONCE and then renders an error page. If that response + does not carry the new cookie the browser is stranded on a dead one mid-ceremony, and the next + click reads as an unexplained sign-out rather than a wrong password. + + Also RED when: the handler stops rebinding its local ``token`` between the two calls — the + password leg would then run against the retired hash and fail closed on a CORRECT password. + """ + service = await _service(engine, require_mfa=True) + await _add(service, "op", Role.OPERATOR) + t0 = 1_000_000.0 + _pin_totp_clock(monkeypatch, t0) + secret = await _enroll_totp(service, "op") + + async with _client(engine, service) as c: + assert (await _login(c)).status_code == 303 + before = c.cookies.get("mf_session") + assert before is not None + + # A strictly later step: enrollment consumed its own (BACKLOG #1021). + t1 = t0 + totp.DEFAULT_PERIOD + _pin_totp_clock(monkeypatch, t1) + r = await c.post( + "/ui/reauth", + data={ + "next": "/ui/account/mfa/disable", + "code": totp.totp(secret, now=t1), + "password": "definitely-not-the-password", + }, + headers={"origin": "http://t"}, + ) + + assert r.status_code == 200 + assert "Incorrect password." in r.text, ( + "the password leg did not run, or ran on a dead hash" + ) + after = c.cookies.get("mf_session") + assert after is not None and after != before, "the rotated cookie was not handed back" + + # The whole point: the browser can still act. A dead cookie would 303 to /ui/login. + assert await service.identity_for_token(after) is not None + assert await service.mfa_satisfied(after) is True, "the code leg's stamp did not survive" + assert await service.identity_for_token(before) is None, ( + "the old cookie still authenticates" + ) diff --git a/tests/test_admin_new_ip.py b/tests/test_admin_new_ip.py index e4874a041..fbf33b9cc 100644 --- a/tests/test_admin_new_ip.py +++ b/tests/test_admin_new_ip.py @@ -133,8 +133,12 @@ async def test_reauth_reanchors_session_to_the_new_ip() -> None: await service.initialize() token, identity = await _enabled_admin(service, client="10.1.1.1") assert await service.flag_new_client_ip(token, "10.2.2.2", path="/users") is True - # Re-verifying from the new address re-anchors the session, clearing the signal. - assert await service.reauth(identity, PW, token=token, client="10.2.2.2") is True + # Re-verifying from the new address re-anchors the session, clearing the signal. The + # re-auth also re-keys the session (ASVS 7.2.4), and `_rekey_token_state` carries the + # new-IP dedupe across — so the follow-up checks run on the ROTATED token. + reauthed = await service.reauth(identity, PW, token=token, client="10.2.2.2") + assert reauthed.ok is True and reauthed.token is not None + token = reauthed.token assert await service.flag_new_client_ip(token, "10.2.2.2", path="/users") is False # The original address is now the unexpected one. assert await service.flag_new_client_ip(token, "10.1.1.1", path="/users") is True @@ -214,9 +218,11 @@ async def test_verify_mfa_reanchors_session_to_the_new_ip( # (enrollment now consumes the activating step, BACKLOG #1021). t0 = 1_000_000.0 pin_totp_clock(monkeypatch, t0) - await service.confirm_mfa_enrollment( + enrolled = await service.confirm_mfa_enrollment( identity, totp.totp(enroll.secret, now=t0), token=token, client="10.1.1.1" ) + assert enrolled.ok and enrolled.token is not None + token = enrolled.token # the confirm re-keyed the session (ASVS 7.2.4) # Roam to a new address → flagged. assert await service.flag_new_client_ip(token, "10.2.2.2", path="/users") is True # Completing MFA from the new address re-anchors the session (parity with reauth), using a code @@ -224,7 +230,9 @@ async def test_verify_mfa_reanchors_session_to_the_new_ip( t1 = t0 + totp.DEFAULT_PERIOD pin_totp_clock(monkeypatch, t1) code = totp.totp(enroll.secret, now=t1) - assert await service.verify_mfa(token, code, client="10.2.2.2") is True + verified = await service.verify_mfa(token, code, client="10.2.2.2") + assert verified.ok is True and verified.token is not None + token = verified.token assert await service.flag_new_client_ip(token, "10.2.2.2", path="/users") is False finally: await store.close() @@ -293,6 +301,7 @@ async def test_admin_route_from_new_ip_forces_step_up_then_clears(engine: Engine # Re-verifying from the new address re-anchors the session; the admin op then succeeds. ok = await b.post("/me/reauth", headers=_auth(token), json={"password": PW}) assert ok.status_code == 200 + token = str(ok.json()["token"]) # the re-auth re-keyed the session (ASVS 7.2.4) assert (await b.post("/users", headers=_auth(token), json=n2)).status_code == 201 diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index cb6ae6fcd..a2559bd1d 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -84,14 +84,30 @@ def _auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} +def _rotated(response: httpx.Response, token: str) -> str: + """The bearer to use AFTER an elevation call (ASVS 7.2.4). + + A successful elevation re-keys the session and returns the new token in the body, so every later + request has to carry it -- keeping the old one would 401 and quietly turn a real assertion into a + test of an expired token. A refusal rotates nothing and the incoming token is handed back.""" + if response.status_code != 200: + return token + fresh = response.json().get("token") + assert isinstance(fresh, str) and fresh, "an elevation route returned no rotated token" + return fresh + + async def _reauth( c: httpx.AsyncClient, token: str, *, purpose: str | None = None, password: str = PW -) -> httpx.Response: - """POST /me/reauth. ADR 0077: pass ``purpose`` to mint a single-use grant bound to that action.""" +) -> tuple[httpx.Response, str]: + """POST /me/reauth. ADR 0077: pass ``purpose`` to mint a single-use grant bound to that action. + + Returns ``(response, the token to use next)`` -- see :func:`_rotated`.""" body: dict[str, str] = {"password": password} if purpose is not None: body["purpose"] = purpose - return await c.post("/me/reauth", json=body, headers=_auth(token)) + r = await c.post("/me/reauth", json=body, headers=_auth(token)) + return r, _rotated(r, token) async def test_unauthenticated_is_rejected_but_health_is_open(engine: Engine) -> None: @@ -157,7 +173,8 @@ async def test_mfa_enroll_confirm_and_step_up_gate( # ADR 0077: enrollment binds to a fresh per-action proof, NOT the login window — a per-action # reauth unlocks each step (enroll, then confirm) exactly once (single-use). - assert (await _reauth(c, tok, purpose="mfa_enroll")).status_code == 200 + _r, tok = await _reauth(c, tok, purpose="mfa_enroll") + assert _r.status_code == 200 r = await c.post("/me/mfa/enroll", headers=_auth(tok)) assert r.status_code == 200 secret = r.json()["secret"] @@ -166,13 +183,15 @@ async def test_mfa_enroll_confirm_and_step_up_gate( # clock so the activating confirm code and the later /auth/mfa-verify code sit in distinct steps # (enrollment now consumes the activating step, BACKLOG #1021). The in-process ASGI server # shares this totp module, so the pin covers its server-side verify too. - assert (await _reauth(c, tok, purpose="mfa_confirm")).status_code == 200 + _r, tok = await _reauth(c, tok, purpose="mfa_confirm") + assert _r.status_code == 200 t0 = 1_000_000.0 pin_totp_clock(monkeypatch, t0) r = await c.post( "/me/mfa/confirm", json={"code": totp.totp(secret, now=t0)}, headers=_auth(tok) ) assert r.status_code == 200 and len(r.json()["recovery_codes"]) == 10 + tok = _rotated(r, tok) # the confirm re-keyed the session (ASVS 7.2.4) st = (await c.get("/me/mfa", headers=_auth(tok))).json() assert st["enabled"] is True and st["required"] is True @@ -191,6 +210,7 @@ async def test_mfa_enroll_confirm_and_step_up_gate( "/auth/mfa-verify", json={"code": totp.totp(secret, now=t1)}, headers=_auth(tok2) ) assert r.status_code == 200 + tok2 = _rotated(r, tok2) # the verify re-keyed the session (ASVS 7.2.4) # Now it passes (password step-up satisfied at login; MFA now satisfied). r = await c.put("/ad-group-map", json={"entries": []}, headers=_auth(tok2)) assert r.status_code == 200 @@ -203,13 +223,16 @@ async def test_mfa_verify_accepts_recovery_code_once(engine: Engine) -> None: 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") # ADR 0077: per-action step-up unlocks enroll + _r, tok = await _reauth( + c, tok, purpose="mfa_enroll" + ) # ADR 0077: per-action step-up unlocks enroll secret = (await c.post("/me/mfa/enroll", headers=_auth(tok))).json()["secret"] - await _reauth(c, tok, purpose="mfa_confirm") # …and a fresh one unlocks confirm + _r, tok = await _reauth(c, tok, purpose="mfa_confirm") # …and a fresh one unlocks confirm confirm = await c.post( "/me/mfa/confirm", json={"code": fresh_totp(secret)}, headers=_auth(tok) ) recovery = confirm.json()["recovery_codes"] + tok = _rotated(confirm, tok) # Fresh login → satisfy the 2nd factor with a recovery code. tok2 = (await _login(c, "adm")).json()["token"] @@ -242,10 +265,12 @@ async def test_mfa_enrollment_requires_explicit_reauth_for_require_mfa_admin( assert r.status_code == 403 and r.headers.get("X-Step-Up-Required") == "1" assert r.headers.get("X-Step-Up-Action") == "mfa_enroll" # names the action to reauth for # A plain (unbound) password re-verify does NOT unlock enroll — the proof must be action-bound. - assert (await _reauth(c, tok)).status_code == 200 + _r, tok = await _reauth(c, tok) + assert _r.status_code == 200 assert (await c.post("/me/mfa/enroll", headers=_auth(tok))).status_code == 403 # Re-prove the password BOUND to the enroll action → enrollment proceeds. - assert (await _reauth(c, tok, purpose="mfa_enroll")).status_code == 200 + _r, tok = await _reauth(c, tok, purpose="mfa_enroll") + assert _r.status_code == 200 r = await c.post("/me/mfa/enroll", headers=_auth(tok)) assert r.status_code == 200 @@ -269,13 +294,16 @@ async def test_require_mfa_admin_is_not_bootstrap_locked_out(engine: Engine) -> ) assert blocked.status_code == 403 and blocked.headers.get("X-MFA-Required") == "1" # ...yet the enroll path is reachable via an action-bound password reauth (no MFA gate there). - assert (await _reauth(c, tok, purpose="mfa_enroll")).status_code == 200 + _r, tok = await _reauth(c, tok, purpose="mfa_enroll") + assert _r.status_code == 200 secret = (await c.post("/me/mfa/enroll", headers=_auth(tok))).json()["secret"] - assert (await _reauth(c, tok, purpose="mfa_confirm")).status_code == 200 + _r, tok = await _reauth(c, tok, purpose="mfa_confirm") + assert _r.status_code == 200 confirmed = await c.post( "/me/mfa/confirm", headers=_auth(tok), json={"code": fresh_totp(secret)} ) assert confirmed.status_code == 200 and confirmed.json()["recovery_codes"] + tok = _rotated(confirmed, tok) # the confirm re-keyed the session (ASVS 7.2.4) # The admin has escaped the required-but-unenrolled state: MFA is active and — because confirming # marked the session second-factor-satisfied — the session is now usable. No lockout occurred. status = (await c.get("/me/mfa", headers=_auth(tok))).json() @@ -698,7 +726,9 @@ async def test_admin_write_floor_has_headroom_over_a_legit_burst(engine: Engine) token = (await _login(c, "adm")).json()["token"] h = _auth(token) body = {"entries": []} - assert (await _reauth(c, token)).status_code == 200 # uncounted (not a step-up route) + _r, token = await _reauth(c, token) + assert _r.status_code == 200 # uncounted (not a step-up route) + h = _auth(token) # the re-auth re-keyed the session (ASVS 7.2.4) # Six sensitive writes back-to-back — twice the worst-case reauth-retry cost — all pass. for _ in range(6): assert (await c.put("/ad-group-map", json=body, headers=h)).status_code == 200 @@ -775,12 +805,16 @@ async def test_require_paced_inherits_the_mfa_access_gate(engine: Engine) -> Non assert pending.status_code == 403 and pending.headers.get("X-MFA-Required") == "1" # Enroll + confirm; confirming satisfies THIS session's factor (the escape path). - assert (await _reauth(c, tok, purpose="mfa_enroll")).status_code == 200 + _r, tok = await _reauth(c, tok, purpose="mfa_enroll") + assert _r.status_code == 200 + h = _auth(tok) # every elevation re-keys the session (ASVS 7.2.4) — re-derive the header secret = (await c.post("/me/mfa/enroll", headers=h)).json()["secret"] - assert (await _reauth(c, tok, purpose="mfa_confirm")).status_code == 200 - assert ( - await c.post("/me/mfa/confirm", json={"code": fresh_totp(secret)}, headers=h) - ).status_code == 200 + _r, tok = await _reauth(c, tok, purpose="mfa_confirm") + assert _r.status_code == 200 + h = _auth(tok) + confirmed = await c.post("/me/mfa/confirm", json={"code": fresh_totp(secret)}, headers=h) + assert confirmed.status_code == 200 + h = _auth(_rotated(confirmed, tok)) # Paced route: allowed again on the far side of the gate — require_paced adds throttling, not # a second-factor requirement of its own; it inherits exactly the one require() applies. @@ -884,7 +918,8 @@ async def test_reauth_survives_an_exhausted_sign_in_budget(engine: Engine) -> No for _ in range(6): # unauthenticated flood exhausts the SHARED sign-in budget await _login(c, "nobody", password="an-entirely-wrong-password") assert (await _login(c, "op")).status_code == 429 # sign-in is indeed throttled - assert (await _reauth(c, token)).status_code == 200 # ...but step-up still works + _r, token = await _reauth(c, token) + assert _r.status_code == 200 # ...but step-up still works async def test_reauth_budget_is_per_actor(engine: Engine) -> None: @@ -897,9 +932,12 @@ async def test_reauth_budget_is_per_actor(engine: Engine) -> None: ta = (await _login(c, "a")).json()["token"] tb = (await _login(c, "b")).json()["token"] for _ in range(2): - assert (await _reauth(c, ta)).status_code == 200 - assert (await _reauth(c, ta)).status_code == 429 # actor a spent its own budget - assert (await _reauth(c, tb)).status_code == 200 # actor b is unaffected + _r, ta = await _reauth(c, ta) + assert _r.status_code == 200 + _r, ta = await _reauth(c, ta) + assert _r.status_code == 429 # actor a spent its own budget + _r, tb = await _reauth(c, tb) + assert _r.status_code == 200 # actor b is unaffected # --- WP-8: anti-automation on the PHI-read endpoints (ASVS 2.4.1) ------------- @@ -979,7 +1017,9 @@ async def test_list_and_revoke_own_session(engine: Engine) -> None: assert fresh.status_code == 403 assert fresh.headers.get("X-Step-Up-Action") == "session_terminate" assert (await c.get("/auth/me", headers=_auth(t1))).status_code == 200 # nothing revoked - assert (await _reauth(c, t2, purpose="session_terminate")).status_code == 200 + _r, t2 = await _reauth(c, t2, purpose="session_terminate") + assert _r.status_code == 200 + h2 = _auth(t2) # the re-auth re-keyed the session (ASVS 7.2.4) assert (await c.delete(f"/me/sessions/{other['id']}", headers=h2)).status_code == 200 assert (await c.get("/auth/me", headers=_auth(t1))).status_code == 401 # revoked assert (await c.get("/auth/me", headers=h2)).status_code == 200 # current still valid @@ -999,7 +1039,8 @@ async def test_revoke_other_sessions_keeps_current(engine: Engine) -> None: assert fresh.status_code == 403 assert fresh.headers.get("X-Step-Up-Action") == "session_terminate" assert (await c.get("/auth/me", headers=_auth(t1))).status_code == 200 # untouched - assert (await _reauth(c, t2, purpose="session_terminate")).status_code == 200 + _r, t2 = await _reauth(c, t2, purpose="session_terminate") + assert _r.status_code == 200 resp = await c.delete("/me/sessions", headers=_auth(t2)) # sign out everywhere else assert resp.status_code == 200 and "1" in resp.json()["detail"] assert (await c.get("/auth/me", headers=_auth(t1))).status_code == 401 @@ -1020,13 +1061,13 @@ async def test_the_api_accepts_a_revoke_of_the_callers_own_current_session(engin await _add(service, "u", Role.VIEWER) async with _client(engine, service) as c: token = (await _login(c, "u")).json()["token"] + re, token = await _reauth(c, token, purpose="session_terminate") + assert re.status_code == 200 + # The session id IS the token hash, so the re-auth's re-key (ASVS 7.2.4) gives the caller's + # own session a NEW id. Read the inventory AFTER the rotation or the id below is the retired + # one and the delete 404s -- which would look like the ownership rule failing. sessions = (await c.get("/me/sessions", headers=_auth(token))).json()["sessions"] current = next(s for s in sessions if s["current"]) - re = await _reauth(c, token, purpose="session_terminate") - assert re.status_code == 200 - # Adopt a rotated token if the re-auth handed one back. ASVS 7.2.4 (BACKLOG #1146) wires - # rotation into this leg, and this test must pin the ownership rule either side of that. - token = re.json().get("token") or token assert ( await c.delete(f"/me/sessions/{current['id']}", headers=_auth(token)) ).status_code == 200 @@ -1048,7 +1089,8 @@ async def test_cannot_revoke_another_users_session(engine: Engine) -> None: # leaking. Clear the gate first, so the 404 below still measures OWNERSHIP rather than the # step-up. Without this reauth the test would pass on the gate and prove nothing about it. assert (await c.delete(f"/me/sessions/{b_sid}", headers=_auth(ta))).status_code == 403 - assert (await _reauth(c, ta, purpose="session_terminate")).status_code == 200 + _r, ta = await _reauth(c, ta, purpose="session_terminate") + assert _r.status_code == 200 # a tries to revoke b's session → 404 (ownership-checked, doesn't confirm/touch it) assert (await c.delete(f"/me/sessions/{b_sid}", headers=_auth(ta))).status_code == 404 assert (await c.get("/auth/me", headers=_auth(tb))).status_code == 200 # b still signed in @@ -1074,7 +1116,8 @@ async def test_revoke_session_requires_reauth_when_stale(engine: Engine) -> None assert ( await c.get("/auth/me", headers=_auth(t1)) ).status_code == 200 # nothing revoked yet - assert (await _reauth(c, t2, purpose="session_terminate")).status_code == 200 + _r, t2 = await _reauth(c, t2, purpose="session_terminate") + assert _r.status_code == 200 assert (await c.delete(f"/me/sessions/{t1_sid}", headers=_auth(t2))).status_code == 200 assert (await c.get("/auth/me", headers=_auth(t1))).status_code == 401 # now revoked @@ -1089,7 +1132,8 @@ async def test_revoke_other_sessions_requires_reauth_when_stale(engine: Engine) stale = await c.delete("/me/sessions", headers=_auth(t2)) assert stale.status_code == 403 and stale.headers.get("X-Step-Up-Required") == "1" assert (await c.get("/auth/me", headers=_auth(t1))).status_code == 200 # t1 untouched - assert (await _reauth(c, t2, purpose="session_terminate")).status_code == 200 + _r, t2 = await _reauth(c, t2, purpose="session_terminate") + assert _r.status_code == 200 assert (await c.delete("/me/sessions", headers=_auth(t2))).status_code == 200 assert (await c.get("/auth/me", headers=_auth(t1))).status_code == 401 # t1 signed out assert (await c.get("/auth/me", headers=_auth(t2))).status_code == 200 # current kept @@ -1107,7 +1151,8 @@ async def test_revoke_no_mfa_user_gate_is_password_only(engine: Engine) -> None: assert stale.status_code == 403 assert stale.headers.get("X-Step-Up-Required") == "1" assert stale.headers.get("X-MFA-Required") is None # NOT the MFA gate - assert (await _reauth(c, t, purpose="session_terminate")).status_code == 200 + _r, t = await _reauth(c, t, purpose="session_terminate") + assert _r.status_code == 200 assert (await c.delete("/me/sessions", headers=_auth(t))).status_code == 200 @@ -1125,7 +1170,8 @@ async def test_revoke_ownership_404_survives_reauth(engine: Engine) -> None: assert ( await c.delete(f"/me/sessions/{b_sid}", headers=_auth(ta)) ).status_code == 403 # stale - assert (await _reauth(c, ta, purpose="session_terminate")).status_code == 200 + _r, ta = await _reauth(c, ta, purpose="session_terminate") + assert _r.status_code == 200 assert ( await c.delete(f"/me/sessions/{b_sid}", headers=_auth(ta)) ).status_code == 404 # own @@ -1230,7 +1276,9 @@ async def test_patch_user_preserves_omitted_fields(engine: Engine) -> None: token = (await _login(c, "root")).json()["token"] h = _auth(token) # 7.5.1: PATCH /users/{id} is now action-bound — mint the admin_user_update grant first. - assert (await _reauth(c, token, purpose="admin_user_update")).status_code == 200 + _r, token = await _reauth(c, token, purpose="admin_user_update") + assert _r.status_code == 200 + h = _auth(token) # the re-auth re-keyed the session (ASVS 7.2.4) r = await c.patch(f"/users/{uid}", headers=h, json={"disabled": True}) assert r.status_code == 200 user = await engine.store.get_user(uid) @@ -1288,7 +1336,9 @@ async def test_admin_reset_password_endpoint(engine: Engine) -> None: assert fresh.status_code == 403 assert fresh.headers.get("X-Step-Up-Action") == "admin_reset_password" # admin reset → a one-time temp returned once, after an action-bound re-proof - assert (await _reauth(c, admin_token, purpose="admin_reset_password")).status_code == 200 + _r, admin_token = await _reauth(c, admin_token, purpose="admin_reset_password") + assert _r.status_code == 200 + admin = _auth(admin_token) # the re-auth re-keyed the session (ASVS 7.2.4) reset = await c.post(f"/users/{carol_id}/reset-password", headers=admin) assert reset.status_code == 200 temp = reset.json()["temp_password"] @@ -1305,12 +1355,18 @@ async def test_admin_reset_password_endpoint(engine: Engine) -> None: # unknown → 404; AD user → 400; your own account → 400 (use change-password). Each needs its # own grant: the gate runs BEFORE the body, so without one these would all be 403 and the # test would stop measuring what it is named for. - assert (await _reauth(c, admin_token, purpose="admin_reset_password")).status_code == 200 + _r, admin_token = await _reauth(c, admin_token, purpose="admin_reset_password") + assert _r.status_code == 200 + admin = _auth(admin_token) assert (await c.post("/users/nope/reset-password", headers=admin)).status_code == 404 - assert (await _reauth(c, admin_token, purpose="admin_reset_password")).status_code == 200 + _r, admin_token = await _reauth(c, admin_token, purpose="admin_reset_password") + assert _r.status_code == 200 + admin = _auth(admin_token) assert (await c.post("/users/ad9/reset-password", headers=admin)).status_code == 400 me_id = (await c.get("/auth/me", headers=admin)).json()["user_id"] - assert (await _reauth(c, admin_token, purpose="admin_reset_password")).status_code == 200 + _r, admin_token = await _reauth(c, admin_token, purpose="admin_reset_password") + assert _r.status_code == 200 + admin = _auth(admin_token) assert (await c.post(f"/users/{me_id}/reset-password", headers=admin)).status_code == 400 @@ -1526,15 +1582,16 @@ async def test_disabling_the_LAST_second_factor_is_a_400_not_a_500( 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") + _r, tok = await _reauth(c, tok, purpose="mfa_enroll") secret = (await c.post("/me/mfa/enroll", headers=_auth(tok))).json()["secret"] t0 = 1_000_000.0 pin_totp_clock(monkeypatch, t0) - await _reauth(c, tok, purpose="mfa_confirm") + _r, tok = await _reauth(c, tok, purpose="mfa_confirm") confirmed = await c.post( "/me/mfa/confirm", json={"code": totp.totp(secret, now=t0)}, headers=_auth(tok) ) assert confirmed.status_code == 200, confirmed.text + tok = _rotated(confirmed, tok) # the confirm re-keyed the session (ASVS 7.2.4) # A LATER step deliberately: the activating code is single-use and cannot be replayed on # /auth/mfa-verify inside its own window (BACKLOG #1021), so reusing it here would 401 and @@ -1545,9 +1602,10 @@ async def test_disabling_the_LAST_second_factor_is_a_400_not_a_500( "/auth/mfa-verify", json={"code": totp.totp(secret, now=t1)}, headers=_auth(tok) ) assert verified.status_code == 200, verified.text + tok = _rotated(verified, tok) # TOTP is now the ONLY second factor and require_mfa defaults on, so the disable must refuse. - await _reauth(c, tok, purpose="mfa_disable") + _r, tok = await _reauth(c, tok, purpose="mfa_disable") r = await c.delete("/me/mfa", headers=_auth(tok)) assert r.status_code == 400, r.text assert "enroll another factor first" in r.text @@ -1645,7 +1703,9 @@ async def test_admin_reset_mfa_refuses_to_target_the_caller(engine: Engine) -> N h = _auth(tok) # A grant bound to THIS action, so the request reaches the route body rather than the gate. - assert (await _reauth(c, tok, purpose="admin_reset_mfa")).status_code == 200 + _r, tok = await _reauth(c, tok, purpose="admin_reset_mfa") + assert _r.status_code == 200 + h = _auth(tok) # the re-auth re-keyed the session (ASVS 7.2.4) mine = await c.post(f"/users/{root_id}/reset-mfa", headers=h) assert mine.status_code == 400, ( "the admin MFA reset accepted the caller's own id. That is a route to zero factors " @@ -1655,7 +1715,9 @@ async def test_admin_reset_mfa_refuses_to_target_the_caller(engine: Engine) -> N # THE OTHER HALF, and without it this test would pass just as well if the route were broken # outright: a DIFFERENT user is still resettable, so recovery is intact. - assert (await _reauth(c, tok, purpose="admin_reset_mfa")).status_code == 200 + _r, tok = await _reauth(c, tok, purpose="admin_reset_mfa") + assert _r.status_code == 200 + h = _auth(tok) other = await c.post(f"/users/{target}/reset-mfa", headers=h) assert other.status_code == 200, ( "cross-user admin MFA reset broke. That is the always-available recovery for a " diff --git a/tests/test_apiclient.py b/tests/test_apiclient.py index 5a2805525..d8c6dad99 100644 --- a/tests/test_apiclient.py +++ b/tests/test_apiclient.py @@ -517,9 +517,10 @@ def test_the_plaintext_escape_refuses_every_credential( def test_a_token_copied_past_the_entry_points_still_cannot_cross() -> None: - """``for_polling`` assigns ``poll._token`` DIRECTLY, so a clamp that lived only on ``set_token`` - would hold by accident rather than by construction. ``_request`` re-checks, so any future path - that sets the attribute without going through an entry point is covered too. + """A clamp that lived only on ``set_token`` would hold by accident rather than by construction. + ``_request`` re-checks, so any path that writes the token without going through an entry point is + covered too — ``_adopt_rotated`` (ASVS 7.2.4) is exactly such a path, and it writes through the + shared cell rather than the entry points. Mutation: delete the token branch in ``_request``. Red: the Authorization header goes out over plaintext http to a non-loopback host.""" @@ -565,3 +566,42 @@ def test_the_remote_plaintext_refusal_no_longer_advertises_the_escape_as_a_fix() assert "carries no credential" in message, ( "the refusal must state what the escape now cannot do" ) + + +# --- ASVS 7.2.4: a rotation must reach the background poll clients ----------- + + +def test_a_rotation_reaches_a_poll_client_cloned_before_it() -> None: + """RED when: for_polling goes back to COPYING the token instead of sharing the cell. + + The console clones a poll client once, at start-up, and then re-authenticates over the life of + the session. With a copied token every elevation would strand that clone on a retired bearer and + every background read on it would 401 -- silently, because the poll client has no handlers and + the main-thread client would carry on working. + """ + client = EngineClient("http://127.0.0.1:8765") + try: + client._token = "before-rotation" + poll = client.for_polling() # cloned BEFORE the rotation, as the console does + assert poll.token == "before-rotation" + + client._token = "after-rotation" # what _adopt_rotated does on an elevation response + assert poll.token == "after-rotation", "the rotation did not reach the poll clone" + finally: + client.close() + + +def test_a_poll_client_never_writes_the_shared_token() -> None: + """RED when: a background read path starts assigning the token. + + The cell is shared, so a writer on a worker thread would now reach the main-thread client too. + Sharing is only safe while the primary stays the sole writer -- this pins the direction, and is + the reason for_polling still installs no step-up/MFA handlers. + """ + client = EngineClient("http://127.0.0.1:8765") + try: + client._token = "primary" + poll = client.for_polling() + assert poll._step_up_handler is None and poll._mfa_handler is None + finally: + client.close() diff --git a/tests/test_mfa.py b/tests/test_mfa.py index cd0061e41..72d479e0c 100644 --- a/tests/test_mfa.py +++ b/tests/test_mfa.py @@ -63,10 +63,12 @@ async def test_enroll_confirm_status_and_recovery_codes() -> None: assert enroll.secret and enroll.otpauth_uri.startswith("otpauth://totp/") assert (await service.mfa_status(identity)).enabled is False # staged, not active - recovery = await service.confirm_mfa_enrollment( + enrolled = await service.confirm_mfa_enrollment( identity, fresh_totp(enroll.secret), token=token ) - assert recovery is not None and len(recovery) == 10 + assert enrolled.ok and len(enrolled.recovery_codes) == 10 + token = enrolled.token # the confirm re-keyed the session (ASVS 7.2.4) + assert token is not None status = await service.mfa_status(identity) assert status.enabled and status.recovery_codes_remaining == 10 and status.required @@ -99,13 +101,16 @@ async def test_login_requires_second_factor_after_enrollment( assert await service.mfa_satisfied(out.token) is False # step-up gate would 403 wrong = "000000" if activating != "000000" else "111111" - assert await service.verify_mfa(out.token, wrong) is False + assert (await service.verify_mfa(out.token, wrong)).ok is False assert await service.mfa_satisfied(out.token) is False # The successful login verify must sit in a strictly later step than enrollment consumed. t1 = t0 + totp.DEFAULT_PERIOD pin_totp_clock(monkeypatch, t1) - assert await service.verify_mfa(out.token, totp.totp(enroll.secret, now=t1)) is True - assert await service.mfa_satisfied(out.token) is True + verified = await service.verify_mfa(out.token, totp.totp(enroll.secret, now=t1)) + assert verified.ok is True + # Read the satisfied state on the ROTATED token: the verify re-keyed the session (7.2.4), + # so out.token no longer resolves and would report False for the wrong reason. + assert await service.mfa_satisfied(verified.token) is True finally: await store.close() @@ -127,14 +132,14 @@ async def test_enrollment_consumes_the_activating_step(monkeypatch: pytest.Monke pin_totp_clock(monkeypatch, t0) activating = totp.totp(enroll.secret, now=t0) # Confirm succeeds and consumes step S0 (returns the recovery codes, not None). - assert await service.confirm_mfa_enrollment(identity, activating, token=_token) is not None + assert (await service.confirm_mfa_enrollment(identity, activating, token=_token)).ok # A fresh login, then replay the SAME activating code while still pinned to step S0: refused, # because enrollment already consumed S0 (the login path advances the high-water mark to S0 # at enroll, so this replay resolves to a non-greater step). out = await service.login("admin", password) assert out.token is not None - assert await service.verify_mfa(out.token, activating) is False + assert (await service.verify_mfa(out.token, activating)).ok is False assert await service.mfa_satisfied(out.token) is False finally: await store.close() @@ -161,20 +166,23 @@ async def test_recovery_code_single_use() -> None: service = AuthService(store, AuthSettings(mfa_recovery_code_count=3)) identity, token, password = await _bootstrap_login(service) enroll = await service.begin_mfa_enrollment(identity) - codes = await service.confirm_mfa_enrollment( + enrolled = await service.confirm_mfa_enrollment( identity, fresh_totp(enroll.secret), token=token ) - assert codes is not None and len(codes) == 3 + assert enrolled.ok and len(enrolled.recovery_codes) == 3 + codes = enrolled.recovery_codes out = await service.login("admin", password) assert out.token is not None - assert await service.verify_mfa(out.token, codes[0]) is True # consumes it + assert (await service.verify_mfa(out.token, codes[0])).ok is True # consumes it assert (await service.mfa_status(identity)).recovery_codes_remaining == 2 out2 = await service.login("admin", password) assert out2.token is not None - assert await service.verify_mfa(out2.token, codes[0]) is False # reuse rejected - assert await service.verify_mfa(out2.token, codes[1]) is True # a fresh one still works + assert (await service.verify_mfa(out2.token, codes[0])).ok is False # reuse rejected + assert ( + await service.verify_mfa(out2.token, codes[1]) + ).ok is True # a fresh one still works finally: await store.close() @@ -201,12 +209,12 @@ async def test_totp_code_is_single_use_within_its_window( code = totp.totp(enroll.secret, now=t1) out = await service.login("admin", password) assert out.token is not None - assert await service.verify_mfa(out.token, code) is True # consumes the step + assert (await service.verify_mfa(out.token, code)).ok is True # consumes the step out2 = await service.login("admin", password) assert out2.token is not None # Same code, still inside its window, fresh session → rejected (replay within the window). - assert await service.verify_mfa(out2.token, code) is False + assert (await service.verify_mfa(out2.token, code)).ok is False finally: await store.close() @@ -247,7 +255,9 @@ async def test_disable_and_admin_reset_clear_mfa(monkeypatch: pytest.MonkeyPatch t0 = 1_000_000.0 pin_totp_clock(monkeypatch, t0) activating = totp.totp(enroll.secret, now=t0) - await service.confirm_mfa_enrollment(identity, activating, token=token) + first = await service.confirm_mfa_enrollment(identity, activating, token=token) + assert first.ok and first.token is not None + token = first.token # re-keyed by the confirm; the re-enroll below needs the live token await service.disable_mfa(identity) assert (await service.mfa_status(identity)).enabled is False @@ -265,7 +275,7 @@ async def test_disable_and_admin_reset_clear_mfa(monkeypatch: pytest.MonkeyPatch reenrolled = await service.confirm_mfa_enrollment( identity, totp.totp(enroll2.secret, now=t1), token=token ) - assert reenrolled is not None + assert reenrolled.ok assert (await service.mfa_status(identity)).enabled is True await service.admin_reset_mfa(identity.user_id, actor="admin") assert (await service.mfa_status(identity)).enabled is False @@ -322,17 +332,18 @@ async def test_recovery_code_consume_is_atomic_under_concurrency() -> None: service = AuthService(store, AuthSettings(mfa_recovery_code_count=3)) identity, token, password = await _bootstrap_login(service) enroll = await service.begin_mfa_enrollment(identity) - codes = await service.confirm_mfa_enrollment( + enrolled = await service.confirm_mfa_enrollment( identity, fresh_totp(enroll.secret), token=token ) - assert codes is not None + assert enrolled.ok + codes = enrolled.recovery_codes outs = [await service.login("admin", password) for _ in range(5)] tokens = [o.token for o in outs] assert all(tokens) results = await asyncio.gather(*(service.verify_mfa(t, codes[0]) for t in tokens)) - assert sum(1 for r in results if r) == 1 # exactly one caller wins the single-use code + assert sum(1 for r in results if r.ok) == 1 # one caller wins the single-use code assert (await service.mfa_status(identity)).recovery_codes_remaining == 2 # consumed once finally: await store.close() @@ -363,14 +374,15 @@ async def test_spending_a_recovery_code_is_audited_distinguishably_and_notified( # are indistinguishable, and the assertion below passes either way. await store.set_user_notify_email(identity.user_id, email="admin-notify@example.org") enroll = await service.begin_mfa_enrollment(identity) - codes = await service.confirm_mfa_enrollment( + enrolled = await service.confirm_mfa_enrollment( identity, fresh_totp(enroll.secret), token=token ) - assert codes is not None and len(codes) == 2 + assert enrolled.ok and len(enrolled.recovery_codes) == 2 + codes = enrolled.recovery_codes out = await service.login("admin", password) assert out.token is not None - assert await service.verify_mfa(out.token, codes[0], client="10.0.0.7") is True + assert (await service.verify_mfa(out.token, codes[0], client="10.0.0.7")).ok is True spent = [e for e in notifier.events if e.event_type == RECOVERY_CODE_USED] assert len(spent) == 1 @@ -425,14 +437,13 @@ async def test_an_ordinary_totp_verify_spends_no_recovery_code_and_announces_not await service.confirm_mfa_enrollment( identity, totp.totp(enroll.secret, now=t0), token=token ) - is not None - ) + ).ok out = await service.login("admin", password) assert out.token is not None t1 = t0 + totp.DEFAULT_PERIOD pin_totp_clock(monkeypatch, t1) - assert await service.verify_mfa(out.token, totp.totp(enroll.secret, now=t1)) is True + assert (await service.verify_mfa(out.token, totp.totp(enroll.secret, now=t1))).ok is True assert [e for e in notifier.events if e.event_type == RECOVERY_CODE_USED] == [] assert [ @@ -455,16 +466,17 @@ async def test_the_losing_racer_reports_no_second_consumption() -> None: ) identity, token, password = await _bootstrap_login(service) enroll = await service.begin_mfa_enrollment(identity) - codes = await service.confirm_mfa_enrollment( + enrolled = await service.confirm_mfa_enrollment( identity, fresh_totp(enroll.secret), token=token ) - assert codes is not None + assert enrolled.ok + codes = enrolled.recovery_codes outs = [await service.login("admin", password) for _ in range(5)] tokens = [o.token for o in outs] assert all(tokens) results = await asyncio.gather(*(service.verify_mfa(t, codes[0]) for t in tokens)) - assert sum(1 for r in results if r) == 1 + assert sum(1 for r in results if r.ok) == 1 assert len([e for e in notifier.events if e.event_type == RECOVERY_CODE_USED]) == 1 assert ( @@ -495,10 +507,10 @@ async def test_mfa_failures_trip_the_per_account_lockout() -> None: good = totp.totp(enroll.secret) wrong = "000000" if good != "000000" else "111111" for _ in range(5): # exhaust lockout_threshold with wrong codes - assert await service.verify_mfa(out.token, wrong) is False + assert (await service.verify_mfa(out.token, wrong)).ok is False # The account is now locked: even a CORRECT code is refused... - assert await service.verify_mfa(out.token, fresh_totp(enroll.secret)) is False + assert (await service.verify_mfa(out.token, fresh_totp(enroll.secret))).ok is False # ...and the lock is shared with the password path (a fresh login is locked too). relogin = await service.login("admin", password) assert relogin.ok is False and relogin.error == "account locked" diff --git a/tests/test_mfa_access_gate.py b/tests/test_mfa_access_gate.py index bba15d82a..12bf41f56 100644 --- a/tests/test_mfa_access_gate.py +++ b/tests/test_mfa_access_gate.py @@ -239,9 +239,11 @@ async def test_an_enrolled_account_cannot_self_promote_by_binding_a_second_facto setup = await service.login("vic", PW) assert setup.ok and setup.token is not None enrollment = await service.begin_mfa_enrollment(identity) - assert await service.confirm_mfa_enrollment( - identity, totp.totp(enrollment.secret), token=setup.token - ) + assert ( + await service.confirm_mfa_enrollment( + identity, totp.totp(enrollment.secret), token=setup.token + ) + ).ok async with _client(engine, service) as c: # The attacker knows the password and nothing else. @@ -253,6 +255,11 @@ async def test_an_enrolled_account_cannot_self_promote_by_binding_a_second_facto # succeeds as a re-auth (the session window legitimately refreshes)... r = await c.post("/me/reauth", json={"password": PW, "purpose": "mfa_enroll"}, headers=h) assert r.status_code == 200 + # The re-auth re-keyed the session (ASVS 7.2.4), so the rest of the chain has to be driven + # with the ROTATED bearer -- otherwise the enroll below would 401 on a dead token and the + # test would "pass" without ever reaching the guard it exists to prove. + tok = str(r.json()["token"]) + h = _auth(tok) # ...but it must NOT have minted the action grant, so the enrollment route stays shut. enroll = await c.post("/me/mfa/enroll", headers=h) @@ -279,9 +286,11 @@ async def test_bootstrap_enrollment_from_a_pending_session_still_works(engine: E tok = await _login(c, "fresh") h = _auth(tok) assert (await c.get("/messages", headers=h)).status_code == 403 - assert ( - await c.post("/me/reauth", json={"password": PW, "purpose": "mfa_enroll"}, headers=h) - ).status_code == 200 + elevated = await c.post( + "/me/reauth", json={"password": PW, "purpose": "mfa_enroll"}, headers=h + ) + assert elevated.status_code == 200 + h = _auth(str(elevated.json()["token"])) # re-keyed by the re-auth assert (await c.post("/me/mfa/enroll", headers=h)).status_code == 200 diff --git a/tests/test_session_rotation_primitive.py b/tests/test_session_rotation_primitive.py index 88ef5f3ca..6149f90fa 100644 --- a/tests/test_session_rotation_primitive.py +++ b/tests/test_session_rotation_primitive.py @@ -7,7 +7,7 @@ must carry with it. Why this file exists at all. The primitive landed ahead of its call sites (the five elevation sites -are the remaining 7.2.4 work, BACKLOG #314), so it had NO coverage and no caller — and its job is +are the remaining 7.2.4 work), so it had NO coverage and no caller — and its job is precisely the part that fails *silently*: three in-memory maps keyed on the session's token hash, each of which strands differently if it is missed. Untested code whose failure mode is silence is worse than absent code, so it is tested here rather than left to the wiring commit. diff --git a/tests/test_session_rotation_wiring.py b/tests/test_session_rotation_wiring.py new file mode 100644 index 000000000..810791e0f --- /dev/null +++ b/tests/test_session_rotation_wiring.py @@ -0,0 +1,330 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""ASVS 7.2.4 — rotation WIRED at the five elevation call sites, asserted by BEHAVIOUR. + +``tests/test_session_rotation_primitive.py`` pins ``_rotate_session_token`` itself. This file pins +that the five ceremonies which RAISE a session's authentication state actually call it, in the right +order, and hand the new token back. + +**Why behaviour and not a call-pattern check.** The absence marker for this requirement was literally +"the primitive has no callers", so a test that asserted the primitive is now called would flip that +marker while proving nothing about the session. Every case below drives ``identity_for_token`` (the +seam every gate authenticates on) or a real route, so a rotation that re-keys the row but strands the +caller, or one that runs before its own stamps, still fails. + +The five sites: ``reauth``, ``verify_mfa``, ``confirm_mfa_enrollment``, ``finish_webauthn_registration``, +``finish_webauthn_assertion``. Each test names the mutation that must turn it RED. +""" + +from __future__ import annotations + +import json + +import pytest +from _totp_clock import fresh_totp, pin_totp_clock + +from messagefoundry.auth import totp +from messagefoundry.auth.identity import Identity +from messagefoundry.auth.service import STEP_UP_ACTION_MFA_ENROLL, AuthService +from messagefoundry.auth.tokens import hash_token +from messagefoundry.config.settings import AuthSettings +from messagefoundry.store.store import MessageStore + + +async def _store() -> MessageStore: + return await MessageStore.open(":memory:") + + +async def _service(**settings: object) -> tuple[AuthService, MessageStore]: + store = await _store() + settings.setdefault("login_rate_limit_enabled", False) + return AuthService(store, AuthSettings(**settings)), store + + +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) + assert out.ok and out.identity is not None and out.token is not None + return out.identity, out.token, boot.password + + +async def _enable_totp( + service: AuthService, + identity: Identity, + token: str, + *, + monkeypatch: pytest.MonkeyPatch, + instant: float, +) -> tuple[str, str]: + """Enroll + confirm TOTP at a PINNED step. Returns (secret, the token the confirm rotated to). + + The step is pinned because enrollment CONSUMES its activating step (single-use, BACKLOG #1021), + so a later ``verify_mfa`` must present a code from a strictly higher step. ``fresh_totp`` + guarantees headroom within a step but cannot advance one. + """ + enroll = await service.begin_mfa_enrollment(identity) + pin_totp_clock(monkeypatch, instant) + elevation = await service.confirm_mfa_enrollment( + identity, totp.totp(enroll.secret, now=instant), token=token + ) + assert elevation.ok and elevation.token is not None + return enroll.secret, elevation.token + + +async def _assert_rotated(service: AuthService, old: str, new: str | None) -> None: + """The whole contract in one place: the old token is dead, the new one authenticates.""" + assert new is not None and new != old, "the ceremony handed back no new token" + assert await service.identity_for_token(old) is None, ( + "the pre-elevation token still authenticates — it was elevated IN PLACE" + ) + assert await service.identity_for_token(new) is not None, "the new token does not authenticate" + + +# --- one case per elevation site -------------------------------------------- + + +async def test_reauth_rotates_the_session() -> None: + """RED when: reauth stops calling _elevated (site 1 of 5). + + Without this, a token captured before a step-up would keep working with the step-up's freshly + widened privileges on a first deployment. + """ + service, store = await _service() + try: + identity, token, password = await _bootstrap_login(service) + elevation = await service.reauth(identity, password, token=token) + assert elevation.ok + await _assert_rotated(service, token, elevation.token) + finally: + await store.close() + + +async def test_verify_mfa_rotates_the_session(monkeypatch: pytest.MonkeyPatch) -> None: + """RED when: verify_mfa stops calling _elevated (site 2 of 5). + + THE site the requirement is about: a pre-MFA token captured before the second factor would + otherwise be elevated in place to a fully authenticated session. + """ + service, store = await _service(require_mfa=True) + try: + identity, token, password = await _bootstrap_login(service) + t0 = 1_000_000.0 + secret, token = await _enable_totp( + service, identity, token, monkeypatch=monkeypatch, instant=t0 + ) + + # Re-login to get a genuinely MFA-PENDING session, which is the state under test. + out = await service.login("admin", password) + assert out.token is not None + pending = out.token + assert await service.mfa_satisfied(pending) is False + + # A strictly later step: enrollment consumed its own. + t1 = t0 + totp.DEFAULT_PERIOD + pin_totp_clock(monkeypatch, t1) + elevation = await service.verify_mfa(pending, totp.totp(secret, now=t1)) + assert elevation.ok + await _assert_rotated(service, pending, elevation.token) + assert await service.mfa_satisfied(elevation.token) is True + finally: + await store.close() + + +async def test_confirm_mfa_enrollment_rotates_the_session() -> None: + """RED when: confirm_mfa_enrollment stops calling _elevated (site 3 of 5). + + The FIRST-enrolment promotion leg. Skipping it is the exact trap of building only the two + owner-named JSON routes: TOTP verify would rotate while the leg that turns a pending session into + a satisfied one for a first enrolment would not. + """ + service, store = await _service() + try: + identity, token, _ = await _bootstrap_login(service) + enroll = await service.begin_mfa_enrollment(identity) + + elevation = await service.confirm_mfa_enrollment( + identity, fresh_totp(enroll.secret), token=token + ) + assert elevation.ok + await _assert_rotated(service, token, elevation.token) + # The one-time codes still come back — the result type carries them, not a separate return. + assert len(elevation.recovery_codes) == 10 + finally: + await store.close() + + +async def test_the_elevated_state_is_readable_on_the_new_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """RED when: any stamp moves AFTER the rotation — the ORDERING INVARIANT, pinned. + + Every session UPDATE but revoke/rotate is rowcount-blind, so a stamp written after the re-key + silently writes nothing and still reports success. The only way to see that is from the NEW + session: it would read back unelevated. Asserted on both stamps verify_mfa makes. + """ + service, store = await _service(require_mfa=True) + try: + identity, token, password = await _bootstrap_login(service) + t0 = 1_000_000.0 + secret, _ = await _enable_totp( + service, identity, token, monkeypatch=monkeypatch, instant=t0 + ) + + out = await service.login("admin", password) + assert out.token is not None + t1 = t0 + totp.DEFAULT_PERIOD + pin_totp_clock(monkeypatch, t1) + elevation = await service.verify_mfa(out.token, totp.totp(secret, now=t1)) + assert elevation.ok and elevation.token is not None + + # mark_session_mfa_verified landed before the rotation... + assert await service.mfa_satisfied(elevation.token) is True + # ...and so did mark_session_reauthed (the step-up window verify_mfa seeds). + assert await service.has_recent_step_up(elevation.token) is True + finally: + await store.close() + + +# --- the negative control ---------------------------------------------------- + + +@pytest.mark.parametrize("ceremony", ["reauth", "verify_mfa", "confirm_mfa_enrollment"]) +async def test_a_failed_elevation_rotates_nothing( + ceremony: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """RED when: any site rotates UNCONDITIONALLY rather than on success. + + NOT decoration. Every other assertion in this file is satisfied by a service that rotates on + every call, which would hand an attacker a session-killing oracle out of a wrong password and + sign the user out on every typo. The original token MUST survive a failed proof. + """ + service, store = await _service() + try: + identity, token, password = await _bootstrap_login(service) + if ceremony == "reauth": + elevation = await service.reauth(identity, "wrong-password", token=token) + elif ceremony == "verify_mfa": + await _enable_totp( + service, identity, token, monkeypatch=monkeypatch, instant=1_000_000.0 + ) + out = await service.login("admin", password) + assert out.token is not None + token = out.token + elevation = await service.verify_mfa(token, "000000") + else: + await service.begin_mfa_enrollment(identity) + elevation = await service.confirm_mfa_enrollment(identity, "000000", token=token) + + assert elevation.ok is False + assert elevation.token is None, "a failed ceremony handed back a token" + assert elevation.session_lost is False, "a wrong proof is not a lost session" + assert await service.identity_for_token(token) is not None, ( + "a FAILED elevation rotated the session — a wrong proof must change nothing" + ) + finally: + await store.close() + + +async def test_a_ceremony_on_a_revoked_session_fails_closed() -> None: + """RED when: _elevated returns ok on a None rotate. + + A correct password against a session revoked underneath the ceremony must not yield a token that + authenticates nothing, and must be distinguishable from a wrong password so the route can say + "sign in again" rather than "incorrect". + """ + service, store = await _service() + try: + identity, token, password = await _bootstrap_login(service) + await service.store.revoke_session(hash_token(token)) + + elevation = await service.reauth(identity, password, token=token) + assert elevation.ok is False + assert elevation.token is None + assert elevation.session_lost is True + finally: + await store.close() + + +# --- the reauth grant trap --------------------------------------------------- + + +async def test_the_action_grant_is_minted_against_the_new_token() -> None: + """RED when: reauth mints the purpose-bound grant BEFORE the rotation (or against the old hash). + + ADR 0077's grant is keyed on the session's token hash. Minted against the retired hash it is + stranded: the ceremony the operator just completed silently no-ops and the next route demands + another step-up. `_rekey_token_state` carries grants across, so minting early would ALSO appear + to work — this pins that the grant is usable on the token the caller was actually handed. + """ + service, store = await _service() + try: + identity, token, password = await _bootstrap_login(service) + elevation = await service.reauth( + identity, password, token=token, purpose=STEP_UP_ACTION_MFA_ENROLL + ) + assert elevation.ok and elevation.token is not None + + # Spend it on the OLD token first: it must not be there. + assert await service.has_action_step_up(token, STEP_UP_ACTION_MFA_ENROLL) is False + assert await service.has_action_step_up(elevation.token, STEP_UP_ACTION_MFA_ENROLL) is True + finally: + await store.close() + + +# --- the WebAuthn legs ------------------------------------------------------- + + +async def test_the_passkey_legs_rotate_the_session() -> None: + """RED when: finish_webauthn_registration or finish_webauthn_assertion stops calling _elevated + (sites 4 and 5 of 5). + + For a passkey-only account the assertion is the ONLY leg of POST /ui/mfa, so a build that rotated + the TOTP sites alone would leave the cell claiming rotation-on-re-authentication while missing the + passkey path entirely. + """ + pytest.importorskip("webauthn") + from webauthn.helpers import base64url_to_bytes + + from tests._soft_webauthn import SoftAuthenticator + + rp, origin = "t", "http://t" + service, store = await _service(require_mfa=False) + try: + identity, token, password = await _bootstrap_login(service) + soft = SoftAuthenticator(rp_id=rp, origin=origin) + + opts = json.loads( + await service.begin_webauthn_registration( + identity, token=token, rp_id=rp, rp_name="MessageFoundry" + ) + ) + registration = await service.finish_webauthn_registration( + identity, + soft.create_response(base64url_to_bytes(opts["challenge"]), transports=["usb"]), + label="k", + token=token, + rp_id=rp, + origin=origin, + ) + assert registration.ok + await _assert_rotated(service, token, registration.token) + token = registration.token + assert token is not None + + # A fresh MFA-pending session, then the assertion leg. + out = await service.login("admin", password) + assert out.token is not None + options = await service.begin_webauthn_assertion(out.token, rp_id=rp) + assert options is not None + assertion = await service.finish_webauthn_assertion( + out.token, + soft.get_response(base64url_to_bytes(json.loads(options)["challenge"])), + rp_id=rp, + origin=origin, + ) + assert assertion.ok + await _assert_rotated(service, out.token, assertion.token) + assert await service.mfa_satisfied(assertion.token) is True + finally: + await store.close() diff --git a/tests/test_step_up.py b/tests/test_step_up.py index 8c5e09f10..3844a0693 100644 --- a/tests/test_step_up.py +++ b/tests/test_step_up.py @@ -126,6 +126,7 @@ async def test_stale_session_blocked_then_reauth_refreshes(engine: Engine) -> No # Correct password refreshes it; the sensitive op now succeeds. ok = await c.post("/me/reauth", headers=_auth(token), json={"password": PW}) assert ok.status_code == 200 + token = _rotated(ok, token) assert (await c.post("/users", headers=_auth(token), json=NEW_USER)).status_code == 201 @@ -138,7 +139,9 @@ async def test_app_replay_route_is_also_step_up_gated(engine: Engine) -> None: # The message doesn't exist, but step-up fires first → 403, not 404. blocked = await c.post("/messages/nonexistent/replay", headers=_auth(token)) assert blocked.status_code == 403 - await c.post("/me/reauth", headers=_auth(token), json={"password": PW}) + token = _rotated( + await c.post("/me/reauth", headers=_auth(token), json={"password": PW}), token + ) # Past the gate now: a missing message is a normal 404 (anything but the step-up 403). passed = await c.post("/messages/nonexistent/replay", headers=_auth(token)) assert passed.status_code != 403 @@ -179,9 +182,9 @@ def resolve_principal(self, username: str) -> AdPrincipal | None: await c.post("/me/reauth", headers=_auth(token), json={"password": "wrong"}) ).status_code == 403 # Correct AD password re-binds and refreshes the window. - assert ( - await c.post("/me/reauth", headers=_auth(token), json={"password": "ad-pw"}) - ).status_code == 200 + rebind = await c.post("/me/reauth", headers=_auth(token), json={"password": "ad-pw"}) + assert rebind.status_code == 200 + token = _rotated(rebind, token) assert (await c.post("/users", headers=_auth(token), json=NEW_USER)).status_code == 201 @@ -204,13 +207,27 @@ async def test_has_recent_step_up_tracks_the_window(engine: Engine) -> None: # --- ADR 0077: action-bound step-up for the durable-takeover routes ---------- +def _rotated(response: httpx.Response, token: str) -> str: + """The bearer to use AFTER an elevation call (ASVS 7.2.4). + + A successful elevation re-keys the session and returns the new token in the body, so every later + request must carry it. A refusal rotates nothing and the incoming token is handed back.""" + if response.status_code != 200: + return token + fresh = response.json().get("token") + assert isinstance(fresh, str) and fresh, "an elevation route returned no rotated token" + return fresh + + async def _reauth( c: httpx.AsyncClient, token: str, *, purpose: str | None = None, password: str = PW -) -> httpx.Response: +) -> tuple[httpx.Response, str]: + """Returns ``(response, the token to use next)`` -- see :func:`_rotated`.""" body: dict[str, str] = {"password": password} if purpose is not None: body["purpose"] = purpose - return await c.post("/me/reauth", headers=_auth(token), json=body) + r = await c.post("/me/reauth", headers=_auth(token), json=body) + return r, _rotated(r, token) async def test_login_window_does_not_unlock_factor_binding(engine: Engine) -> None: @@ -232,7 +249,8 @@ async def test_login_window_does_not_unlock_factor_binding(engine: Engine) -> No assert r.headers.get("X-Step-Up-Required") == "1" assert r.headers.get("X-Step-Up-Action") == action # the 403 names the action to reauth # A per-action reauth for enroll unlocks exactly enroll (the staged secret is returned). - assert (await _reauth(c, token, purpose="mfa_enroll")).status_code == 200 + r, token = await _reauth(c, token, purpose="mfa_enroll") + assert r.status_code == 200 enrolled = await c.post("/me/mfa/enroll", headers=_auth(token)) assert enrolled.status_code == 200 and enrolled.json()["secret"] @@ -245,13 +263,15 @@ async def test_action_grant_is_single_use_and_bound(engine: Engine) -> None: async with _client(engine, service) as c: token = await _login(c, "boss") # One reauth → one enroll. - assert (await _reauth(c, token, purpose="mfa_enroll")).status_code == 200 + r, token = await _reauth(c, token, purpose="mfa_enroll") + assert r.status_code == 200 assert (await c.post("/me/mfa/enroll", headers=_auth(token))).status_code == 200 # Single-use: the grant was consumed, so a second enroll re-prompts. again = await c.post("/me/mfa/enroll", headers=_auth(token)) assert again.status_code == 403 and again.headers.get("X-Step-Up-Action") == "mfa_enroll" # Bound: an enroll grant does NOT unlock confirm (a different action). - assert (await _reauth(c, token, purpose="mfa_enroll")).status_code == 200 + r2, token = await _reauth(c, token, purpose="mfa_enroll") + assert r2.status_code == 200 confirm = await c.post("/me/mfa/confirm", headers=_auth(token), json={"code": "000000"}) assert ( confirm.status_code == 403 and confirm.headers.get("X-Step-Up-Action") == "mfa_confirm" @@ -280,7 +300,8 @@ async def test_admin_user_update_is_action_bound(engine: Engine) -> None: assert blocked.headers.get("X-Step-Up-Required") == "1" assert blocked.headers.get("X-Step-Up-Action") == "admin_user_update" # A per-action reauth unlocks exactly one PATCH (single-use). - assert (await _reauth(c, token, purpose="admin_user_update")).status_code == 200 + r, token = await _reauth(c, token, purpose="admin_user_update") + assert r.status_code == 200 ok = await c.patch(f"/users/{target_id}", headers=_auth(token), json=body) assert ok.status_code == 200, ok.text # Consumed → a second PATCH re-prompts for the same action. @@ -336,11 +357,16 @@ async def test_login_and_verify_mfa_never_grant_an_action( pin_totp_clock(monkeypatch, t1) token2 = (await service.login("boss", PW)).token assert token2 is not None - assert await service.verify_mfa(token2, totp.totp(enroll.secret, now=t1)) is True + verified = await service.verify_mfa(token2, totp.totp(enroll.secret, now=t1)) + assert verified.ok is True and verified.token is not None + token2 = verified.token # re-keyed by the verify (ASVS 7.2.4) assert await service.has_recent_step_up(token2) is True # verify_mfa re-anchored the window assert await service.has_action_step_up(token2, "mfa_disable") is False # but no grant - # Only reauth(purpose=…) mints one — and it is single-use. - assert await service.reauth(identity, PW, token=token2, purpose="mfa_disable") is True + # Only reauth(purpose=…) mints one — and it is single-use. The grant is minted against the + # ROTATED hash (ASVS 7.2.4), so the check has to run on the token reauth handed back. + granted = await service.reauth(identity, PW, token=token2, purpose="mfa_disable") + assert granted.ok is True and granted.token is not None + token2 = granted.token assert await service.has_action_step_up(token2, "mfa_disable") is True # consumes it assert await service.has_action_step_up(token2, "mfa_disable") is False # gone @@ -361,7 +387,8 @@ async def test_opt_out_restores_session_window(engine: Engine) -> None: # reauth carrying no purpose. await _make_stale(service, token) assert (await c.post("/me/mfa/enroll", headers=_auth(token))).status_code == 403 - assert (await _reauth(c, token)).status_code == 200 + r, token = await _reauth(c, token) + assert r.status_code == 200 assert (await c.post("/me/mfa/enroll", headers=_auth(token))).status_code == 200 @@ -381,7 +408,8 @@ async def test_mfa_pending_and_ad_do_not_deadlock(engine: Engine) -> None: assert blocked.headers.get("X-Step-Up-Required") == "1" assert blocked.headers.get("X-MFA-Required") is None # no MFA deadlock # The password-only per-action reauth unlocks enrollment for the MFA-pending session. - assert (await _reauth(c, token, purpose="mfa_enroll")).status_code == 200 + r, token = await _reauth(c, token, purpose="mfa_enroll") + assert r.status_code == 200 assert (await c.post("/me/mfa/enroll", headers=_auth(token))).status_code == 200 @@ -417,10 +445,12 @@ def resolve_principal(self, username: str) -> AdPrincipal | None: identity = await service.identity_for_token(token) assert identity is not None # Wrong AD password: the live re-bind fails and mints nothing. - assert await service.reauth(identity, "wrong", token=token, purpose="mfa_disable") is False + assert (await service.reauth(identity, "wrong", token=token, purpose="mfa_disable")).ok is False assert await service.has_action_step_up(token, "mfa_disable") is False # Correct AD password: the re-bind succeeds and the single-use grant is minted. - assert await service.reauth(identity, "ad-pw", token=token, purpose="mfa_disable") is True + rebound = await service.reauth(identity, "ad-pw", token=token, purpose="mfa_disable") + assert rebound.ok is True and rebound.token is not None + token = rebound.token assert await service.has_action_step_up(token, "mfa_disable") is True diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index 9b94e1d72..ddde6d295 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -65,8 +65,13 @@ async def _enroll( *, label: str = "test key", auth: SoftAuthenticator | None = None, -) -> SoftAuthenticator: - """Run a full real registration ceremony; returns the enrolled soft authenticator.""" +) -> tuple[SoftAuthenticator, str]: + """Run a full real registration ceremony. + + Returns ``(authenticator, token)`` -- the token is the ROTATED one, because enrolling a passkey + marks the session MFA-satisfied and every elevation re-keys the session (ASVS 7.2.4). The one + passed in has stopped authenticating by the time this returns, so callers must take the new one. + """ auth = auth or SoftAuthenticator(rp_id=RP, origin=ORIGIN) opts = json.loads( await service.begin_webauthn_registration( @@ -74,7 +79,7 @@ async def _enroll( ) ) challenge = base64url_to_bytes(opts["challenge"]) - ok = await service.finish_webauthn_registration( + elevation = await service.finish_webauthn_registration( identity, auth.create_response(challenge, transports=["usb"]), label=label, @@ -82,19 +87,25 @@ async def _enroll( rp_id=RP, origin=ORIGIN, ) - assert ok is True - return auth + assert elevation.ok is True and elevation.token is not None + return auth, elevation.token async def _assert_once( service: AuthService, token: str, auth: SoftAuthenticator, *, sign_count: int | None = None -) -> bool: +) -> tuple[bool, str]: + """Run one real assertion ceremony; returns ``(ok, the token to use next)``. + + A successful assertion re-keys the session (ASVS 7.2.4), so the caller must rebind its token or + every later call runs against a hash that no longer resolves. A FAILED assertion rotates nothing + and the incoming token is handed straight back.""" options = await service.begin_webauthn_assertion(token, rp_id=RP) assert options is not None challenge = base64url_to_bytes(json.loads(options)["challenge"]) - return await service.finish_webauthn_assertion( + elevation = await service.finish_webauthn_assertion( token, auth.get_response(challenge, sign_count=sign_count), rp_id=RP, origin=ORIGIN ) + return elevation.ok, (elevation.token or token) async def _events(service: AuthService, username: str) -> list[str]: @@ -113,7 +124,7 @@ async def test_register_then_assert_e2e() -> None: status = await service.mfa_status(identity) assert status.webauthn_enrolled is False and status.required is False - auth = await _enroll(service, identity, token) + auth, token = await _enroll(service, identity, token) # Registration options exclude the enrolled credential on the next ceremony. opts = json.loads( await service.begin_webauthn_registration( @@ -131,7 +142,8 @@ async def test_register_then_assert_e2e() -> None: # The enrolling session was marked MFA-verified (confirm_mfa_enrollment parity). assert await service.mfa_satisfied(token) is True - assert await _assert_once(service, token, auth) is True + ok, token = await _assert_once(service, token, auth) + assert ok is True actions = await _events(service, identity.username) assert "auth.webauthn_enrolled" in actions and "auth.webauthn_verified" in actions finally: @@ -143,13 +155,14 @@ async def test_fresh_session_is_mfa_pending_until_assertion() -> None: try: service = await _service(store) identity, token, password = await _bootstrap_login(service) - auth = await _enroll(service, identity, token) + auth, token = await _enroll(service, identity, token) out = await service.login("admin", password) assert out.ok and out.token is not None fresh = out.token assert await service.mfa_satisfied(fresh) is False # webauthn-enrolled ⇒ required - assert await _assert_once(service, fresh, auth) is True + ok, fresh = await _assert_once(service, fresh, auth) + assert ok is True assert await service.mfa_satisfied(fresh) is True finally: await store.close() @@ -164,14 +177,16 @@ async def test_assertion_stamps_mfa_only_never_reauth() -> None: try: service = await _service(store) identity, token, password = await _bootstrap_login(service) - auth = await _enroll(service, identity, token) + auth, token = await _enroll(service, identity, token) out = await service.login("admin", password) fresh = out.token assert fresh is not None before = await store.get_session(hash_token(fresh)) assert before is not None and before.reauth_at is None # MFA-pending: no seeded step-up - assert await _assert_once(service, fresh, auth) is True + ok, fresh = await _assert_once(service, fresh, auth) + assert ok is True + # Read back under the ROTATED token: the assertion re-keyed the row. after = await store.get_session(hash_token(fresh)) assert after is not None assert after.mfa_verified_at is not None @@ -192,7 +207,7 @@ async def test_registration_rejects_wrong_origin() -> None: ) challenge = base64url_to_bytes(opts["challenge"]) evil = SoftAuthenticator(rp_id=RP, origin="http://evil") - ok = await service.finish_webauthn_registration( + elevation = await service.finish_webauthn_registration( identity, evil.create_response(challenge), label="evil", @@ -200,7 +215,7 @@ async def test_registration_rejects_wrong_origin() -> None: rp_id=RP, origin=ORIGIN, ) - assert ok is False # origin binding — the phishing-resistance property + assert elevation.ok is False # origin binding — the phishing-resistance property assert "auth.webauthn_failed" in await _events(service, identity.username) assert (await service.mfa_status(identity)).webauthn_enrolled is False finally: @@ -216,16 +231,22 @@ async def test_sign_count_cas_clone_detection_nonzero() -> None: service = await _service(store) identity, token, _ = await _bootstrap_login(service) auth = SoftAuthenticator(rp_id=RP, origin=ORIGIN, sign_count=5) - await _enroll(service, identity, token, auth=auth) + _, token = await _enroll(service, identity, token, auth=auth) - assert await _assert_once(service, token, auth, sign_count=6) is True + # Each SUCCESSFUL assertion rotates, so the token is rebound through the chain; the failed + # ones rotate nothing and hand the same token straight back. + ok, token = await _assert_once(service, token, auth, sign_count=6) + assert ok is True # A cloned authenticator replays a non-advancing counter: py_webauthn rejects it and the # service audits the clone signal. - assert await _assert_once(service, token, auth, sign_count=6) is False + ok, token = await _assert_once(service, token, auth, sign_count=6) + assert ok is False assert "auth.webauthn_clone_suspected" in await _events(service, identity.username) - assert await _assert_once(service, token, auth, sign_count=5) is False + ok, token = await _assert_once(service, token, auth, sign_count=5) + assert ok is False # The genuine key advancing again is fine. - assert await _assert_once(service, token, auth, sign_count=7) is True + ok, token = await _assert_once(service, token, auth, sign_count=7) + assert ok is True finally: await store.close() @@ -235,9 +256,10 @@ async def test_sign_count_zero_synced_passkey_accepted_repeatedly() -> None: try: service = await _service(store) identity, token, _ = await _bootstrap_login(service) - auth = await _enroll(service, identity, token) # sign_count stays 0 (synced passkey) + auth, token = await _enroll(service, identity, token) # sign_count 0 (synced passkey) for _ in range(3): - assert await _assert_once(service, token, auth, sign_count=0) is True + ok, token = await _assert_once(service, token, auth, sign_count=0) + assert ok is True creds = await store.list_webauthn_credentials(identity.user_id) assert creds[0].sign_count == 0 and creds[0].last_used_at is not None finally: @@ -252,7 +274,7 @@ async def test_challenge_single_use_ttl_and_per_user_bound() -> None: try: service = await _service(store) identity, token, _ = await _bootstrap_login(service) - auth = await _enroll(service, identity, token) + auth, token = await _enroll(service, identity, token) # Single-use: the replay of an already-consumed challenge fails (covered E2E above); an # expired challenge fails legibly. Swap in a controllable clock. @@ -262,10 +284,10 @@ async def test_challenge_single_use_ttl_and_per_user_bound() -> None: assert options is not None challenge = base64url_to_bytes(json.loads(options)["challenge"]) clock[0] = wa.CHALLENGE_TTL_SECONDS + 1 # expire it - ok = await service.finish_webauthn_assertion( + expired = await service.finish_webauthn_assertion( token, auth.get_response(challenge, sign_count=0), rp_id=RP, origin=ORIGIN ) - assert ok is False + assert expired.ok is False assert "auth.webauthn_failed" in await _events(service, identity.username) # A new ceremony overwrites the session's pending one: the FIRST challenge dies. @@ -274,12 +296,10 @@ async def test_challenge_single_use_ttl_and_per_user_bound() -> None: o2 = await service.begin_webauthn_assertion(token, rp_id=RP) assert o1 is not None and o2 is not None c1 = base64url_to_bytes(json.loads(o1)["challenge"]) - assert ( - await service.finish_webauthn_assertion( - token, auth.get_response(c1, sign_count=0), rp_id=RP, origin=ORIGIN - ) - is False + stale = await service.finish_webauthn_assertion( + token, auth.get_response(c1, sign_count=0), rp_id=RP, origin=ORIGIN ) + assert stale.ok is False # Per-user cap evicts the user's OWN oldest — never another principal's (two-user # interleave); the global safety bound refuses with a cause-naming error. @@ -336,7 +356,7 @@ async def test_duplicate_label_and_duplicate_credential_rejected() -> None: try: service = await _service(store) identity, token, _ = await _bootstrap_login(service) - auth = await _enroll(service, identity, token, label="mykey") + auth, token = await _enroll(service, identity, token, label="mykey") # Same label again (different authenticator) → legible refusal via the integrity path. opts = json.loads( @@ -381,7 +401,7 @@ async def test_last_factor_delete_refused_while_required() -> None: # require_mfa targets local Administrators — the bootstrap admin qualifies. service = await _service(store, notifier=notifier, require_mfa=True) identity, token, _ = await _bootstrap_login(service) - await _enroll(service, identity, token) + _, token = await _enroll(service, identity, token) creds = await store.list_webauthn_credentials(identity.user_id) with pytest.raises(ValueError, match="enroll another factor first"): await service.delete_webauthn_credential(identity, creds[0].credential_id_hash) @@ -396,7 +416,7 @@ async def test_last_factor_delete_notifies_when_not_required() -> None: notifier = _FakeNotifier() service = await _service(store, notifier=notifier) # require_mfa off identity, token, _ = await _bootstrap_login(service) - await _enroll(service, identity, token) + _, token = await _enroll(service, identity, token) creds = await store.list_webauthn_credentials(identity.user_id) # Self-scoped: a foreign/unknown hash removes nothing. @@ -417,7 +437,7 @@ async def test_admin_reset_mfa_clears_webauthn_credentials() -> None: try: service = await _service(store) identity, token, _ = await _bootstrap_login(service) - await _enroll(service, identity, token) + _, token = await _enroll(service, identity, token) await service.admin_reset_mfa(identity.user_id, actor="boss") assert await store.has_webauthn_credentials(identity.user_id) is False # Sessions were revoked (existing semantics unchanged). @@ -433,14 +453,12 @@ async def test_assertion_failures_never_lock_the_account() -> None: try: service = await _service(store) identity, token, _ = await _bootstrap_login(service) - await _enroll(service, identity, token) + _, token = await _enroll(service, identity, token) for _ in range(10): - assert ( - await service.finish_webauthn_assertion( - token, '{"rawId": "garbage"}', rp_id=RP, origin=ORIGIN - ) - is False + garbage = await service.finish_webauthn_assertion( + token, '{"rawId": "garbage"}', rp_id=RP, origin=ORIGIN ) + assert garbage.ok is False user = await store.get_user(identity.user_id) assert user is not None assert user.locked_until is None and user.failed_attempts == 0 @@ -455,8 +473,8 @@ async def test_verify_mfa_stays_totp_specific() -> None: try: service = await _service(store) identity, token, _ = await _bootstrap_login(service) - await _enroll(service, identity, token) - assert await service.verify_mfa(token, "123456") is False + _, token = await _enroll(service, identity, token) + assert (await service.verify_mfa(token, "123456")).ok is False user = await store.get_user(identity.user_id) assert user is not None and user.failed_attempts == 0 finally: @@ -470,7 +488,7 @@ async def test_rp_mismatch_makes_credentials_unusable() -> None: try: service = await _service(store) identity, token, _ = await _bootstrap_login(service) - await _enroll(service, identity, token) + _, token = await _enroll(service, identity, token) assert await service.begin_webauthn_assertion(token, rp_id="other.example") is None finally: await store.close() From 67c17f55b40a6f55c52f3ad6d32a00547082bd41 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 15:05:15 -0500 Subject: [PATCH 11/17] fix(tests): two rotation call sites my #1146 commit left behind (BACKLOG #1146) 02b33cb5b changed five AuthService methods to return Elevation and updated the call sites, but staged an explicit sixteen-file list. The change actually touched eighteen: tests/test_auth_hardening.py and tests/test_last_admin_guard.py were edited and not staged, so the branch shipped four red tests. Measured, not assumed: at 02b33cb5b those two files give 4 failed, 32 passed; with these edits, 36 passed. Both are the same mechanical adoption every other caller took. `_reauth` now returns `(response, token_to_use_next)` -- a successful re-auth re-keys the session, so a caller holding the old bearer 401s on its next request, and a refusal rotates nothing and hands the incoming token back. THE PROCESS DEFECT IS MINE AND IS WORTH NAMING. I staged the file list the builder REPORTED rather than the one `git status` showed. A report is a claim about the work; the tree is the work. The two agreed for every other row today and diverged here, silently, because a shorter list still commits cleanly and the tests I chose to run did not include either file. The check that would have caught it costs nothing: diff the reported paths against `git status --porcelain` before staging. Co-Authored-By: Claude Opus 5 --- tests/test_auth_hardening.py | 26 +++++++++++++++++--------- tests/test_last_admin_guard.py | 18 +++++++++++------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/tests/test_auth_hardening.py b/tests/test_auth_hardening.py index 703bbc3ab..749e86e07 100644 --- a/tests/test_auth_hardening.py +++ b/tests/test_auth_hardening.py @@ -88,12 +88,21 @@ async def _login(c: httpx.AsyncClient, username: str, password: str = PW, provid async def _reauth( c: httpx.AsyncClient, token: str, *, purpose: str | None = None, password: str = PW -) -> httpx.Response: - """POST /me/reauth; ``purpose`` mints the single-use per-action grant (ADR 0077).""" +) -> tuple[httpx.Response, str]: + """POST /me/reauth; ``purpose`` mints the single-use per-action grant (ADR 0077). + + Returns ``(response, the token to use next)``: a successful re-auth re-keys the session + (ASVS 7.2.4), so the caller must adopt the new bearer or every later request 401s. A refusal + rotates nothing and hands the incoming token back.""" body: dict[str, str] = {"password": password} if purpose is not None: body["purpose"] = purpose - return await c.post("/me/reauth", json=body, headers=_auth(token)) + r = await c.post("/me/reauth", json=body, headers=_auth(token)) + if r.status_code != 200: + return r, token + fresh = r.json().get("token") + assert isinstance(fresh, str) and fresh, "an elevation route returned no rotated token" + return r, fresh def _auth(token: str) -> dict[str, str]: @@ -199,17 +208,16 @@ async def test_must_change_password_blocks_until_rotated(engine: Engine) -> None # The account is NOT bricked: enrollment rides require_reauth_only_action, which opts out of # the access gate, so the escape path is reachable from the pending session itself. - assert ( - await _reauth(c, tok, purpose="mfa_enroll", password="a-rotated-passphrase-99") - ).status_code == 200 + r, tok = await _reauth(c, tok, purpose="mfa_enroll", password="a-rotated-passphrase-99") + assert r.status_code == 200 secret = (await c.post("/me/mfa/enroll", headers=_auth(tok))).json()["secret"] - assert ( - await _reauth(c, tok, purpose="mfa_confirm", password="a-rotated-passphrase-99") - ).status_code == 200 + r, tok = await _reauth(c, tok, purpose="mfa_confirm", password="a-rotated-passphrase-99") + assert r.status_code == 200 confirmed = await c.post( "/me/mfa/confirm", json={"code": fresh_totp(secret)}, headers=_auth(tok) ) assert confirmed.status_code == 200 + tok = str(confirmed.json()["token"]) # the confirm re-keyed the session (ASVS 7.2.4) # Confirming satisfies THIS session's factor, so the estate is reachable again. assert (await c.get("/users", headers=_auth(tok))).status_code == 200 diff --git a/tests/test_last_admin_guard.py b/tests/test_last_admin_guard.py index b4eaf9751..54fd6f77e 100644 --- a/tests/test_last_admin_guard.py +++ b/tests/test_last_admin_guard.py @@ -68,16 +68,20 @@ async def _admin_session(c: httpx.AsyncClient, service: AuthService) -> tuple[di return h, my_id -async def _reauth_update(c: httpx.AsyncClient, h: dict[str, str]) -> None: +async def _reauth_update(c: httpx.AsyncClient, h: dict[str, str]) -> dict[str, str]: """7.5.1: PATCH /users/{id} is action-bound — mint a fresh single-use admin_user_update grant (single-use, so re-mint before each PATCH). The acting admin's password was rotated in - ``_admin_session``.""" + ``_admin_session``. + + Returns the REFRESHED header: the re-auth re-keys the session (ASVS 7.2.4), so the bearer the + caller passed in is dead on return and every later request has to carry the new one.""" r = await c.post( "/me/reauth", headers=h, json={"password": "a-rotated-passphrase-99", "purpose": "admin_user_update"}, ) assert r.status_code == 200, r.text + return _auth(str(r.json()["token"])) async def _create_user(c: httpx.AsyncClient, h: dict[str, str], username: str, role: str) -> str: @@ -97,12 +101,12 @@ async def test_non_last_admin_can_be_disabled_and_deleted(engine: Engine) -> Non h, _ = await _admin_session(c, service) root2 = await _create_user(c, h, "root2", "administrator") # disable one of two admins → allowed (one remains) - await _reauth_update(c, h) + h = await _reauth_update(c, h) assert ( await c.patch(f"/users/{root2}", headers=h, json={"disabled": True}) ).status_code == 200 # re-enable and delete it → allowed (DELETE is window-gated, not action-bound) - await _reauth_update(c, h) + h = await _reauth_update(c, h) assert ( await c.patch(f"/users/{root2}", headers=h, json={"disabled": False}) ).status_code == 200 @@ -116,7 +120,7 @@ async def test_non_admin_can_always_be_disabled_and_deleted(engine: Engine) -> N async with _client(engine, service) as c: h, _ = await _admin_session(c, service) viewer = await _create_user(c, h, "viewer1", "viewer") - await _reauth_update(c, h) + h = await _reauth_update(c, h) assert ( await c.patch(f"/users/{viewer}", headers=h, json={"disabled": True}) ).status_code == 200 @@ -159,7 +163,7 @@ async def test_disable_and_delete_routes_carry_last_admin_guard(engine: Engine) # Self-guard precedes the last-admin guard on both routes (acting admin == sole admin). PATCH # is action-bound (7.5.1), so mint the grant first — the self-guard 400 lives in the body, # behind the step-up dep the grant satisfies. - await _reauth_update(c, h) + h = await _reauth_update(c, h) r_disable = await c.patch(f"/users/{my_id}", headers=h, json={"disabled": True}) assert r_disable.status_code == 400 assert "your own account" in r_disable.json()["detail"] @@ -170,7 +174,7 @@ async def test_disable_and_delete_routes_carry_last_admin_guard(engine: Engine) # predicate the guard keys on returns True for it (and the second is no longer protected). root2 = await _create_user(c, h, "root2", "administrator") assert await service.is_last_enabled_admin(my_id) is False - await _reauth_update(c, h) + h = await _reauth_update(c, h) assert ( await c.patch(f"/users/{root2}", headers=h, json={"disabled": True}) ).status_code == 200 From 30b9ce3896344c85f477443a1ad6c2940fc25fa4 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 15:05:54 -0500 Subject: [PATCH 12/17] refactor: apply the /simplify pass over the packet D branch (BACKLOG #1111, #1113, #1114, #1145) Four review agents over the ten-commit diff. What they found that was worth fixing: **#1111 -- the reload outcome was vocabulary without a report.** Three reviewers independently found ReloadOutcome, ReloadStepFailure and reload_detail had ZERO production callers: POST /config/reload still called reload(), which projects the failures away, so a degraded apply was still answered as clean success -- the exact defect the commit named. The route and the dual-control executor now call reload_detail, ReloadResult carries `degraded` and `failures`, and the failed steps reach the AUDIT row as well as the response body. The audit half matters more than the body: the response goes to one caller once, and a reload whose reference sets never re-armed has to be findable afterwards. Mutation-checked -- dropping failed_steps from the audit reds the new test while the response assertions stay green. api/app.py was contended when #1111 landed and is free now. **#1114 -- the database poll fetched a message body to choose a log word.** The ceiling fetched `poll_max_rows + 1`, the usual probe idiom for "is there more". A row here can carry a body (`body_column`), so on every poll that probe row was marshalled out of the driver and discarded. Now it fetches exactly the ceiling and treats a full batch as the signal, which cannot tell "exactly N remained" from "more remain" -- so the message says so rather than naming a remainder. The negative-control test sat exactly ON the boundary and would have stopped being a control; it now runs three rows against a ceiling of four. **#1114 -- the ceiling multiplied a redundant stat.** Under `sort="mtime"` the min-age filter and the sort key were the same stat, read twice per candidate. That was always waste; the ceiling made it worse by turning one tick into many over a shrinking set. Decorate-sort-undecorate reads it once. The larger finding -- that the ceiling bounds the ingest and not the listing -- is real, is NOT fixed here, and is written into `_candidates` with what fixing it would cost, because it changes what the per-candidate screens mean for the budget. **#1145 -- the tokenless poll would 401 forever.** Dropping the bearer made that failure deterministic rather than transient: LIVE_STATUS_PLAN is a compile-time constant, so against an auth-enabled engine every tick 401s and waiting changes nothing. It now stands the timer down, gated on a TOKENLESS 401 specifically -- an authenticated 401 is a dead session, a different fact that must not silently stop the poller. applySettings() re-arms. **#1113 -- one of three elevation adopters bypassed the shared adopter.** Two reviewers flagged it. `confirm_mfa` assigned `self._token` directly, which was safe only by accident of `MfaConfirmResponse.token` being a required str elsewhere -- a property of a different file, not of the rule `_adopt_rotated` states. Also: `resolve_poll_ceiling` takes `Any` rather than `object`, dropping a `type: ignore` a later reader would have had to decide whether to trust. NOT APPLIED, and the disagreement is the reason. Two reviewers proposed extracting the three outbound-host refusals into a shared helper; a third measured that the identical idiom already appears at twelve pre-existing sites in transports/, so extracting only the three new ones would create a SECOND idiom rather than remove one. Skipped on that evidence. Likewise `_at_ceiling`, where the two log messages differ substantively (one is PHI-redacted), and the shared-test-fake extraction, which is real but is churn this branch should not carry. Co-Authored-By: Claude Opus 5 --- ide/src/liveStatus.ts | 22 ++++++++++-- ide/src/test/suite/live-status.test.ts | 22 ++++++++++++ messagefoundry/api/app.py | 45 +++++++++++++++++++++---- messagefoundry/api/models.py | 11 +++++- messagefoundry/apiclient/client.py | 16 +++++---- messagefoundry/transports/base.py | 9 +++-- messagefoundry/transports/database.py | 23 +++++++------ messagefoundry/transports/file.py | 38 +++++++++++++++++---- tests/test_api_reload.py | 43 +++++++++++++++++++++++ tests/test_poll_source_tick_ceilings.py | 22 ++++++++---- 10 files changed, 210 insertions(+), 41 deletions(-) diff --git a/ide/src/liveStatus.ts b/ide/src/liveStatus.ts index 8931f7337..ce80dcdac 100644 --- a/ide/src/liveStatus.ts +++ b/ide/src/liveStatus.ts @@ -31,7 +31,7 @@ import * as vscode from "vscode"; import { peekToken } from "./auth"; import { engineUrl, environments } from "./cli"; -import { getJson } from "./engineClient"; +import { getJson, HttpError } from "./engineClient"; import { resolveEngineStatusTarget } from "./engineStatusModel"; import { assertTargetAllowed } from "./engineTarget"; import type { GraphProvider } from "./graphTree"; @@ -85,6 +85,16 @@ export class LiveStatusPoller implements vscode.Disposable { this.timer = setInterval(() => void this.poll(), intervalMs); } + /** Stop the timer without touching the decorations already shown, for a failure that repeating + * cannot fix. Distinct from `applySettings()`'s stop, which also clears the tree: here the engine + * simply will not answer this route tokenlessly, and the rows are already undecorated. */ + private standDown(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + /** One poll cycle. Every failure path degrades silently to "no live data" — a background timer * must never surface an error toast loop (the status bar already tells the user the engine is * down; a missing/expired session is a normal state, not an error). */ @@ -110,13 +120,21 @@ export class LiveStatusPoller implements vscode.Disposable { try { const rows = await getJson(url, entry.route, bearer); map = Array.isArray(rows) ? buildRuntimeMap(rows) : undefined; - } catch { + } catch (e) { // Unauthorized / unreachable / non-JSON → undecorated rows, silently. Nothing is cleared // here: the poll sends no bearer, so a 401 is the engine saying "this route needs auth", // NOT evidence that the cached session died. Clearing on it would sign the user out from a // timer over a request their session never took part in. `auth.withAuth` still clears on a // 401 from a request that DID carry the token — the only place that inference is sound. map = undefined; + // A 401 against a TOKENLESS plan entry is deterministic, not transient: the plan is a + // compile-time constant, so nothing this timer can do will make the next attempt succeed. + // Left running it would issue a guaranteed-waste request every intervalMs for the life of + // the window. Stand down instead; applySettings() re-arms on a settings or target change, + // which is the only thing that could change the answer. + if (e instanceof HttpError && e.status === 401 && !entry.authenticated) { + this.standDown(); + } } } } finally { diff --git a/ide/src/test/suite/live-status.test.ts b/ide/src/test/suite/live-status.test.ts index 6bd3847b4..ef874beff 100644 --- a/ide/src/test/suite/live-status.test.ts +++ b/ide/src/test/suite/live-status.test.ts @@ -228,6 +228,28 @@ suite("liveStatus poll — the timer may not carry a bearer (AUTH-IDLE / CWE-613 "liveStatus must not clear a token from a background timer", ); }); + + test("a tokenless 401 STANDS THE TIMER DOWN rather than retrying for the life of the window", () => { + // Dropping the bearer made this failure DETERMINISTIC: LIVE_STATUS_PLAN is a compile-time + // constant with authenticated:false, so against an auth-enabled engine every tick 401s and no + // amount of waiting changes it. Left running that is one guaranteed-waste request every + // intervalMs, forever — 360-720/hour per open window. applySettings() re-arms, which is the only + // thing that can change the answer. + const text = fs.readFileSync(LIVE_STATUS_TS, "utf8"); + assert.ok( + text.includes("standDown"), + "vacuity guard: the stand-down path must exist in the shell at all", + ); + assert.ok( + /e\.status === 401 && !entry\.authenticated/.test(text), + "the stand-down must be gated on a TOKENLESS 401 — an authenticated 401 is a dead session, " + + "which is a different fact and must not silently stop the poller", + ); + assert.ok( + /clearInterval/.test(text.slice(text.indexOf("private standDown"))), + "standDown must actually clear the interval, not merely mark a flag", + ); + }); }); suite("liveStatus contributions", () => { diff --git a/messagefoundry/api/app.py b/messagefoundry/api/app.py index 5d69c2e01..c48041b2f 100644 --- a/messagefoundry/api/app.py +++ b/messagefoundry/api/app.py @@ -550,11 +550,22 @@ async def _config_reload(p: Mapping[str, Any]) -> dict[str, Any]: # gate surfaces it). The same fingerprint-bearing config_reload audit row is written so the # released reload is bound to the bytes that actually loaded (defeating attribution-laundering). config_dir = p.get("config_dir") - registry = await engine.reload(config_dir, dry_run=False, propagate=True) - await _record_reload_audit(engine, actor=str(p["requester"]), dir_arg=config_dir) + # reload_detail for parity with the inline route (BACKLOG #1111): a released reload that + # swapped the graph and then failed a follow-on step must report the same degraded outcome + # the inline path reports, or dual control would be the quieter of the two. + outcome = await engine.reload_detail(config_dir, dry_run=False, propagate=True) + registry = outcome.registry + await _record_reload_audit( + engine, + actor=str(p["requester"]), + dir_arg=config_dir, + failed_steps=[f.step for f in outcome.failures], + ) return { "inbound": len(registry.inbound), "outbound": len(registry.outbound), + "degraded": outcome.degraded, + "failures": [f.step for f in outcome.failures], } gate.register("dead_letter_replay", "Replay dead-lettered deliveries", _replay) @@ -564,7 +575,12 @@ async def _config_reload(p: Mapping[str, Any]) -> dict[str, Any]: async def _record_reload_audit( - engine: Engine, *, actor: str, dir_arg: object, client: str | None = None + engine: Engine, + *, + actor: str, + dir_arg: object, + client: str | None = None, + failed_steps: Sequence[str] = (), ) -> None: """Write the ``config_reload`` audit row with the ADR 0041 D1 content fingerprint of what loaded. @@ -576,7 +592,12 @@ async def _record_reload_audit( ``client`` (ADR 0150) is the address of the actor named in the row. The inline endpoint passes the requester's own address. The dual-control executor deliberately does NOT: there the row's ``actor`` is the original *requester*, while the request in flight belongs to the *approver*, so stamping the - approver's address would attribute one person's action to another's host — worse than NULL.""" + approver's address would attribute one person's action to another's host — worse than NULL. + + ``failed_steps`` names the follow-on steps that did not complete when the graph DID swap + (BACKLOG #1111). It is recorded on the row rather than only returned, because the response goes + to one caller once and the audit is what a later reader has: a reload whose reference sets never + re-armed must be findable after the fact, not only by whoever happened to read the 200.""" fingerprint: dict[str, object] = {} if engine.last_reload_dir is not None: try: @@ -593,6 +614,7 @@ async def _record_reload_audit( "inbound": len(rr.registry.inbound) if rr else 0, "outbound": len(rr.registry.outbound) if rr else 0, "dry_run": False, + **({"degraded": True, "failed_steps": list(failed_steps)} if failed_steps else {}), **fingerprint, } ), @@ -2982,9 +3004,14 @@ async def reload_config( # propagate=True on the real apply so an operator reload on one node bumps the cluster-wide # config version and every other node converges (Track B Step 6); a dry_run never propagates # (it doesn't apply anything) and single-node ignores it (is_clustered() False). - registry = await engine.reload( + # reload_detail, not reload: the graph swap can succeed while a follow-on step (the + # provenance fingerprint, the reference-set reconcile, the cluster version bump) fails, + # and reload() projects that away to a Registry. Reporting it is the whole point of + # BACKLOG #1111 -- without this the route answers a degraded apply as clean success. + outcome = await engine.reload_detail( req.config_dir, dry_run=req.dry_run, propagate=not req.dry_run ) + registry = outcome.registry except ConfigReloadDenied as exc: await engine.store.record_audit( "config_reload_denied", @@ -3052,7 +3079,11 @@ async def reload_config( ) else: await _record_reload_audit( - engine, actor=user.username, dir_arg=req.config_dir, client=client_ip(request) + engine, + actor=user.username, + dir_arg=req.config_dir, + client=client_ip(request), + failed_steps=[f.step for f in outcome.failures], ) rr = engine.registry_runner return ReloadResult( @@ -3062,6 +3093,8 @@ async def reload_config( handlers=len(registry.handlers), running=bool(rr and rr.running), dry_run=req.dry_run, + degraded=outcome.degraded, + failures=[f.step for f in outcome.failures], ) # --- messages ------------------------------------------------------------ diff --git a/messagefoundry/api/models.py b/messagefoundry/api/models.py index 2eb52a85e..b570b0382 100644 --- a/messagefoundry/api/models.py +++ b/messagefoundry/api/models.py @@ -433,7 +433,14 @@ class ReloadRequest(RequestModel): class ReloadResult(BaseModel): """Summary of the graph that is now live after a reload — or, for a dry run, the graph that - *would* go live (``dry_run=True``; ``running`` then reflects the still-current graph).""" + *would* go live (``dry_run=True``; ``running`` then reflects the still-current graph). + + ``degraded`` reports the third outcome (ASVS 2.3.3, BACKLOG #1111): the graph SWAPPED and a + follow-on step did not complete, so ``failures`` names each one. A 200 with ``degraded`` True + is not a clean reload — the new graph is live and an operator has a step to finish by hand. + Reporting outright failure there would describe an engine that does not exist; reporting plain + success would hide the step. The step labels are stable and PHI-free + (``config_fingerprint``, ``reference_sync``, ``cluster_propagate``).""" inbound: int outbound: int @@ -441,6 +448,8 @@ class ReloadResult(BaseModel): handlers: int running: bool dry_run: bool = False + degraded: bool = False + failures: list[str] = [] class ConfigProvenance(BaseModel): diff --git a/messagefoundry/apiclient/client.py b/messagefoundry/apiclient/client.py index e895aa25d..0a9bad7a7 100644 --- a/messagefoundry/apiclient/client.py +++ b/messagefoundry/apiclient/client.py @@ -577,12 +577,16 @@ def confirm_mfa(self, code: str) -> list[str]: codes (shown **once**). Raises :class:`ApiError` (400) on a wrong code. Confirming an enrolment elevates the session, so it also re-keys it (ASVS 7.2.4) and the new - token is adopted here — the caller keeps getting just the codes.""" - result = _decode( - self._request("POST", "/me/mfa/confirm", json={"code": code}), MfaConfirmResponse - ) - self._token = result.token - return result.recovery_codes + token is adopted here — the caller keeps getting just the codes. + + Through :meth:`_adopt_rotated` like the other two elevation calls, deliberately. Assigning + ``self._token`` directly worked only by accident of an annotation elsewhere: + ``MfaConfirmResponse.token`` is a required ``str`` today, so a token-less body fails in + ``_decode`` rather than clearing the session. That is a property of a different file, not of + the rule, and the rule is the one thing all three sites must share.""" + response = self._request("POST", "/me/mfa/confirm", json={"code": code}) + self._adopt_rotated(response) + return _decode(response, MfaConfirmResponse).recovery_codes def verify_mfa(self, code: str) -> None: """Satisfy the current session's second factor with a TOTP or single-use recovery code. Raises diff --git a/messagefoundry/transports/base.py b/messagefoundry/transports/base.py index 59dce7065..570033a9f 100644 --- a/messagefoundry/transports/base.py +++ b/messagefoundry/transports/base.py @@ -19,7 +19,7 @@ from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass, field from enum import Enum -from typing import ClassVar, Protocol +from typing import Any, ClassVar, Protocol from messagefoundry.config.models import ConnectorType, ContentType, Destination, Source @@ -88,7 +88,7 @@ DEFAULT_MAX_ITEMS_PER_POLL = 500 -def resolve_poll_ceiling(value: object, *, knob: str, transport: str) -> int | None: +def resolve_poll_ceiling(value: Any, *, knob: str, transport: str) -> int | None: """Read one poll source's per-tick ceiling from its settings: a positive count, or ``None`` for the documented unlimited opt-out (a falsy ``0``/``None``). @@ -101,7 +101,10 @@ def resolve_poll_ceiling(value: object, *, knob: str, transport: str) -> int | N if not value: return None # A non-numeric setting raises here, which is the same build-time refusal a bad value gets below. - ceiling: int = int(value) # type: ignore[call-overload] + # ``value`` is typed Any rather than object because every call site reads it out of an untyped + # settings mapping; object would need a `type: ignore` on this line, and a suppression a reader + # has to decide whether to trust is worse than the honest Any. + ceiling: int = int(value) if ceiling < 1: raise ValueError( f"{transport} {knob}={value!r} must be a positive number of items per poll " diff --git a/messagefoundry/transports/database.py b/messagefoundry/transports/database.py index 49c93449d..34a49b375 100644 --- a/messagefoundry/transports/database.py +++ b/messagefoundry/transports/database.py @@ -1185,8 +1185,8 @@ async def _select(self) -> tuple[list[str], list[Any]]: **The ceiling is charged at the FETCH, not after it.** ``fetchmany`` leaves the rest of the result set in the driver and the cursor is closed on the way out, so a poll of a table holding a - million rows pulls the ceiling (plus one probe row, see below) into memory rather than all of - them — the ``fetchall`` this replaced materialised the whole set before anything could bound it. + million rows pulls exactly the ceiling into memory rather than all of them — the ``fetchall`` + this replaced materialised the whole set before anything could bound it. The rows not taken are untouched in the table, so the next poll re-runs ``poll_statement`` and takes the next batch; nothing is dropped, errored or marked. Progress depends on the ``mark_statement`` removing a handled row from ``poll_statement``'s own predicate, which is the @@ -1204,15 +1204,18 @@ async def _select(self) -> tuple[list[str], list[Any]]: if self._poll_max_rows is None: rows = list(await cur.fetchall()) else: - # limit + 1 (the same probe auth/oidc uses on a bounded read): one row past the - # ceiling is enough to know a backlog is waiting, and it is dropped from the batch — - # never handed to the handler, never marked, so the next poll selects it again. - rows = list(await cur.fetchmany(self._poll_max_rows + 1)) - if len(rows) > self._poll_max_rows: - rows = rows[: self._poll_max_rows] + # Exactly the ceiling, NOT ceiling+1. The +1 probe is the usual idiom for "is there + # more?", and it is wrong here: this connector's rows can carry a message BODY + # (`body_column`), so the probe row would marshal a whole payload out of the driver + # and discard it on every poll — hundreds of KB every `poll_seconds` to decide one + # word in a log line. A full batch is the signal instead: it means the ceiling bound + # this poll, and cannot distinguish "exactly N remained" from "more remain", which is + # why the message says at least rather than naming a remainder. + rows = list(await cur.fetchmany(self._poll_max_rows)) + if len(rows) == self._poll_max_rows: logger.info( - "DATABASE source reached poll_max_rows (%s) this poll; the rest of the result " - "set is left for the next poll (deferred, not dropped)", + "DATABASE source filled poll_max_rows (%s) this poll; any remaining rows are " + "left for the next poll (deferred, not dropped)", self._poll_max_rows, ) finally: diff --git a/messagefoundry/transports/file.py b/messagefoundry/transports/file.py index bf89844c7..77bd45e61 100644 --- a/messagefoundry/transports/file.py +++ b/messagefoundry/transports/file.py @@ -826,7 +826,21 @@ def _oversize(self, path: Path) -> bool: return False # vanished/locked — let the read path handle it def _candidates(self) -> list[Path]: - """Files ready to process, honoring recursion, min-age, and sort order.""" + """Files ready to process, honoring recursion, min-age, and sort order. + + **The per-tick ceiling bounds the INGEST, not this listing, and the asymmetry is real rather + than an oversight.** Selecting the first N in name or mtime order requires knowing the whole + candidate set, so the glob and the per-candidate screens below are paid every tick regardless + of the ceiling. In steady state that is unchanged from before the ceiling existed. Draining a + LARGE backlog is where it bites: the ceiling turns one expensive tick into many, so this + listing is now paid once per tick over a shrinking set instead of once in total. + + Bounding it properly is a separate change and a real one -- deferring ``is_file`` and + ``_within_root`` into the scan loop so they are paid only for candidates actually reached, + which is available under ``sort="name"`` because that key needs no syscall, and not under + ``sort="mtime"`` because the key IS the syscall. It also costs the accurate ``remaining`` + count the ceiling's log line carries. Not folded in here: it changes what the screens mean + for the ceiling's budget, and this method's contract is worth keeping simple.""" globber = self.directory.rglob if self.recursive else self.directory.glob try: matched = list(globber(self.pattern)) @@ -845,13 +859,23 @@ def _candidates(self) -> list[Path]: and self.error_dir not in p.parents and self._within_root(p) ] - if self.min_age_seconds > 0: - cutoff = time.time() - self.min_age_seconds - files = [p for p in files if _mtime(p) <= cutoff] # skip files still being written + # Decorate-sort-undecorate under `sort="mtime"`: the min-age filter and the sort key are the + # SAME stat, and reading it twice per candidate doubled the syscalls on the one path that + # already pays the most. That cost is charged on every tick, and the per-tick ceiling means a + # backlog is now drained over many ticks rather than one, so a redundant stat is multiplied + # by the number of ticks it takes to drain. Under `sort="name"` the key is pure and no stat + # is needed at all. if self.sort == "mtime": - files.sort(key=_mtime) - else: - files.sort(key=lambda p: p.name) + cutoff = time.time() - self.min_age_seconds if self.min_age_seconds > 0 else None + dated = [(_mtime(p), p) for p in files] + if cutoff is not None: + dated = [pair for pair in dated if pair[0] <= cutoff] # still being written + dated.sort(key=lambda pair: pair[0]) + return [p for _, p in dated] + if self.min_age_seconds > 0: + cutoff_name = time.time() - self.min_age_seconds + files = [p for p in files if _mtime(p) <= cutoff_name] # skip files still being written + files.sort(key=lambda p: p.name) return files def _within_root(self, path: Path) -> bool: diff --git a/tests/test_api_reload.py b/tests/test_api_reload.py index 7ea449cad..400da40a3 100644 --- a/tests/test_api_reload.py +++ b/tests/test_api_reload.py @@ -53,6 +53,10 @@ async def test_reload_endpoint_applies_config(client: httpx.AsyncClient, tmp_pat _write_valid_config(cfg, tmp_path / "in", tmp_path / "out") r = await client.post("/config/reload", json={"config_dir": str(cfg)}) assert r.status_code == 200, r.text + # Compared WHOLE rather than field-by-field on purpose: this is the shape contract the shipped + # apiclient and console read. BACKLOG #1111 added `degraded` and `failures`, and their values + # here are the clean-reload NEGATIVE CONTROL -- a route that reported every apply as degraded + # would satisfy the degraded-path test and only fail here. assert r.json() == { "inbound": 1, "outbound": 1, @@ -60,9 +64,48 @@ async def test_reload_endpoint_applies_config(client: httpx.AsyncClient, tmp_pat "handlers": 1, "running": True, "dry_run": False, + "degraded": False, + "failures": [], } +async def test_a_degraded_apply_is_reported_and_audited_not_answered_as_clean( + engine: Engine, client: httpx.AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A reload whose graph SWAPPED but whose follow-on step failed answers 200 with degraded=True. + + RED when: the route calls Engine.reload instead of reload_detail, or drops either field. That + reverts to reporting a degraded apply as a clean success, which is the defect BACKLOG #1111 + exists to fix -- and it is invisible without this test, because the status code does not move. + + The negative control is test_reload_endpoint_applies_config, which pins degraded False on a + clean reload; a route hardcoding degraded=True passes this test and fails that one. + """ + cfg, inbox, outdir = tmp_path / "cfg", tmp_path / "in", tmp_path / "out" + _write_valid_config(cfg, inbox, outdir) + + async def _boom(*_a: object, **_k: object) -> None: + raise RuntimeError("reference sync exploded") + + monkeypatch.setattr(engine, "_reconcile_reference_sync", _boom) + r = await client.post("/config/reload", json={"config_dir": str(cfg)}) + assert r.status_code == 200, r.text + body = r.json() + assert body["degraded"] is True + assert body["failures"] == ["reference_sync"] + # The graph really did swap -- otherwise this is a test about an enum, not about the defect. + assert body["inbound"] == 1 and body["running"] is True + rows = [ + json.loads(row["detail"]) + for row in await engine.store.list_audit(limit=50) + if row["action"] == "config_reload" + ] + assert rows and rows[0].get("failed_steps") == ["reference_sync"], ( + "the degraded step must reach the AUDIT row, not only the response body -- the response " + "goes to one caller once, the audit is what a later reader has" + ) + + async def test_reload_failures_are_audited( engine: Engine, client: httpx.AsyncClient, tmp_path: Path ) -> None: diff --git a/tests/test_poll_source_tick_ceilings.py b/tests/test_poll_source_tick_ceilings.py index a6002e186..f555568fb 100644 --- a/tests/test_poll_source_tick_ceilings.py +++ b/tests/test_poll_source_tick_ceilings.py @@ -502,10 +502,12 @@ async def test_db_poll_stops_at_the_shipped_ceiling_and_leaves_the_rest_in_the_t await src._poll_once() assert len(handler.bodies) == DEFAULT_MAX_ITEMS_PER_POLL assert table.rows == [(500, _ADT.format(n=500))] # deferred, unmarked, still selectable - # The ceiling is charged at the FETCH: the driver is asked for the ceiling plus one probe row, - # never for the whole result set. - assert fetches == [("fetchmany", DEFAULT_MAX_ITEMS_PER_POLL + 1)] - assert "reached poll_max_rows" in caplog.text + # The ceiling is charged at the FETCH: the driver is asked for EXACTLY the ceiling, never for the + # whole result set and never for a probe row past it. A row here can carry a message body + # (`body_column`), so a ceiling+1 probe would marshal a whole payload out of the driver and throw + # it away on every poll, to decide one word in a log line. + assert fetches == [("fetchmany", DEFAULT_MAX_ITEMS_PER_POLL)] + assert "filled poll_max_rows" in caplog.text async def test_db_second_poll_drains_the_deferred_rows() -> None: @@ -530,9 +532,16 @@ async def test_db_poll_below_the_ceiling_is_unchanged(caplog: pytest.LogCaptureF log line. Red mutation: fetch ``poll_max_rows - 1``, or log the ceiling unconditionally — either reds here - while the over-ceiling tests stay green.""" + while the over-ceiling tests stay green. + + STRICTLY below, three rows against a ceiling of four, and the margin is load-bearing. Since the + fetch asks for exactly the ceiling rather than a probe row past it, a FULL batch is the only + signal that more may remain, so a result set of exactly ``poll_max_rows`` logs the deferral even + when the table happens to be empty behind it. That is the accepted imprecision of not paying for + a probe row, and this test would silently stop being a negative control if it sat on the + boundary.""" table = _FakeTable(3) - src = _db_source(poll_max_rows=3) + src = _db_source(poll_max_rows=4) fetches = _attach(src, table) handler = _RecordingHandler() src._handler = handler @@ -541,6 +550,7 @@ async def test_db_poll_below_the_ceiling_is_unchanged(caplog: pytest.LogCaptureF assert [b.decode() for b in handler.bodies] == [_ADT.format(n=n) for n in range(3)] assert table.rows == [] # all three marked assert fetches == [("fetchmany", 4)] + assert "poll_max_rows" not in caplog.text # nothing deferred, so nothing said assert "poll_max_rows" not in caplog.text From a37c7446932313950caf8f4bd0b56b2f60e68e89 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 15:11:00 -0500 Subject: [PATCH 13/17] docs(backlog): record packet D's landings, and correct two rows that were wrong (BACKLOG #1109, #1110, #1111, #1112, #1113, #1114, #1145, #1146, #1149, #1350) Ten rows, all still OPEN. Every one carries Closing-act: scorecard-rescore, which lives in the separate vault clone and is the Lander's act by the owner ruling of 2026-09-05. A builder cannot close these and did not try. Landed code plus an open row is the intended outcome here, not a shortfall. TWO ROWS ASSERTED SOMETHING FALSE AT HEAD, and the corrections are the part worth reading. Both were the JUSTIFICATION rather than the finding, which is the harder kind to catch: the defect reproduces, a reviewer confirms it, and nothing downstream ever tests the reasoning. #1110 said the inbound outbound-host rule is enforced while the outbound half is not, and called that asymmetry the defect. Both halves are enforced at config/wiring.py:4461. What is genuinely unguarded at every layer is a BLANK host: wiring tests `is None`, so host="" passes, and getaddrinfo("") resolves to the machine's own LAN interfaces. #1112 said no advisory-locking precedent exists in the engine, resting on a six-token grep that still returns zero. tray/instance.py is a single-instance guard on a Local\ named mutex via ctypes CreateMutexW, which none of those tokens match. #1149 needed no build at all -- its action binding had already shipped. What it left behind were three statements that its own gate made false, including an in-source docstring still saying session revocation has no step-up. That is the same defect class in reverse: prose asserting the absence of a control that exists. Two rows record a residual that is NOT closed, deliberately, so a green run cannot imply closure: #1112's unsharded second-serve path, which would re-pend every in-flight row store-wide, and #1114's listing cost, which the per-tick ceiling multiplies rather than bounds. #1114 and #1145 say in terms that they do not move their cells; #1113's cell is an owner-ruled permanent partial and this was its "fix them regardless" half. Anchors are on symbols, not line numbers. Several this packet touched had drifted again since August, and #1109's own citations now point at lines its fix deleted. The notes carry no branch name: this file is public, and the leak gate correctly refused a worktree slug as an internal project name. The date is what a reader needs; the pull request carries the provenance. Co-Authored-By: Claude Opus 5 --- docs/BACKLOG.md | 318 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 4b5988261..4ae8e1526 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -7684,6 +7684,35 @@ substance held on both.** The file-family call site is at `transports/remotefile in the same run is the file family itself: a probe finding the check absent on all six sources would be broken, not a finding, and it found it present on both file sources. + +**BUILT 2026-09-06: the conformance `profile` parameter, the last named subject on this row's +proposed-work list that was still unbuilt. The item stays OPEN and this does NOT move the cell** -- the +HL7 limb is untouched, `validation.strict` still ships False deliberately, and the method question this +row names is still undecided. + +**DELETED, not enforced, and the branch was chosen on evidence rather than by default.** No +conformance-profile type ships anywhere (the `class .*Profile` hits are harness load profiles; the +"conformance profile" strings in `parsing/fhir/peek.py` and ADR 0022 are FHIR `meta.profile[]` URLs, a +different concept), no ADR covers it, `docs/HL7-VALIDATION.md` never mentions it, and the feature is +BACKLOG #78, re-scored twice to demand-gate. A RAISE was ruled out too: with zero callers the branch is +dead by construction, and the signature would still advertise a keyword that does nothing. + +**The measurement, with its positive control:** `profile=` reaches `validate()` ZERO times, against 13 +for the sibling `expected_version=` in the same run. All 41 `profile=` hits tree-wide are harness load +profiles and DR callbacks. The parameter was keyword-only, so no positional call could reach it. + +**The same false affordance existed one layer up and went with it.** `Validation.profile` was reachable +from no authoring path -- `inbound()` never passed it, so `connections.toml` could not set it -- and +nothing read it, yet its comment promised an operator that naming a conformance profile would do +something. Removing it changes no construction: the model takes Pydantic's default `extra="ignore"`, so +`Validation(profile=...)` was silently ignored before and is silently ignored now. + +**A docstring that was the source of the confusion is corrected:** `Validation` said `strict` runs +"hl7apy **profile** validation". hl7apy does STRUCTURAL validation and takes no profile. + +**Five citations in this file quoted the removed parameter as evidence** that it is an accepted no-op. +They went stale in the good direction -- the code got better and the fix deleted the line the citation +quoted -- and `parsing/validate.py`'s `def validate(` has moved under the new comment block. ## 1110. research an honest pass for ASVS 2.2.3 -- whether cross-field reasonableness is shippable at all in a code-first engine, or belongs to the feed author > 🔢 **Re-scored 2026-08-20 -> P2.** Value **5/10** · Difficulty **5/10** · _fill-in_. The gap is unchanged: consistency.py:11-17 still describes itself as a compose-it-yourself toolkit, its only non-test importer is samples/consistency/validated_adt.py:27, and nothing on the shipped message path calls it. The research question is genuinely open and its most likely output is a negative finding, so worth-if-built is bounded by the clean existing workaround of a Handler composing the primitives; difficulty carries the HL7-general rule-set research plus, if a set exists, a check on the message hot path with tests. _(was 5/10 · 6/10.)_ @@ -7718,6 +7747,44 @@ be broken, not a finding, and it found it present on both file sources. **Proposed work, by subject, all unallocated:** the outbound peer-address rule, making the three silently-defaulting connectors refuse a missing host as the others already do; the IDE conditional-required premise correction, which must land whether or not the host fix does, because `ide/src/connectionForm.ts:129-131` justifies not blocking a save with "only the engine can judge the condition, and it already does so loudly at load" -- a compensating control resting on a premise the measurement above falsifies; a structured `requiredWhen` predicate emitted by the engine, replacing the bare word-match at `messagefoundry/config/connection_schema.py:53`, consumed and enforced by both IDE authoring paths and the loader; the API and web-console request-layer combination-rule census and declaration; and a combination-rule inventory MEASURED rather than prose-grepped, so the documentation requirement and this enforcement requirement rest on one enumerated set instead of two independent word-matches. + +**BUILT 2026-09-06: the outbound peer-address rule. The item stays OPEN and NOTHING claims the +cell moves** -- this row is explicit that re-scoring on this would be the same trap wearing a +config-plane hat. + +**THIS ROW'S OWN JUSTIFICATION IS FALSE AND IS CORRECTED HERE.** The text above says the INBOUND half +of the rule is enforced while the outbound half is not, and calls that asymmetry what makes it a +defect. Both halves are enforced, 350 lines apart in the same file: `config/wiring.py:4461` already +refuses an ABSENT host for MLLP, TCP and X12 outbound, through the shared `build_outbound_connection` +core, so both the code-first surface and `connections.toml` refuse. Measured through the TOML loader, +which is the GUI's own save target. + +**WHAT IS GENUINELY UNGUARDED AT EVERY LAYER IS A BLANK HOST, and nobody had named it.** Wiring tests +`settings.get("host") is None`, so `host=""` passes it and the connector kept the empty string. That is +not loopback: measured on this machine, `getaddrinfo("")` resolves to the host's own LAN interfaces, so +on a first deployment a blank host would dial the engine's own box OFF-LOOPBACK -- and where the same +engine runs a listener on that port the delivery would SUCCEED into its own intake rather than failing, +surfacing as a misdelivered feed rather than a connection error. X12 was worse: its `str()` turned a +`None` host into the literal hostname `"None"`. + +All three dialing destinations now refuse a missing, blank or non-string host at construction, matching +`EmailDestination`, `DirectDestination` and `DicomScuDestination`. `build_check_registry` builds every +deployed outbound, so the refusal fires at `check` and dry-run. + +**`wiring.py`'s `is None` test is left alone deliberately:** tightening it must stay a falsy test rather +than an isinstance one, because `host=env(...)` puts a truthy `EnvRef` there. + +**Destination-versus-source was established per site from the enclosing class and its `register_*` +call, not from line numbers.** The `s.get("host") or "127.0.0.1"` lines in `MLLPSource`, `TcpSource` +and `X12Source` are untouched -- loopback is the correct default for a LISTENER bind -- and a source +guard test pins that it still is. + +**Not done, and the reason is a premise rather than scope:** the IDE `connectionForm.ts` comment +justifying not blocking a save says "only the engine can judge the condition, and it already does so +loudly at load". That is TRUE today for outbound `mllp.host`, measured through the loader, so writing +"your fix made this true" would have put a false attribution in the record. Worth its own row: +`conditionallyRequired` derives from a bare word-match over comment prose, so the set it covers is +whatever wording happens to match rather than a curated list. ## 1111. research an honest pass for ASVS 2.3.3 -- what a business-logic transaction boundary means where the approval gate commits before it executes > 🔢 **Re-scored 2026-08-20 -> P2.** Value **5/10** · Difficulty **3/10** · _fill-in_. The audit-record defect the item was filed on is fixed (approvals.py:141-157 plus :181-208), but the pinned verb is still unmet: a partially applied executor is now recorded 'failed' with its effect landed, which app.py:537-548 exhibits concretely because config_reload swaps the graph before its audit row is written. So the remainder is the item's real research question -- the correct boundary and whether the three operations are replay-safe -- rather than only an out-of-repo re-score (value 5, difficulty 3 for a bounded API-side decision plus its tests). _(was 6/10 · 5/10.)_ @@ -7754,6 +7821,37 @@ be broken, not a finding, and it found it present on both file sources. **Proposed work, by subject, all unallocated:** the approvals executor replay-safety and request-binding work, per executor, as the precondition for everything else; the approvals executor outcome contract with its startup reconciliation sweep, preserving the existing race guard; the config-reload success-reporting fix, since `messagefoundry/pipeline/engine.py:1598` swaps the live graph before `:1602`, `:1610` and `:1622` can raise, so a raise today reports a FAILED reload while the new graph is live and possibly already propagated; a composable write-transaction boundary on the Store protocol, proven on the server CI legs (`messagefoundry/store/base.py` exposes none -- measured zero against a four-hit positive control in the same file); wrapping the identity and authorization flows in it so an authorization change and its session revocation cannot land apart; DR activation and release recovery; and the sweep PERFORMED rather than asserted, recorded with its instrument and its false-negative limit stated. + +**BUILT 2026-09-06: the config-reload success-reporting fix, one named subject from this row's +proposed-work list. The item stays OPEN and this does NOT move the cell** -- the approvals executor +replay-safety, the store transaction boundary and DR activation recovery are all untouched, and this +row records at least 14 further uncompensated flows in `auth/service.py` alone. + +**Each post-swap step was ASKED whether it could move before the swap**, rather than assuming none +could: + +* the reference-set reconcile: NO. `_make_reference_runner` reads its specs through a lambda closing + over the live registry, so pre-swap it would materialise the OLD graph's reference sets and a reload + that ADDS a set would leave it unarmed. Moving it changes what it means. +* the provenance fingerprint: YES, and it moved. `config_fingerprint_detail` is a pure offline fold + over the directory bytes and never reads the live graph. Moving it also narrows the gap between the + bytes `load_config` read and the bytes the reload is credited with, and puts the local import's + `ImportError` -- which `except OSError` never covered -- on the honest side of the swap. +* the cluster version bump: NO. It TELLS other nodes to converge, so bumping first would announce a + config this node had not applied. + +What cannot move is now reported instead of swallowed, in three discriminable states: a raise means +nothing was applied; applied with no failures is clean; applied WITH named failures means the graph is +live and a step did not finish. + +**The operator-facing half landed in the same branch.** `POST /config/reload` and the dual-control +executor both call `reload_detail`; `ReloadResult` carries `degraded` and `failures`; and the failed +step names reach the AUDIT row as well as the response body, because the response goes to one caller +once and the audit is what a later reader has. + +**Mutated three ways:** reverting the reference-sync guard, reporting the swap itself as degraded (the +dishonest direction, caught by the pre-swap negative control), and moving the fingerprint back. Each +reds a different test. ## 1112. research an honest pass for ASVS 2.3.4 -- a quota that holds across concurrent uploads and across engine shards at once > 🔢 **Re-scored 2026-08-20 -> P2.** Value **5/10** · Difficulty **5/10** · _fill-in_. Only the cross-shard limb remains and it is small: settings.py:461-466 records in shipped code that shards over one uploads_dir enforce one budget, so the remainder is an at-most N-1 file overshoot on a subsystem that is OFF on shipped defaults (uploads_dir default None, settings.py:442) and whose cell the owner already ruled holds at partial. Difficulty is the store-row-versus-advisory-lock decision plus a demonstrated two-shard concurrent-writer run. _(was 6/10 · 5/10.)_ @@ -7802,6 +7900,39 @@ be broken, not a finding, and it found it present on both file sources. **Disclosed by the building lane itself, unprompted, after its pull request was open.** *This is the store-test blind spot this project already knows about in another form -- a local run silently skips the server-database legs, so "the suite is green" and "the backend works" are different sentences.* **DO NOT READ THE GREEN SUITE AS COVERING THEM.** The remaining work is a reservation test that runs against Postgres and SQL Server, not a re-read of the SQLite one. + +**BUILT 2026-09-06: the `serve --shard` unified-store guard. The item stays OPEN and this does +NOT move the cell** -- the remaining limb is a Postgres and SQL Server reservation test, which needs a +server-DB rig. + +**THIS NARROWS; IT DOES NOT CLOSE, and the reason is that the two routes this row offers are NOT +substitutes.** Neither subsumes the other: + +* A store-open single-writer lock structurally cannot see a LONE `serve --shard a` against a + multi-shard config. That is one writer, so nothing trips -- but `filter_registry_for_shard` arms + ADR 0073 lane ownership whenever the config declares more than one shard, so lanes owned by shards + nobody started would get no delivery consumer at all: ACKed at ingress, never delivered, nothing + reporting it. The entrypoint guard refuses that. +* The entrypoint guard cannot see two plain `serve` processes on one SQLite file. There is no + engine-shard universe to refuse. + +**A WORSE UNGUARDED PATH WAS FOUND WHILE ENUMERATING AND IS NOT CLOSED HERE.** `Engine._owned_lanes` +returns `None` when `registry.shard_id is None`, so an UNSHARDED second `serve` calls +`reset_stale_inflight(owned=None)` -- documented as "every inflight row at startup is this node's own +crash residue". On a first deployment a second plain `serve` against the same `--db` would re-pend +every in-flight row store-wide, including a live sibling's. Recorded in the source and the test module +so a green run cannot imply closure. + +**The enumeration ran with both controls in the same pass** -- the plain-`serve` store open was found +and the supervisor correctly returned zero -- so the twelve-site result is a measurement rather than a +pattern matching one spelling. + +**ONE PREMISE THIS ROW CARRIES IS FALSE AND IS CORRECTED.** "No advisory-locking precedent exists in +the engine" rests on a six-token grep that still returns zero, but `messagefoundry/tray/instance.py` is +a single-instance guard on a `Local\` named mutex via ctypes `CreateMutexW` (ADR 0113), which none of +those tokens match. It is Windows-only and outside the engine packages, so the store-open lock still +needs a POSIX arm and a stale-lock policy for the six admin CLIs that legitimately open the store. That +is ADR-shaped and belongs in its own item. ## 1113. research an honest pass for ASVS 2.3.5 -- which flows in a PHI engine are high-value enough to demand a second approver, under a default that must not strand a single-operator site > 🔢 **Re-scored 2026-08-20 -> P2.** Value **5/10** · Difficulty **6/10** · _money pit_. The code matches the item's substance: the gate ships off, three operations are registered and all three are guarded at api/app.py:2236, :2730 and :2837, and the default approvable set names only two, so no user-administration and no PHI-export flow is approvable in any configuration. Value stays mid-band because the consequence is a narrow opt-in control gap with no first-deployment product effect; difficulty is 6 because an honest pass most likely means widening the registry across API, settings, audit and console while resolving the self-approval availability cost at api/approvals.py:120. _(was 5/10 · 6/10.)_ @@ -7841,6 +7972,36 @@ be broken, not a finding, and it found it present on both file sources. **OWNER RULING 2026-08-22 -- the verdict stands; both load-bearing clauses are amended.** The re-scoping found **13 distinct client call sites yielding 6 distinct shipped defects**, against the 3 the earlier pass recorded, and **1 of 6 built-in roles reachable by 3 assignment paths**. The scope-out reasoning survives intact and both dishonest moves stay dishonest, so the verdict does not move. But the ruling turns on exactly two sentences and **both are measurably narrower than the code**. First, dual control is honoured on the console only in the sense that it **stops** you, not that it **works** there: there are zero release callers and no cookie on the JSON API. Second, the second approver is not a second operator but a **second full administrator**, minted most cheaply by mapping a directory group, and asked for **less credential proof than the requester was**. Both sentences are corrected in place, because a later ruling would otherwise inherit them as descriptions of a working mechanism. + +**BUILT 2026-09-06 -- the "fix them regardless of what the verdict does" half of this row. +The item stays OPEN and NOTHING here claims the cell moves:** 2.3.5 is an owner-ruled permanent +`partial`, reaffirmed 2026-08-22. + +**All three shipped client defects this row names are fixed together, because three behaviours for one +wire state was the underlying defect.** The engine answers 202 with a `PendingApprovalResponse` when +dual control holds an operation; the shared client raised only at 400 and above, so the hold arrived as +success. `replay_dead_letters` and `reload_config` then parsed it bare, bypassing the module's own +decoder, and on a first deployment with the gate on a correctly-working hold would have raised an +unhandled pydantic `ValidationError` out of the client -- breaking the contract that file's docstring +says the decoder exists to preserve. `purge_connection` used the decoder and would have reported the +hold as engine version skew. + +All three now return ` | PendingApprovalResponse`, discriminated on the status code and decoded +through `_decode`, so a malformed body of either shape is still an `ApiError` and never a bare +`ValidationError`. That mirrors the engine's own route signatures and the console's existing +`isinstance` narrowing rather than inventing a second convention. + +**Callers were measured, not assumed.** The web console needed nothing -- it reaches the engine through +the in-process seam, not the apiclient, and already discriminates. `harness/monitor.py` reports a hold +as a status line rather than an error. `harness/load/connscale/probe.py` would have TIMED a held reload +as a fast one, reading as the O(connections) cost getting cheaper. + +**Mutated both ways:** reverting one call to the bare `model_validate` reds three tests; reporting +every 2xx as held reds exactly the three negative controls. + +**Deliberately not built, named by subject:** the IDE held-promote defect, where `engineClient.ts` +casts any 2xx body to its expected type and `promote.ts` would announce a successful promote with +undefined counts for a reload that was only held. TypeScript, a separate surface and a separate review. ## 1114. research an honest pass for ASVS 2.4.1 -- anti-automation on a data plane whose senders are machines and whose intake has no authentication > 🔢 **Re-scored 2026-08-20 -> P2.** Value **7/10** · Difficulty **5/10** · _quick win_. A default install would still take messages at an unbounded rate on first deployment, and ~~for non-MLLP inbounds there is no opt-in bound at all, so no workaround exists there~~ **[FALSIFIED 2026-09-04 at `a2eef0f3` -- struck, not deleted. The pacing keys now reach FOUR of the nine externally-facing inbound factories; five still reach nothing. See "Re-measured 2026-09-04" below for the probe and its controls.]**. Difficulty 5 covers the ruling plus ~~the likely follow-on of taking pacing across the transport registry to at least the raw-TCP inbound, with tests and docs~~ **[BOTH SPENT: the raw-TCP limb was delivered 2026-09-03, and the ruling it prices was written on 2026-08-16, BEFORE this score was set. The numbers are left as the scoring pass recorded them -- re-scoring is that pass's act, not a builder's -- but neither half of the justification now holds.]** _(was 8/10 · 7/10.)_ @@ -7907,6 +8068,39 @@ be broken, not a finding, and it found it present on both file sources. **One residual measured here and deliberately left unedited.** That same row concludes that "the code-first and the TOML surface both express them" across all four named factories. For `X12` the second half is false, for the reason the separate-subject paragraph above already gives: `_TRANSPORTS` carries no `x12` key, so the shipped sentence generalises past its own premise. The gap is already pinned with its own positive control in `tests/test_ingress_message_pacing.py::test_x12_has_no_toml_surface_at_all_which_is_a_separate_gap`; what is new here is only that the security prose overstates it. Left for its own diff -- this pass touched no document outside this row, and a security sentence deserves a change a reviewer can see on its own. + +**BUILT 2026-09-06: shipped-ON per-tick ceilings for the File, RemoteFile and Database poll +sources. The item stays OPEN and THIS DOES NOT MOVE THE CELL** -- this row is explicit that a builder +should not build in order to move it, and the two acts that would are the owner's. + +**Why these may default ON when the network-listener pacer may not**, stated in the code because a +later reader will otherwise "fix" the inconsistency the wrong way: deferral on a poll source is not a +drop. A file the scan does not reach is still in the drop directory; a row the poll does not fetch is +still in the table, unmarked. Nothing is quarantined, errored or accepted-and-dropped, so the +count-and-log invariant is untouched -- an item never received has no disposition to record. + +**The number is anchored on the repo's own published measurements rather than picked.** 500 per tick +is 500/s on File and 100/s on the other two at the shipped intervals, at or above every rate this +engine has been measured achieving (`docs/THROUGHPUT.md` ~450 at intake and ~60 end-to-end; +`docs/SYSTEM-REQUIREMENTS.md` ~97 sustained, ~107 burst). `capture_max_rows=100` was deliberately NOT +reused -- 100 rows per 5-second poll is 20/s, BELOW the measured sustained rate, so it would have +throttled a real feed. + +**Fair progress is the subtle half.** Only an item the tick FINISHED with charges the budget. Every arm +that leaves an item for a later retry (locked or vanished file, scan-hook malfunction, handler failure, +unsafe listing name) deliberately does not charge, because charging them would let one stuck +early-sorting item eat the whole ceiling every tick and starve the healthy items behind it. + +**Two anchors in this row point at the CAPTURE path rather than the poll path** and are corrected: the +poll `fetchall` was at `database.py:1181`, and the capture row ceiling is at `:899`/`:952`. The defect +itself reproduced exactly. + +**A residual measured on the way and left recorded rather than fixed:** the ceiling bounds the INGEST, +not the listing. `_candidates` still globs and screens the whole directory every tick, so draining a +large backlog now pays that listing once per tick over a shrinking set instead of once in total. +Bounding it properly means deferring the per-candidate screens into the scan loop, which is available +under `sort="name"` and not under `sort="mtime"`, and costs the accurate remaining count the ceiling's +log carries. Written into `_candidates` with that cost stated. ## 1115. research an honest pass for ASVS 2.4.2 -- whether human-timing pacing is meaningful for an engine whose only human surface is the console > 🔢 **Re-scored 2026-08-20 -> P3.** Value **4/10** · Difficulty **6/10** · _money pit_. The research half is delivered and the code it was written against is unchanged: the /ui surface charges nothing (zero allow_admin_write references in the web console) and the only pacing is a per-request per-actor budget at config/settings.py:2017-2021, never a flow timer. Value 4 because on a first deployment this is a coverage and calibration gap on an admin surface rather than a data-plane exposure; difficulty 6 because a flow timer spanning login, MFA enrolment and approve-then-decide is a new mechanism across auth service, API and console, and the floor has to come from a measurement the record does not have. _(was 3/10 · 5/10.)_ @@ -9660,6 +9854,40 @@ A recycled name inside one directory is **not** cross-IdP spoofing, so a 6.8.1 r **Still not an honest pass:** documenting the Kerberos absence and scoring on that, because a signed relaxation is never a pass and documenting an absence is not documenting a control; re-citing the existing signed acceptance, which is a recorded decision and not a satisfied verb; and now a third -- publishing the extension's lifetime row without either fixing the live-status poll or writing the honest version of the sentence, which would put an unmeasured coordination claim into the shipped security document. Proposed work, all unallocated and named by subject: the federated session-coordination inventory section in `docs/SECURITY.md`, one row per system with lifetime, termination and re-authentication columns; the session-holder enumeration fix at `docs/SECURITY.md:1368`, or its conversion to an "at least" form; the IDE live-status bearer-on-a-timer work, preferring the simple correct end state of not carrying the bearer on a timer at all; the Kerberos service-ticket lifetime cap on the Windows acceptor with its three gates; the Linux and GSSAPI decision, which must be settled before the inventory can be written truthfully; an AD-lab measurement of `ptsExpiry` through the Negotiate acceptor before the cap is relied on; and a record correction re-anchoring `settings.py:1817` and rewriting the residual so it grades this requirement's documentation verb. + +**BUILT 2026-09-06: the IDE live-status bearer-on-a-timer, which this row names as preferring +"the simple correct end state of not carrying the bearer on a timer at all". The item stays OPEN and +the cell does NOT move** -- the Kerberos ticket-lifetime limb is untouched and the cell carries its +signed acceptance to 2027-01-14. + +**The premise is sharper than this row states.** `ide/src/statusBar.ts` opens with a load-bearing block +whose first item says its own poll sends NO TOKEN, because a bearer on a timer would keep refreshing +the engine's idle clock and make the 30-minute idle timeout unreachable (CWE-613). `liveStatus.ts` did +exactly that, on a 5-to-10-second timer. Two files, one rule, opposite behaviour. + +**Keeping the bearer safe is not available to a client, and that was measured rather than assumed.** +Of the four `identity_for_token` call sites in `api/`, three take the `activity=True` default and one +is a hardcoded `activity=False` WebSocket keepalive. No header, query parameter or route lets a CALLER +ask for `activity=False`. That surface is an engine-side change and is named, not built. + +**Tokenlessness is DATA, not a comment** -- `LIVE_STATUS_PLAN` carries `authenticated: false` and CI +asserts it. A comment a later edit can contradict is how the two files diverged in the first place. + +**Accepted cost, stated where it bites:** against an auth-enabled engine the rows stay undecorated. The +setting ships OFF by default, and the full monitor remains the web console, which reads the same data +under `activity=False`. + +**Two documents asserted the defect was fine and are corrected.** ADR 0091's shipped-status bullet +called it "auth is passive" -- passive there meant never prompts, and it was not passive about the idle +clock. And `docs/SECURITY.md`'s enumeration of PHI-scoped token holders omitted the extension, which is +the only holder putting the token in durable OS-managed storage that outlives the process; it is now an +"at least" form per SDS-3.6. + +**Unverified by execution, and the code says so:** the idle-clock claim rests on reading +`identity_for_token`'s signature and the route's dependency chain, not on running an engine. + +**Untouched, named only:** the `sspilib` Kerberos ticket-lifetime cap and its three correctness gates, +the Linux/GSSAPI decision, the AD-lab measurement, and the federated session-coordination inventory. ## 1146. research an honest pass for ASVS 7.2.4 -- what session-token rotation on re-authentication must not break > 🔢 **Re-scored 2026-08-20 -> P1.** Value **8/10** · Difficulty **6/10** · _big bet_. Verified unchanged: the primitive is defined at auth/service.py:1614 and implemented on all three backends, but a tree-wide search finds no caller outside tests, and reauth (:1796-1832) stamps state on hash_token(token) rather than rotating, so a token minted at the password leg would survive the second factor. Value 8 because on a first deployment a pre-MFA token captured before the second factor would be elevated in place to a fully authenticated session; difficulty 6 because rotation must be wired without stranding the token-hash-keyed state the primitive's own test names, and in-flight requests and open WebSocket subscriptions have to be reasoned about at every elevation site. _(was 8/10 · 6/10.)_ @@ -9692,6 +9920,41 @@ A recycled name inside one directory is **not** cross-IdP spoofing, so a 6.8.1 r **Still not an honest pass, and the cheapest wiring now comes with cover.** The 2026-07-25 owner ruling on the token-delivery contract names exactly two JSON routes; building precisely those two looks like building to the owner's own words and would leave `confirm_mfa_enrollment`, `finish_webauthn_registration` and `finish_webauthn_assertion` un-rotated -- the legs that turn an MFA-pending session into an MFA-satisfied one for a first enrollment or a passkey-only account, and for `POST /ui/mfa` the passkey assertion is the ONLY leg. So the owner-named subset covers TOTP and misses the passkey path entirely while the cell would read "rotates on re-authentication". A partial covering is a legitimate increment and never a score: if sites are deferred the residual must NAME them. And because the cell's absence marker is literally the primitive's own call pattern, the pass must rest on a behavioural regression test -- a pre-elevation token stops authenticating at the moment of elevation, one case per site with a named red mutation -- and the residual must cite that test rather than the marker flipping. Proposed work, all unallocated and named by subject: the five-site rotation wiring under that ordering invariant; the breaking token-delivery contract on the three JSON re-authentication routes with `Cache-Control: no-store`; the apiclient token-adoption work, including replacing the copied-token polling clone with a shared token cell and rewriting the now-false `for_polling` invariant docstring; the console cookie re-set across the seven post-elevation response paths; login supersession on the three console legs plus written rationales for both bearer login legs; the IDE sign-in supersession; the console self-session revoke identifier work, which today would report a revoke that did not happen; the session-rotation audit event, since the in-place re-key leaves no store trace; a bounded console WebSocket reconnect, explicitly not a token grace window; the behavioural regression suite; the seam digest regeneration and the roughly forty affected test call sites; and the ledger-citation repair in the test file, replacing the number with the subject. + +**BUILT 2026-09-06: rotation wired at ALL FIVE elevation sites. The item stays OPEN** -- the +closing act is a scorecard re-score, which no builder performs. + +**Not the cheap subset, deliberately.** This row warns that the 2026-07-25 owner ruling names two JSON +routes and that building precisely those would look like building to the owner's own words while +leaving `confirm_mfa_enrollment`, `finish_webauthn_registration` and `finish_webauthn_assertion` +un-rotated -- and that for `POST /ui/mfa` the passkey assertion is the ONLY leg. All five are wired. + +**The ordering invariant lives in ONE place.** A private `_elevated()` is the sole caller of +`_rotate_session_token`, so the rule that every rowcount-blind stamp must land BEFORE the rotation is +stated once rather than in nine route handlers. Both site-specific traps are handled: `reauth` decides +`_factor_binding_is_blocked` before the rotation and mints its grant after, against the new hash; +`verify_mfa`'s three-write group lands first. A rotation on a vanished session reports `session_lost`, +which the routes map to 401 rather than the 403 a wrong proof gets -- a correct password must not be +reported as incorrect because the session died mid-ceremony. + +**The pass rests on BEHAVIOUR, not on the marker**, exactly as this row requires: the cell's absence +claim keys on the primitive's own call pattern, so wiring it for effect would flip the marker and +change nothing. `tests/test_session_rotation_wiring.py` asserts a pre-elevation token stops +authenticating at the moment of elevation, one case per site, with a mutation-checked negative control +(making `reauth` rotate unconditionally reds it and nothing else). + +**The WebSocket question this row asks is answered and recorded in code.** A rotation would drop an +open `/ws/stats` socket at the next revalidation tick, which is fail-closed and correct; `app.js` +resumes the HTTP poll carrying the new cookie, so completing MFA would cost the live push for the rest +of that page's life. A liveness regression, not correctness or data loss -- which is why the bounded +reconnect is deferred rather than built. + +**Also repaired, as this row asks:** the test file's citation of a backlog number that cannot resolve +from a public checkout now names the subject instead. + +**Still unbuilt, named by subject:** the IDE sign-in supersession; login supersession on the three +console cookie-minting legs and written rationales for both bearer login legs; the console +self-session revoke identifier; and the bounded console WebSocket reconnect. ## 1147. research an honest pass for ASVS 7.4.3 -- offering session termination as part of the MFA-change ceremony rather than beside it > 🔢 **Re-scored 2026-08-20 -> P2.** Value **5/10** · Difficulty **4/10** · _fill-in_. disable_mfa still offers and revokes nothing, and the post-disable redirect still lands on a page whose only relation to session termination is a link, so the option remains adjacent to the ceremony rather than part of it. The capability ships and is one click away, which caps value; difficulty 4 covers research plus a uniform ceremony across five factor-change paths without cutting the caller's own session mid-flow. _(was 5/10 · 4/10.)_ @@ -9862,6 +10125,30 @@ Interpreter named rather than assumed, since the extra-gated coverage hazard abo **Still not an honest pass, and the trap has moved somewhere worse than the item names.** The item disqualifies flipping `seed_reauth` on the password leg, and that stands -- `has_recent_step_up` is read at six call sites governing 26 engine and 33 console step-up dependencies, so a flip would silently change the freshness posture of a neighbouring requirement's entire scored surface. But on the shipped default the password leg ALREADY passes `seed_reauth=False`, so the move actually tempting today is deleting the single `mark_session_reauthed` line inside `verify_mfa` -- a one-line change that moves the cell, looks like a tightening, and is worse on three counts: it would force a fresh password step-up on all 59 step-up-gated routes immediately after every MFA login; the line is not a seeding line at all, its own comment at `auth/service.py:2196-2198` says it re-anchors the session to the address that completed the second factor so a roamed administrator clears the new-client-IP signal with one proof, so deleting it breaks a control belonging to a different requirement; and it would be invisible to a reviewer reading only the diff against the residual's stated mechanism. Also disqualified: gating `GET /me/sessions` so the whole surface "looks protected", since the pinned parenthetical binds terminate only and friction on viewing buys no limb; and treating the login-supersession question on `GET /ui/sso` as verdict-neutral -- it is not, because declining it leaves the terminate limb unmet on one of the three cookie-minting login legs. Proposed work, all unallocated and named by subject: the session-terminate action-binding build across the four self-service routes; the test inversion, since the two currently-passing immediate-terminate tests are the instrument that measures the gap; the ADR 0077 amendment recording the session-terminate action, since that ADR scopes the vocabulary to durable-takeover factor-binding routes; the `docs/SECURITY.md:1418-1434` session-inventory documentation gap, which enumerates all four routes and never mentions the password re-proof gate that already shipped; the current-session revocability correction at `docs/SECURITY.md:1433`, which says the current session is only revocable via sign-out while `revoke_own_session` accepts it; the stale in-source docstring at `pages/account.py:508` asserting the sessions page carries no step-up; and the residual and anchor repair, including the reviewer attribution and re-score trigger the cell has never carried. + +**BUILT 2026-09-06, and the build was NOT the one this row asks for -- that had already +landed. The item stays OPEN.** + +**A builder arriving here should not rebuild the action binding.** `STEP_UP_ACTION_SESSION_TERMINATE` +ships, both JSON terminate routes and both console twins take the action-bound reauth-only factory, +both continuations carry the `action=` tag, and the two immediate-terminate tests are inverted with +their reasoning recorded in place. + +**What was left behind were three shipped statements that the gate itself made false**, which is the +SDS-3.7 shape in reverse -- prose asserting the absence of a control that now exists: + +* `messagefoundry_webconsole/pages/account.py` said revoking one's own sessions is + "cookie-authenticated self-service (no step-up)". A stale absence claim beside a control reads as a + licence to remove the gate for consistency. +* `docs/SECURITY.md`'s session-inventory section enumerated all four routes and never mentioned the + password re-proof gate. +* The same section asserted the current session is "only revocable via Sign out". That is a property + of the console PAGE, not of the endpoint: `revoke_own_session` checks ownership and nothing else, so + on a first deployment `DELETE /me/sessions/{id}` would accept the caller's own current session id + and revoke it. + +That last claim is DERIVED rather than asserted -- a test drives the real route, and a mutation adding +the current-session guard the prose implied makes it answer 404, which reds the assertion. ## 1150. research an honest pass for ASVS 7.6.1 -- bounding time since the IdP authentication event without forcing a credential prompt every round trip > 🔢 **Re-scored 2026-08-20 -> P2.** Value **5/10** · Difficulty **5/10** · _fill-in_. With federation enabled a first deployment could not bound time since the IdP authentication event at all, and the one shipped lever destroys single sign-on, so there is no acceptable workaround. Difficulty 5: an owner ruling plus one setting, a max_age parameter on the existing authorize call and an auth_time comparison at the id_token seam, with a fallback for IdPs that omit the claim. _(was 5/10 · 7/10.)_ @@ -18780,6 +19067,37 @@ rest on specification alone, including that `Content-Disposition: attachment` su rendering. That is the gap a pass would have to close with a real user agent. + +**BUILT 2026-09-06. THE ITEM STAYS OPEN -- the closing act is a scorecard re-score, which no +builder performs.** All three pieces the 2026-08-23 research named are done. + +**The classifier is now an ALLOW-LIST.** `_BROWSER_ACTIVE_SUBTYPE_TOKENS`, `_BROWSER_ACTIVE_TYPES` and +`_is_browser_active_mime` are deleted. `_safe_attachment_content_type` returns the CANONICAL key from a +ten-entry exact-match table, so no attacker-influenced byte reaches the `Content-Type` header at all; +everything else is `application/octet-stream`. Completeness is now a property of a short reviewable +list rather than the unprovable negative the research named -- `application/hta` was the counterexample +that carried none of the four tokens. + +**`mimetypes.guess_extension` is gone**, and the reason is stronger than "an unstated contract": on +Windows it reads the HOST REGISTRY, so the served filename extension was a property of the machine the +engine happens to run on rather than of the product. Measured on this host, `guess_extension( +"application/hta")` returns `.hta`. The extension now comes from the same table, defaulting to `.bin`. + +**The PDF question is decided in writing**, above the table, and `application/pdf` STAYS on the list. +The clause this control answers is about executing in the APPLICATION ORIGIN; PDF script runs in the +viewer against the document, so a downgrade narrows nothing while costing the type hint on the +commonest clinical attachment. What would narrow the local-open threat is content scanning, which this +route does not do -- named there as unfiled work, by subject. + +**Unchanged and deliberately so:** the CSP, the unconditional `Content-Disposition`, `nosniff`, the +middleware and `attachment_mime_agrees`. The allow-list decides what is DECLARED, never whether a file +is served: an unrecognised type downloads exactly as a refused one does. + +**Differential control:** restoring the deny-list behind the new names reds 19 of 94. + +**STILL UNMEASURED, and no code comment claims otherwise: no browser was exercised.** That +`Content-Disposition: attachment` suppresses inline rendering still rests on specification alone. That +is the gap a pass would have to close with a real user agent. ## 1352. research an honest pass for ASVS 11.4.4 -- key-derivation parameters that balance security against brute force, at every derivation site > 🔢 **Filed 2026-08-23 - not started. RESEARCH item: the goal is an HONEST pass, and "cannot honestly reach pass" is a valid finding.** ASVS **11.4.4** (L2) is one of the three cells the assessment record holds as CONTESTED between assessors rather than merely unbuilt, so the blocker is a reading to be settled before any build is scoped. Value **5/10** - Difficulty **3/10** - _fill-in_. From f9651bbb70dc1387e8b400042690c6b7c93f68fb Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 15:28:04 -0500 Subject: [PATCH 14/17] refactor(auth): derive Elevation.ok instead of storing it (BACKLOG #1146) The last finding from this branch's /simplify pass, deferred until now only because messagefoundry/auth/service.py was held by a sibling ASVS packet every time it was reached for. That packet's author reviewed the finding, agreed, and deliberately left it to land under this number. `ok` was a second spelling of `token is not None`, and the invariant held across all 19 constructions without exception. The cost was not the field. Because the dataclass could represent a state the system never produces, mypy could not narrow `token` from `ok`, so every consuming site wrote if not elevation.ok or elevation.token is None: where the second clause carried no information the first did not -- it existed to re-derive the invariant in a form the checker accepts. As a property the combination cannot be constructed, and one clause narrows `token` to `str` on the happy branch for free. Nine sites collapse. The three `outcome.ok` sites are LoginOutcome, a DIFFERENT type that overlaps only on `ok`/`token`, and are untouched -- named here because a blanket sweep on `.ok` would have taken them and quietly changed a login path. `session_lost` is NOT redundant with `token is None` and stays a field: it distinguishes a wrong proof from a session that vanished mid-ceremony, which two routes branch on to pick 401 over 403. `recovery_codes` stays on the shared type for the one site that populates it, because a second return channel would reintroduce the shape divergence the class exists to prevent. Mutation-checked rather than assumed: forcing the property to `return True` reds four tests, including the revoked-session fail-closed case, so the derivation carries weight rather than restating something already pinned elsewhere. Co-Authored-By: Claude Opus 5 --- messagefoundry/api/auth_routes.py | 6 +-- messagefoundry/auth/service.py | 51 +++++++++++++-------- messagefoundry_webconsole/routes/account.py | 4 +- messagefoundry_webconsole/routes/core.py | 8 ++-- 4 files changed, 40 insertions(+), 29 deletions(-) diff --git a/messagefoundry/api/auth_routes.py b/messagefoundry/api/auth_routes.py index 5c473779a..2d17698fd 100644 --- a/messagefoundry/api/auth_routes.py +++ b/messagefoundry/api/auth_routes.py @@ -367,7 +367,7 @@ async def reauth( # back in X-Step-Up-Action). None => refresh only the session window, as before. purpose=body.purpose, ) - if not elevation.ok or elevation.token is None: + if elevation.token is None: # session_lost is a good password on a session revoked mid-ceremony: 401, not the 403 a # wrong password gets, so the client re-authenticates instead of re-prompting for a # password that was already correct. @@ -399,7 +399,7 @@ async def mfa_verify( if token is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid code") elevation = await service.verify_mfa(token, body.code, client=_client(request)) - if not elevation.ok or elevation.token is None: + if elevation.token is None: # A correct code on a session revoked mid-ceremony is already a 401 here, so unlike # /me/reauth there is no status to split — only the message differs. detail = "session ended; sign in again" if elevation.session_lost else "invalid code" @@ -464,7 +464,7 @@ async def confirm_mfa( ) except ValueError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc - if not elevation.ok or elevation.token is None: + if elevation.token is None: if elevation.session_lost: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session ended; sign in again") raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid code") diff --git a/messagefoundry/auth/service.py b/messagefoundry/auth/service.py index 37264f8f8..a611a08d6 100644 --- a/messagefoundry/auth/service.py +++ b/messagefoundry/auth/service.py @@ -186,13 +186,24 @@ class Elevation: happened rather than report a correct credential as incorrect. ``recovery_codes`` is populated only by :meth:`AuthService.confirm_mfa_enrollment` (shown once). + + ``ok`` is DERIVED, not stored, and that is load-bearing rather than tidiness. Held as a field it + was a second spelling of ``token is not None`` -- true of all 19 constructions -- so the type + could represent a state the system never produces, mypy could not narrow ``token`` from ``ok``, + and every consuming site paid ``if not elevation.ok or elevation.token is None``: a second clause + whose only job was to re-derive the first in a form the checker accepts. As a property the + impossible combination cannot be constructed and one clause narrows. """ - ok: bool token: str | None = None session_lost: bool = False recovery_codes: tuple[str, ...] = () + @property + def ok(self) -> bool: + """Elevated: a new session token was minted. See the class docstring for the three states.""" + return self.token is not None + @dataclass(frozen=True) class MfaEnrollment: @@ -1883,14 +1894,14 @@ async def _elevated( detail=_json({"ceremony": ceremony, "reason": "session_gone"}), client=client, ) - return Elevation(ok=False, session_lost=True) + return Elevation(session_lost=True) await self._audit( "auth.session_rotated", actor=actor, detail=_json({"ceremony": ceremony}), client=client, ) - return Elevation(ok=True, token=rotated, recovery_codes=recovery_codes) + return Elevation(token=rotated, recovery_codes=recovery_codes) async def identity_for_token( self, token: str | None, *, activity: bool = True @@ -2082,7 +2093,7 @@ async def reauth( ok = await self._reauth_ad(identity.username, password) else: ok = await self.verify_current_password(identity, password) - elevation = Elevation(ok=False) + elevation = Elevation() if ok: # (1) Every stamp for this elevation, against the OLD hash. The rotation carries these # columns forward; a stamp issued after it would silently write nothing. @@ -2449,7 +2460,7 @@ async def confirm_mfa_enrollment( detail=_json({"phase": "enroll"}), client=client, ) - return Elevation(ok=False) + return Elevation() plain = totp.generate_recovery_codes(self._settings.mfa_recovery_code_count) hashes = [await self._argon2(hash_password, c) for c in plain] await self._store.enable_totp(identity.user_id, recovery_code_hashes=hashes) @@ -2480,13 +2491,13 @@ async def verify_mfa( before the second factor would be elevated in place to a fully authenticated session on a first deployment.""" if not token: - return Elevation(ok=False) + return Elevation() session = await self._store.get_session(hash_token(token)) if session is None or session.revoked_at is not None: - return Elevation(ok=False) + return Elevation() user = await self._store.get_user(session.user_id) if user is None or user.disabled or not user.totp_enabled: - return Elevation(ok=False) + return Elevation() now = time.time() # Per-account lockout covers the SECOND factor too (parity with the password path): a run of # wrong codes locks the account, so MFA guessing isn't bounded only by the shared per-IP login @@ -2498,7 +2509,7 @@ async def verify_mfa( detail=_json({"reason": "locked"}), client=client, ) - return Elevation(ok=False) + return Elevation() if await self._verify_second_factor(user, code, client=client): # ORDER-CRITICAL: this whole three-write group lands against the OLD hash, and only then # does the session rotate. Moving any of them after the rotation writes NOTHING and reports @@ -2528,7 +2539,7 @@ async def verify_mfa( client=client, detail={"failed_attempts": attempts}, ) - return Elevation(ok=False) + return Elevation() async def _verify_second_factor( self, user: UserRecord, code: str, *, client: str | None = None @@ -2805,7 +2816,7 @@ async def finish_webauthn_registration( detail=_json({"phase": "enroll"}), client=client, ) - return Elevation(ok=False) + return Elevation() credential_id_hash = hash_bytes(result.credential_id) if await self._store.get_webauthn_credential(credential_id_hash) is not None: raise ValueError("this passkey is already enrolled") @@ -2899,13 +2910,13 @@ async def finish_webauthn_assertion( secrets and a flaky authenticator must not lock the account; abuse is bounded by the route's ``allow_login_attempt`` gate + cookie-holder-only reachability + these audits.""" if not token: - return Elevation(ok=False) + return Elevation() session = await self._store.get_session(hash_token(token)) if session is None or session.revoked_at is not None: - return Elevation(ok=False) + return Elevation() user = await self._store.get_user(session.user_id) if user is None or user.disabled: - return Elevation(ok=False) + return Elevation() now = time.time() # A locked account is refused BEFORE any verify (verify_mfa parity). if user.locked_until is not None and now < user.locked_until: @@ -2915,7 +2926,7 @@ async def finish_webauthn_assertion( detail=_json({"reason": "locked"}), client=client, ) - return Elevation(ok=False) + return Elevation() pending = self._webauthn_challenges.pop((hash_token(token), "assert")) if pending is None or pending.user_id != user.id: await self._audit( @@ -2924,7 +2935,7 @@ async def finish_webauthn_assertion( detail=_json({"reason": "expired"}), client=client, ) - return Elevation(ok=False) + return Elevation() try: raw_id = webauthn.credential_id_from_response(response_json) except webauthn.WebAuthnVerificationError: @@ -2934,7 +2945,7 @@ async def finish_webauthn_assertion( detail=_json({"reason": "malformed"}), client=client, ) - return Elevation(ok=False) + return Elevation() cred = await self._store.get_webauthn_credential(hash_bytes(raw_id)) if cred is None or cred.user_id != user.id or cred.rp_id != rp_id: # Unknown credential, another user's, or minted under a different origin — same @@ -2945,7 +2956,7 @@ async def finish_webauthn_assertion( detail=_json({"reason": "unknown_credential"}), client=client, ) - return Elevation(ok=False) + return Elevation() try: new_count = webauthn.verify_assertion( response_json=response_json, @@ -2964,7 +2975,7 @@ async def finish_webauthn_assertion( detail=_json({"label": cred.label}) if clone else None, client=client, ) - return Elevation(ok=False) + return Elevation() if not await self._store.update_webauthn_sign_count( cred.credential_id_hash, expected=cred.sign_count, new=new_count, used_at=now ): @@ -2975,7 +2986,7 @@ async def finish_webauthn_assertion( detail=_json({"label": cred.label}), client=client, ) - return Elevation(ok=False) + return Elevation() await self._store.mark_session_mfa_verified(hash_token(token)) await self._audit("auth.webauthn_verified", actor=user.username, client=client) return await self._elevated( diff --git a/messagefoundry_webconsole/routes/account.py b/messagefoundry_webconsole/routes/account.py index edad22299..124236d41 100644 --- a/messagefoundry_webconsole/routes/account.py +++ b/messagefoundry_webconsole/routes/account.py @@ -289,7 +289,7 @@ async def ui_mfa_verify( # A correct code on a session revoked mid-enrolment: MFA IS now on, but this browser's # cookie is dead, so the recovery codes cannot be shown here. Land on login. return login_redirect_response() - if not elevation.ok or elevation.token is None: + if elevation.token is None: return HTMLResponse(pages.mfa_confirm_page(error="Invalid code."), status_code=400) # Activated: the recovery codes render ONCE — never re-fetchable. The confirm re-keyed the # session (ASVS 7.2.4), so this response must carry the new cookie or the operator is signed @@ -471,7 +471,7 @@ async def ui_webauthn_verify( return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) if elevation.session_lost: return JSONResponse({"ok": False, "error": "session expired"}, status_code=401) - if not elevation.ok or elevation.token is None: + if elevation.token is None: return JSONResponse( {"ok": False, "error": "passkey verification failed"}, status_code=400 ) diff --git a/messagefoundry_webconsole/routes/core.py b/messagefoundry_webconsole/routes/core.py index 848278840..88c37920a 100644 --- a/messagefoundry_webconsole/routes/core.py +++ b/messagefoundry_webconsole/routes/core.py @@ -786,7 +786,7 @@ async def ui_mfa_submit(request: Request) -> Response: raise HTTPException(429, "too many attempts", headers={"Retry-After": "30"}) form = dict(parse_qsl((await request.body()).decode("utf-8", "replace"))) elevation = await auth.verify_mfa(token, form.get("code", ""), client=client) - if elevation.ok and elevation.token is not None: + if elevation.token is not None: # The session was re-keyed (ASVS 7.2.4), so the cookie this browser holds is now dead. # Re-set it on the redirect or the operator is signed out by their own correct code. resp = RedirectResponse("/ui", status_code=303) @@ -938,7 +938,7 @@ def _keep_session(resp: Response, tok: str) -> Response: ) if code_elevation.session_lost: return login_redirect_response() # session ended under a correct code - if code_elevation.ok and code_elevation.token is not None: + if code_elevation.token is not None: token = code_elevation.token # rotation 1 of 2 else: wa_options, wa_notice = await _reauth_webauthn_state( @@ -962,7 +962,7 @@ def _keep_session(resp: Response, tok: str) -> Response: ) if pw_elevation.session_lost: return login_redirect_response() - if not pw_elevation.ok or pw_elevation.token is None: + if pw_elevation.token is None: still_unsatisfied = not await auth.mfa_satisfied(token) wa_options, wa_notice = await _reauth_webauthn_state( request, auth, token, mfa, not still_unsatisfied @@ -1028,7 +1028,7 @@ async def ui_reauth_webauthn(request: Request) -> Response: ) if elevation.session_lost: return JSONResponse({"ok": False, "error": "session expired"}, status_code=401) - if not elevation.ok or elevation.token is None: + if elevation.token is None: return JSONResponse( {"ok": False, "error": "passkey verification failed"}, status_code=400 ) From 84e348bd73769faa42bafaec811201a6c740eaff Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 16:06:43 -0500 Subject: [PATCH 15/17] fix(webconsole): the Elevation call site my own refactor missed (BACKLOG #1146) f9651bbb7 made `ok` a derived property and rewrote 19 constructions. There were TWENTY. The one it missed is the ternary's else-branch in /ui/reauth's code leg, so on HEAD a re-auth submitted with an EMPTY code raises `Elevation.__init__() got an unexpected keyword argument 'ok'` -- established by execution, not by reading. That is a 500 on a real operator path. BOTH INSTRUMENTS I USED SHARED ONE BLIND SPOT, which is the part worth recording. I enumerated with `grep -rn 'Elevation(' messagefoundry/` and typechecked with `mypy messagefoundry`. Both are scoped to the ENGINE package; the missed site is in `messagefoundry_webconsole/`. Measured now: 19 constructions under `messagefoundry/`, 20 including the console. So the count I checked against was the count my own instrument defined, and it agreed with itself. The refactor changed a type consumed across a package boundary, so the population was never the engine package. `mypy messagefoundry messagefoundry_webconsole` reports it immediately -- 301 source files rather than 268. Co-Authored-By: Claude Opus 5 --- messagefoundry_webconsole/routes/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messagefoundry_webconsole/routes/core.py b/messagefoundry_webconsole/routes/core.py index 88c37920a..6706309b1 100644 --- a/messagefoundry_webconsole/routes/core.py +++ b/messagefoundry_webconsole/routes/core.py @@ -934,7 +934,7 @@ def _keep_session(resp: Response, tok: str) -> Response: if mfa_enrolled and not satisfied: code = form.get("code", "").strip() code_elevation = ( - await auth.verify_mfa(token, code, client=client) if code else Elevation(ok=False) + await auth.verify_mfa(token, code, client=client) if code else Elevation() ) if code_elevation.session_lost: return login_redirect_response() # session ended under a correct code From 2063598c150fdb1d4c6abc93def5def21f27bc48 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 6 Sep 2026 16:07:28 -0500 Subject: [PATCH 16/17] chore(seam): bump ENGINE_UI_SEAM for the Elevation return types (BACKLOG #1146) Changing five AuthService methods to return `Elevation` changed the engine-side contract the web console builds its deps bundle from, so the seam digest moves to 3dc2d790c35d9368. ALL THREE FILES SHIP TOGETHER AND THAT IS NOT TIDINESS. The console is a separately built wheel holding exactly one accepted seam, so a partial update is a hard startup refusal (UiSeamMismatch) for a deploying site rather than a warning -- which is the point of the mechanism, and the reason the generator and the hand-set constant are two steps rather than one. Generated with `scripts/webconsole_seam_snapshot.py --write` for the engine constant and the golden; `SUPPORTED_ENGINE_SEAMS` set by hand to match, as the procedure the gate prints requires. The gate caught exactly what it exists for: a cross-package contract change made in the engine and not carried to the console. My #1146 commit changed the return types and did not move the seam, and nothing in the engine-scoped checks I ran would ever have said so. Co-Authored-By: Claude Opus 5 --- messagefoundry/api/_ui_seam.py | 2 +- messagefoundry_webconsole/__init__.py | 2 +- tests/golden/webconsole_seam.snapshot | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/messagefoundry/api/_ui_seam.py b/messagefoundry/api/_ui_seam.py index f4069f051..bddc72fd4 100644 --- a/messagefoundry/api/_ui_seam.py +++ b/messagefoundry/api/_ui_seam.py @@ -134,7 +134,7 @@ #: proof is that commit 40a4d5d9 added a REQUIRED ``UploadedFileList.scope`` field the console renders #: unconditionally while touching no seam file at all. Regenerate with #: ``python scripts/webconsole_seam_snapshot.py --write``; never hand-edit it to silence a gate. -ENGINE_UI_SEAM: str = "266cbfd342b22819" +ENGINE_UI_SEAM: str = "3dc2d790c35d9368" @dataclass(frozen=True, slots=True) diff --git a/messagefoundry_webconsole/__init__.py b/messagefoundry_webconsole/__init__.py index e98767b2f..fcab2fcb4 100644 --- a/messagefoundry_webconsole/__init__.py +++ b/messagefoundry_webconsole/__init__.py @@ -45,7 +45,7 @@ # If cross-seam support is ever genuinely wanted, re-widen this set AND add the CI matrix that # installs the MIN and MAX supported engine builds — the claim and its test land together, or not # at all. -SUPPORTED_ENGINE_SEAMS: frozenset[str] = frozenset({"266cbfd342b22819"}) +SUPPORTED_ENGINE_SEAMS: frozenset[str] = frozenset({"3dc2d790c35d9368"}) #: The vendored static assets shipped in THIS wheel (mounted at /ui/static by :func:`mount_ui`). STATIC_DIR = Path(__file__).parent / "static" diff --git a/tests/golden/webconsole_seam.snapshot b/tests/golden/webconsole_seam.snapshot index ecad57e77..9fc1ba69d 100644 --- a/tests/golden/webconsole_seam.snapshot +++ b/tests/golden/webconsole_seam.snapshot @@ -7,7 +7,7 @@ # by hand (BACKLOG #1220) - so a newly rendered DTO is covered with nobody editing a list. ## ENGINE_UI_SEAM -266cbfd342b22819 +3dc2d790c35d9368 ## dataclass messagefoundry.api._ui_seam.UiDeps engine_seam @@ -117,10 +117,10 @@ begin_oidc_login: (self, *, client: 'str | None', public_origin: 'str') -> 'tupl begin_webauthn_assertion: (self, token: 'str | None', *, rp_id: 'str') -> 'str | None' begin_webauthn_registration: (self, identity: 'Identity', *, token: 'str', rp_id: 'str', rp_name: 'str') -> 'str' complete_oidc_login: (self, *, flow_id: 'str', state: 'str', code: 'str', client: 'str | None', public_origin: 'str') -> 'LoginOutcome' -confirm_mfa_enrollment: (self, identity: 'Identity', code: 'str', *, token: 'str', client: 'str | None' = None) -> 'list[str] | None' +confirm_mfa_enrollment: (self, identity: 'Identity', code: 'str', *, token: 'str', client: 'str | None' = None) -> 'Elevation' delete_webauthn_credential: (self, identity: 'Identity', credential_id_hash: 'str', *, client: 'str | None' = None) -> 'bool' -finish_webauthn_assertion: (self, token: 'str | None', response_json: 'str', *, client: 'str | None' = None, rp_id: 'str', origin: 'str') -> 'bool' -finish_webauthn_registration: (self, identity: 'Identity', response_json: 'str', *, label: 'str', token: 'str', client: 'str | None' = None, rp_id: 'str', origin: 'str') -> 'bool' +finish_webauthn_assertion: (self, token: 'str | None', response_json: 'str', *, client: 'str | None' = None, rp_id: 'str', origin: 'str') -> 'Elevation' +finish_webauthn_registration: (self, identity: 'Identity', response_json: 'str', *, label: 'str', token: 'str', client: 'str | None' = None, rp_id: 'str', origin: 'str') -> 'Elevation' flag_new_client_ip: (self, token: 'str | None', client_ip: 'str | None', *, path: 'str') -> 'bool' has_action_step_up: (self, token: 'str | None', action: 'str') -> 'bool' has_recent_step_up: (self, token: 'str | None') -> 'bool' @@ -133,11 +133,11 @@ mfa_satisfied: (self, token: 'str | None') -> 'bool' mfa_status: (self, identity: 'Identity') -> 'MfaStatus' oidc_enabled: property -> 'bool' oidc_flow_ttl_seconds: property -> 'int' -reauth: (self, identity: 'Identity', password: 'str', *, token: 'str', client: 'str | None' = None, purpose: 'str | None' = None) -> 'bool' +reauth: (self, identity: 'Identity', password: 'str', *, token: 'str', client: 'str | None' = None, purpose: 'str | None' = None) -> 'Elevation' revoke_other_sessions: (self, identity: 'Identity', current_token_hash: 'str', *, actor: 'str') -> 'int' revoke_own_session: (self, identity: 'Identity', session_id: 'str', *, actor: 'str') -> 'bool' store: property -> 'AdminStore' -verify_mfa: (self, token: 'str | None', code: 'str', *, client: 'str | None' = None) -> 'bool' +verify_mfa: (self, token: 'str | None', code: 'str', *, client: 'str | None' = None) -> 'Elevation' webauthn_available: (self) -> 'bool' ## app.state attributes the console sets/reads @@ -210,7 +210,7 @@ messagefoundry.api.models.PendingApprovalResponse: approval_id, detail, operatio messagefoundry.api.models.PoolInfo: acquire_wait, backend, claim_pool, idle, max_size, size messagefoundry.api.models.PoolWaitInfo: count, max_ms, mean_ms, p50_ms, p95_ms, p99_ms messagefoundry.api.models.ReloadRequest: config_dir, dry_run -messagefoundry.api.models.ReloadResult: dry_run, handlers, inbound, outbound, routers, running +messagefoundry.api.models.ReloadResult: degraded, dry_run, failures, handlers, inbound, outbound, routers, running messagefoundry.api.models.SearchPresetCreateRequest: criteria, name messagefoundry.api.models.SearchPresetCriteria: channel_id, content, control_id, field_path, field_value, limit, message_type, status, target messagefoundry.api.models.SecurityLoosening: risk, switch From 569349ee91bfd1b7ab3025fd4ae5c79c7c24c282 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Wed, 9 Sep 2026 09:05:01 -0500 Subject: [PATCH 17/17] fix(tests): classify two new response surfaces the guards flagged Two required legs were red on this branch for the same reason: a new response surface landed without the classification its guard requires. tests/test_no_store_phi_coverage.py -- ElevatedResponse.detail is bound to None (projects no store column). MEASURED from the call sites, not from the field name or the class docstring: the model is constructed at exactly two sites, both engine-authored string literals, and neither route reads the store. messagefoundry/api/auth_routes.py:393 detail="re-verified" (/me/reauth) messagefoundry/api/auth_routes.py:423 detail="verified" (/auth/mfa-verify) packaging/messagefoundry-webconsole/tests/test_ui_csp_canary.py -- auth_routes joins _EMITTERS rather than _NOT_UI_EMITTERS. It writes exactly one browser security header, Cache-Control at line 171, and app.py already contributes that name, so the emitted set gains no entry and no new degrade-contract bucket is needed. _NOT_UI_EMITTERS was rejected on evidence: an entry there must state that no browser ever reaches the response, and these routes sit on the same origin that serves /ui, so that reason would be false. The seventeen merge conflicts against main are deliberately untouched; the poll-ceiling collision needs an owner decision, recorded on the PR. Co-Authored-By: Claude Opus 5 --- .../messagefoundry-webconsole/tests/test_ui_csp_canary.py | 2 ++ tests/test_no_store_phi_coverage.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/packaging/messagefoundry-webconsole/tests/test_ui_csp_canary.py b/packaging/messagefoundry-webconsole/tests/test_ui_csp_canary.py index d7623396f..75095b3d0 100644 --- a/packaging/messagefoundry-webconsole/tests/test_ui_csp_canary.py +++ b/packaging/messagefoundry-webconsole/tests/test_ui_csp_canary.py @@ -43,6 +43,7 @@ import messagefoundry import messagefoundry.api.app as engine_app +import messagefoundry.api.auth_routes as engine_auth_routes import messagefoundry.api.client_networks as engine_client_networks import messagefoundry.api.header_floor as engine_header_floor import messagefoundry.api.request_timeout as engine_request_timeout @@ -518,6 +519,7 @@ async def test_only_all_canary_batches_are_silenced( #: cannot see. The console package itself is always read; these are the additions. _EMITTERS = ( engine_app, + engine_auth_routes, engine_client_networks, engine_header_floor, engine_request_timeout, diff --git a/tests/test_no_store_phi_coverage.py b/tests/test_no_store_phi_coverage.py index dee506b28..c84b9d889 100644 --- a/tests/test_no_store_phi_coverage.py +++ b/tests/test_no_store_phi_coverage.py @@ -148,6 +148,9 @@ def _classified_columns() -> dict[str, str]: ("SecurityEventInfo", "detail"): "audit_log.detail", # --- composed in the route body; no store column to rate ----------------------------------- ("SimpleMessage", "detail"): None, # a literal operation-result string + # Both construction sites are engine-authored literals -- auth_routes.py `detail="re-verified"` + # on /me/reauth and `detail="verified"` on /auth/mfa-verify. Neither route reads the store. + ("ElevatedResponse", "detail"): None, ("IntegrityResult", "detail"): None, # the backend's own integrity-check output ("PendingApprovalResponse", "detail"): None, # why the action is held for a second approver ("AlertTestEmailResult", "detail"): None, # a safe_exc-scrubbed SMTP send failure