Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ deliberate — replace `<version>` with the current release shown at the top of
needs (each is opt-in and lazy-imported):

```bash
pip install "messagefoundry-webconsole==<version>" # the browser web console (/ui) — the operator UI; most operators want this
pip install -e packaging/messagefoundry-webconsole # the browser web console (/ui) — the operator UI; most operators want this (source tree: not on PyPI yet)
pip install "messagefoundry[postgres]==<version>" # PostgreSQL store backend (production server DB)
pip install "messagefoundry[sqlserver]==<version>" # SQL Server store backend (+ OS-level ODBC Driver 18)
pip install "messagefoundry[sftp]==<version>" # SFTP transport for the REMOTEFILE connector
Expand Down
5 changes: 4 additions & 1 deletion docs/INSTALL-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,10 @@ It ships as a separate, version-matched wheel (`messagefoundry-webconsole`) that
in-process; install it alongside the engine and turn on `[api].serve_ui`:

```powershell
pip install "messagefoundry-webconsole==0.1.0" # the /ui web console, into the same venv
pip install -e packaging/messagefoundry-webconsole # the /ui web console, into the same venv
# NOTE: the console is NOT on PyPI yet — install it from the source tree, as above. Installing it
# by bare name from an index would resolve an UNCLAIMED distribution, i.e. whatever a third party
# has uploaded under that name, with its build backend executing at install time (ASVS 15.2.4).
# then set [api].serve_ui = true in your service settings and (re)start the engine
```

Expand Down
2 changes: 1 addition & 1 deletion docs/MENTAL-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ Keep the message store on a fast *local* disk, not a network share — the stage

## 13. Deployment & operations

- **Install:** the supported production artifact is the signed, version-pinned PyPI wheel (pip install "messagefoundry==0.1.0"); then messagefoundry init scaffolds your own config repo (ADR 0017). Extras are opt-in: \[postgres\], \[sqlserver\], \[harness\] (the PySide6 test harness), \[sftp\]. The `/ui` web console installs alongside as the separate `messagefoundry-webconsole` distribution.
- **Install:** the supported production artifact is the signed, version-pinned PyPI wheel (pip install "messagefoundry==0.1.0"); then messagefoundry init scaffolds your own config repo (ADR 0017). Extras are opt-in: \[postgres\], \[sqlserver\], \[harness\] (the PySide6 test harness), \[sftp\]. The `/ui` web console installs alongside as the separate `messagefoundry-webconsole` distribution — **from the source tree** (`pip install -e packaging/messagefoundry-webconsole`) until the release phase publishes it; the name is not yet claimed on PyPI.

- **Run headless:** python -m messagefoundry serve --config samples/config --db ./messagefoundry.db --env dev — API on http://127.0.0.1:8765 (GET /connections, /messages, /stats, WS /ws/stats).

Expand Down
3 changes: 2 additions & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,7 @@ one-to-one — that is why the bind/exposure posture occupies two rows and the A
| Gated operation × requester-vs-approver identity × hold age | the pending-approval record: the operation name, the requesting identity, and the hold's creation time | `[approvals].enabled` **and** the operation is in `[approvals].operations` and has no approved unexpired release; the approver is the requester; the hold is older than `expiry_hours` | **DENY** the immediate execution — **202** hold + `approval.requested` audit; **403** on self-approval; **409** once expired or already decided | off; `['connection_purge','dead_letter_replay']`; 72 h | `[approvals].enabled`, `operations`, `expiry_hours` |
| mTLS client-certificate subject | the qualified subject-RDN / SAN names of a **verified** peer certificate | exact match against a deny-by-default map (empty map = feature off) | **ALLOW** — resolve to that principal's Identity (RBAC then authorizes); a disabled account grants none | `{}` = off | `[api].tls_client_cert_identities` (requires `tls_client_ca_file`) |
| Operator-listener peer client certificate | the TLS peer certificate presented at the API / `/ui` handshake | `[api].tls_client_ca_file` set (requires `tls_cert_file`) → `ssl.CERT_REQUIRED` plus strict RFC 5280 verify flags (`api/tls.py:47-50`); no client certificate, or one not issued by that CA | **DENY** — the TLS handshake fails, so the request never reaches the ASGI stack at all: no middleware runs, no route matches, no identity is resolved, and no 403 body is produced | unset = off (server-only TLS, no peer-certificate decision on the control plane) | `[api].tls_client_ca_file` |
| Declared token class of a federated assertion | the `typ` JOSE header, and the presence of an `events` claim, on a **signature-verified** JWS | `typ` present and — normalised `.strip().lower()` then `application/`-stripped — not `jwt`, so `at+jwt` (RFC 9068 access token), `logout+jwt` and `secevent+jwt` are refused while an **absent** `typ` is allowed (RFC 7519 §5.1 makes the header advisory); or the claim set carries `events`, i.e. an RFC 8417 security event token. Every such token is minted by the **same issuer under the same key**, so no signature or key rung distinguishes it | **DENY** the sign-in — `ClaimsError("wrong_token_type")` at the key-selection rung, `ClaimsError("unexpected_events_claim")` ahead of the nonce compare (a logout token carries no nonce, so a later check would misreport it as a browser-binding failure) | on | (no knob) |
| Federated authentication-context claims (`amr` / `acr`) | the `amr` list / `acr` string of a **signature-verified** `id_token` | `oidc_require_mfa_claim` on **and** neither an `amr` value in `[auth].oidc_mfa_amr_values` (default `["mfa"]`) nor an `acr` in `oidc_required_acr_values` (default `[]`, so the `amr` arm alone decides) | **DENY** the sign-in — `ClaimsError("mfa_claim_missing")`. An IdP **assertion**, never a proof | on, `["mfa"]` / `[]` | `[auth].oidc_require_mfa_claim`, `oidc_mfa_amr_values`, `oidc_required_acr_values` |
| UPN suffix of the federated username claim | the suffix after the FIRST `@` of the username claim | `oidc_username_strip_domain` on (default) **and** the suffix is not in `oidc_allowed_username_domains` (or `[auth].ad_domain`). With stripping **off** the claim is used verbatim and no suffix check runs | **DENY** the sign-in — `ClaimsError("username_domain_not_allowed")` | on | `[auth].oidc_allowed_username_domains`, `oidc_username_strip_domain` |
| Bootstrap-admin age × admin population | `users.created_at` for the built-in bootstrap account × whether a second enabled Administrator exists | still unclaimed (`must_change_password` set) **and** (`now ≥ created_at + bootstrap_expiry_hours × 3600` **or** another enabled admin exists); `0` = no time expiry | **DENY** — the account is disabled, **all** its sessions revoked, `auth.bootstrap_admin_retired` audited. A *claimed* (password-changed) bootstrap account is never touched | 72 h | `[auth].bootstrap_expiry_hours` |
Expand Down Expand Up @@ -1398,7 +1399,7 @@ Comparative properties on the dimensions the table's four columns cannot carry:
| **Local** | passkeys only (WebAuthn origin-bound, `attestation=none`, `user_verification=preferred`); password/TOTP are phishable | TOTP is single-use per 30 s step (`totp_skew_steps` default `0`); recovery codes single-use; passkey challenges are 64-byte CSPRNG, single-use, 120 s TTL, with a strict sign-counter compare-and-set | argon2id password hash (t=3, m=64 MiB, p=4); TOTP secret **cipher-encrypted**; recovery codes argon2id-hashed; COSE public keys **plaintext by design** | built (TOTP + passkeys) | disable the account or revoke sessions — immediate |
| **AD** | none | none beyond TLS | **none** — only the service-account bind password (env or a `[secrets]` reference, fail-closed) | delegated and **unverifiable** — the simple-bind leg issues MFA-satisfied under a signed relaxation; the engine receives no evidence (contrast OIDC's signature-verified `amr`/`acr`, which is enforced) | disabling in AD does **not** end a live session on its own; `[auth].ad_session_recheck_seconds` (default **300 s**) closes it, bounded by interval × strikes |
| **Kerberos** | none (single-leg, no channel binding) | ticket lifetime is the domain's | **none** — the acceptor keytab/SPN is OS-owned | delegated, unverifiable | as AD |
| **OIDC** | the IdP's, not the engine's | strongest of the four: server-side PKCE verifier + `state` (constant-time compare) + `nonce`, single-use flow, a `__Host-`-prefixed browser-binding cookie the callback requires, and a kid/alg/signature/`iss`/`aud`/`exp`/`iat`/`nbf` ladder under a bounded clock skew | **none** — only the confidential-client secret (env-only or a `[secrets]` reference, resolved eagerly at startup) | asserted via `amr`/`acr` **and enforced** — with `[auth].oidc_require_mfa_claim` on (default) a token carrying no configured `amr`/`acr` is refused at claims validation, and only then is the session minted MFA-verified; switch it off and the federated session is minted **un**verified, which `mfa_satisfied` refuses. This is the one directory leg whose factor the engine actually verifies | as AD, plus the `id_token.exp` cap; no refresh tokens and no RP-initiated logout |
| **OIDC** | the IdP's, not the engine's | strongest of the four: server-side PKCE verifier + `state` (constant-time compare) + `nonce`, single-use flow, a `__Host-`-prefixed browser-binding cookie the callback requires, and a `typ`/kid/alg/signature/`events`/`iss`/`aud`/`exp`/`iat`/`nbf`/`nonce`/`sub` ladder under a bounded clock skew — `typ` and `events` assert the token **class** (an access token or a logout token carries the same issuer and key), and `sub`/`iat` are required rather than optional | **none** — only the confidential-client secret (env-only or a `[secrets]` reference, resolved eagerly at startup) | asserted via `amr`/`acr` **and enforced** — with `[auth].oidc_require_mfa_claim` on (default) a token carrying no configured `amr`/`acr` is refused at claims validation, and only then is the session minted MFA-verified; switch it off and the federated session is minted **un**verified, which `mfa_satisfied` refuses. This is the one directory leg whose factor the engine actually verifies | as AD, plus the `id_token.exp` cap; no refresh tokens and no RP-initiated logout |
| **mTLS** | n/a (no interactive ceremony) | n/a | **none** — the engine holds only the pinned client CA and the name map | none, structurally | **no revocation checking** — `VERIFY_X509_STRICT` is strict path validation, not OCSP/CRL; live revocation is the org's PKI. Engine-side: remove the allow-list entry (config change → restart) or disable the mapped account |

**Where each pathway is enforced, and what turns it on:** Local → `POST /auth/login` + `POST /ui/login`
Expand Down
2 changes: 1 addition & 1 deletion docs/SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ same-origin at `/ui` (not part of the service runtime — a separate, version-ma
mounts in-process):

```powershell
pip install "messagefoundry-webconsole" # into the engine venv
pip install -e packaging/messagefoundry-webconsole # into the engine venv (not on PyPI yet)
# set [api].serve_ui = true in the service settings, then (re)start the service
```

Expand Down
2 changes: 1 addition & 1 deletion docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ pip install -e ".[dicom]" # DICOM C-STORE SCP + codec — headers/SR only
pip install -e ".[otel]" # OpenTelemetry/OTLP export seam (the /metrics endpoint itself needs no extra)
```

(For a deployment wheel, the same extras apply: `pip install "messagefoundry[harness]==0.1.0"`, and the web console installs as its own wheel `pip install "messagefoundry-webconsole==0.1.0"`.) SQLite is the zero-dependency default — you need no extra to run the sample config.
(For a deployment wheel, the same extras apply: `pip install "messagefoundry[harness]==0.1.0"`, and the web console installs from the source tree with `pip install -e packaging/messagefoundry-webconsole` — it is **not published to an index yet**, so installing it by bare name would resolve an unclaimed distribution.) SQLite is the zero-dependency default — you need no extra to run the sample config.

### 3. Run the engine headless (dev)

Expand Down
10 changes: 7 additions & 3 deletions docs/adr/0022-fhir-resource-codec-rest-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,10 @@ in → `fhir.resources` resource out, hand-authored (no pure-Python v2↔FHIR co
that **reuses the shared module-level helpers** in [transports/rest.py](../../messagefoundry/transports/rest.py) —
**exactly as the SOAP destination does**. `SoapDestination` ([transports/soap.py](../../messagefoundry/transports/soap.py))
is *not* a wrapper of `RestDestination`; it is a sibling `DestinationConnector` that imports rest.py's
`_NO_REDIRECT_OPENER`/`_NoRedirectHandler`, `_insecure_opener`, `_redact_url`, `enforce_outbound_length_limits`,
`refuse_cleartext_credentials`, plus `signer_from_destination` — and follows rest.py's status→retry idiom.
`_NO_REDIRECT_OPENER`/`_NoRedirectHandler`, `_insecure_opener`, `_redact_url`, `enforce_outbound_length_limits`
+ `enforce_send_time_length_limits` + `enforce_signature_header_limits` (ASVS 4.2.5 -- the construction gate
sees only static config; the send-time gate bounds the per-call URL, the per-message headers and the minted
bearer), `refuse_cleartext_credentials`, plus `signer_from_destination` — and follows rest.py's status→retry idiom.
`FhirDestination` does the **same**: it **does not compose or instantiate `RestDestination`**, and it does
**not** re-implement HTTP. It implements the `DestinationConnector` contract: one `async def send(self, payload:
str) -> DeliveryResponse | None`, optional `aclose`/`test_connection` overrides, and it raises **only**
Expand All @@ -195,7 +197,9 @@ str) -> DeliveryResponse | None`, optional `aclose`/`test_connection` overrides,
- The TLS posture: the no-redirect, TLS-verifying opener (`_NO_REDIRECT_OPENER`/`_NoRedirectHandler` — a 3xx is
raised, never followed: the PHI-redirect defense, ASVS 15.3.2) and the `verify_tls=False` escape gated by
`MEFOR_ALLOW_INSECURE_TLS` (`insecure_tls_allowed()`), plus the cleartext-credential refusal.
- `enforce_outbound_length_limits`, `refuse_cleartext_credentials`, `_redact_url`, and the optional JWS signer
- `enforce_outbound_length_limits` / `enforce_send_time_length_limits` / `enforce_signature_header_limits`
(ASVS 4.2.5; the FHIR per-call URL is message-derived, so the send-time arm is load-bearing here in a way it
is not for REST), `refuse_cleartext_credentials`, `_redact_url`, and the optional JWS signer
hook (`signer_from_destination`, ADR 0018) for signed bodies.
- The retry classification **idiom** from rest.py's `_post`: **2xx → delivered**; status in `_RETRYABLE_4XX =
{408, 429}` **or** `5xx` → `DeliveryError` (transient → pipeline retries with backoff); **any other 4xx** (and a
Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0025-dicom-codec-store-connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ right; built behind the Phase-1 slice:
[transports/rest.py](../../messagefoundry/transports/rest.py) — **exactly as the SOAP and FHIR destinations do**.
`SoapDestination`/`FhirDestination` are *not* wrappers of `RestDestination`; each is a sibling `DestinationConnector`
that imports rest.py's `_NO_REDIRECT_OPENER`/`_NoRedirectHandler`, `_insecure_opener`, `_redact_url`,
`enforce_outbound_length_limits`, `refuse_cleartext_credentials`, the `_RETRYABLE_4XX` retry idiom, plus
`enforce_outbound_length_limits` (ASVS 4.2.5 -- applied to the DERIVED `_target_url`, not just `base_url`),
`refuse_cleartext_credentials`, the `_RETRYABLE_4XX` retry idiom, plus
`signer_from_destination` — and follows rest.py's status→retry idiom. `DicomWebDestination` does the **same**: it
**does not compose or instantiate `RestDestination`**, and it does **not** re-implement HTTP. It implements the
`DestinationConnector` contract: one `async def send(self, payload: str) -> DeliveryResponse | None`, optional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,24 @@ Server-side `state` is a CSRF/mix-up defence, **not** a browser binding — whoe
- **AC-2** — WHEN a federated login completes, THE SYSTEM SHALL resolve roles from on-prem AD via
`resolve_principal`, never from a token claim.
→ `tests/test_auth_oidc_service.py`
- **AC-3** — IF the `id_token` fails any verification rung (signature, `iss`, `aud`/`azp`, `exp`/`iat`
skew, `nonce`, `kid` unknown/ambiguous, key below the floor), THEN THE SYSTEM SHALL refuse the login,
mint no session, and audit a closed-set reason slug.
- **AC-3** — IF the `id_token` fails any verification rung (declared token class via `typ`/`events`,
signature, `iss`, `aud`/`azp`, `exp`/`iat` skew, required `sub`, `nonce`, `kid` unknown/ambiguous,
key below the floor), THEN THE SYSTEM SHALL refuse the login, mint no session, and audit a
closed-set reason slug.
→ `tests/test_auth_oidc.py`

*Amended 2026-07-28 (ASVS 9.2.2).* The class rungs were added because an access token
(`typ: at+jwt`), a back-channel logout token (`logout+jwt`) and an RFC 8417 security event token
are minted by the **same issuer under the same key** as the `id_token`: every signature and key
rung passes on them, so without an explicit class assertion the only thing between an access token
and an accepted login is nonce equality. An **absent** `typ` is still accepted — RFC 7519 §5.1
makes the header advisory and refusing it would lock out conforming IdPs.

> **Forward note for cell 10.5.5 (back-channel logout receiver).** A logout token legitimately
> carries `events` and no `nonce`, so `validate_id_token` will refuse it — correctly. Build that
> receiver its **own** logout-token ladder (OIDC Back-Channel Logout §2.4: require `events`, forbid
> `nonce`, require `sid`/`sub`). Do **not** relax `unexpected_events_claim` to reuse this function:
> that silently reopens ASVS 9.2.2 and no test outside this ADR note would catch it.
- **AC-4** — IF the protected header declares `alg` outside the configured allow-list, THEN THE SYSTEM
SHALL raise before any signature computation (`none` and `HS*` are unrepresentable in
`SignatureAlgorithm`).
Expand Down
2 changes: 1 addition & 1 deletion docs/testing/FEATURE-COVERAGE-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@ Recommended tests to close gaps:
| HTTPFHIR-3 | REST/SOAP/FHIR response capture (accepted/no_reply, encrypted at rest) | 0013,0003 | test_response_capture.py, test_response_headers_capture.py, test_fhir_transport.py, test_soap_wssecurity.py | covered | backend: encryption-at-rest SQLite-only | med | M |
| HTTPFHIR-4 | Captured response-header allow-list (#154) | 0013 | test_response_headers_capture.py | covered | SOAP/FHIR wiring not separately driven | low | S |
| HTTPFHIR-5 | Per-message dynamic HTTP headers (#68): projection + injection safety | 0081 | test_rest_transport.py, test_fhir_transport.py | covered | — | med | S |
| HTTPFHIR-6 | Outbound URL/header length limits (ASVS 4.2.5) | 0003 | test_rest_transport.py over-length url/header | covered | SOAP/FHIR share helper, no own assertion | low | S |
| HTTPFHIR-6 | Outbound URL/header length limits, construction **and send time** (ASVS 4.2.5) | 0003 | test_rest_transport.py over-length url/header + the send-time gate (message header, minted bearer, signature); test_fhir_lookup.py read-URL bound; test_apiclient.py constant parity | covered | SOAP/FHIR share the helper and have no own send-time assertion; _probe paths asserted only indirectly | low | S |
| HTTPFHIR-7 | SOAP plain mode: envelope POST + version headers | 0003,0015 | test_soap_transport.py 1.1/1.2 headers + send | covered | — | med | S |
| HTTPFHIR-8 | SOAP Fault classification + no fault-body echo | 0015 | test_soap_transport.py, test_soap_wssecurity.py | covered | phi: no canary assertion on fault body | med | S |
| HTTPFHIR-9 | SOAP WS-Addressing/WS-Security stamping (purity, PasswordText/Digest, escaping) | 0015 | test_soap_wssecurity.py | covered | — | high | S |
Expand Down
Loading
Loading