diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 911cf3da..febd1fb9 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -4637,7 +4637,7 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre ## 1013. The `[auth] enabled=false` startup arm keys on the bind alone, so auth-off behind a declared terminator still starts -> πŸ”’ **Filed 2026-08-04 β€” not started.** Value **7/10** Β· Difficulty **4/10** Β· _quick win_. The auth-off startup arm reads `not settings.auth.enabled and not settings.api.is_loopback`, so it does not fire for a declared TLS-terminating proxy. A PHI instance with authentication **entirely off** behind a declared terminator starts with **no refusal and no warning** β€” while the same topology with auth ON but MFA off is refused by the gate #326 fixed. The two arms disagree about what "exposed" means, in the same file, for the same topology. +> βœ… **Fixed 2026-08-06.** Value **7/10** Β· Difficulty **4/10** Β· _quick win_. The auth-off startup arm read `not settings.auth.enabled and not settings.api.is_loopback` (the bind alone), so it did not fire for a declared TLS-terminating proxy: a PHI instance with authentication **entirely off** behind a declared terminator would have started with **no refusal and no warning** on first deployment β€” while the same topology with auth ON but MFA off is refused by the gate #326 fixed. The two arms disagreed about what "exposed" means, in the same file, for the same topology. The auth-off arm now consults the single `instance_exposed` definition (hoisted above it), so it refuses on a non-loopback bind OR a declared terminator. **Cluster:** Security / startup gates. **Priority:** P1. **Verdict:** build. **Severity:** high on first deployment β€” no authentication at all on an off-loopback PHI instance. @@ -4653,6 +4653,8 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre ⚠️ **THE REMEDY IS UNPROVEN β€” do not read this item as prescribing one.** Nobody has established that hoisting `instance_exposed` to the auth-off arm is safe. That arm runs **early** in the startup ladder, and whether the settings it reads are fully resolved at that point is unknown. **That ordering question is the actual work of this item**, not the two-line re-key it superficially resembles. +> **AMENDED 2026-08-06 β€” remedy proven; the load-order question is resolved.** The prerequisite this item flagged as unproven holds. `instance_exposed`'s inputs are fully resolved where the auth-off arm runs: its two fields β€” `settings.api.host` (through `is_loopback`) and `settings.api.tls_terminated_upstream` β€” are read straight off the loaded config, and the only in-place mutation of `settings.api.*` between the arm and the former definition site is `serve_ui` (twice), which the predicate does not read. So the single definition was hoisted above the auth-off arm with a byte-identical value, and the arm was widened to consult it (refuse on a non-loopback bind OR a declared terminator). Exactly one definition site remains, per the pointer comment #326 left ("`instance_exposed` is NOT re-derived here") β€” the hoist shifts that comment's line, so it is named rather than pinned to a number. + **#326 HAS LANDED** (PR #189), and the re-verification this paragraph asked for was performed at `17374679`: the arm moved `:1080` to `:1112`, `instance_exposed` moved `:2368` to `:1917`, `admin_exposed` is now `admin_exposed = instance_exposed` at `:1939`, and the separation narrowed from 1,288 lines to **805**. The duplicate definition at the former `:2368` is **gone**, replaced by a pointer comment at `:2454` ("`instance_exposed` is NOT re-derived here. It is defined ONCE, above"), so there is now exactly ONE definition site to move rather than two to keep in sync. **The load-bearing property survives the move and so does the difficulty-4 pricing:** the arm at `:1112` still sits ABOVE the definition at `:1917`, so it still cannot reference it without hoisting, and the ordering question is still the actual work. Only the numbers changed. ⚠️ **A consequence of #326 that this item does not cover, and that no gate can see.** Re-keying `admin_exposed` onto `instance_exposed` means the MFA-at-exposure refusal now fires on a declared-TLS-terminator topology where it previously could not β€” a posture change under **ASVS 6.3.3**, whose citations all still resolve, so nothing went red. Raised by the vault drift-repair pass of 2026-08-04; 6.3.3 needs re-validating against the code rather than being assumed still correct. Not folded in here. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 13782a03..f0d346c3 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -89,7 +89,7 @@ engine binds. Three planes sit at different exposure levels: | Plane | What it is | Where it binds | Posture | |---|---|---|---| -| **Management** | web console (`/ui`) / IDE β†’ engine API | loopback by default (or a restricted management subnet) | auth + RBAC + full audit, **on by default** (`[security].require_sign_in`, default `true`) β€” disabling it is refused on a non-loopback bind, but on **loopback** it is permitted and drops the plane to a full-privilege no-RBAC identity; smallest surface β€” keep it off general-user VLANs | +| **Management** | web console (`/ui`) / IDE β†’ engine API | loopback by default (or a restricted management subnet) | auth + RBAC + full audit, **on by default** (`[security].require_sign_in`, default `true`) β€” disabling it is refused on a non-loopback bind **or a loopback bind behind a declared TLS terminator**, but on a bare **loopback** bind with no declared terminator it is permitted and drops the plane to a full-privilege no-RBAC identity; smallest surface β€” keep it off general-user VLANs | | **Data** | inbound feeds you *receive* (MLLP, TCP/X12, DB-poll) | the **internal network interface** β€” feeds come from other systems on your LAN, not `127.0.0.1` | **TLS on the wire where the channel has it** (enable MLLP-over-TLS; **TCP/X12 have none** β€” segment them) + the `[egress]`/ingress allow-lists + your network segmentation. PHI must not cross the LAN in cleartext | | **Inbound web service** | a partner *calls into* MEFOR (`Http()` source) | its own connector-owned socket | built (ADR 0023) β€” per-connection TLS + opt-in mTLS + IP allow-list, **no bearer/basic partner auth**. Both peer controls are **optional and unenforced** β€” a TLS-on listener with neither accepts any peer; see the caveat below | @@ -274,7 +274,7 @@ authentication on the channel Β· **Egress gate** = the `[egress]` allow-list tha | Channel | Bind default | TLS support | Auth | Ingress/egress gate | Off-loopback guarded? | |---|---|---|---|---|---| -| **Engine API** (FastAPI/uvicorn) | `[security].local_access_only` = true β†’ `127.0.0.1` | **Yes** β€” in-process via `tls_cert_file`/`tls_key_file`, *or* upstream via `tls_terminated_upstream` + `trusted_proxies`; `tls_min_version` (β‰₯1.2); opt-in mTLS via `tls_client_ca_file`; HSTS over https | Bearer token + session RBAC β€” **required by default** (`[security].require_sign_in`, default `true`); `false` is refused on a non-loopback bind, and on loopback yields a full-privilege *system* identity with no RBAC | β€” (auth-gated) | **Yes** β€” refused without TLS or a trusted terminator, and `--allow-insecure-bind` is clamped inert on an enforcing PHI instance (the default); also refused if sign-in is disabled on a non-loopback bind | +| **Engine API** (FastAPI/uvicorn) | `[security].local_access_only` = true β†’ `127.0.0.1` | **Yes** β€” in-process via `tls_cert_file`/`tls_key_file`, *or* upstream via `tls_terminated_upstream` + `trusted_proxies`; `tls_min_version` (β‰₯1.2); opt-in mTLS via `tls_client_ca_file`; HSTS over https | Bearer token + session RBAC β€” **required by default** (`[security].require_sign_in`, default `true`); `false` is refused on a non-loopback bind or a loopback bind behind a declared TLS terminator, and on a bare loopback bind with no declared terminator yields a full-privilege *system* identity with no RBAC | β€” (auth-gated) | **Yes** β€” refused without TLS or a trusted terminator, and `--allow-insecure-bind` is clamped inert on an enforcing PHI instance (the default); also refused if sign-in is disabled on a non-loopback bind or a loopback bind behind a declared terminator | | **MLLP source** | `[inbound].bind_host` = `127.0.0.1` | **Yes** β€” per-connection opt-in `tls=true` + `tls_cert_file`/`tls_key_file`; opt-in mTLS via `tls_ca_file`; β‰₯TLS 1.2. **Plaintext by default** | None (MLLP has no app auth) | β€” | **Yes** β€” non-loopback plaintext refused (`check_mllp_tls_exposure`) | | **HTTP source** (`Http()`, ADR 0023) | `[inbound].bind_host` = `127.0.0.1` | **Yes** β€” per-connection opt-in `tls=true` + `tls_cert_file`/`tls_key_file`; opt-in mTLS via `tls_ca_file`. **Plaintext by default** | mTLS client cert only β€” **no bearer/basic partner auth**, and **neither mTLS nor the IP allow-list is required**: with TLS on and both unset the listener accepts any peer | per-connection `source_ip_allowlist` β€” **optional, defaults to no restriction** | **Yes** β€” non-loopback plaintext refused (`check_http_tls_exposure`) β€” but the gate checks **only** that TLS is on, **never** that a peer control exists (unlike the DICOM SCP row below) | | **DICOM C-STORE SCP** (`DICOM()`, ADR 0025) | `[inbound].bind_host` = `127.0.0.1` | **Yes** β€” per-connection opt-in `tls=true` + cert/key; opt-in mTLS via `tls_ca_file`. **Plaintext by default** | `calling_ae_allowlist` / `require_called_ae_title` / mTLS (DIMSE has no transport auth of its own) | per-connection `source_ip_allowlist` | **Yes** β€” non-loopback plaintext refused (`check_dimse_tls_exposure`), **and** a non-loopback SCP with *no* peer control (calling-AE allow-list, IP allow-list, or mTLS) is refused at construction | diff --git a/docs/REMOTE-CONSOLE.md b/docs/REMOTE-CONSOLE.md index 17fd92c3..ceaaad0f 100644 --- a/docs/REMOTE-CONSOLE.md +++ b/docs/REMOTE-CONSOLE.md @@ -167,8 +167,9 @@ carries the recommended hardening for an exposed console (client-certificate dev Auth is on by default; remote users sign in with local accounts (Β± TOTP MFA) or AD/LDAP. Note: -- With `[security].require_sign_in = false`, an off-loopback bind is **hard-refused** (loopback is the - only no-auth posture). +- With `[security].require_sign_in = false`, an exposed instance is **hard-refused** β€” an off-loopback + bind, or a loopback bind behind a declared TLS terminator (a bare loopback bind with no declared + terminator is the only no-auth posture). - `[security].require_mfa` is **on by default**, and MFA is an access gate: an enrolled-pending session gets `403` + `X-MFA-Required: 1` on every authorized route. **Leave it on** β€” that default, not the startup gate below, is the control. diff --git a/docs/SECURITY-LOOSENING.md b/docs/SECURITY-LOOSENING.md index c7a77878..6d2317b2 100644 --- a/docs/SECURITY-LOOSENING.md +++ b/docs/SECURITY-LOOSENING.md @@ -177,9 +177,10 @@ trail. ### `require_sign_in = false` β€” disable authentication - **What you lose:** every request runs as a full-privilege *system* identity; no RBAC. - **When acceptable:** a **loopback-only** embedding/dev harness. -- **Compensating controls:** loopback bind only. -- **Still refused:** a non-loopback bind with auth off is a **hard refuse** β€” serving full-privilege admin to - the network is never one "I accept the risk" away, at any posture. +- **Compensating controls:** a loopback bind with no declared TLS terminator only. +- **Still refused:** an exposed instance with auth off β€” a non-loopback bind, **or** a loopback bind behind a + declared TLS terminator β€” is a **hard refuse** β€” serving full-privilege admin to the network is never one "I + accept the risk" away, at any posture. ### `require_mfa = false` β€” single-factor admin - **What you lose:** the Administrator role authenticates with a password only (no native TOTP second @@ -299,8 +300,8 @@ trail. - **Compensating controls:** return to `enforce` before carrying real patient traffic; the warnings + startup **AUDIT** line + posture view keep the deviation visible. - **Still refused (even at `warn`):** the **no-auth-to-the-network** hard refuse (`require_sign_in = false` on - a non-loopback bind) is unconditional at **any** enforcement level β€” `enforcement = warn` does **not** open - it β€” and the unconditional ePHI audit floor is untouched. `enforcement` is **binary** (no `off`): silencing + an exposed instance β€” a non-loopback bind, or a loopback bind behind a declared TLS terminator) is + unconditional at **any** enforcement level β€” `enforcement = warn` does **not** open it β€” and the unconditional ePHI audit floor is untouched. `enforcement` is **binary** (no `off`): silencing a PHI cleartext hop *entirely* is only reachable by declaring the box synthetic (`handles_real_patient_data = false`), never by the dial ([ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md)). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 80a4c8c0..ccf370f6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -33,7 +33,8 @@ The in-process embedding factory `create_app(engine)` is **fail-closed**: with n attached it denies every protected route (503) unless the caller explicitly opts out with `create_app(..., allow_no_auth=True)` β€” the deliberate embedding/local-dev escape hatch. The `serve` path runs auth-enabled by default; if `[auth] enabled = false` it sets that opt-in itself, and -`__main__` refuses to serve auth-off on a non-loopback host β€” and, even with auth enabled, a +`__main__` refuses to serve auth-off on an exposed instance β€” a non-loopback host, or a loopback host +behind a declared TLS terminator β€” and, even with auth enabled, a non-loopback bind requires **TLS**: in-process (`[api].tls_cert_file`, WP-13a) or terminated at a trusted upstream proxy (`tls_terminated_upstream` + `trusted_proxies`, WP-15), or β€” as a dev override β€” an explicit `serve --allow-insecure-bind` (without any of these, bearer tokens + PHI would cross the @@ -1094,7 +1095,7 @@ one-to-one β€” that is why the bind/exposure posture occupies two rows and the A | PHI-read volume, per actor | `identity.user_id` | > 120 reads (`phi_read_rate_limit_per_actor`) per 60 s (`phi_read_rate_limit_window_seconds`); the global dimension `phi_read_rate_limit_global` defaults to `0` = **off** | **THROTTLE** 429 + `Retry-After: 10`, WARNING-logged, charged at **admission** before any store work | on, 120 / 60 s | `[auth].phi_read_rate_limit_enabled` | | Admin-write rate, per actor | `identity.user_id` Γ— request method | **non-GET only**; > 12 writes (`admin_write_rate_limit_per_actor`) per 1.0 s (`admin_write_rate_limit_window_seconds`); no global dimension (`glob=0`) | **THROTTLE** 429 + `Retry-After: 1`, WARNING-logged. **JSON API only** β€” no `/ui` route charges it at this release | on, 12 writes / 1.0 s | `[auth].admin_write_rate_limit_enabled` | | Serve-hop security posture | declared data class (`[ai].data_class`, or derived from `[ai].environment`) Γ— `[security].enforcement` Γ— (`api.is_loopback` **or** `exposure_protected`), via `phi_read_hop_disposition` | disposition is REFUSE β€” a **PHI** instance under `enforcement = enforce` whose serve hop is neither loopback, nor in-process TLS, nor a declared TLS-terminating proxy. Setting `[security].enforcement = warn` turns the refusal into WARN-and-serve; a non-PHI declared data class removes it entirely | **DENY** 403 (PHI-free message) on every **JSON-API** PHI-read route (`require_phi_read`, plus the step-up bulk routes), **before** any identity work. **Not applied on the `/ui` browse routes** β€” `enforce_phi_read_hop` has no console call site, so those get the per-actor budget only (pinned by `test_the_ui_phi_browse_gap_is_disclosed`) | ALLOW on loopback | `[security].enforcement`, `[ai].data_class`/`environment`, `[api].tls_cert_file`, `tls_terminated_upstream` + `trusted_proxies` | -| Bind / exposure posture β€” refusing arms | `[api].host` loopback-ness, `tls_terminated_upstream`, `trusted_proxies`, `public_origin`; derived `instance_exposed` (loopback-ness **or** a declared terminator) and `admin_exposed`, plus `ui_exposed` for the `/ui` arms only; `[security].enforcement`; declared data class | auth off on a non-loopback bind; `/ui` exposed without the required origin/TLS declarations; `admin_exposed` + PHI + `enforcing` + `require_mfa` explicitly opted out | **DENY at startup** β€” `serve` prints an error and exits **2**. The refuse/warn dial is `[security].enforcement` (default `enforce`), **not** `production`: the auth-off and `/ui`-exposure arms refuse **unconditionally**, and the `require_mfa` arm refuses when the declared data class is PHI **and** enforcement is `enforce` β€” which includes the non-production `dev` and `staging` environments, both of which derive PHI β€” and warns otherwise. `[security].allow_single_factor_admin_when_exposed = true` downgrades that one arm to permitted-but-audited. **`admin_exposed` is `instance_exposed`, and reads no console flag** (BACKLOG #326): the ADR 0143 degrade arms rewrite `serve_ui` in place earlier in the same startup, so deriving an exposure decision from it made this arm and the dual-control arm below miss a declared-proxy instance whose console had been degraded or disabled β€” while the ASVS 11.7.1 arm called that same boot exposed. The same attributes force the session cookie's `Secure` flag + HSTS, and permit WebAuthn `rp_id` derivation from the request URL **only** on a loopback bind with no proxy declared | loopback, nothing declared | `[api].*`, `[security].enforcement`, `[security].allow_single_factor_admin_when_exposed`, `[ai].data_class`/`environment` | +| Bind / exposure posture β€” refusing arms | `[api].host` loopback-ness, `tls_terminated_upstream`, `trusted_proxies`, `public_origin`; derived `instance_exposed` (loopback-ness **or** a declared terminator) and `admin_exposed`, plus `ui_exposed` for the `/ui` arms only; `[security].enforcement`; declared data class | auth off on an exposed instance β€” a non-loopback bind **or** a declared terminator (`instance_exposed`); `/ui` exposed without the required origin/TLS declarations; `admin_exposed` + PHI + `enforcing` + `require_mfa` explicitly opted out | **DENY at startup** β€” `serve` prints an error and exits **2**. The refuse/warn dial is `[security].enforcement` (default `enforce`), **not** `production`: the auth-off and `/ui`-exposure arms refuse **unconditionally**, and the `require_mfa` arm refuses when the declared data class is PHI **and** enforcement is `enforce` β€” which includes the non-production `dev` and `staging` environments, both of which derive PHI β€” and warns otherwise. `[security].allow_single_factor_admin_when_exposed = true` downgrades that one arm to permitted-but-audited. **`admin_exposed` is `instance_exposed`, and reads no console flag** (BACKLOG #326): the ADR 0143 degrade arms rewrite `serve_ui` in place earlier in the same startup, so deriving an exposure decision from it made this arm and the dual-control arm below miss a declared-proxy instance whose console had been degraded or disabled β€” while the ASVS 11.7.1 arm called that same boot exposed. The same attributes force the session cookie's `Secure` flag + HSTS, and permit WebAuthn `rp_id` derivation from the request URL **only** on a loopback bind with no proxy declared | loopback, nothing declared | `[api].*`, `[security].enforcement`, `[security].allow_single_factor_admin_when_exposed`, `[ai].data_class`/`environment` | | Bind / exposure posture β€” dual-control arm | `admin_exposed` (= `instance_exposed`: an off-loopback bind **or** a declared TLS terminator β€” never the console flag, BACKLOG #326) Γ— `[approvals].enabled` Γ— declared data class | `admin_exposed` **and** PHI **and** `[approvals].enabled` off β€” high-value actions complete on one caller's authority | **LOG** β€” a startup **WARNING only, on every instance including production**; `serve` does **not** refuse. The refuse arm is an explicit unresolved owner fork recorded in `__main__.py`, not a shipped control | approvals off | `[approvals].enabled` | | Pending federated-login flows, per client IP | the `client_ip` recorded on each staged flow | β‰₯ **16** pending flows from this address (`DEFAULT_PER_IP_CAP`, no knob), or β‰₯ `oidc_flow_cache_max` (**512**) engine-wide; 300 s TTL; **reject-when-full, never evict** (evict-oldest would turn a start-leg flood into a login DoS) | **DENY** the start leg β€” `FlowCacheFullError` β†’ **303** to `/ui/login?e=rate_limited`, WARNING-logged, deliberately **never** audited so a flood cannot amplify into `audit_log` growth | 16 / 512 / 300 s | `[auth].oidc_flow_cache_max`, `oidc_flow_ttl_seconds` | | `Sec-Fetch-Mode` on the federated sign-in legs | the browser fetch-metadata header on `GET /ui/sso`, `POST /ui/oidc/start`, `GET /ui/oidc/callback` | header **present** and not `navigate` (absent = allowed, for non-browser clients). Distinct from the `Sec-Fetch-Site` row below: a different header, a different surface, and `assert_same_origin` deliberately does **not** run on the callback leg, whose `Sec-Fetch-Site` is legitimately cross-site | **DENY** β€” 303 β†’ `/ui/login?e=sso_failed`\|`oidc_failed`, plus an **audited** `auth.login_failed` row carrying the closed-set slug `non_navigation_fetch`. Evaluated **after** the login limiter, so the audit write is itself rate-bounded | on | (no knob) | diff --git a/docs/SYSTEM-REQUIREMENTS.md b/docs/SYSTEM-REQUIREMENTS.md index a8636c25..e50e5af6 100644 --- a/docs/SYSTEM-REQUIREMENTS.md +++ b/docs/SYSTEM-REQUIREMENTS.md @@ -140,7 +140,7 @@ SEV-SNP needs EPYC 7003+ and TDX needs 5th Gen Xeon Scalable+, which is newer th | Purpose | Default | Notes | |---|---|---| -| **Engine API** (HTTP + WebSocket) | `127.0.0.1:8765` | **Loopback by default**; **authentication required by default** β€” `[security].require_sign_in` defaults `true`, and it is an operator switch, not a fixed property of the software. Setting it `false` is **refused outright on a non-loopback bind** (no flag covers it), so the one posture where auth can legitimately be off is precisely the one this row describes: on **loopback** with sign-in disabled, every request runs as a **full-privilege system identity with no RBAC**, reachable by any local process on the host. Put `require_sign_in` on your config-review checklist rather than recording "the API requires authentication" as a given. **In-process TLS is built and opt-in** (WP-13a, [ADR 0002](adr/0002-phase2-transport-security-and-strong-auth.md)): set `[api].tls_cert_file` (plus `tls_key_file` when the key is a separate PEM) and the engine terminates TLS in uvicorn, so the API **and** the `/ws/stats` WebSocket serve `https`/`wss`. TLS **1.2 floor** (`tls_min_version` β€” `1.2` or `1.3`), optional `tls_ciphers`, and **opt-in mTLS** via `tls_client_ca_file` (a client certificate is then required and verified). A **TLS-terminating reverse proxy** remains the supported alternative (`tls_terminated_upstream` + `trusted_proxies`). An off-loopback bind needs one of the two β€” **and on the shipped PHI + `enforcement = enforce` default each branch carries a second precondition that is a refusal, not an advisory**: in-process TLS additionally needs an *attested revocation posture* (the engine performs no OCSP/CRL, so `serve` refuses an off-loopback in-process-TLS bind unless `MEFOR_TLS_REVOCATION_ATTESTED=1` declares that your PKI enforces revocation β€” [ADR 0078](adr/0078-certificate-revocation-posture.md)); the terminator branch additionally needs `[api].proxy_intra_service_auth` **and** `[api].proxy_tls_min_version`. Neither is covered by `--allow-insecure-bind`; both are worked through in [DEPLOYMENT.md](DEPLOYMENT.md) Β§Before you expose off-loopback. The browser console refuses an unprotected off-loopback bind outright. | +| **Engine API** (HTTP + WebSocket) | `127.0.0.1:8765` | **Loopback by default**; **authentication required by default** β€” `[security].require_sign_in` defaults `true`, and it is an operator switch, not a fixed property of the software. Setting it `false` is **refused outright on a non-loopback bind β€” or a loopback bind behind a declared TLS terminator** (no flag covers it), so the one posture where auth can legitimately be off is a bare **loopback** bind with no declared terminator: with sign-in disabled there, every request runs as a **full-privilege system identity with no RBAC**, reachable by any local process on the host. Put `require_sign_in` on your config-review checklist rather than recording "the API requires authentication" as a given. **In-process TLS is built and opt-in** (WP-13a, [ADR 0002](adr/0002-phase2-transport-security-and-strong-auth.md)): set `[api].tls_cert_file` (plus `tls_key_file` when the key is a separate PEM) and the engine terminates TLS in uvicorn, so the API **and** the `/ws/stats` WebSocket serve `https`/`wss`. TLS **1.2 floor** (`tls_min_version` β€” `1.2` or `1.3`), optional `tls_ciphers`, and **opt-in mTLS** via `tls_client_ca_file` (a client certificate is then required and verified). A **TLS-terminating reverse proxy** remains the supported alternative (`tls_terminated_upstream` + `trusted_proxies`). An off-loopback bind needs one of the two β€” **and on the shipped PHI + `enforcement = enforce` default each branch carries a second precondition that is a refusal, not an advisory**: in-process TLS additionally needs an *attested revocation posture* (the engine performs no OCSP/CRL, so `serve` refuses an off-loopback in-process-TLS bind unless `MEFOR_TLS_REVOCATION_ATTESTED=1` declares that your PKI enforces revocation β€” [ADR 0078](adr/0078-certificate-revocation-posture.md)); the terminator branch additionally needs `[api].proxy_intra_service_auth` **and** `[api].proxy_tls_min_version`. Neither is covered by `--allow-insecure-bind`; both are worked through in [DEPLOYMENT.md](DEPLOYMENT.md) Β§Before you expose off-loopback. The browser console refuses an unprotected off-loopback bind outright. | | **Inbound MLLP / TCP listeners** | operator-defined (samples use e.g. `2575`, `2600`) | Open to sending systems via firewall. **MLLP-over-TLS is built and opt-in per connection** (WP-13b, `tls = true`, TLS 1.2+ β€” see [CONNECTIONS.md](CONNECTIONS.md)): an inbound presents `tls_cert_file`/`tls_key_file` as its server identity and opts into **mTLS** with `tls_ca_file`; an outbound **verifies the partner's certificate by default** (`tls_verify`, `tls_check_hostname`, both `true`). Plaintext is still the **default**, so a non-loopback MLLP listener **must** set `tls = true` β€” it is refused at wiring time otherwise (`check_mllp_tls_exposure`), before the engine starts. A cleartext MLLP **egress** off loopback is likewise **refused at construction** (`messagefoundry check` / dry-run / the serve pre-flight) on any instance where `[security].enforcement = enforce` β€” the default, and **regardless of data class**: [ADR 0153](adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md) removed the data label from that authority, so a *synthetic* box is refused too. The only **per-connection** way across is `cleartext_accepted = true` + `cleartext_reason` (crossed, but warned + audited at every construction); `[security].enforcement = warn` downgrades the refusal to a warning **globally**, which is the operator off-switch. (`tls_hop_attested` appears in the refusal messages and the gates do read it, but it has **no authoring surface** β€” no factory parameter and no `connections.toml` key β€” so do not plan around it.) | | **Outbound** | as configured | Reachability to downstream partners and, for server DBs, to the database host. | | Installer egress | HTTPS | Outbound access for the service installer to fetch the pinned NSSM binary (or pre-stage it). | diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index ae1b1ce0..a4a7f6d0 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -1107,12 +1107,31 @@ def _serve(args: argparse.Namespace) -> int: if effective_root is not None and not Path(settings.store.path).is_absolute(): settings.store.path = str(effective_root / settings.store.path) - # Fail closed: with auth disabled the API answers as a full-privilege system identity, so a - # non-loopback bind would publish admin access to the network. Loopback is the only no-auth posture. - if not settings.auth.enabled and not settings.api.is_loopback: + # THE SINGLE DEFINITION of "this instance is exposed" (BACKLOG #326): an off-loopback bind OR a + # declared upstream TLS terminator. Hoisted here so its earliest consumer β€” the auth-off arm just + # below (BACKLOG #1013) β€” can read it; the full rationale (why not `serve_ui`, why deliberately + # narrow) sits at the MFA-at-exposure gate that was its original first consumer. Defined ONCE: a + # second copy is exactly how the ASVS 11.7.1 and 6.3.3 arms once disagreed about the same boot (#326). + instance_exposed = not settings.api.is_loopback or settings.api.tls_terminated_upstream + + # Fail closed: with auth disabled the API would answer as a full-privilege system identity, so any + # exposed instance would publish admin access to the network with no authentication at all. Exposure + # is EITHER a non-loopback bind OR a declared upstream TLS terminator on a loopback bind β€” the same + # `instance_exposed` the MFA-at-exposure gate consults (BACKLOG #1013: this arm previously keyed on + # the bind alone, so an auth-off PHI instance behind a declared terminator would have started + # silently on first deployment). A true loopback posture with no declared terminator is the only + # place no-auth may run. + if not settings.auth.enabled and instance_exposed: + exposure_desc = ( + f"non-loopback host {settings.api.host!r}" + if not settings.api.is_loopback + else "loopback host behind a declared TLS-terminating reverse proxy " + "([api].tls_terminated_upstream)" + ) print( - "error: refusing to serve with [auth] enabled=false on non-loopback host " - f"{settings.api.host!r}; enable auth or bind 127.0.0.1", + f"error: refusing to serve with [auth] enabled=false on {exposure_desc}; the API would " + "answer as a full-privilege system identity with no authentication. Enable auth or bind a " + "loopback host with no declared terminator.", file=sys.stderr, ) return 2 @@ -1891,9 +1910,10 @@ def _serve(args: argparse.Namespace) -> int: file=sys.stderr, ) - # THE SINGLE DEFINITION OF "this instance is exposed" (BACKLOG #326). Derived here, above the first - # consumer, from two fields no earlier arm reassigns β€” `is_loopback` and `tls_terminated_upstream` - # are read straight off the loaded config and are never mutated in place, unlike `serve_ui`. + # THE SINGLE DEFINITION OF "this instance is exposed" (BACKLOG #326) is derived above, before the + # auth-off arm (BACKLOG #1013) that also consumes it, from two fields no earlier arm reassigns β€” + # `is_loopback` and `tls_terminated_upstream` are read straight off the loaded config and are never + # mutated in place, unlike `serve_ui`. # # WHY IT CANNOT READ `settings.api.serve_ui`: that field is flipped to False IN PLACE twice above β€” # the ADR 0143 soft-degrade when the console wheel is absent, and the ADR 0143 auto-degrade when a @@ -1914,7 +1934,6 @@ def _serve(args: argparse.Namespace) -> int: # cleared for exactly this input β€” a DEFAULT-on console plus a set `public_origin` β€” so on the # commonest shape of this posture it does not print at all. Citing it as the compensating control # would have rested that control on a premise measurement contradicts. - instance_exposed = not settings.api.is_loopback or settings.api.tls_terminated_upstream # MFA-at-exposure posture (sec-mfa-on; WP-14, ASVS 6.3.3): an off-loopback bind serving local # accounts puts admin authentication on the network, where a single password factor is far weaker. diff --git a/tests/test_cli.py b/tests/test_cli.py index d2db6cb1..4415d57b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -611,6 +611,78 @@ def test_serve_non_loopback_with_auth_off_refused_despite_flag( assert "refusing to serve the API on non-loopback" not in err # ...not the bind gate +def test_serve_refuses_auth_off_behind_declared_terminator( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # BACKLOG #1013 (positive regression): auth entirely off behind a DECLARED TLS terminator is exposed + # exactly like an off-loopback bind β€” the API would answer as a full-privilege identity with no + # authentication β€” so the auth-off startup arm must refuse it, not only the non-loopback case. A + # loopback bind (default local_access_only) + a declared terminator -> instance_exposed True, auth + # off. The arm returns before the env-required check (like the non-loopback case above), so no --env, + # store key, or server mock is needed. + monkeypatch.chdir(tmp_path) + (tmp_path / "messagefoundry.toml").write_text( + "security.require_sign_in = false\n" + "[api]\n" + "tls_terminated_upstream = true\n" + 'trusted_proxies = ["10.0.0.1"]\n', # settings.py requires this alongside the terminator + encoding="utf-8", + ) + assert main(["serve", "--config", str(SAMPLES_CONFIG)]) == 2 + err = capsys.readouterr().err + # rc alone is not enough: the later env-required gate also returns 2, so the message asserts are + # what give this test teeth (they disappear if the arm reverts to the bare bind check). + assert "enabled=false" in err # the no-auth gate fired + assert ( + "tls_terminated_upstream" in err or "reverse proxy" in err + ) # named the terminator exposure + + +def test_serve_auth_off_on_unexposed_loopback_still_starts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + from messagefoundry.store.crypto import generate_key + + # BACKLOG #1013 (negative control): the arm was WIDENED, not broadened to fire on any auth-off. A + # true loopback dev instance with no declared terminator (instance_exposed False) is the supported + # no-auth flow and must still start silently. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEY", generate_key()) + monkeypatch.setattr("messagefoundry.api.create_managed_app", lambda **kw: object()) + monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) + (tmp_path / "messagefoundry.toml").write_text( + "security.handles_real_patient_data = false\n" + "security.local_access_only = true\n" + "security.require_sign_in = false\n", + encoding="utf-8", + ) + assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 0 + # The widened arm stayed silent because the instance is not exposed. + assert "refusing to serve with [auth] enabled=false" not in capsys.readouterr().err + + +def test_serve_auth_on_behind_terminator_unaffected_by_arm( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # BACKLOG #1013: the arm is inert under auth ON even when exposed. A synthetic loopback instance + # behind a declared terminator is instance_exposed True, but auth is on by default (require_mfa + # defaults on -> the MFA-at-exposure gate stays quiet; synthetic keeps the PHI gates quiet), so the + # auth-off arm must not fire. + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("messagefoundry.api.create_managed_app", lambda **kw: object()) + monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) + (tmp_path / "messagefoundry.toml").write_text( + "security.handles_real_patient_data = false\n" + "security.local_access_only = true\n" + "[api]\n" + "tls_terminated_upstream = true\n" + 'trusted_proxies = ["10.0.0.1"]\n', + encoding="utf-8", + ) + assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 0 + assert "enabled=false" not in capsys.readouterr().err # the arm did not fire (auth is on) + + def test_serve_insecure_bind_clamp_keys_on_enforcement_not_tier( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: