diff --git a/assets/MessageFoundry-Secure-Development-Standards.pdf b/assets/MessageFoundry-Secure-Development-Standards.pdf index 9d037fb..c544582 100644 Binary files a/assets/MessageFoundry-Secure-Development-Standards.pdf and b/assets/MessageFoundry-Secure-Development-Standards.pdf differ diff --git a/assets/docs/MessageFoundry-Adoption-Guide.pdf b/assets/docs/MessageFoundry-Adoption-Guide.pdf index 8219e32..c446d7a 100644 Binary files a/assets/docs/MessageFoundry-Adoption-Guide.pdf and b/assets/docs/MessageFoundry-Adoption-Guide.pdf differ diff --git a/assets/docs/MessageFoundry-Architecture.pdf b/assets/docs/MessageFoundry-Architecture.pdf index ca3cc86..3a5b41e 100644 Binary files a/assets/docs/MessageFoundry-Architecture.pdf and b/assets/docs/MessageFoundry-Architecture.pdf differ diff --git a/assets/docs/MessageFoundry-Configuration.pdf b/assets/docs/MessageFoundry-Configuration.pdf index 32961ed..e22eead 100644 Binary files a/assets/docs/MessageFoundry-Configuration.pdf and b/assets/docs/MessageFoundry-Configuration.pdf differ diff --git a/assets/docs/MessageFoundry-Install-Guide.pdf b/assets/docs/MessageFoundry-Install-Guide.pdf index 4e1cf7e..3d86b7d 100644 Binary files a/assets/docs/MessageFoundry-Install-Guide.pdf and b/assets/docs/MessageFoundry-Install-Guide.pdf differ diff --git a/assets/docs/MessageFoundry-Mental-Model.pdf b/assets/docs/MessageFoundry-Mental-Model.pdf index 648abe3..704817e 100644 Binary files a/assets/docs/MessageFoundry-Mental-Model.pdf and b/assets/docs/MessageFoundry-Mental-Model.pdf differ diff --git a/assets/docs/MessageFoundry-Remote-Console-Customer-Guide.pdf b/assets/docs/MessageFoundry-Remote-Console-Customer-Guide.pdf index 1ad5c2e..100ad26 100644 Binary files a/assets/docs/MessageFoundry-Remote-Console-Customer-Guide.pdf and b/assets/docs/MessageFoundry-Remote-Console-Customer-Guide.pdf differ diff --git a/assets/docs/MessageFoundry-System-Requirements.pdf b/assets/docs/MessageFoundry-System-Requirements.pdf index fb03746..f1c8f3f 100644 Binary files a/assets/docs/MessageFoundry-System-Requirements.pdf and b/assets/docs/MessageFoundry-System-Requirements.pdf differ diff --git a/assets/docs/MessageFoundry-User-Guide.pdf b/assets/docs/MessageFoundry-User-Guide.pdf index ceaa5b1..81c9e8a 100644 Binary files a/assets/docs/MessageFoundry-User-Guide.pdf and b/assets/docs/MessageFoundry-User-Guide.pdf differ diff --git a/assets/docs/MessageFoundry-Users-and-Security.pdf b/assets/docs/MessageFoundry-Users-and-Security.pdf index 1a2b829..c63d44a 100644 Binary files a/assets/docs/MessageFoundry-Users-and-Security.pdf and b/assets/docs/MessageFoundry-Users-and-Security.pdf differ diff --git a/assets/docs/_md/Adoption-Guide.md b/assets/docs/_md/Adoption-Guide.md index 6bfa928..9510c1d 100644 --- a/assets/docs/_md/Adoption-Guide.md +++ b/assets/docs/_md/Adoption-Guide.md @@ -86,7 +86,7 @@ use the table below alongside them when planning. | Validation & load tooling (`generate`, `check`, `dryrun`, the test harness, the load harness) | ✅ Built — see §8/§9 and [LOAD-TESTING.md](LOAD-TESTING.md) | | Windows-service deployment via NSSM | ✅ Built — see [SERVICE.md](SERVICE.md) | | **Native transport TLS** (API + MLLP) | ✅ Built — in-process API TLS (HTTPS/WSS) + per-connection MLLP-over-TLS, ≥TLS 1.2, opt-in mTLS, and a **fail-closed off-loopback bind guard** (a non-loopback bind without TLS is refused). Raw TCP/X12 stay plaintext (loopback/proxy). See [DEPLOYMENT.md](DEPLOYMENT.md). | -| **Native MFA** (TOTP, local accounts) | ✅ Built — RFC 6238 TOTP + single-use recovery codes; `[auth].require_mfa` enforces a second factor for local Administrators at the step-up boundary. AD/Entra users' MFA stays delegated to the IdP. See [SECURITY.md](SECURITY.md). | +| **Native MFA** (TOTP, local accounts) | ✅ Built — RFC 6238 TOTP + single-use recovery codes; `[security].require_mfa` (**on by default**) enforces a second factor for **every local account** — `require_mfa_scope` defaults to `every_local_account`; set it to `administrators` for the narrower posture. AD/Entra users' MFA stays delegated to the IdP. See [SECURITY.md](SECURITY.md). | | **Off-box log + audit forwarding** | ✅ Built — `[logging].forward_*` ships operational logs + PHI-redacted audit rows to a syslog/SIEM collector, over **native TLS** when you set `forward_protocol = "tls"` (RFC 5425, ADR 0080; port 6514, CA anchor via `forward_tls_*`). Residual: the transport **default** is UDP, so TLS is a per-deployment opt-in — set it, or front the collector with a local TLS-forwarding agent. See [PHI.md](PHI.md) §7. | | **Active-passive HA / failover** | ✅ Built (Track B) — opt-in leader/standby cluster on a **shared server-DB** store (PostgreSQL or SQL Server): only the leader runs the graph, self-fencing leadership lease, immediate on-promotion recovery. Single-node stays the byte-identical default. See [CLUSTERING.md](CLUSTERING.md) + §14. | @@ -251,7 +251,8 @@ the built-in default `samples/config` exists only in a source checkout), `--serv > ⚠️ **The active environment is required.** `serve` refuses to start (exit 2) without `--env ` > (or `[ai].environment`) — there is no silent `prod` default, so a missing env can never resolve > another environment's values/secrets. Built-in names `dev`/`staging`/`prod` carry a default posture; -> a custom name (e.g. `test`, `poc`) also needs `[ai].data_class` + `[ai].production`. The active +> a custom name (e.g. `test`, `poc`) also needs `[security].handles_real_patient_data` + +> `[security].production_instance` (both booleans). The active > environment is logged at startup. > 🔑 **`dev` now carries the PHI posture ([ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md)) — provide a store key or declare synthetic.** @@ -272,12 +273,14 @@ the built-in default `samples/config` exists only in a source checkout), `--serv ### 4.4 Run it as a Windows service (the supported production run-mode) -Use the elevated installer; install under a **least-privilege virtual account** rather than the -default LocalSystem: +Use the elevated installer. It already defaults to a **least-privilege per-service virtual account** +(`NT SERVICE\`, no password); pass `-ServiceAccount` only to run under a *different* +account, and `-AllowLocalSystem` to opt out to LocalSystem. `-Environment` is **required** (ADR 0017) — +the installer refuses without it rather than registering a service that dies on every start: ```powershell # from an elevated shell -scripts\service\install-service.ps1 -ServiceAccount "NT SERVICE\MessageFoundry" +scripts\service\install-service.ps1 -Environment prod ``` The installer is idempotent, auto-downloads a SHA-256-pinned NSSM, bakes absolute `serve` paths into @@ -397,8 +400,8 @@ Full references: **[SECURITY.md](SECURITY.md)**, **[PHI.md](PHI.md)**, and **[DE - [ ] **For Active Directory:** use **LDAPS** with a trusted CA, never set `MEFOR_ALLOW_INSECURE_TLS` in production, and configure the directory's lockout/complexity policy (the engine's account lockout covers local accounts only). AD/Entra MFA is enforced by your directory; **local - accounts** use the engine's **native TOTP MFA** (`[auth].require_mfa`, WP-14) — enable it before - an off-loopback PHI exposure. + accounts** use the engine's **native TOTP MFA** (`[security].require_mfa`, WP-14) — it is **on by + default for every local account**, so leave it on before an off-loopback PHI exposure. - [ ] **Populate the fail-closed `[egress]` allowlist** (it defaults to unrestricted) for REST/Database destinations. - [ ] **Keep logging at `INFO` or above** and `expose_docs` off in production. Full payloads are never @@ -442,7 +445,7 @@ Key semantics to internalize: - [ ] **Wire real alerts.** Configure the `[alerts]` **webhook and/or email** notifier — do **not** rely on the default logging-only sink. The conservative defaults (FIFO head-of-line blocking, retry-forever, STOP-on-internal-error) are only safe if a human gets paged when a lane stalls. -- [ ] **Set `[delivery]` buildup thresholds** (`max_oldest_seconds` defaults to 300s; set a `max_depth` +- [ ] **Set `[delivery]` buildup thresholds** (`buildup_max_oldest_seconds` defaults to 300s; set a `buildup_max_depth` sized to each connection's throughput) so `queue_buildup` fires before a stuck lane silently backs up. Buildup detection now covers the ingress and routed stages too, not just outbound. - [ ] **Choose `RetryPolicy` per outbound deliberately:** retry-forever for partners that must never @@ -553,7 +556,8 @@ host, start the engine, confirm `/health`, run `/status/integrity-check` (SQLite and spot-check `/messages` and dispositions. **Keep the store bounded.** `[retention]` is **off by default (kept forever)**. Set `max_db_mb` (drives -a `storage_threshold` alert), `messages_days` / `dead_letter_days` (body purge), and the daily VACUUM +a `storage_threshold` alert), `[security].delete_message_bodies_after_days` / `[retention].dead_letter_days` +(body purge), and the daily VACUUM so the store doesn't grow unbounded and a full disk doesn't take you down mid-pilot. --- diff --git a/assets/docs/_md/Architecture.md b/assets/docs/_md/Architecture.md index 9bba9c0..55aabc6 100644 --- a/assets/docs/_md/Architecture.md +++ b/assets/docs/_md/Architecture.md @@ -302,8 +302,12 @@ viewed/searched/replayed messages, and **encryption-at-rest** for message bodies the store cipher when a key is set, with owner-only DB/WAL file permissions and required volume encryption covering the rest. -**Roadmap (not yet enforced — see [PHI.md](PHI.md)):** structlog **log redaction**, **MLLPS / TLS** -for transport, and **retention/purge** enforcement. +**Also built (see [PHI.md](PHI.md)):** **PHI redaction on the logging path** — stdlib `logging` only +(deliberately not structlog), with an always-on handler filter plus the `safe_exc()` chokepoint, so an +exception rendered into a stored disposition or a log line is scrubbed of HL7-shaped content; +**MLLP-over-TLS** (TLS 1.2+, server-certificate verification with hostname checking, opt-in mTLS) +configured per connection, with an off-loopback plaintext listener refused at startup; and +**retention/purge** enforcement, run by the engine's async `RetentionRunner`. ## Module map diff --git a/assets/docs/_md/Configuration.md b/assets/docs/_md/Configuration.md index cb2bf3b..1bf1228 100644 --- a/assets/docs/_md/Configuration.md +++ b/assets/docs/_md/Configuration.md @@ -85,7 +85,7 @@ backend-limited. | `auth` | enum | `sql` | `sql` · `integrated` · `entra` (SQL Server). `integrated` connects `Trusted_Connection=yes` — the **service account's** Windows identity authenticates (no SQL password); the turnkey **gMSA** walkthrough (grant the gMSA a SQL login + run the service under it) is [`DEPLOY-SERVER-DB.md` §1.1](DEPLOY-SERVER-DB.md). | | `username` | str | — | server DBs (required when `auth = sql`) | | `password` | secret | — | **env only** (`MEFOR_STORE_PASSWORD`) | -| `require_managed_identity` | bool | `false` | delegated-identity precondition (#203, ASVS 13.2.1/13.3.2): when `true`, `serve` **refuses** (production) / **warns** (non-production) unless the store authenticates via a managed identity — SQL Server `auth = integrated`/`entra`. SQLite is exempt; Postgres cannot satisfy it. Off by default | +| `require_managed_identity` | bool | `false` | delegated-identity precondition (#203, ASVS 13.2.1/13.3.2): when `true`, `serve` **refuses to start (exit 2)** unless the store authenticates via a managed identity — SQL Server `auth = integrated`/`entra`. SQLite is exempt; Postgres cannot satisfy it. Off by default. **The refuse/warn split is `[security].enforcement`, not the deployment tier** — `enforce` is the shipped default on `dev` and `staging` as much as on `prod`, so a staging box that turns this on and leaves `auth = "sql"` is **refused**, not warned; it downgrades to a warning only under `enforcement = warn` | | `encrypt`, `trust_server_certificate` | bool | `true`/`false` | TLS to the DB | | `ssl_root_cert` | path | — | server DBs — pin the DB server's certificate by **file** so a private/self-signed DB CA verifies **without** a machine-wide trust import, on the **secure** posture only (`encrypt = true`, `trust_server_certificate = false`) — it never disables verification. **Postgres:** an asyncpg `SSLContext` CA-bundle (chain + hostname still checked). **SQL Server:** the ODBC Driver **18.1+** `ServerCertificate` keyword (a leaf/exact-cert match; needs driver ≥ 18.1). Rejected for SQLite (no TLS); a missing file fails loud at load. A path, not a secret — may live in the file. See [`DEPLOY-SERVER-DB.md` §5](DEPLOY-SERVER-DB.md). | | `multi_subnet_failover` | bool | `false` | **SQL Server only** — emit the ODBC `MultiSubnetFailover=Yes` keyword so a client connecting to an Always On Availability Group **listener** reaches the current primary promptly across subnets, instead of serially waiting out each replica subnet's DNS/TCP timeout on failover. A no-op for Postgres/SQLite (they never see the ODBC string). Off by default — only a multi-subnet AOAG needs it. | @@ -174,25 +174,30 @@ The one row that *does* vary: > **MLLP-over-TLS** is built too (WP-13b — per-connection `tls`/`tls_*` on the `MLLP(...)` connector, > see [CONNECTIONS.md](CONNECTIONS.md)), and the §0 **exposed-gate is enforced**: a non-loopback -> *plaintext* MLLP listener is refused at startup unless `serve --allow-insecure-bind`. Gate #4's -> transport-TLS subset is complete, and **native TOTP MFA (WP-14) is also built** (`[auth].require_mfa`, -> local accounts). See [ADR 0002](adr/0002-phase2-transport-security-and-strong-auth.md). +> *plaintext* MLLP listener is refused at startup — `serve --allow-insecure-bind` is **clamped inert** +> on the shipped posture and does not lift it. Gate #4's transport-TLS subset is complete, and +> **native TOTP MFA (WP-14) is also built** (`[security].require_mfa`, local accounts — the old +> `[auth].require_mfa` spelling is **rejected at config load**). +> See [ADR 0002](adr/0002-phase2-transport-security-and-strong-auth.md). > **WebAuthn passkeys (WP-14b, [ADR 0068](adr/0068-browser-webauthn-passkeys-offloopback.md)).** > Browser passkeys for local users need the optional **`[webauthn]` extra** > (`pip install messagefoundry[webauthn]`) — no new `[auth]` setting: installing the extra + a user > enrolling on `/ui/account` is the opt-in (extra-less installs show a legible notice, never an -> error). The WebAuthn RP identity rides **`[api].public_origin`** when set; a plain loopback -> deployment derives it from the request URL, but **behind a declared reverse proxy** -> (`tls_terminated_upstream`) ceremonies **fail closed until `public_origin` is set** — and note -> that **changing `public_origin`'s host later invalidates every enrolled passkey** (they pin -> their mint-time RP; the account page marks them "unusable (origin changed)"). +> error). The WebAuthn RP identity rides the external origin — set it as +> **`[security].web_console_public_address`** (the internal field is still `api.public_origin`, but +> `[api].public_origin` is a relocated key and is **rejected at config load**, row above). A plain +> loopback deployment derives the RP from the request URL, but **behind a declared reverse proxy** +> (`tls_terminated_upstream`) an unset origin is a startup **refusal**, not a degraded ceremony: with +> the console served, `serve` exits 2 until it is set — and note that **changing that host later +> invalidates every enrolled passkey** (they pin their mint-time RP; the account page marks them +> "unusable (origin changed)"). > **Off-loopback browser-console walkthrough (L5b, ADR 0068 §8).** The two supported postures: > **in-process TLS** (`tls_cert_file` [+`tls_key_file`]) — the browser connects directly to the > engine — or a **declared upstream terminator** (`tls_terminated_upstream = true` + -> `trusted_proxies = [""]` + **`public_origin`**, which the L5b ladder now -> requires). `trusted_proxies` entries match the proxy's **direct TCP peer address exactly** (CIDR +> `trusted_proxies = [""]` + **`[security].web_console_public_address`**, +> which the L5b ladder now requires). `trusted_proxies` entries match the proxy's **direct TCP peer address exactly** (CIDR > supported, but scope it to the proxy pool — every host inside an entry may forge its own source > address; watch the `::1`-vs-`127.0.0.1` mismatch) — a *syntactically valid but wrong* entry silently > disables the forwarded-header rewrite, collapsing audit/rate-limit source IPs to the proxy. An @@ -221,7 +226,7 @@ byte-identical SSL context. ### `[inbound]` — inbound listener defaults | Key | Type | Default | Notes | |---|---|---|---| -| `bind_host` | str | `127.0.0.1` | the **default** network interface every inbound MLLP/TCP listener binds to. Authors never set a `host` on an inbound connection (a wiring error if they do) — it's a per-environment operator decision here. Binding `0.0.0.0` exposes unauthenticated MLLP to the network, so it's deliberate (DEV typically loopback, PROD a specific NIC behind a firewall). A non-loopback bind **requires `tls=true`** on each MLLP connection (the §0 exposed-gate refuses a plaintext off-loopback listener at startup) unless `serve --allow-insecure-bind` is passed. A single connection may override this with a per-connection `bind_address` (and restrict peers with `source_ip_allowlist`) — MLLP/TCP only; see [CONNECTIONS.md](CONNECTIONS.md). | +| `bind_host` | str | `127.0.0.1` | the **default** network interface every inbound MLLP/TCP listener binds to. Authors never set a `host` on an inbound connection (a wiring error if they do) — it's a per-environment operator decision here. Binding `0.0.0.0` exposes unauthenticated MLLP to the network, so it's deliberate (DEV typically loopback, PROD a specific NIC behind a firewall). A non-loopback bind **requires `tls=true`** on each MLLP connection: the §0 exposed-gate refuses a plaintext off-loopback listener at startup with a `WiringError`. `serve --allow-insecure-bind` downgrades that refusal **only when the instance is not both enforcing and PHI** — and since `enforcement = enforce` is the default and all three built-in env names (`dev`/`staging`/`prod`) derive PHI, on a stock instance the flag is **clamped inert** and the bind still fails. A single connection may override this with a per-connection `bind_address` (and restrict peers with `source_ip_allowlist`) — MLLP/TCP only; see [CONNECTIONS.md](CONNECTIONS.md). | | `ack_after` | enum | `ingest` | the **default** ACK timing every inbound inherits (staged pipeline, [ADR 0001](adr/0001-staged-pipeline-architecture.md)). `ingest` = ACK-on-receipt, once the raw message is durably committed to the ingress stage and **before** routing/transform/delivery. `delivered` (defer the ACK until delivery succeeds) is **not built** — wiring it raises a `WiringError`, so it fails loud rather than silently ACKing early. A connection's own `ack_after=` overrides this. | | `stream_inflight_budget_bytes` | int (bytes) | `0` | aggregate cap on the **total** bytes of over-threshold message bodies concurrently mid-detach across **all** inbounds (#149, [ADR 0105](adr/0105-streaming-very-large-hl7-attachments-detach-the-opaque-document-from-the-transformable-skeleton.md)). A detach that would push the running total over it is refused with backpressure (the message is NAK'd/`ERROR`'d, never accepted-and-dropped), so a burst of very large documents can't exhaust memory. `0` (default) = unlimited — a *single* body is still bounded by the per-connection `max_message_bytes`. Only over-threshold streaming detaches count against it. | @@ -772,9 +777,13 @@ checked against the resolved (`env()`-substituted) destination. > search parameter. Default-off keeps the flat form for back-compat, which leaves the query-encoding an author > responsibility — the structured form is the safe path either way. -> `serve` warns at startup in a `prod`/`staging` environment when egress is fully open (no allowlist set -> **and** `deny_by_default` off) — a transform could then send PHI anywhere. Lock it down with -> `deny_by_default = true` or the per-transport lists above. +> **Fully-open egress on a PHI instance is a startup REFUSAL, not a warning.** With no +> `[egress].allowed_*` list set and deny-by-default off, `serve` **exits 2** — on **any** PHI instance, +> and all three built-in environment names (`dev`, `staging`, `prod`) derive PHI, not just +> `prod`/`staging` — under `[security].enforcement = enforce`, the shipped default. It downgrades to a +> stderr warning only under `enforcement = warn`; a **synthetic** instance is exempt. Lock it down with +> the per-transport lists above and/or **`[security].block_unlisted_outbound = true`** (the old +> `[egress].deny_by_default` spelling is rejected at config load). > The webhook/SMTP **alert** sinks carry no message bodies (no PHI) and keep their own host allowlists > in `[alerts]` (`webhook_allowed_hosts` / `smtp_allowed_hosts`). @@ -846,7 +855,7 @@ best-effort and runs on a background task, so it never blocks or hangs a deliver | `email_subject_template` | str | _unset_ | optional **operator-editable** alert-email subject (#138, [ADR 0127](adr/0127-operator-editable-alert-email-templates-with-a-non-phi-variable-allowlist.md)). Unset (the default, with its two siblings) = the fixed subject + key/value body, byte-identical to before. When set it is a `{name}` template over a **closed non-PHI variable allow-list**, validated at config load and **fail-closed** — an unknown or message-derived reference raises rather than rendering | | `email_body_template` | str | _unset_ | the same, for the **plain-text** body. The plain-text part is **always** sent, even when an HTML alternative is configured | | `email_html_template` | str | _unset_ | the same, adding an **HTML alternative** part whose substituted *values* are HTML-escaped. Never HTML-only — it supplements `email_body_template`, it does not replace it | -| `security_notifications_required` | bool | `true` | **secure-by-default gate (BACKLOG #188, ASVS 6.3.5/6.3.7).** On a **PHI** instance, if no effective out-of-band security-notification channel is configured — `[auth].notify_security_events` on **and** `email_smtp_host` + `email_from` set — `serve` **refuses to start in production** and **warns** in a non-production PHI env. Set `false` to accept the pull-only `GET /me/security-events` feed instead (audited). | +| `security_notifications_required` | bool | `true` | **secure-by-default gate (BACKLOG #188, ASVS 6.3.5/6.3.7).** On a **PHI** instance, if no effective out-of-band security-notification channel is configured — `[auth].notify_security_events` on **and** `email_smtp_host` + `email_from` set — `serve` **refuses to start (exit 2)**. **The refuse/warn split is `[security].enforcement`, not the production tier** — the gate reads `enforcing`, `enforce` is the shipped default, and **all three** built-in env names derive PHI, so `serve --env staging` on stock defaults with no `[alerts]` SMTP is refused, not warned. It warns only under `enforcement = warn`. Set `false` to accept the pull-only `GET /me/security-events` feed instead (audited). | | `realert_seconds` | num | 300 | suppress re-notifying the same (event, connection) more often than this (anti-spam for a flapping lane). A matching rule's `cooldown_seconds` overrides it. | | `rules` | list | `[]` | ordered `[[alerts.rules]]` table array — per-event severity, transport routing, thresholds, suppression, cooldown (see below). Empty = today's behaviour (every event → every transport at `warning`). | @@ -1240,7 +1249,7 @@ regardless of any value here — byte-identical to the former production-PHI ref | `require_encryption_for_remote` | bool | `true` | any off-machine access must be over TLS (config-file twin of `--allow-insecure-bind`; can't relax production-PHI) | | `serve_web_console` | bool | `true` | mount the browser ops console at `/ui` — **on by default** ([ADR 0143](adr/0143-web-console-on-by-default-disableable-with-loopback-secure-context-browser-hardening.md)); set `false` to shrink to a JSON-only surface. Default-on applies to **local loopback** binds; on an exposed instance a default-on console auto-degrades to JSON-only unless explicitly enabled with TLS + `web_console_public_address` | | `web_console_public_address` | str | `""` | external origin when the console is exposed off-box (CSRF/CSWSH + WebAuthn RP-id) | -| `allowed_client_networks` | list[str] | `[]` | **`[BUILT]` ([ADR 0151](adr/0151-operator-surface-source-network-allow-list-security-allowed-client-networks.md)):** source-address allow-list for the **operator API + web console**. **Empty (the default) = no restriction.** Non-empty = a request whose client address is outside every listed network is refused **403 in middleware, before routing and before sign-in** (also covers `/ui`, `/ui/static`, `/ws/stats`). Entries are CIDR networks or bare hosts (`"10.20.0.0/16"`, `"2001:db8::/48"`, `"10.20.4.7"` → `/32`), IPv4 + IPv6 mixed; malformed entries are **refused at load** and valid ones are stored normalized (`10.1.2.3/24` → `10.1.2.0/24`). **Loopback is always allowed**, with no knob (the tray `/health` poll, an on-box browser, `messagefoundry check` and a container HEALTHCHECK cannot be allow-listed). **Operator surface only** — the ingest listeners keep their own per-connection `[inbound].source_ip_allowlist`. **It matches the address uvicorn reports, so it is INERT behind an UNDECLARED proxy / NAT / a bridged container** — declare the proxy in `[api].trusted_proxies` or this does nothing; `curl /health` and read `observed_client` to check. Setting it **tightens `[api].trusted_proxies` to single hosts** (a broad range would let every host inside it forge its own source address). Startup-only: a lockout costs a service restart. Defence-in-depth **behind** the host firewall, not the primary network control — read OFF-LOOPBACK-DEPLOYMENT.md first. Env: `MEFOR_SECURITY_ALLOWED_CLIENT_NETWORKS` (**comma**-separated). | +| `allowed_client_networks` | list[str] | `[]` | **`[BUILT]` ([ADR 0151](adr/0151-operator-surface-source-network-allow-list-security-allowed-client-networks.md)):** source-address allow-list for the **operator API + web console**. **Empty (the default) = no restriction.** Non-empty = a request whose client address is outside every listed network is refused **403 in middleware, before routing and before sign-in** (also covers `/ui`, `/ui/static`, `/ws/stats`). Entries are CIDR networks or bare hosts (`"10.20.0.0/16"`, `"2001:db8::/48"`, `"10.20.4.7"` → `/32`), IPv4 + IPv6 mixed; malformed entries are **refused at load** and valid ones are stored normalized (`10.1.2.3/24` → `10.1.2.0/24`). **Loopback is always allowed**, with no knob (the tray `/health` poll, an on-box browser, `messagefoundry check` and a container HEALTHCHECK cannot be allow-listed). **Operator surface only** — the ingest listeners keep their own per-connection `source_ip_allowlist` (an attribute on the connection in `connections.toml`, **not** a key in the `[inbound]` section — put it there and it is silently ignored). **It matches the address uvicorn reports, so it is INERT behind an UNDECLARED proxy / NAT / a bridged container** — declare the proxy in `[api].trusted_proxies` or this does nothing; `curl /health` and read `observed_client` to check. Setting it **tightens `[api].trusted_proxies` to single hosts** (a broad range would let every host inside it forge its own source address). Startup-only: a lockout costs a service restart. Defence-in-depth **behind** the host firewall, not the primary network control — read OFF-LOOPBACK-DEPLOYMENT.md first. Env: `MEFOR_SECURITY_ALLOWED_CLIENT_NETWORKS` (**comma**-separated). | | `encrypt_stored_data` | bool | `true` | PHI encrypted at rest (key from the environment) | | `allow_unencrypted_phi` | bool | `false` | audited escape: start a PHI instance with **no** key | | `allow_unencrypted_phi_under_strict_enforcement` | bool | `false` | the **second acknowledgment** required to start a PHI instance keyless under strict enforcement ([ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md)). Under `enforcement = enforce`, `allow_unencrypted_phi = true` on its own is **not** enough — `serve` still refuses to start (exit 2) unless this is also set, so the highest-risk posture (real PHI + strict enforcement) is never one flag away from plaintext at rest. Under `enforcement = warn` the single `allow_unencrypted_phi` flag still governs. With both set the instance starts with PHI bodies, summary/metadata and the error columns **unencrypted at rest**, and the startup AUDIT line names **both** flags. A **loosening** — `security_loosenings()` reports it, so it is never silent | diff --git a/assets/docs/_md/Install-Guide.md b/assets/docs/_md/Install-Guide.md index 3319724..196e85f 100644 --- a/assets/docs/_md/Install-Guide.md +++ b/assets/docs/_md/Install-Guide.md @@ -209,8 +209,9 @@ its `MEFOR_*` environment. Two things every instance must state: - **`[ai].environment`** — a free-form name (`test`, `prod`, `poc`, …) that selects `environments/.toml`. -- **Security posture, explicit and decoupled from the name:** `[ai].data_class` (`synthetic` | `phi` — - does this instance carry *real* PHI?) and `[ai].production` (is this a production tier?). Built-in names +- **Security posture, explicit and decoupled from the name:** `[security].handles_real_patient_data` + (`true` | `false` — does this instance carry *real* PHI?) and `[security].production_instance` + (`true` | `false` — is this a production tier?). Built-in names `dev`/`staging`/`prod` derive a sensible default posture; **any custom name must state posture explicitly** — the engine fails closed rather than guess. diff --git a/assets/docs/_md/Mental-Model.md b/assets/docs/_md/Mental-Model.md index 9e126e4..56e1c5b 100644 --- a/assets/docs/_md/Mental-Model.md +++ b/assets/docs/_md/Mental-Model.md @@ -146,7 +146,7 @@ MessageFoundry isn’t one program — it’s a small toolkit arranged around th |----|----|----| | **Engine service** | messagefoundry serve *(as a Windows service via NSSM)* | The headless runtime: owns the store, runs the Connection/Router/Handler graph through the staged queue, and exposes the localhost HTTP/WebSocket API. Everything else talks to this. | | **Command-line tool** | messagefoundry \ | One binary, many jobs: serve, init (scaffold a config repo), validate / graph / dryrun / check (the commit/CI gate), connection (edit connections.toml), generate (synthetic HL7), plus key/audit security ops. The introspection commands touch no network — git hooks and the VS Code extension shell them. | -| **Admin & monitoring web console** | browse to the engine's `/ui` (`[api].serve_ui`; `messagefoundry-webconsole` wheel) | The operator GUI, in the browser: connection dashboard, message browser with per-message disposition + delivery/audit trail, HL7 parse-tree viewer, dead-letter queue with replay, and user/session/MFA management. A pure API client — it never touches the DB. (The former PySide6 desktop console was retired — BACKLOG #103.) | +| **Admin & monitoring web console** | browse to the engine's `/ui` (**on by default**; disable with `[security].serve_web_console = false`; `messagefoundry-webconsole` wheel) | The operator GUI, in the browser: connection dashboard, message browser with per-message disposition + delivery/audit trail, HL7 parse-tree viewer, dead-letter queue with replay, and user/session/MFA management. A pure API client — it never touches the DB. (The former PySide6 desktop console was retired — BACKLOG #103.) | | **VS Code configuration extension** | the ide/ extension (open in VS Code, press F5) | The authoring surface: a New Route Wizard, validate-on-save, a Test Bench that dry-runs .hl7 files with before/after diffs, Stage → Promote to a running engine, and an HL7-aware @messagefoundry AI chat participant. Shells the CLI’s introspection commands. | | **Test harness** | python -m harness *(standalone PySide6)* | Exercises a running engine with synthetic, PHI-free traffic: Send / Receive / File / Compose / Monitor tabs (inject ACK faults, malformed messages, delivery failures), headless CI scenarios that assert dispositions, and a separate asyncio load-testing engine with tunable profiles (warmup → ramp → soak) and an SLO report. | | **Tee relay** | python -m tee *(standalone; no engine imports)* | A migration de-risking tool: sit in front of a legacy engine and a shadow MessageFoundry, ACK on receipt, and forward the same bytes to both so you can compare output before cutover (rollback = stop the relay). *Test/synthetic data only — not PHI-hardened.* | @@ -335,7 +335,7 @@ Keep the message store on a fast *local* disk, not a network share — the stage - **Store.** SQLite (WAL) is the bundled, zero-setup default for single-node; **PostgreSQL 13+** or **SQL Server 2022/2025** for production (run the server DB on its own host; SQL Server also needs the OS-level ODBC Driver 18, RCSI recommended). MySQL/Oracle aren’t supported. -- **Clients.** The **browser web console** served under `/ui` (`[api].serve_ui`; [ADR 0065](adr/0065-web-ops-dashboard.md)) is the operator UI, alongside the VS Code extension for authoring. The `/ui` console is **not** in the engine wheel: it ships as a separately-versioned second distribution (`messagefoundry-webconsole`) that the engine **mounts same-origin, in-process** — install it alongside the engine to use `serve_ui` ([WEBCONSOLE-PACKAGE.md](WEBCONSOLE-PACKAGE.md)). The former PySide6 desktop console was retired (BACKLOG #103); PySide6 now backs only the standalone test harness. A web browser is needed to use the `/ui` console; the engine itself operates headless. +- **Clients.** The **browser web console** served under `/ui` (`[security].serve_web_console` — **on by default** at a loopback bind; [ADR 0065](adr/0065-web-ops-dashboard.md), [ADR 0143](adr/0143-web-console-on-by-default-disableable-with-loopback-secure-context-browser-hardening.md)) is the operator UI, alongside the VS Code extension for authoring. The `/ui` console is **not** in the engine wheel: it ships as a separately-versioned second distribution (`messagefoundry-webconsole`) that the engine **mounts same-origin, in-process** — install it alongside the engine and the console is served without further configuration ([WEBCONSOLE-PACKAGE.md](WEBCONSOLE-PACKAGE.md)). The former PySide6 desktop console was retired (BACKLOG #103); PySide6 now backs only the standalone test harness. A web browser is needed to use the `/ui` console; the engine itself operates headless. - **Network.** The engine API binds 127.0.0.1:8765 by default (auth-required; in-process TLS for off-loopback exposure); inbound MLLP/TCP listeners use operator-defined ports on a trusted segment; outbound reachability (and, for a server DB, the DB host) as configured. diff --git a/assets/docs/_md/Remote-Console-Customer-Guide.md b/assets/docs/_md/Remote-Console-Customer-Guide.md index 909134a..0406a58 100644 --- a/assets/docs/_md/Remote-Console-Customer-Guide.md +++ b/assets/docs/_md/Remote-Console-Customer-Guide.md @@ -64,21 +64,41 @@ Place them somewhere the engine's service account can read, e.g. `C:\MessageFoun ## Step 2 — Configure the engine server -Edit the engine's configuration file, **`messagefoundry.toml`**, on the server. Add or update the -`[api]` section: +Edit the engine's configuration file, **`messagefoundry.toml`**, on the server. Exposure is controlled +from the `[security]` section, and the certificate paths from `[api]`: ```toml +[security] +local_access_only = false # reachable from off this machine +listen_address = "0.0.0.0" # or a specific NIC IP, e.g. "10.0.0.12" +serve_web_console = true # REQUIRED explicitly when exposed (see note below) +web_console_public_address = "https://engine-host:8765" # the origin the browser uses + [api] -host = "0.0.0.0" # listen on the network (or a specific NIC IP) port = 8765 # the API port the console connects to tls_cert_file = "C:/MessageFoundry/tls/engine-cert.pem" tls_key_file = "C:/MessageFoundry/tls/engine-key.pem" ``` +**That block alone will not start the engine.** Because the engine performs no OCSP/CRL revocation +checking, an off-loopback bind that terminates TLS in-process is refused (**exit 2**) until you attest +your revocation posture. That attestation is an **environment variable, not a TOML key**: + +``` +setx MEFOR_TLS_REVOCATION_ATTESTED 1 +``` + +Under NSSM set it on the service rather than in an interactive shell — +`nssm set MessageFoundry AppEnvironmentExtra MEFOR_TLS_REVOCATION_ATTESTED=1`. Setting it is you +taking responsibility for revocation checking ([ADR 0078](adr/0078-certificate-revocation-posture.md)). + Notes: -- `host = "0.0.0.0"` listens on all network interfaces; you can instead use a specific address (e.g. - `"10.0.0.12"`) to limit it to one network. +- `listen_address = "0.0.0.0"` listens on all network interfaces; you can instead use a specific + address (e.g. `"10.0.0.12"`) to limit it to one network. +- `serve_web_console` must be set **explicitly** when the engine is exposed. The console is on by + default only for loopback binds — on an exposed instance a default-on console silently degrades to + JSON-only. - If your private key is **password-protected**, supply the passphrase via the environment variable `MEFOR_API_TLS_KEY_PASSWORD` — never put it in the file. - The engine **will refuse to start** if you open it to the network **without** a certificate (this @@ -157,7 +177,8 @@ This is optional and off by default. |---|---| | `certificate verify failed` / "not trusted by the trust provider" | The PC doesn't trust the engine's certificate. Add `--cacert `, or install the issuing CA into the PC's Windows certificate store. | | `refusing to use plaintext http to non-loopback host …` | You used an `http://` address to a remote engine. Use the `https://` address (configure the certificate in Step 2). | -| The **engine** won't start after editing the config | You set `host` to a network address without a certificate. Add `tls_cert_file` (+ `tls_key_file`) under `[api]`, or revert `host` to `127.0.0.1`. | +| The **engine** won't start after editing the config | Either you exposed it without a certificate — add `tls_cert_file` (+ `tls_key_file`) under `[api]` — or you have not attested revocation: set `MEFOR_TLS_REVOCATION_ATTESTED=1` (Step 2). To back out entirely, set `[security].local_access_only = true`. | +| `moved to [security]. … and is no longer accepted` | You used a pre-ADR-0118 key such as `[api].host`. Exposure now lives in `[security]` (`local_access_only` / `listen_address`); the old spellings are rejected when the config loads. | | "hostname mismatch" when connecting | The certificate's name (SAN) doesn't match the address in `--url`. Reissue the certificate for the correct hostname/IP. | | Console can't reach the server at all | Check the firewall on the engine server (default port **8765/TCP**) and that the service is running. | diff --git a/assets/docs/_md/System-Requirements.md b/assets/docs/_md/System-Requirements.md index b1eea96..8253f69 100644 --- a/assets/docs/_md/System-Requirements.md +++ b/assets/docs/_md/System-Requirements.md @@ -125,14 +125,14 @@ SEV-SNP needs EPYC 7003+ and TDX needs 5th Gen Xeon Scalable+, which is newer th |---|---| | **Web console** (the operator UI) | A modern browser — **nothing to install on the operator's machine.** The engine serves the console same-origin under `/ui` from its own FastAPI app ([ADR 0065](adr/0065-web-ops-dashboard.md)), **on by default** since [ADR 0143](adr/0143-web-console-on-by-default-disableable-with-loopback-secure-context-browser-hardening.md) (`[security].serve_web_console`; set it to `false` for a JSON-API-only deployment). It ships as a separately-versioned wheel, `messagefoundry-webconsole`, mounted in-process. This is the **sole operator console** — the PySide6 desktop console was retired ([ADR 0032](adr/0032-console-desktop-launch.md)). | | **VS Code extension** | Visual Studio Code (current stable) — route wizard, validate-on-save, test bench, stage→promote. | -| Test harness — *optional, not needed to run the engine* | The standalone synthetic send/receive/load harness (`python -m harness`) is the **only** PySide6 (Qt) surface left: `pip install messagefoundry[harness]`, Windows / Linux / macOS, a separate process reaching the engine only over the HTTP API. It is a **testing tool** — an engine host that does not run it needs no Qt and no GUI at all. | +| Test harness — *optional, not needed to run the engine* | The standalone synthetic send/receive/load harness (`python -m harness`) is the **only** PySide6 (Qt) surface left. It ships as its **own distribution**, released in lockstep with the engine and deliberately **not** included in the engine wheel: `pip install messagefoundry-harness` (which pulls `messagefoundry[harness]`, i.e. PySide6). Windows / Linux / macOS, a separate process reaching the engine only over the HTTP API. It is a **testing tool** — an engine host that does not run it needs no Qt and no GUI at all. | ## Network & ports | Purpose | Default | Notes | |---|---|---| -| **Engine API** (HTTP + WebSocket) | `127.0.0.1:8765` | **Loopback by default**, authentication-required. **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 — in-process TLS needs no override flag, and 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 should set `tls = true` — and a cleartext MLLP **egress** off loopback is **refused at startup** on a production-PHI instance unless the hop is attested. | +| **Engine API** (HTTP + WebSocket) | `127.0.0.1:8765` | **Loopback by default**, authentication-required. **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 **neither branch starts a stock instance on its own**. Because the engine performs no OCSP/CRL revocation check, in-process TLS off loopback is refused (**exit 2**) unless you also set `MEFOR_TLS_REVOCATION_ATTESTED=1` — an environment variable, not a TOML key ([ADR 0078](adr/0078-certificate-revocation-posture.md)); the proxy branch additionally wants `proxy_intra_service_auth` and `proxy_tls_min_version` on a PHI instance at the shipped enforcement. 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`: an off-loopback cleartext listener is **refused at wiring time** with a `WiringError` before the engine starts, and `serve --allow-insecure-bind` is itself clamped — a PHI instance at the shipped enforcement refuses even with the flag. A cleartext MLLP **egress** off loopback is likewise **refused** whenever `[security].enforcement = enforce` (the default) — **regardless of data class**, since [ADR 0153](adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md) removed the synthetic-instance exemption. The escapes are an attested hop, `cleartext_accepted = true` (warn + audit), or `enforcement = warn`. | | **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/assets/docs/_md/User-Guide.md b/assets/docs/_md/User-Guide.md index 6ebd5e1..f463853 100644 --- a/assets/docs/_md/User-Guide.md +++ b/assets/docs/_md/User-Guide.md @@ -129,7 +129,7 @@ When the engine runs from somewhere other than the repo root (e.g. under the ser **Network / auth posture.** The API binds **`127.0.0.1:8765`** and **requires authentication** by default. A non-loopback bind without TLS is refused at startup; configure native TLS (or an upstream terminator) to expose it. Details: [SECURITY.md](SECURITY.md) and [DEPLOYMENT.md](DEPLOYMENT.md). -**Store encryption (PHI instances).** On a PHI-carrying environment (`data_class = phi`), `serve` warns — and on a *production* PHI instance **refuses to start** — if no store encryption key is configured. Mint one with `messagefoundry gen-key` (set it as `MEFOR_STORE_ENCRYPTION_KEY`), or on Windows DPAPI-protect it to a file with `messagefoundry protect-key --generate --out ` and point `[store].encryption_key_file` at it. The full key story is in [PHI.md](PHI.md). +**Store encryption (PHI instances).** On a PHI instance (`[security].handles_real_patient_data = true`), `serve` **refuses to start** — in *every* environment, `dev` and `staging` included, not just production — if no store encryption key is configured. Mint one with `messagefoundry gen-key` (set it as `MEFOR_STORE_ENCRYPTION_KEY`), or on Windows DPAPI-protect it to a file with `messagefoundry protect-key --generate --out ` and point `[store].encryption_key_file` at it. The full key story is in [PHI.md](PHI.md). Confirm it's up: @@ -158,7 +158,7 @@ Put it under version control with **Set Up Version Control & Checks** in the IDE ### 5. Open the admin console (in a browser) -The console is the **browser web console** served same-origin by the engine at `/ui` (install the `messagefoundry-webconsole` wheel alongside the engine and set `[api].serve_ui = true`). With the engine running, browse to: +The console is the **browser web console** served same-origin by the engine at `/ui` — install the `messagefoundry-webconsole` wheel alongside the engine and it is served automatically, **on by default** at a loopback bind (turn it off with `[security].serve_web_console = false`). With the engine running, browse to: ``` http://127.0.0.1:8765/ui @@ -488,7 +488,7 @@ The console is served by the engine itself, so start the engine first (note the python -m messagefoundry serve --config samples/config --db ./messagefoundry.db --env dev ``` -Then open the web console in a browser (the engine serves it at `/ui` when `[api].serve_ui` is on): +Then open the web console in a browser (the engine serves it at `/ui` by default, with the `messagefoundry-webconsole` distribution installed): ``` http://127.0.0.1:8765/ui @@ -606,7 +606,7 @@ The SMTP password is a secret — supply it via `MEFOR_ALERTS_EMAIL_PASSWORD`, n - **A lane stopped processing.** A `connection_stopped` alert means an outbound's worker halted on an internal/code error (`internal_error = stop`). The messages are preserved for replay; fix the cause, then reload/restart the connection. - **A connection shows `failed`.** A connection that can't build or bind **at startup** (bad settings, a port already in use) is isolated as a degraded `failed` status instead of taking the engine down — every other lane keeps running ([ADR 0031](adr/0031-startup-connection-fault-isolation.md)). Fix the config/bind, then recover it: restart an inbound (`POST /connections/{name}/start`), or reload to rebuild a failed outbound. (Reload itself stays fail-fast — a broken config is rejected whole, never partially applied.) - **Backlog growing.** A `queue_buildup` alert usually means a retry-forever head is blocking its FIFO lane, or the downstream is down. Check the destination, then inspect/purge or replay the blocking row. -- **Console can't reach the engine.** The API binds `127.0.0.1:8765` by default and requires auth; confirm the engine is serving (`python -m messagefoundry serve --config samples/config --db ./messagefoundry.db --env dev`), that `[api].serve_ui` is on with the `messagefoundry-webconsole` distribution installed, and that your browser is pointed at that host/port's `/ui`. +- **Console can't reach the engine.** The API binds `127.0.0.1:8765` by default and requires auth; confirm the engine is serving (`python -m messagefoundry serve --config samples/config --db ./messagefoundry.db --env dev`), that the `messagefoundry-webconsole` distribution is installed and the console has not been turned off (`[security].serve_web_console`), and that your browser is pointed at that host/port's `/ui`. - **Low disk / store growing.** `GET /status` reports DB size and free disk; a `storage_threshold` alert fires past `[retention].max_db_mb`. Tune retention in `[retention]` ([CONFIGURATION.md](CONFIGURATION.md)) — purges null PHI bodies while keeping the message/disposition rows, so counts and audit stay intact. The row is kept; its PHI columns — operator-attached `metadata` included — are blanked. --- diff --git a/assets/docs/_md/Users-and-Security.md b/assets/docs/_md/Users-and-Security.md index 038d8ac..32c73e9 100644 --- a/assets/docs/_md/Users-and-Security.md +++ b/assets/docs/_md/Users-and-Security.md @@ -145,7 +145,7 @@ Two factor types ship, both for **local** accounts: A required-but-unenrolled administrator is never locked out: the enrollment routes sit behind an action-bound *password* step-up, not the MFA gate, so the bootstrap admin enrolls and then satisfies it. -**Enterprise/AD users get MFA through their own identity provider.** A directory login is satisfied by the directory's controls (for example Entra Conditional Access or an MFA proxy) and is never prompted for an engine factor. **State this plainly, because it is the honest weakness:** on the AD and Kerberos pathways the engine issues the session MFA-satisfied and receives **no evidence** of what the directory actually enforced — so a password on the AD pathway reaches the same PHI surface as a passkey-backed local administrator. OIDC federation is the one delegated pathway carrying engine-side evidence: **by default** (`[auth].oidc_require_mfa_claim`, shipped on) it refuses a sign-in whose *signature-verified* token asserts no configured MFA claim — an operator can switch that requirement off, and then the pathway carries no engine-side MFA evidence either. Even on, that is an IdP **assertion**, cryptographically verified — not a proof that MFA was enforced, and we will not describe it as one. +**Enterprise/AD users get MFA through their own identity provider.** A directory login is satisfied by the directory's controls (for example Entra Conditional Access or an MFA proxy) and is never prompted for an engine factor. **State this plainly, because it is the honest weakness:** on the AD and Kerberos pathways the engine issues the session MFA-satisfied and receives **no evidence** of what the directory actually enforced — so a password on the AD pathway reaches the same PHI surface as a passkey-backed local administrator. OIDC federation is the one delegated pathway carrying engine-side evidence: **by default** (`[auth].oidc_require_mfa_claim`, shipped on) it refuses a sign-in whose *signature-verified* token asserts no configured MFA claim — an operator can switch that requirement off, but that does **not** quietly degrade the pathway to the AD posture — it fails closed: federated sessions are then minted un-verified and the MFA access gate refuses them, so federated sign-in stops working unless `[security].require_mfa` is also turned off (a breaking change in 0.3.1). Even on, that is an IdP **assertion**, cryptographically verified — not a proof that MFA was enforced, and we will not describe it as one. When the API is bound **off loopback** with the local-account MFA requirement **explicitly turned off**, the service makes the posture explicit at startup: it refuses to start a PHI instance under enforcing configuration and warns otherwise, mirroring the keyless-store and open-egress startup gates. That refusal has exactly one lever: a **dedicated acknowledgement switch that does nothing else** (`allow_single_factor_admin_when_exposed`), which downgrades it to a loud, audited warning while enforcement stays at its default — intended for an exposure where an MFA-enforcing proxy supplies the second factor. So the remediation set is three: leave MFA required, keep the bind on loopback, or make that acknowledgement deliberately and see it named in the posture endpoint. diff --git a/docs/secure-development-standards.md b/docs/secure-development-standards.md index 6fceee5..a21c440 100644 --- a/docs/secure-development-standards.md +++ b/docs/secure-development-standards.md @@ -319,7 +319,7 @@ The project maintains a current evidence set so any claim is backed: MessageFoundry (MEFOR) is an open-source **HL7 v2.x interface engine** — a candidate alternative to commercial engines (Corepoint, Mirth Connect, Rhapsody, Cloverleaf). It routes and transforms clinical messages between systems. -**Technology stack:** Python 3.14+, FastAPI/uvicorn, aiosqlite/SQLite (WAL), `python-hl7`/`hl7apy`, PySide6 (desktop UI), Windows/PowerShell deployment; MLLP transport with native MLLP-over-TLS (opt-in via cert config — ADR 0002); application-layer AES-256-GCM encryption at rest (database-native where the backend provides it). Durable message store with FIFO/per-key ordering and dead-letter handling. +**Technology stack:** Python 3.14+, FastAPI/uvicorn, aiosqlite/SQLite (WAL), `python-hl7`/`hl7apy`, a same-origin browser ops console served at `/ui`, PySide6 (the standalone test harness only — the desktop admin console was retired, BACKLOG #103), Windows/PowerShell deployment; MLLP transport with native MLLP-over-TLS (opt-in via cert config — ADR 0002); application-layer AES-256-GCM encryption at rest (database-native where the backend provides it). Durable message store with FIFO/per-key ordering and dead-letter handling. ### A.2 Interfaces and surfaces @@ -327,8 +327,11 @@ MessageFoundry (MEFOR) is an open-source **HL7 v2.x interface engine** — a can - **REST and SOAP** web-service interfaces — **outbound destinations built** (per-connection bearer / Basic-over-TLS; SOAP WS-Security + XML-DSig per ADR 0015). A **generic inbound HTTP listener is built** (ADR 0023) as the substrate REST/SOAP-in ride on; ADR 0003/0004 framed the original non-HL7 transport + payload-agnostic ingress design. - **Database** source (inbound poll) and destination — ADR 0003. - **File-handler interface** (file-drop pickup / output). -- **PySide6 desktop client**, plus an **opt-in read-only web ops dashboard** served under `/ui` - (`[api].serve_ui`, off by default — [ADR 0065](adr/0065-web-ops-dashboard.md)). +- **Browser ops console** served same-origin under `/ui` — the **sole operator console** since the + PySide6 desktop client was retired (BACKLOG #103). It is **on by default** at a loopback bind + (`[security].serve_web_console`; [ADR 0065](adr/0065-web-ops-dashboard.md), + [ADR 0143](adr/0143-web-console-on-by-default-disableable-with-loopback-secure-context-browser-hardening.md)) + and carries **write** actions (replay, purge, connection flags), not read-only as at its M1. ### A.3 OWASP ASVS 5.0 Level 3 — chapter applicability @@ -374,8 +377,9 @@ current position on it. with optional client-certificate **mTLS** (API `tls_client_ca_file`; MLLP `tls_ca_file`), an off-loopback bind guard, and a certificate-expiry monitor — ADR 0002 / WP-13a/13b. - **Operator strong-auth (control plane):** native **RFC 6238 TOTP MFA** for **local** accounts - (ADR 0002 WP-14, built 2026-06-17) — enrolled per user, enforced for the Administrator role via - `[auth].require_mfa` and re-verified at the sensitive-operation step-up boundary; AD/Entra users' + (ADR 0002 WP-14, built 2026-06-17) — enrolled per user, enforced via `[security].require_mfa` + (on by default; `require_mfa_scope` defaults to every local account) and re-verified at the + sensitive-operation step-up boundary; AD/Entra users' MFA is delegated to the IdP. Recovery codes are argon2id-hashed; the TOTP secret is store-cipher protected. - **Operator / directory (control plane, not interface auth):** **LDAPS** directory bind @@ -446,8 +450,8 @@ each release and on any trigger below. Those are maintainer-internal documents; [`SECURITY-DOCS-POLICY.md`](SECURITY-DOCS-POLICY.md) explains what is withheld and what you can request. - **6.3.3 — multi-factor authentication.** **Satisfied for local accounts** — native RFC 6238 TOTP MFA - is **built** (ADR 0002 WP-14, 2026-06-17), enforced for the Administrator role via `[auth].require_mfa` - at the step-up boundary; **AD/Entra-account MFA is delegated to the IdP** (the supported enterprise + is **built** (ADR 0002 WP-14, 2026-06-17), enforced via `[security].require_mfa` (on by default, + for every local account) at the step-up boundary; **AD/Entra-account MFA is delegated to the IdP** (the supported enterprise path). No longer a deferred Fail. *(Hardware/WebAuthn second factors are now **built** — browser WebAuthn passkeys as the phishing-resistant second factor at the step-up boundary, ADR 0068 / WP-14b, behind the `[webauthn]` extra.)*