diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06f337aa0..3976d2bc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2587,33 +2587,35 @@ jobs: MEFOR_STORE_PASSWORD: "Str0ng_P@ssw0rd!" MEFOR_STORE_TRUST_SERVER_CERTIFICATE: "true" MEFOR_ALLOW_INSECURE_TLS: "1" # trusted-network escape for the container's self-signed cert - # DECLARE THE DATA CLASS HONESTLY (ADR 0148 GIVEN 1 + docs/SECURITY-LOOSENING.md - # "`handles_real_patient_data = false`"). This leg processes harness-GENERATED synthetic HL7 on - # a throwaway service container; asserting PHI was a false declaration about the instance, and - # the register names exactly this case as acceptable: "a CI runner ... that only ever processes - # synthetic / sample HL7". Since GIVEN 1 the built-in `dev` env derives PHI, so a genuinely - # throwaway CI box must now set this EXPLICITLY — it is no longer the `dev` default. + # THE DATA-CLASS DECLARATION THIS LEG USED TO CARRY IS GONE (BACKLOG #1279). Every instance + # carries patient data now, so `handles_real_patient_data = false` no longer exists and this + # leg names the ONE gate it actually needs relaxed instead. # - # It is also what makes the leg run at all. This job was RED for four consecutive nights - # (2026-07-27..30) on: + # THE GATE, and why it is the enforcement dial and not something narrower. This job was RED + # for four consecutive nights (2026-07-27..30) on: # ValueError: SQL Server TLS is weakened (trust_server_certificate=true or encrypt=false) - # Under enforcing-PHI the `MEFOR_ALLOW_INSECURE_TLS` escape above is clamped INERT by design - # (#200 / ADR 0092 decision 2 — `weakened_tls_escape_permitted`), so a self-signed container - # can never be trusted from a PHI instance. The previous comment here said this leg "validates - # PHI+enforce green, not a synthetic opt-out" — an intent that is UNREACHABLE with a - # `services:` container: GitHub starts it before any step runs, so a cert generated in a step - # cannot be mounted into it. Whoever set that provisioned the PHI posture's retention and - # egress requirements but not its TLS one. + # `weakened_tls_escape_permitted` clamps `MEFOR_ALLOW_INSECURE_TLS` INERT while the instance + # is enforcing (#200 / ADR 0092 decision 2), so the escape above does nothing under the + # shipped `enforce`. The clamp used to require enforcing AND PHI, and declaring the box + # synthetic was how this leg escaped it; with every instance PHI the only key left is the + # enforcement dial. `warn` honors the escape and keeps every gate reporting. # - # WHAT THIS GIVES UP, stated plainly: the leg no longer exercises the PHI+enforce path. Its + # It is what makes the leg run at all, and the reason a narrower fix does not exist here: the + # container's certificate is self-signed and GitHub starts a `services:` container before any + # step runs, so a cert generated in a step cannot be mounted into it. There is no per-hop + # attestation for the store hop to carry instead. + # + # WHAT THIS GIVES UP, stated plainly: the leg no longer exercises the enforcing path. Its # actual job is a load/throughput smoke of the SQL Server store, and `sqlserver-store` carries - # the functional coverage. Restoring PHI+enforce here needs a REAL certificate — the job moved + # the functional coverage. Restoring `enforce` here needs a REAL certificate — the job moved # off `services:` onto a `docker run` with a generated cert mounted and trusted — which is - # worth doing deliberately, not as a side effect of unbreaking a nightly. + # worth doing deliberately, not as a side effect of unbreaking a nightly. Note this is now a + # NARROWER relaxation than the one it replaces: the retired declaration silenced nineteen + # gates, `enforcement: warn` downgrades them to warnings and silences none. # - # The PHI-shaped provisions below are KEPT even though synthetic does not require them: they - # cost nothing and keep the leg measuring a realistic configuration. - MEFOR_SECURITY_HANDLES_REAL_PATIENT_DATA: "false" + # The provisions below are now REQUIRED rather than courtesy: under the PHI posture a keyless + # start, an unbounded retention window and an unlisted egress each have their own gate. + MEFOR_SECURITY_ENFORCEMENT: "warn" # Bounded PHI-body retention windows + a locked-down egress allowlisting the loopback sink. # The store key is minted at runtime below. The security-notification gate is skipped because # auth is off (no accounts to notify). diff --git a/CHANGELOG.md b/CHANGELOG.md index 5095b1e13..54e5e92fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,31 @@ All notable changes to MessageFoundry are documented here. The format follows ([`DEPLOY-SERVER-DB.md`](docs/DEPLOY-SERVER-DB.md) §1.2), which it previously was not. ([BACKLOG #1008](docs/BACKLOG.md)) +### Removed +- **BREAKING: `[security].handles_real_patient_data` is gone, and with it the whole data-class axis.** + Every instance carries patient data; the PHI gates apply unconditionally. Setting the key — or its + pre-ADR-0118 spelling `[ai].data_class` — now **refuses at load** with a message naming the switch to + reach for instead. Removed with it: the `DataClass` enum, `HopPosture.is_phi`, the `data_class` and + `synthetic_relaxation` fields on `SecurityPosture`, and `data_class` on `AiPolicy`. + `derived_posture()` / `require_posture()` return the production tier alone. + **Why, in one line: it turned off nineteen start-up gates on one line, and it was not the audited + opt-out the documentation claimed.** `security_loosenings()` never named it, so the serve-time + loosening warning — the thing that fires for every other deviation — did not fire for the widest + relaxation the product shipped. The completeness test that should have caught that exempted the field + with a reason that was false, in an exemption branch that could never execute. + **What to use instead:** the gate you actually mean. Each is separately named, separately audited and + separately reported — `allow_unencrypted_phi` (plus `allow_unencrypted_phi_under_strict_enforcement` + under the shipped `enforcement = enforce`), `block_unlisted_outbound`, + `allow_keeping_phi_indefinitely`, `allow_single_factor_admin_when_exposed`, + `allow_unverified_alert_smtp_tls`, `[alerts].security_notifications_required`, a per-connection + `cleartext_accepted` / `tls_revocation_attested`, or the `[security].enforcement` dial. + **What this costs:** a box that ran key-free on the declaration now needs a key or the audited + per-gate ack. Nothing is deployed (there is no migration), and both in-repo users of the declaration — + CI's SQL Server load leg and the failover load harness — moved to per-gate relaxations that are + *narrower* than what they replace. See + [ADR 0186](docs/adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) + and BACKLOG #1279. + ### Changed - **Web console engine UI seam `93ba1f10b9dccfc8` -> `b93f38d097f97a45`.** `SecurityPosture` gained the additive `store_privilege` object above, and `StorePrivilegeView` joins the discovered surface. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 9770154bf..7d4566aa1 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -17042,9 +17042,10 @@ measurement from this row's subject and it is named here rather than performed.* > > **Two failures appeared in BOTH arms and are pre-existing, not sandbox-related.** > `OB_IMMUNIZATION_BODYCRED` and `OB_IMMUNIZATION_REGISTRY` fail to start because `environments/dev.toml` -> carries none of the `registry_*` values; the engine isolates them and continues. The smoke also needs -> `[security].handles_real_patient_data = false`, or `serve --env dev` refuses to start without a store -> encryption key. +> carries none of the `registry_*` values; the engine isolates them and continues. The smoke also needs a +> store encryption key, or `serve --env dev` refuses to start. **CORRECTED 2026-09-09 (BACKLOG #1279):** +> this line said to set `[security].handles_real_patient_data = false`, which the loader now REFUSES. +> Mint a key, or set `[security].allow_unencrypted_phi` (plus its strict-enforcement ack). > > **THE DOCUMENTATION HALF OF THIS ROW SHIPPED AND STANDS AT THE CURRENT DEFAULT.** The five findings > this row named are now written down, phrased for an opt-in mode rather than a default one. Finding 1 @@ -17153,9 +17154,10 @@ anything about this change.** So **"the samples still load and run" is now earned** for the six MLLP sample feeds and the X12 one. Two pre-existing failures appear in every run including the `mode=off` control and are **not** sandbox-related: `OB_IMMUNIZATION_BODYCRED` and `OB_IMMUNIZATION_REGISTRY` fail to build because -`environments/dev.toml` carries none of the `registry_*` values. Note the smoke needs -`[security].handles_real_patient_data = false`, or `serve --env dev` refuses to start without a store -encryption key. +`environments/dev.toml` carries none of the `registry_*` values. Note the smoke needs a store encryption +key, or `serve --env dev` refuses to start. **CORRECTED 2026-09-09 (BACKLOG #1279):** this line said to +set `[security].handles_real_patient_data = false`, which the loader now REFUSES; mint a key, or set +`[security].allow_unencrypted_phi` (plus its strict-enforcement ack). *FOUR CONSEQUENCES THIS ROW DOES NOT NAME, EACH FOUND BY READING THE SHIPPED CODE.* Any of them can turn "one default plus a release note" into something a reader would have been misled by. @@ -17226,7 +17228,23 @@ note the row did not budget for. Suggest **difficulty 5-6**, and dispatch it to can finish a suite rather than merely a lane with hours. ## 1279. treat every instance as carrying patient data and retire the synthetic-data declaration -> 🔢 **Re-scored 2026-08-20 -> P2.** Value **6/10** · Difficulty **6/10** · _big bet_. The opt-out still ships at settings.py:3738 and still translates to the enum at :4234-4235, while the comment at :3733-3735 continues to contradict :2318, which has said PHI since ADR 0148. Value is a secure-defaults simplification rather than a shipped-default defect, since only an explicit declaration loses the refusals; difficulty stays high on surface alone -- 77 data_class occurrences across the engine plus 45 in tests, and the api/models.py wire-contract change -- with no migration cost added per section 0. _(previously unscored.)_ +> ✅ **SHIPPED 2026-09-09 -- owner ruling, given directly: "I don't want to use handles_real_patient_data any more. I want mefor to always take a PHI posture as the default. Users can adjust individual settings as they need, but not use handles_real_patient_data = false as a combined override." Recorded as [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md).** +> +> **NOT YET ON `main` when this line was written.** It lands with the PR that carries this edit; a reader who needs it verified should check `main` rather than trust this banner, which is the builder's claim about its own branch. +> +> **WHAT LANDED.** `[security].handles_real_patient_data`, `[ai].data_class` and the `DataClass` enum are removed. `HopPosture` loses `is_phi`; `derived_posture()`/`require_posture()` return the production tier alone; `SecurityPosture` drops `data_class` + `synthetic_relaxation` and `AiPolicy` drops `data_class`. Both key spellings are REFUSED at load (`_REMOVED_KEYS`) with a message naming the per-gate switches, rather than ignored -- a config asserting the gates are off while the engine runs them all is a silent contradiction. CI's SQL Server load leg and the failover harness take per-gate relaxations; both are NARROWER than the declaration they replace. +> +> **THREE THINGS THIS ROW DID NOT KNOW, ALL MEASURED AT `0ce6d95cf` BEFORE THE CHANGE.** +> +> 1. **The lever was not the audited opt-out the docs claimed, and the test that should have caught that could not fire.** `docs/SECURITY-LOOSENING.md` said it was *"named by `security_loosenings()`, surfaced in `GET /security/posture`, and warned at `serve`"*. `security_loosenings()` spans 364 lines and contained ZERO occurrences of `handles_real_patient_data`, `data_class`, `DataClass` or `synthetic`. The serve-time loosening warning reads that registry, so it never fired for the widest relaxation shipped. `tests/test_security_posture_defaults.py` exempted the field with the reason *"the data-class lever has its own entry keyed on the derived posture"* -- there was no such entry, and the exemption was also UNREACHABLE, because the completeness loop skips any field whose default is not a `bool` and this one defaults `None`. Two lines of dead code carrying a false statement about a security control, inside the test that exists to prevent exactly that (SDS-3.7). +> 2. **The row's count of nineteen is right, and it was re-derived independently rather than trusted.** Sixteen branches in `_serve`, one lifespan check in `api/app.py`, two dispositions in `config/tls_policy.py`. Eight are hard refusals under the shipped `enforcement = enforce`. +> 3. **The row's own line anchors had drifted** (`settings.py:3730`/`:3738` for one field across two scorings). Every site here was re-located by symbol. The row's substantive claims all held. +> +> **THE STALE COMMENT THE ROW FLAGGED WAS STILL THERE AND IS FIXED** -- `settings.py` described the derivation as `dev` -> synthetic, contradicting `_KNOWN_ENV_POSTURE` since ADR 0148. Two further stale doc lines went with it: `SECURITY-LOOSENING.md` claimed declaring a box synthetic was the only way to silence a PHI cleartext hop (ADR 0153 had removed that arm), and `DEPLOYMENT.md` claimed the declaration was audited in `security_loosenings()`. +> +> **ONE RESIDUAL, FILED AND UNALLOCATED, and it is a real gap rather than tidy-up.** ADR 0153 left `api_phi_hop_disposition` and `forward_hop_disposition` keyed on the data label because neither cell is a connection and so neither can carry a per-hop `cleartext_accepted`. Removing the label resolves those carve-outs by subtraction and makes 0153's recorded follow-up load-bearing: under `enforce`, an unproven API serve hop and an unattested plaintext log-forwarding hop now have NO per-cell way to accept a risk. The `[security]`-level declaration for each is unbuilt. Name the subject, not a number -- it is unallocated. +> +> _Original filing follows._ **Re-scored 2026-08-20 -> P2.** Value **6/10** · Difficulty **6/10** · _big bet_. The opt-out still ships at settings.py:3738 and still translates to the enum at :4234-4235, while the comment at :3733-3735 continues to contradict :2318, which has said PHI since ADR 0148. Value is a secure-defaults simplification rather than a shipped-default defect, since only an explicit declaration loses the refusals; difficulty stays high on surface alone -- 77 data_class occurrences across the engine plus 45 in tests, and the api/models.py wire-contract change -- with no migration cost added per section 0. _(previously unscored.)_ > > **Filed 2026-08-16 - not started. THE PRODUCT LETS AN OPERATOR DECLARE THAT AN INSTANCE HOLDS ONLY SYNTHETIC DATA, AND THAT DECLARATION UNLOCKS NINETEEN START-UP RELAXATIONS.** The lever is `[security].handles_real_patient_data` ([`config/settings.py:3730`](../messagefoundry/config/settings.py)), translated to `[ai].data_class` at `settings.py:4226-4227`, over the `DataClass` enum at [`config/ai_policy.py:66-75`](../messagefoundry/config/ai_policy.py). **THE CHANGE: remove the distinction entirely and treat every instance as carrying patient data.** > **THIS REMOVES AN OPT-OUT; IT DOES NOT FLIP A DEFAULT -- state it that way or the item overstates itself.** `dev` **already** derives the patient-data posture: `_KNOWN_ENV_POSTURE["dev"] = (DataClass.PHI, False)` (`settings.py:2310`, [ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) GIVEN 1), and `serve` requires an environment (`settings.py:2348-2350`, enforced by `require_posture()` at `__main__.py:1191`). **A stock development box is treated as carrying patient data today.** The only configurations that are not are those that explicitly declared `handles_real_patient_data = false`. Those are what stop working. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cfdaf4d98..e8a213574 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -324,7 +324,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 (`check_mllp_tls_exposure`) raises a `WiringError` for a plaintext off-loopback listener at wiring time, before the engine starts. `serve --allow-insecure-bind` downgrades that to a warning **only on a non-enforcing or synthetic instance** — it is clamped inert whenever the hop is enforcing PHI, which is the shipped default on all three built-in env names, so on a stock instance the flag buys nothing. A single connection may override the interface with a per-connection `bind_address` (and restrict peers with a per-connection `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 (`check_mllp_tls_exposure`) raises a `WiringError` for a plaintext off-loopback listener at wiring time, before the engine starts. `serve --allow-insecure-bind` downgrades that to a warning **only on a non-enforcing instance** — it is clamped inert whenever the hop is enforcing, which is the shipped default on all three built-in env names, so on a stock instance the flag buys nothing. Declaring the instance synthetic was the other way past the clamp and it is gone ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)); the `[security].enforcement` dial is the only one left. A single connection may override the interface with a per-connection `bind_address` (and restrict peers with a per-connection `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. | @@ -652,7 +652,8 @@ document ([SECURITY-DOCS-POLICY.md](SECURITY-DOCS-POLICY.md)). ### `[ai]` — AI coding assistance policy Implemented (see [AI.md](AI.md)). Controls the IDE AI assistant across the **OFF→PHI-safe** range; the policy is centrally governed and **posture-clamped**. `mode`/`data_scope` plus the active -environment NAME + posture (`environment`/`data_class`/`production`) govern the policy; `provider`, +environment NAME + production tier (`environment` / `[security].production_instance`) govern the +policy — there is no data-class axis left to clamp against ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)); `provider`, `model`, `endpoint`, `api_key` and `allowed_endpoints` are **live** under `mode = managed_endpoint` — the engine broker ([ADR 0135](adr/0135-engine-brokered-ai-assistance-customer-managed-llm-egress-with-per-use-audit.md)). Only `baa_attested` is still a forward-compat placeholder (accepted-but-ignored). @@ -661,7 +662,7 @@ Only `baa_attested` is still a forward-compat placeholder (accepted-but-ignored) | `mode` | enum | `byo` | `off` · `byo` · `managed_endpoint` · `managed_claude` · `managed_claude_baa`. **`managed_endpoint` is built** — the engine brokers one `code_only` prompt to a customer-managed / self-hosted LLM over `POST /ai/chat`, audited per use (ADR 0135); it never reaches `phi` scope. `managed_claude`/`managed_claude_baa` are **future** — not serviceable by the current IDE | | `data_scope` | enum | `code_only` | `code_only` · `synthetic` · `deidentified` · `phi`, least→most sensitive; capped by `production` posture and by `mode` (only `managed_claude_baa` reaches `phi`) | | `environment` | str | — | free-form active-environment **name** (ADR 0017); selects `environments/.toml` + `current_environment()`. **Required** for `serve` (no default) | -| `data_class` | | | **→ moved to `[security].handles_real_patient_data`** (ADR 0118) — set it there; no longer accepted in `[ai]`. | +| `data_class` | | | **→ REMOVED** ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)) — every instance carries patient data, so there is no data class to set. It moved to `[security].handles_real_patient_data` under ADR 0118 and that key is retired too; both spellings are refused at load. Relax the individual gate you mean instead. | | `production` | | | **→ moved to `[security].production_instance`** (ADR 0118) — set it there; no longer accepted in `[ai]`. | | `provider` | str | `claude` | names the provider the broker addresses, and is recorded in the per-use audit. It does **NOT** select a request shape — the broker builds one wire shape unconditionally (the Anthropic Messages body), and nothing dispatches on this value. **Validated at config load (BACKLOG #95):** only a provider the engine can actually service is accepted, so an unserviceable name is refused up front rather than failing at request time | | `model` | str | `claude-opus-4-8` | the model the broker asks for; **read** under `mode = managed_endpoint` (also echoed on the reply) | @@ -689,7 +690,7 @@ Only `baa_attested` is still a forward-compat placeholder (accepted-but-ignored) | `forward_tls_ca_file` | str | — | PEM trust anchor for the collector's cert (**required** when `forward_protocol = "tls"` and verification is on). Only this CA is trusted — the public system bundle is **not** loaded, so an on-prem SIEM's private cert is anchored explicitly | | `forward_tls_verify` | bool | `true` | verify + hostname-check the collector's certificate. `false` is the documented **insecure** opt-out (`CERT_NONE`, no CA file needed) — lab / pinned-network only | | `forward_tls_client_cert` | str | — | optional PEM cert+key chain for **mutual** TLS to the collector | -| `forward_hop_attested` | bool | `false` | **acknowledged opt-out** for a plaintext / unverified-TLS collector hop (#200, ADR 0092 — the `[logging]` sibling of a connection's `tls_hop_attested`). A hop that is not verified TLS is now decided by the shared posture gradient: **refused** on an enforcing PHI instance, warned on a non-enforcing PHI instance, allowed for loopback / synthetic. Set this (with a reason) to affirm the hop is secure by other means — e.g. a dedicated out-of-band management VLAN | +| `forward_hop_attested` | bool | `false` | **acknowledged opt-out** for a plaintext / unverified-TLS collector hop (#200, ADR 0092 — the `[logging]` sibling of a connection's `tls_hop_attested`). A hop that is not verified TLS is now decided by the shared posture gradient: **refused** on an enforcing instance, warned on a non-enforcing one, allowed for a loopback collector. The synthetic arm is gone with the declaration that fed it ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)). Set this (with a reason) to affirm the hop is secure by other means — e.g. a dedicated out-of-band management VLAN | | `forward_hop_attested_reason` | str | — | why the hop is secure, recorded for the audit trail. **Mandatory when `forward_hop_attested = true`** (ADR 0153 retro-fitted the flag-implies-reason rule: an attestation that suppresses a refusal must record WHY, or it is worthless when audited) — the flag alone now fails at load. Rejected without the flag, and must be non-empty | | `require_time_sync` | bool | `false` | **opt-in** startup clock-sync gate (ASVS 16.2.2, ADR 0080): before listeners start, probe `ntp_peer` and warn on skew. Requires `ntp_peer`. Default = no-op | | `ntp_peer` | str | — | NTP/SNTP host to compare the local clock against (**required** when `require_time_sync`) | @@ -721,9 +722,9 @@ Only `baa_attested` is still a forward-compat placeholder (accepted-but-ignored) > PHI-redacted but still carries usernames, connection names, message ids, client addresses, and the > tamper-evident audit chain. `serve` therefore decides the forwarding hop with the **same** authority > the transports use, *before* the handler is installed: a hop that is not verified TLS is **refused** -> on an enforcing PHI instance, **warned** on a non-enforcing PHI one, and **allowed** for a loopback -> collector (so the "plaintext to `127.0.0.1` + a local agent" deployment is untouched) or a synthetic -> instance. To keep a plaintext off-box hop, either move to `forward_protocol = "tls"` or set +> on an enforcing instance, **warned** on a non-enforcing one, and **allowed** for a loopback +> collector (so the "plaintext to `127.0.0.1` + a local agent" deployment is untouched). There is no +> longer a synthetic arm to fall into. To keep a plaintext off-box hop, either move to `forward_protocol = "tls"` or set > `forward_hop_attested` with a reason — an acknowledged escape, not a silent default. ### `[retention]` @@ -980,9 +981,9 @@ transport's list is set, an outbound of that transport not on it is **refused at > instance has to use. (The two lists still *gate* their own transports normally once the instance > starts; they just do not count as "egress is restricted" for this gate.) > -> Under `enforcement = warn` the two refusals become warnings and start. A **synthetic** instance -> (`[security].handles_real_patient_data = false`) is exempt entirely — there, and only there, empty -> really does mean unrestricted. **Practical rule: enumerate the destinations you intend, per transport.** +> Under `enforcement = warn` the two refusals become warnings and start. **No instance is exempt** +> — the synthetic declaration that used to exempt one entirely was retired in [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md). +> **Practical rule: enumerate the destinations you intend, per transport.** | Key | Type | Default | Notes | |---|---|---|---| @@ -998,11 +999,11 @@ transport's list is set, an outbound of that transport not on it is **refused at | `proxy_no_proxy` | list | `[]` | the site-wide `NO_PROXY`-style **bypass list** inherited by a connection that sets no per-connection `proxy_no_proxy`. Each entry is a host, `.suffix`, `*.suffix` or `*`. Via env: comma-separated `MEFOR_EGRESS_PROXY_NO_PROXY` | | `deny_by_default` | | | **→ moved to `[security].block_unlisted_outbound`** (ADR 0118) — set it there; no longer accepted in `[egress]`. | -> **Fully-open egress on a PHI instance is a startup REFUSAL, not a warning** (see the table above). +> **Fully-open egress is a startup REFUSAL, not a warning** (see the table above). > With none of the six **counted** allowlists set (`allowed_smtp`/`allowed_direct` do not count — -> see the ⚠️ above), `serve` **exits 2** on any **PHI** instance — all three built-in env names, not +> see the ⚠️ above), `serve` **exits 2** on **every** instance — all three built-in env names, not > just `prod`/`staging` — under `[security].enforcement = enforce`, the default; it downgrades to a -> stderr warning only under `enforcement = warn`. A synthetic instance stays quiet. Lock it down with +> stderr warning only under `enforcement = warn`, which is now the only dial that moves it. Lock it down with > the per-transport lists above and/or **`[security].block_unlisted_outbound = true`** — note that key > lives in `[security]`; `[egress].deny_by_default` is a relocated key and is **rejected at config > load** (row below). @@ -1417,7 +1418,7 @@ DBA-delegated (#52): config-only, or skipped, per `config_only_on_server_db`. | `verify_after_backup` | bool | `true` | run the lightweight restore-verify after every backup (open + `integrity_check` + row-count). On by default — a backup nobody has opened is a backup that silently doesn't restore | | `full_restore_verify` | bool | `false` | the heavier verify: restore the snapshot to a throwaway temp DB and open it through the real `open_store` path. On-demand / opt-in extra, deliberately **not** the per-backup default | | `config_only_on_server_db` | bool | `true` | on a Postgres/SQL Server store the DB backup is DBA-delegated (#52), so back up the **config bundle only**. `false` = skip the backup entirely on a server-DB store (not even a config-only archive) | -| `allow_unencrypted` | bool | `false` | audited escape permitting a **cleartext** archive on a **no-key synthetic** instance (the parallel of `[security].allow_unencrypted_phi`). A **PHI** instance with no key still **refuses** to write an unencrypted archive regardless of this flag | +| `allow_unencrypted` | bool | `false` | audited escape permitting a **cleartext** archive on a **no-key** instance (the parallel of `[security].allow_unencrypted_phi`). Left `false`, a keyless instance **refuses** to write the archive rather than putting message bodies on disk in the clear. **This row used to say a PHI instance refuses regardless of the flag. That was never true of the code** — `BackupRunner` reads the key and this flag and nothing else, so on a keyless instance setting it would write a plaintext archive. Every instance carries patient data now ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)), so configure `MEFOR_STORE_ENCRYPTION_KEY` instead of reaching for this | ### `[dr]` — third-tier disaster-recovery standby A **right-sized DR box** that activates only when the whole HA pair / site is gone and then runs **only the @@ -1482,11 +1483,11 @@ HMAC on an HKDF-derived subkey (`mefor/audit-chain/v1`), which that actor cannot under `cipher_provider = "vault_transit"` the MAC is computed **inside** Transit instead (`audit_mac_key()` is `None` there **by design** — that is not the keyless case). -In practice a **PHI** instance cannot start keyless without deliberate acknowledgment (the keyless-PHI -refusal, `[security].allow_unencrypted_phi` + `…_under_strict_enforcement`), so a normally-configured PHI -deployment does get the keyed chain. The unkeyed chain is what a **synthetic** instance runs, and what an -**acknowledged keyless** PHI instance runs — check `[store].encryption_key` / `encryption_key_file` before -you record "tamper-evident audit log" in a risk register. +In practice an instance cannot start keyless without deliberate acknowledgment (the keyless-PHI +refusal, `[security].allow_unencrypted_phi` + `…_under_strict_enforcement`), so a normally-configured +deployment does get the keyed chain. The unkeyed chain is what an **acknowledged keyless** instance +runs — and since [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) that acknowledgment is the only route to it. Check `[store].encryption_key` / +`encryption_key_file` before you record "tamper-evident audit log" in a risk register. **And the walk does not catch a truncated tail.** `verify_audit_chain` detects modified or deleted **older** rows, but deleting the **newest** rows leaves a prefix that still chains cleanly, so a bare @@ -1588,7 +1589,7 @@ and a PHI weakening under **strict enforcement** (`enforcement = enforce`, the d | `allow_unencrypted_phi_under_strict_enforcement` | bool | `false` | the **second acknowledgment** required to start a PHI instance keyless under strict enforcement ([ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md)). Under `enforcement = enforce`, `allow_unencrypted_phi = true` on its own is **not** enough — `serve` still refuses to start (exit 2) unless this is also set, so the highest-risk posture (real PHI + strict enforcement) is never one flag away from plaintext at rest. Under `enforcement = warn` the single `allow_unencrypted_phi` flag still governs. With both set the instance starts with PHI bodies, summary/metadata and the error columns **unencrypted at rest**, and the startup AUDIT line names **both** flags. A **loosening** — `security_loosenings()` reports it, so it is never silent | | `allow_single_factor_admin_when_exposed` | bool | `false` | permit **single-factor admin on an exposed PHI instance** (ADR 0140). With `require_sign_in` on, `require_mfa` explicitly off, and the instance exposed — a **non-loopback bind**, **or** a declared TLS-terminating proxy (`[api].tls_terminated_upstream`) — a PHI instance under `enforcement = enforce` **refuses to start** (exit 2) — the Administrator role would authenticate with a single factor over the network. Setting this permits that start; it is recorded in a WARNING-level AUDIT line and the ordinary exposure warning still prints. A **loosening** — `security_loosenings()` reports it. **The exposure test does not consult the browser console** ([BACKLOG #326](archive/backlog/BACKLOG-CLOSED.md#326-mfa-at-exposure-refusal-reads-serve_ui-after-it-is-flipped-off); ADR 0140 amendment). It did, and that made the arm miss the topology this document recommends: a loopback bind behind a declared terminator with `serve_web_console` left at its default, where the ADR 0143 auto-degrade clears the console flag in place before the gate reads it. The exposed surface that authenticates with one factor is the **JSON operator API**, which the proxy serves whether or not `/ui` is mounted, so the predicate is the bind-and-proxy posture alone and the refusal fires on at least: an off-loopback bind; a declared proxy with the console left default-on; and a declared proxy with `serve_web_console = false`. **This refusal is the one exception to the "a new refusal fires only on a new opt-in" scoping rule** stated three rows below on `require_memory_encryption_declaration` — by owner ruling of 2026-08-04, recorded in the [ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment, which is the single source for why. Nothing new gates it. **One residual is deliberately left open:** an **undeclared** proxy — `web_console_public_address` set with no `tls_terminated_upstream` — does not count as exposed here, because nothing was declared, so exposure would be an *inference*, and an inference must not refuse. It **warns** instead, on its own dedicated arm: on a PHI instance with `require_mfa` explicitly off, startup prints that if that origin is served by an undeclared proxy the Administrator role is single-factor over the network and this refusal cannot see it. Do **not** read the ADR 0068 §8 undeclared-proxy warning as that control — it is about the `/ui` session cookie and HSTS, and it is suppressed entirely when the ADR 0143 auto-degrade clears the console flag, which the same `web_console_public_address` triggers. **Prefer `require_mfa = true` — and know its scope.** Under the shipped `require_mfa_scope = "every_local_account"` it requires a second factor from **every** account, *not* only Administrators and (since BACKLOG #1144) *not* only local ones, so a non-interactive bearer-token service account becomes MFA-pending and cannot enrol unattended. **There is one remedy.** Set `require_mfa_scope = "administrators"` (itself reported as a loosening, and it leaves every Administrator in scope) — see that row below. **Making it a directory (AD/Kerberos) principal is no longer the other one:** directory identities used to be out of scope under either value, their factor delegated to the directory, and BACKLOG #1144 retired that. **mTLS is not one either.** A `[api].tls_client_cert_identities` mapping does grant a cert-identity that never meets the MFA gate, but that plane is admitted on exactly **one** route (`GET /service/identity`, `require_service_cert`) and carries no session, so an account "moved to mTLS" can read back its own identity and nothing else — it cannot replay, purge, poll status, or do any work a service account exists for. The `[api].tls_client_cert_identities` row above is the authority on that reach. An AD-only deployment is therefore in scope for **all** of its accounts — its directory principals, its local bootstrap admin, and any local service accounts | | `allow_unverified_alert_smtp_tls` | bool | `false` | the **acknowledgment** required to start an enforcing PHI instance whose `[alerts]` SMTP hop does not authenticate the relay — i.e. `[alerts].email_use_tls = false` (cleartext) or `[alerts].email_tls_verify = false` (encrypted but accepts any certificate) ([#323](archive/backlog/BACKLOG-CLOSED.md#323-smtp-tls-is-unverified-on-all-three-send-paths)). Covers BOTH shapes deliberately: cleartext is strictly worse than unauthenticated TLS, so gating only the second would hand an operator a bypass onto the worse posture. Without it `serve` refuses to start (exit 2); with it the start is permitted and named in a WARNING-level `AUDIT:` line. An **acknowledgment switch rather than the clamped `MEFOR_ALLOW_INSECURE_TLS` escape** the connectors use, because this cell is constructed outside the `active_hop_posture` scope where that clamp would be inert. A **loosening** — `security_loosenings()` reports it, so it is never silent | -| `memory_encryption_operator_declared` | bool | `false` | **`[BUILT]` ([ADR 0152](adr/0152-in-use-data-protection-for-phi-platform-memory-encryption-attestation-asvs-11-7-1.md) rung 2, ASVS 11.7.1):** the operator's **declaration** that this host provides hardware memory encryption (AMD SEV-SNP / Intel TDX), so PHI is protected in RAM **while it is being processed**. The engine cannot verify it — a local CPU flag is emitted by the OS whose integrity the requirement protects against — so this records **who took responsibility**, the same discipline as `MEFOR_TLS_REVOCATION_ATTESTED`. It is deliberately **not** called "attested": in confidential computing that word means a CPU-signed quote verified against the silicon vendor's root PKI (ADR 0152 rung 3, **not built**). An **exposed** PHI instance without it **warns and starts** — on every environment, at both `enforcement` settings; it refuses only if `require_memory_encryption_declaration` is also set. A **positive platform read-out does not substitute for it** (a read-out must never relax a control). **Loopback and synthetic instances are byte-identical** (never consulted). If the platform read-out positively contradicts this, the contradiction is **warned at start and reported** as `memory_encryption_readout_contradicts_declaration` on `GET /security/posture` — but **never refused** (the read-out is a self-report, not evidence, and has known false negatives: driver not loaded, container without the device node mapped, Azure CVM paravisor). **Setting this does not make the instance ASVS 11.7.1-compliant** — see the read-out note below the table. Env: `MEFOR_SECURITY_MEMORY_ENCRYPTION_OPERATOR_DECLARED` | +| `memory_encryption_operator_declared` | bool | `false` | **`[BUILT]` ([ADR 0152](adr/0152-in-use-data-protection-for-phi-platform-memory-encryption-attestation-asvs-11-7-1.md) rung 2, ASVS 11.7.1):** the operator's **declaration** that this host provides hardware memory encryption (AMD SEV-SNP / Intel TDX), so PHI is protected in RAM **while it is being processed**. The engine cannot verify it — a local CPU flag is emitted by the OS whose integrity the requirement protects against — so this records **who took responsibility**, the same discipline as `MEFOR_TLS_REVOCATION_ATTESTED`. It is deliberately **not** called "attested": in confidential computing that word means a CPU-signed quote verified against the silicon vendor's root PKI (ADR 0152 rung 3, **not built**). An **exposed** PHI instance without it **warns and starts** — on every environment, at both `enforcement` settings; it refuses only if `require_memory_encryption_declaration` is also set. A **positive platform read-out does not substitute for it** (a read-out must never relax a control). **A loopback instance is byte-identical** (never consulted) — the arm keys on exposure alone, and the synthetic half of that pair went with [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md). If the platform read-out positively contradicts this, the contradiction is **warned at start and reported** as `memory_encryption_readout_contradicts_declaration` on `GET /security/posture` — but **never refused** (the read-out is a self-report, not evidence, and has known false negatives: driver not loaded, container without the device node mapped, Azure CVM paravisor). **Setting this does not make the instance ASVS 11.7.1-compliant** — see the read-out note below the table. Env: `MEFOR_SECURITY_MEMORY_ENCRYPTION_OPERATOR_DECLARED` | | `require_memory_encryption_declaration` | bool | `false` | **`[BUILT]` (ADR 0152 rung 2):** turn the row-12 warning above into a **refusal** — an **exposed** PHI instance with no `memory_encryption_operator_declared` then **refuses to start** under `enforcement=enforce` (and still warns under `warn`). **Opt-in by design, and the default is load-bearing:** the property is a **host** property that no operator can satisfy on Windows (the read-out is always `null` there), and "exposed" includes the recommended loopback-behind-proxy topology, so a refusal by default would stop working dev/staging/prod deployments from booting on upgrade over something they cannot change. Same scoping rule as `[security].allowed_client_networks`' companion refusal (ADR 0151): a new refusal fires only on a new opt-in. **One exception exists, and it is recorded:** the `allow_single_factor_admin_when_exposed` refusal three rows above was corrected under BACKLOG #326 and fires with no new opt-in gating it — see that row and the [ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment for the reasoning; do not generalise it. Set it in an estate that has standardized on confidential-computing hosts and wants a missing declaration to be fatal. Env: `MEFOR_SECURITY_REQUIRE_MEMORY_ENCRYPTION_DECLARATION` | | `organization_domains` | list[str] | `[]` | **`[BUILT]` (ASVS 3.7.3):** domains that count as **inside** your organization. The console interposes a "you are leaving this site" page, with a cancel, before any navigation to a destination **not** covered here. ASVS asks about destinations outside the application's **control**, and control is *organisational* rather than topological — your own AD FS is a different host, a different origin, and squarely yours — so this is a declared domain list, **not** a same-origin test. Matched on a **label boundary**: `hospital.example` covers `adfs.hospital.example` and **not** `evilhospital.example` (a bare suffix test would admit the lookalike, which is the failure that makes an interstitial worse than none). **Empty is the STRICT position, not the lax one:** with nothing declared, *every* absolute `http(s)` destination is treated as external and gets the page — including your own IdP. Declaring your domains here is the correct fix for that, **not** `external_link_allowlist`. Entries are bare domains: a URL, scheme or `*` wildcard is refused at config load, because all three look right and match nothing. Env: `MEFOR_SECURITY_ORGANIZATION_DOMAINS` | | `external_link_interstitial` | bool | `true` | **`[BUILT]` (ASVS 3.7.3):** show the "you are leaving this site" page at all. Setting it `false` means the console navigates off-site with **no notification and no cancel** — that is the control itself, so this is a posture decision rather than a convenience one, and `serve` prints a warning naming it at every start. The federated sign-in leg is affected: with the interstitial on and the IdP outside `organization_domains`, `GET /ui/oidc/start` renders the page and the flow is minted only on confirm (`POST`), which also closes the standing hole where any external page could begin a sign-in by linking to the start leg. Env: `MEFOR_SECURITY_EXTERNAL_LINK_INTERSTITIAL` | @@ -1602,7 +1603,7 @@ and a PHI weakening under **strict enforcement** (`enforcement = enforce`, the d | `delete_message_bodies_after_days` | int | `30` | bounded PHI-body retention; `0` = keep indefinitely (audited). **Leaving it unset does not apply 30 through the desugar** — the internal window stays `0`, and the `[retention]` startup gate then defaults it to 30 days on a PHI instance under **either** enforcement dial. This row used to say the gate refuses under `enforce` and auto-bounds only under `warn`; it does not — only an **explicit** `0` reaches the refusal. See the note under this table | | `allow_keeping_phi_indefinitely` | bool | `false` | audited escape: unbounded PHI retention | | `audit_all_authorization_decisions` | bool | `true` | ePHI access is **always** audited regardless of this switch; this adds full *authorization-decision* tracing on top. **On by default since BACKLOG #1277** (2026-09-02), which reversed the scoped `false` [ADR 0118](adr/0118-secure-by-default-security-configuration-section.md) §5 recorded on 2026-07-17: the flooding that default guarded against was attributed to console polling, and the console never reaches the gate. Setting it `false` narrows the trail to the state-changing surface, leaves every authenticated read unrecorded, and is reported as a **loosening**. Cost of `true`: one `auth.permission_granted` row per authenticated request on each `require()`-gated route, and **nothing prunes it** — `[retention].audit_days` is reserved and unenforced, so watch [`[retention]`](#retention) `max_db_mb`. "Always audited" is about **coverage**, not about how hard those rows are to alter afterwards: the audit chain is only cryptographically tamper-*evident* on a **keyed** store, and its verify does not catch a truncated tail — see [`[integrity]`](#integrity) | -| `handles_real_patient_data` | bool | *derived* | the master data-class lever (was `[ai].data_class = "phi"`). Unset ⇒ derived from the environment name — **all three built-in names (`dev`/`staging`/`prod`) now derive PHI** ([ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) GIVEN 1, so the default/CI path exercises the encryption/egress/retention controls rather than first meeting them in production); a genuinely-synthetic dev/CI box must set `false` **explicitly** (a loud, audited opt-out), and a custom-named env must declare it | +| `handles_real_patient_data` | | | **→ REMOVED** ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)) — **every instance carries patient data** and the PHI gates apply unconditionally. Setting it is refused at load, with a message naming the per-gate switch to reach for instead. It turned off nineteen start-up gates on one line, each of which already had its own named, audited, separately-reported switch: `allow_unencrypted_phi`, `block_unlisted_outbound`, `allow_keeping_phi_indefinitely`, `allow_single_factor_admin_when_exposed`, `allow_unverified_alert_smtp_tls`, `[alerts].security_notifications_required`, a per-connection `cleartext_accepted`, the process-wide `MEFOR_TLS_REVOCATION_ATTESTED`, or the `enforcement` dial below. (This row offered a per-connection `tls_revocation_attested` beside `cleartext_accepted`. There is no such lever: the field has no factory parameter and no `connections.toml` key, so nothing can author it, and [DEPLOYMENT.md](DEPLOYMENT.md)'s maintenance rule names it and forbids offering it as one.) | | `enforcement` | `enforce` \| `warn` | `enforce` | the serve-gate **refuse/warn dial** + the [ADR 0092](adr/0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md) escape-clamp key ([ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) GIVEN 2). `enforce` (default) **refuses** every PHI serve-gate violation and shuts every blunt escape-clamp — byte-identical to the former production-tier behaviour; `warn` logs + audits + continues and honours the escapes (a loud, audited loosening, named by `security_loosenings()`). **Decoupled from `production_instance`** (env `MEFOR_SECURITY_ENFORCEMENT`) | | `production_instance` | bool | *derived* | production-tier posture (was `[ai].production`). Derived from the environment name when unset (`prod` → yes; `dev`/`staging` → no). **Informational since [ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md)** — drives the AI data-scope ceiling, the DEBUG-log refusal, and reporting, **not** the serve-gate refuse/warn dial (that is `enforcement`) | @@ -1615,7 +1616,7 @@ and a PHI weakening under **strict enforcement** (`enforcement = enforce`, the d > | Row | Reads as | Internal field with `[security]` absent | What an unconfigured PHI instance actually does | > |---|---|---|---| > | `block_unlisted_outbound` | `true` | `egress.deny_by_default = False` | the [`[egress]`](#egress) gate decides: with none of the six **counted** `allowed_*` lists `serve` **exits 2** (`allowed_smtp`/`allowed_direct` do not count); with ≥1 counted list it **flips deny-by-default on** for the transports you left empty | -> | `delete_message_bodies_after_days` | `30` | `retention.messages_days = 0` | the [`[retention]`](#retention) gate defaults each *unset* window to 30 days on a PHI instance under **both** enforcement dials; an **explicit** `0` refuses to start (exit 2) under `enforce` and warns under `warn`; a synthetic instance keeps bodies forever. This cell previously had the refuse / auto-bound split backwards | +> | `delete_message_bodies_after_days` | `30` | `retention.messages_days = 0` | the [`[retention]`](#retention) gate defaults each *unset* window to 30 days on a PHI instance under **both** enforcement dials; an **explicit** `0` refuses to start (exit 2) under `enforce` and warns under `warn`. There is no synthetic instance left to exempt ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)); the audited keep-forever opt-out is `[security].allow_keeping_phi_indefinitely`. This cell previously had the refuse / auto-bound split backwards | > > Neither is a silent fail-open — both paths end in a refusal or an audited flip, and `serve` > back-fills the `[security]` object from the resolved internal values before serving, so @@ -1627,8 +1628,10 @@ and a PHI weakening under **strict enforcement** (`enforcement = enforce`, the d **Editing is IDE-only**: the VS Code extension's *Edit Security Settings* command (which shells `messagefoundry security show|set`) is the sole authoring surface. The **web console is read-only** — the -effective posture, active loosenings, and the synthetic-relaxation notice are surfaced at -`GET /security/posture` (authenticated, `monitoring:read`). Authentication & RBAC *plumbing* remains in +effective posture and the active loosenings are surfaced at +`GET /security/posture` (authenticated, `monitoring:read`). The `synthetic_relaxation` field went with +the declaration it described ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)); a relaxed control is a per-gate switch now and is named +individually in `loosenings`. Authentication & RBAC *plumbing* remains in **`[auth]`** (see [SECURITY.md](SECURITY.md)); the at-rest-encryption *key* is a secret supplied via `MEFOR_STORE_ENCRYPTION_KEY` / `[store].encryption_key_file` ([PHI.md](PHI.md#3-encryption-at-rest)). diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index 0841e55a3..60f6827d8 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -252,8 +252,8 @@ duplicate name (across **any** of these files) and an inbound that binds a route > factory parameter, no `connections.toml` key), so do not plan a per-hop revocation posture around it; > and routing egress through a revocation-checking proxy does **not** change the decision — the > authority has an input for it that no call site sets. Anything else is a *posture change* rather than -> a fix: `[security].handles_real_patient_data = false` silences the gate instance-wide and -> `[security].enforcement = warn` downgrades it to a WARN. +> a fix: `[security].enforcement = warn` downgrades it to a WARN. Nothing silences it instance-wide +> any more — the synthetic declaration that did was retired in [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md). > > **The engine's other verifying TLS hops are not gated at all** — the DICOM C-STORE SCU with > `tls=true`, FTPS, the `Database(...)` destination / `DatabasePoll(...)` source, the SQL Server store @@ -1371,7 +1371,9 @@ wrapping an HL7 payload) — **not** the full envelope. The transport builds the [ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md)) leaving it empty does **not** mean "unrestricted". With no `[egress]` allowlist at all, `serve` refuses to start; with any other list set, it flips `[security].block_unlisted_outbound` on and an empty `allowed_http` - then refuses *every* HTTP destination. Empty-means-unrestricted survives only on a synthetic instance. + then refuses *every* HTTP destination. Empty-means-unrestricted survives only where an operator + writes `[security].block_unlisted_outbound = false` — the explicit, audited opt-out. No instance can + declare its way out of the flip any more ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)), and the flip does not read `[security].enforcement` either. See [CONFIGURATION.md `[egress]`](CONFIGURATION.md#egress) for the full behaviour table. - **`ws_timestamp_ttl_seconds` must be ≥ the worst-case retry backoff.** The timestamp is re-stamped on each `send()`, but a held FIFO lane plus a short TTL can fail the peer's `Expires` check. @@ -1480,8 +1482,10 @@ config load/reload. On a **PHI** instance (every built-in env name by default, [ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md)) an **empty** `allowed_smtp` does **not** mean "unrestricted": with no counted `[egress]` allowlist `serve` refuses to start, and with one set it flips `[security].block_unlisted_outbound` on, so an empty -`allowed_smtp` refuses *every* SMTP destination. Empty-means-unrestricted survives only on a synthetic -instance — see [CONFIGURATION.md `[egress]`](CONFIGURATION.md#egress). (The key is +`allowed_smtp` refuses *every* SMTP destination. Empty-means-unrestricted survives only where an +operator writes `[security].block_unlisted_outbound = false` — the explicit, audited opt-out. No +instance can declare its way out of the flip any more ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)), and the flip does not read +`[security].enforcement` either — see [CONFIGURATION.md `[egress]`](CONFIGURATION.md#egress). (The key is `[security].block_unlisted_outbound`; `[egress].deny_by_default` moved there under ADR 0118 and is **rejected at config load**.) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 288463d09..3ed8966a3 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -37,11 +37,10 @@ DICOM C-STORE SCP, raw TCP/X12 — is refused off-loopback without TLS at wiring **The cleartext-bind escapes are clamped shut on the shipped posture** ([ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md), ADR 0092 decision 2). `serve --allow-insecure-bind` — and its config twin `[security].require_encryption_for_remote = false` — only warn-and-cross while the instance is **not** -enforcing-PHI. `[security].enforcement` defaults `enforce`, and all three built-in environment names -(`dev`, `staging`, `prod`) now derive `data_class = phi`, so a **stock instance refuses the cleartext +enforcing. `[security].enforcement` defaults `enforce`, so a **stock instance refuses the cleartext bind even with the flag**. Crossing it is a deliberate, recorded loosening: set -`[security].enforcement = warn`, or declare the box synthetic with -`[security].handles_real_patient_data = false`. Neither is a supported production setting. +`[security].enforcement = warn`. That is now the only way — the synthetic declaration that also did it +was retired in [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md), and it is not a supported production setting either. --- @@ -459,9 +458,9 @@ switch that turns the remaining fail-closed verification checks into best-effort **It is not the only way to weaken TLS, so do not audit for it alone.** Three further families of lever sit outside this variable entirely, and a config review that greps for `MEFOR_ALLOW_INSECURE_TLS` will miss all of them: per-connection **`cleartext_accepted` + `cleartext_reason`** (the sanctioned cleartext-hop -declaration — warned, audited and reported); **`[security].enforcement = warn`** or -**`handles_real_patient_data = false`** (instance-wide, and they downgrade or silence the gates -themselves); and per-connection +declaration — warned, audited and reported); **`[security].enforcement = warn`** (instance-wide, and it +downgrades the gates themselves — its retired companion `handles_real_patient_data = false` silenced +them outright and is now refused at load, [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)); and per-connection **[`tls_allow_expired`](#tls_allow_expired--the-weakening-with-no-posture-gate-at-all)**, which no environment variable or posture clamp covers at all — the loosening register **does** report it, so the posture read-out is where to audit it. @@ -557,9 +556,12 @@ and **refuses to start** under `[security].enforcement = enforce` (it warns at ` listen type is now exposed-gated. - **The clamp, precisely** ([ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md), ADR 0092 decision 2): all four inbound gates and the API gate honour `--allow-insecure-bind` only while - the instance is **not** (`enforcement = enforce` **and** PHI). Both halves are the default, so on a stock - instance the flag changes nothing — the recorded loosening is `[security].enforcement = warn` or - `[security].handles_real_patient_data = false`. These refusals also name `tls_hop_attested`, which the + the instance is **not enforcing**. That was a two-part test until + [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) + removed the PHI conjunct — `wiring_runner.py` reads `return not posture.enforcing` — and `enforce` is + the default, so on a stock instance the flag changes nothing. The recorded loosening is + `[security].enforcement = warn`. These + refusals also name `tls_hop_attested`, which the gates do read, but that field has no authoring surface on a connection today (see [the escape hatch](#the-mefor_allow_insecure_tls-escape-hatch)). - **Browser console (`/ui`)**: an off-loopback `/ui` additionally requires in-process TLS or a declared @@ -580,11 +582,13 @@ bind-guard ladder above: (`[api].tls_cert_file`). Loopback binds and proxy-terminated binds never reach this and start unchanged. - **Outbound** — **seven** verifying outbound TLS hops are **refused at construction** (`messagefoundry - check` / dry-run / reload / the serve pre-flight) on an instance that is **PHI *and* - `enforcement = enforce`**, when the hop is off-loopback. That list is the **whole gated set, not a + check` / dry-run / reload / the serve pre-flight) on an instance under + **`enforcement = enforce`**, when the hop is off-loopback. That list is the **whole gated set, not a sample**: **MLLP-over-TLS, REST, SOAP, FHIR, DICOMweb (https), SMTP/EMAIL, and the PostgreSQL store - hop** — the only cells that construct a `RevocationHopGuard`. A non-enforcing PHI instance **warns** - instead; a synthetic instance is unaffected (see *The ways across*, below). + hop** — the only cells that construct a `RevocationHopGuard`. A non-enforcing instance **warns** + instead, and that is the only dial left: declaring the instance synthetic used to exempt it and + [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) + removed that (see *The ways across*, below). **Every other verifying TLS hop the engine dials is ungated.** It validates the chain — and nothing asks it for an attestation, warns, or refuses. **Do not book revocation as an estate-wide engine @@ -633,12 +637,11 @@ attestation: and no `connections.toml` key, so it is unreachable from config today. The blanket env var is the only attestation you can actually set. Do not plan a per-hop revocation posture around it.) 3. **Stay on loopback**, which neither gate reaches. -4. **Declare the box synthetic** — `[security].handles_real_patient_data = false` **silences the - outbound gate entirely**: the disposition returns ALLOW before it ever reaches the refuse arm, on - every hop, with no per-hop record. This is the widest crossing on the list and the easiest to reach - for by accident (it is also a plausible way to quieten startup output), so treat a synthetic - declaration on a box that carries real feeds as a **revocation-control failure**, not a labelling nit. - It is named in `security_loosenings()` / `GET /security/posture` — audit it there. +4. **(Retired.)** `[security].handles_real_patient_data = false` used to sit here and **silenced the + outbound gate entirely** — ALLOW before the refuse arm, on every hop, with no per-hop record. It was + the widest crossing on this list, the easiest to reach for by accident, and this page claimed it was + audited in `security_loosenings()`, which it never was. [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) removed it; the key is refused at + load. Nothing on this list silences the gate without a per-hop record any more. 5. **`[security].enforcement = warn`** — downgrades every outbound revocation refusal to a **warning** and lets the hop proceed. Also a named loosening in the posture read-out. @@ -660,6 +663,6 @@ on it — **reported is not gated**, and the two must never be written as if eit and a field with no factory parameter and no `connections.toml` key (`tls_hop_attested`, `tls_revocation_attested`) must never be offered as an operator lever. Two more rules of thumb: state a control **with its default and its off-switch** (`require_sign_in`, -`enforcement`, `handles_real_patient_data`), and never describe `[egress]` as bounding a *transform* — +`enforcement`), and never describe `[egress]` as bounding a *transform* — it bounds declared **destinations**. Cross-referenced from `PHI.md` §4, `CLUSTERING.md`, and ADRs 0002 / 0078 / 0148 / 0153.* diff --git a/docs/EARLY-ADOPTER-GUIDE.md b/docs/EARLY-ADOPTER-GUIDE.md index d4c2a2fee..b97bbea05 100644 --- a/docs/EARLY-ADOPTER-GUIDE.md +++ b/docs/EARLY-ADOPTER-GUIDE.md @@ -250,19 +250,25 @@ 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 +> another environment's values/secrets. Built-in names `dev`/`staging`/`prod` carry a default tier; +> a custom name (e.g. `test`, `poc`) must declare `[security].production_instance`. There is nothing +> else to declare beside it — every instance carries patient data +> ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)), +> and both `[ai].data_class` and `[security].handles_real_patient_data` are refused at load. 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.** -> Since ADR 0148 (GIVEN 1) the built-in `dev` env derives the **PHI** data-class, so your first run exercises +> 🔑 **`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 take the audited keyless acks.** +> Since ADR 0148 (GIVEN 1) the built-in `dev` env carries patient data like every other, so your first run exercises > the same at-rest-encryption path production uses (rather than first meeting it in prod). `serve --env dev` > therefore **refuses to start (exit 2) without a store encryption key**. Two ways forward for a local run: > - **Recommended — mint a throwaway dev key** (exercises the real encryption path): run `messagefoundry > gen-key` and set the printed base64 value as `MEFOR_STORE_ENCRYPTION_KEY` (a dev key is fine; **never > commit it**). -> - **Genuinely no-PHI box** — declare it synthetic: set `[security].handles_real_patient_data = false` (a -> loud, audited opt-out) to run **key-free**, for a dev/CI box that only ever processes synthetic HL7. +> - **Run key-free anyway** — set `[security].allow_unencrypted_phi = true`, plus +> `allow_unencrypted_phi_under_strict_enforcement = true` under the shipped `enforcement = enforce` +> (ADR 0140: keyless PHI under strict enforcement is never one flag away). Both are audited at every +> start. There is no longer a way to declare a box synthetic and skip this — every instance carries +> patient data ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)). > > The refuse/warn severity of the PHI serve-gate ladder is the `[security].enforcement` dial (default > `enforce`, byte-identical to the former production behaviour). On a **loopback** dev bind you hit only the diff --git a/docs/INSTALL-GUIDE.md b/docs/INSTALL-GUIDE.md index dd54d05be..633dfbedb 100644 --- a/docs/INSTALL-GUIDE.md +++ b/docs/INSTALL-GUIDE.md @@ -209,10 +209,11 @@ 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 - `dev`/`staging`/`prod` derive a sensible default posture; **any custom name must state posture - explicitly** — the engine fails closed rather than guess. +- **Production tier, explicit and decoupled from the name:** `[security].production_instance` (`true` | + `false` — is this a production tier?). Built-in names `dev`/`staging`/`prod` derive a sensible + default; **any custom name must state it explicitly** — the engine fails closed rather than guess. + There is no second declaration: **every instance carries patient data** and the PHI gates apply + unconditionally ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)). Secrets and host-specific overrides come from the environment, e.g. `MEFOR_VALUE_` for values used by `env("…")` in the graph, and `MEFOR_
_` for service settings. Precedence is **CLI flag > @@ -282,7 +283,7 @@ instance.** You do **not** maintain per-environment branches. Each host differs ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ TEST host │ │ PROD host │ │ POC host │ │ env=test │ │ env=prod │ │ env=poc │ - │ data_class=… │ │ data_class=phi│ │ production=f │ + │ production=f │ │ production=t │ │ production=f │ │ MEFOR_* (test)│ │ MEFOR_* (prod)│ │ MEFOR_* (poc) │ └───────────────┘ └───────────────┘ └───────────────┘ engine 0.1.0 wheel engine 0.1.0 wheel engine 0.1.0 wheel (pinned, identical) diff --git a/docs/PHI.md b/docs/PHI.md index 42ec52646..f96632427 100644 --- a/docs/PHI.md +++ b/docs/PHI.md @@ -321,21 +321,21 @@ for defense-in-depth without swapping the `aiosqlite` connector. key, then drop the retired key. An undecryptable value (corrupt blob / missing key) is contained — the row is dead-lettered, never crashes a worker. **Fail-closed (secure-by-default; H3, OWASP *Fail Securely* / SDS §4.3 PW.9):** `serve` **refuses to - start with no key on ANY PHI instance** — the refusal is gated on the resolved **`[ai].data_class == - phi`**, *not* the environment label, so a custom-named dev/test box holding near-real PHI fails closed + start with no key on ANY instance** — the refusal is gated on **neither** a data class **nor** the + environment label, so a custom-named dev/test box holding near-real PHI fails closed exactly like `prod`/`staging` (closing the EF-3 perception gap where non-prod only warned). Since [ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) (GIVEN 1) **all three built-in envs (`dev`/`staging`/`prod`) derive PHI**, so the default/CI path is key-required too — a - **genuinely-synthetic** box (`data_class != phi`) stays **key-free** only when it declares - `[security].handles_real_patient_data = false` **explicitly** (a loud, audited opt-out — it is no longer - the `dev` default). Two further explicit overrides: `[store].require_encryption = true` forces the refusal - even for a synthetic instance; `[security].allow_unencrypted_phi = true` is the loud, **audited** opt-out that + key is required on **every** instance: [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) retired the synthetic declaration, so no + box can opt out of this gate as a class. Two further explicit overrides: + `[store].require_encryption = true` forces the refusal past the audited opt-out below; `[security].allow_unencrypted_phi = true` is the loud, **audited** opt-out that lets a PHI instance start keyless anyway (it still emits the UNENCRYPTED-at-rest warning, and `require_encryption` wins over it) — and under **strict enforcement** (`[security].enforcement = enforce`, the default) keyless PHI additionally requires the second ack `[security].allow_unencrypted_phi_under_strict_enforcement = true` ([ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) / ADR 0148). The effective posture (encryption on/off, key **source**, key **fingerprint**, - `data_class`, per-backend column coverage) is surfaced at the authenticated, `MONITORING_READ`-gated - **`GET /security/posture`** route (M5) — never key bytes; every access is audited. + per-backend column coverage) is surfaced at the authenticated, `MONITORING_READ`-gated + **`GET /security/posture`** route (M5) — never key bytes; every access is audited. The view carried a + `data_class` field until ADR 0186 removed it with the declaration behind it. 3. **Pluggable key sourcing — the KeyProvider seam `[BUILT]` (ASVS 13.3.3; ADR 0019 amended 2026-06-18, PR #377).** Where the DEK *comes from* is now routed through a pluggable **KeyProvider** seam ([store/keyprovider.py](../messagefoundry/store/keyprovider.py)) selected by the `[store].key_provider` @@ -1012,18 +1012,22 @@ emits a single record. A hop counts as secure **only** when it is TLS with verif else goes to the gradient: **loopback collector → ALLOW** (the "point `udp`/`tcp` at `127.0.0.1` and let a local rsyslog/Vector/SIEM agent add TLS" deployment is preserved byte-identically), **`forward_hop_attested` → ALLOW** (with a mandatory non-empty `forward_hop_attested_reason` — the `[logging]` sibling of a -connection's `tls_hop_attested`), **synthetic instance → ALLOW**, **clamped global escape → WARN**, -**enforcing PHI instance → REFUSE (`serve` exits 2)**, **non-enforcing PHI → WARN**. The three named +connection's `tls_hop_attested`), **clamped global escape → WARN**, +**enforcing instance → REFUSE (`serve` exits 2)**, **non-enforcing → WARN**. The three named remedies are therefore: native TLS, a loopback agent, or an attested hop. -> **Why this cell still reads the data label, when the transport cells no longer do.** +> **Why this cell has no way to accept the risk, when a connection does.** > [ADR 0153](adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md) removed -> the `synthetic → ALLOW` arm from the shared cleartext-hop authority, and its *Explicitly out of scope* -> table keeps this forwarder on the old keying **deliberately**: it is not a connection, so it has -> nowhere to carry a per-hop `cleartext_accepted` declaration, and refusing it instead would create a -> deviation the loosening registry cannot express. The arm is therefore restated explicitly inside -> `forward_hop_disposition` rather than inherited from the authority. A `[logging]` sibling of -> `cleartext_accepted` is the recorded follow-up. +> the `synthetic → ALLOW` arm from the shared cleartext-hop authority and kept it here, on its +> *Explicitly out of scope* table, **deliberately**: this forwarder is not a connection, so it has +> nowhere to carry a per-hop `cleartext_accepted` declaration, and refusing it outright would create a +> deviation the loosening registry cannot express. +> [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) +> then removed the data label itself, so that restated arm had no instance left to fire on and is gone +> from `forward_hop_disposition`. 0153's scope reasoning stands, and it now describes a **gap rather +> than an escape**: a `[logging]` sibling of `cleartext_accepted` is the recorded follow-up and the +> only way this cell could ever express an acceptance. Until it exists, the three remedies above are +> the whole list. **Availability.** The forwarder never blocks the engine *indefinitely* — UDP is fire-and-forget; a `tcp` **or `tls`** collector that is **unreachable at startup** (or whose certificate fails to verify — @@ -1057,7 +1061,7 @@ with materially different PHI profiles, so they get their own rows; stream 4 is | **1. General application log** | operational events, worker/connection lifecycle, exception **types**, warnings, every alert that the `LoggingAlertSink` fallback implements when no `[alerts]` transport is configured (see row 13 for the two it does not) | single-line text (`[logging].format = "text"`, the default) or one JSON object per line (`"json"`); UTC `Z` timestamps in both | stdout by default — under NSSM the supervisor captures stdout/stderr to `\logs\service.out.log` / `service.err.log`; **plus** the opt-in engine-owned `[logging].file` when configured (#122, ADR 0162), which carries the identical filter chain and whose write failures roll it aside and, on a second failure, stop this process's connections | day-to-day operations, incident triage, and the source of the support-bundle tail in row 14 | at rest: the NSSM installer creates `\logs` and locks the whole DataDir with `icacls /inheritance:r` to SYSTEM + Administrators + the service account (best-effort — a failure warns, never aborts). Over the API: `GET /logs/tail` requires the dedicated **`logs:view`** permission **and** `require_phi_read`, and every served page writes a `logs_view` audit row (line **count** only, never content) | NSSM rotates by **size** (`AppRotateBytes` 10 MB) and never deletes by age; age deletion is `[retention].app_log_days` over `[logging].log_dir` (`.log`/`.txt`, by mtime, **content never read**), optionally preceded by in-place gzip on `[retention].app_log_compress_days` (integrity-validated before the original is removed; the archive keeps the source's mtime, so the same delete window ages it out). Both default 0 = keep forever, uncompressed | **Can contain PHI.** The engine's own permission catalog classifies this as a PHI read surface (`logs:view`: "best-effort redaction, residual single-token PHI possible"). Defence: never-log-bodies rule, `safe_exc()` at the source, the three handler filters, python-hl7 loggers silenced. **Residual:** a single-token identifier is not scrubbed | | **2. `uvicorn` request/access log** (sub-stream of 1) | one line per HTTP request — method, **full request line including the query string**, status, timing | inherits stream 1's format | inherits stream 1's sink | request tracing, latency and error triage | inherits stream 1's | inherits stream 1's | **Can contain PHI.** `configure_logging` clears uvicorn's own handlers and propagates to the root, so the four filters apply; `serve` passes `log_config=None` and never disables `access_log`, so at the default `INFO` level every request is logged. OIDC `code`/`state` **are** scrubbed. **Not** scrubbed: a `?content=…` / `?field_value=…` that arrives anyway — no route has declared either since BACKLOG #1184, but the access line is built from the raw `query_string` rather than from what a route binds (the single-token residual above) | | **3. `messagefoundry.audit` off-box tee** (sub-stream of 1) | one JSON object per **committed** `audit_log` row: `event`/`ts`/`action`/`actor`/`channel_id`/`client`/`detail`, plus the `row_id`/`row_hash` anchor pair (BACKLOG #1198) that lets a collector tie the copy back to the row it came from and detect a gap in the chain | JSON | emitted after the row is durably committed and **outside** the store write lock; rides stream 1's handlers | shipping audit evidence to a SIEM so it survives a host compromise | inherits stream 1's | inherits stream 1's | `detail` is passed through the `safe_text` PHI chokepoint **before** it leaves the process; `client` is forwarded verbatim as a discrete field so a SIEM can index it. The `row_hash` anchor carries no PHI directly, but on a **keyless** store it is a plain SHA-256 whose other preimage members travel in the same record or the one before it, so a reader of the forwarded stream could test offline guesses at a span `safe_text` cut; it is a keyed MAC once the store cipher is active. Best-effort: a logging failure is caught, never raised into the audit write. **Pinned to `INFO`** — it is emitted even at `[logging].level = WARNING` | -| **4. Off-box syslog/SIEM forwarder** — the shared **transport** for 1–3 | a copy of every record from 1–3 | `forward_format`, default **JSON** (independent of the stdout format) | the operator's collector (`forward_host`/`_port`) | off-box evidence retention / SIEM correlation | **default-on when a collector is named.** Transport: `udp` (default) / `tcp` / **`tls`** (RFC 5425, CA-anchored, verified by default). `serve` gates the hop on the shared posture gradient before the handler is installed: verified TLS ungated; otherwise loopback / attested / synthetic ALLOW, non-enforcing PHI WARN, **enforcing PHI REFUSE (exit 2)** | the collector's, not the engine's | the identical four filters are installed on this handler, so the forwarded copy is PHI-redacted and credential-scrubbed — but it still carries usernames, connection names, message ids, client addresses and the audit chain. That is the engine's own stated reason for gating the hop | +| **4. Off-box syslog/SIEM forwarder** — the shared **transport** for 1–3 | a copy of every record from 1–3 | `forward_format`, default **JSON** (independent of the stdout format) | the operator's collector (`forward_host`/`_port`) | off-box evidence retention / SIEM correlation | **default-on when a collector is named.** Transport: `udp` (default) / `tcp` / **`tls`** (RFC 5425, CA-anchored, verified by default). `serve` gates the hop on the shared posture gradient before the handler is installed: verified TLS ungated; otherwise loopback / attested ALLOW, non-enforcing WARN, **enforcing REFUSE (exit 2)** — there is no synthetic arm, and [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) left this cell with no per-hop way to accept the risk at all | the collector's, not the engine's | the identical four filters are installed on this handler, so the forwarded copy is PHI-redacted and credential-scrubbed — but it still carries usernames, connection names, message ids, client addresses and the audit chain. That is the engine's own stated reason for gating the hop | | **5. `audit_log` table** (SQLite, Postgres, SQL Server) | who / what / **where-from** / when of auth + PHI *access* and admin actions — plus, while `[security].audit_all_authorization_decisions` is on (**default `true`** since BACKLOG #1277, 2026-09-02, which reversed the ADR 0118 §5 `false`; the internal field it desugars to is `audit_all_authz`, whose old `[diagnostics]` TOML spelling is **refused at load** — ADR 0118), an `authz` row for **every** authorization decision including successes. That is the shipped volume of this stream, not an opt-in addition to it: one row per authenticated request on each `require()`-gated route, and this row's retention cell records that **nothing prunes the table** — `actor`, `action`, `channel_id`, `client`, `detail`, `row_hash` | JSON `detail`; **tamper-evident hash chain** over `prev_hash` + the row (the `client` address is **inside** the chained payload — ADR 0150) | the store database | HIPAA §164.312(b) audit controls; incident response; `verify_audit_chain` integrity checks | `GET /audit` requires **`audit:read`**; `GET /audit/export` requires the separate **`audit:export`** and streams CSV with formula-injection neutralisation, recording its own `audit.export` row *before* streaming; `GET /me/security-events` is a per-user view of the same table | **`[retention].audit_days` is reserved and NOT enforced — keep-forever by design** (the audit-retention requirement, ~6 years — **not** chain-breakage; [§8](#8-retention--purge) states the position and cites its source of record) | `detail` is stored **in the clear** (it is not a cipher-covered column): its protection is that writers only ever store filter shapes, counts and ids — never bodies or credentials — plus the store ACL and the volume layer | | **6. `message_events` table** | the per-message disposition timeline — the **complete** vocabulary is `received`, `routed`, `unrouted`, `filtered`, `transformed`, `delivered`, `failed`, `dead`, `error`, `replayed`, `resent`, `reingressed`, `passthrough`, `passthrough_dropped`, `cancelled`, `edit_resend`, `edit_resubmit`, `viewed`, `not_deployed`, and the ADR 0154 synchronous-reply pair `reply_returned` / `reply_timeout` (names, counts and `waited_ms` only — **never** a fragment of the partner's reply body) (CI asserts this list against the engine's own `MESSAGE_EVENT_KINDS`). `[diagnostics].message_events` can thin the set, but never below the compliance floor `viewed` / `dead` / `error` / `failed` / `not_deployed` / `reply_timeout` | rows: `message_id`, `ts`, `event`, `destination`, `detail` | the store database | operator timeline on the message-detail view; the `viewed` row is the HIPAA PHI-access record | `GET /messages/{id}` under **`messages:view_raw`** + `require_phi_read`; the read itself writes a `viewed` event **and** a `message_view` audit row | no dedicated window — `purge_message_bodies` sets `message_events.detail` to `NULL` in the same transaction that blanks the body, so it inherits `[retention].messages_days` | `detail` is `safe_text()`-scrubbed **then** cipher-encrypted (AAD `("message_events","detail",message_id,ts,event)`). Verbosity gate `[diagnostics].message_events` = `all` (default) / `errors` / `off`, with a **compliance floor that can never be thinned**: `viewed`, `dead`, `error`, `failed`, `not_deployed`, `reply_timeout` are retained at every level (`reply_timeout` is the one row that explains a "we called you and got a 504" complaint, so an instance that thinned its logs would lose exactly the record it is later asked for) | | **7. `connection_event` table — DEFAULT ON** (`[diagnostics].connection_events = true`) | transport/lifecycle events per connection: `established`, `closed` (reason `eof` or `idle_timeout` — no path produces any other), `idle_timeout`, `at_capacity`, `peer_not_allowlisted`, `frame_oversize`, `framing_error`, `peer_reset`, the inbound-HTTP intake-auth refusals `intake_auth_failed` / `auth_subject_denied` / `auth_rate_limited` (ADR 0154 D6 — peer address and mode only; **never** the credential, a prefix of it, or its length. Each of these also writes a tamper-evident audit-log row — the copy that survives an operator turning this diagnostics stream off), plus the runner's `connection_lost` / `connection_restored`. That is the whole vocabulary, asserted in CI against the literal emit call sites in `transports/` and the pipeline runner **and** cross-checked against the console's own filter tuple. The MLLP, raw-TCP and HTTP listeners emit these; the **DICOM inbound C-STORE SCP** and the **`ISA`/`IEA`-framed X12 inbound** emit none — the runner injects the sink onto **every** source (`wiring_runner.py`, over the base-class `on_connection_event` field), so both connectors *have* the wiring and simply never call it — so this stream covers those three listeners plus the runner's outbound-lane transitions — not literally every connection. An X12 feed's connects, allow-list refusals and at-capacity refusals are therefore **absent** from this stream | rows: `ts`, `connection`, `transport`, `direction`, `kind`, `peer_host`, `message_id` (correlation hint), `reason` | the store database, **all three backends** | Corepoint-style transport diagnostics — "did the sender connect, and why did it drop" | `GET /events` and `GET /connections/{name}/events` under **`monitoring:read`** (**not** a PHI permission) with per-channel RBAC — an out-of-scope `connection=` is 403'd *and* audited — server-clamped to ≤1000 rows | `[retention].connection_event_retention_hours` (its own **hours** window); 0 inherits `[retention].messages_days`; both 0 = keep forever. Plain age `DELETE` (metadata-only) | **`reason` is free text that can carry sensitive fragments.** Defended twice — `safe_exc()` at the source, `safe_text(reason)[:200]` at the store — then cipher-encrypted (AAD `("connection_event","reason",connection,ts,kind)`). Every other column is config metadata; the table is documented **metadata-only** — never a frame, body or HL7 field value. Writes are a pure side observer: a bounded in-memory queue drained by a background task outside any handoff transaction, so a flood can never block a listener or pin a message disposition | diff --git a/docs/SECURITY-LOOSENING.md b/docs/SECURITY-LOOSENING.md index 8312d7257..f6f5e9f32 100644 --- a/docs/SECURITY-LOOSENING.md +++ b/docs/SECURITY-LOOSENING.md @@ -61,8 +61,7 @@ section reference. | | `allow_keeping_phi_indefinitely` | `false` | | | `audit_all_authorization_decisions` | `true` (see note) | | Enforcement dial | `enforcement` | `enforce` (refuse; `warn` = loud audited loosening) | -| Posture lever | `handles_real_patient_data` | *derived from environment* | -| | `production_instance` | *derived from environment* | +| Production tier | `production_instance` | *derived from environment* | | Outside `[security]` | `[store].aad_bind` | `true` (at-rest values bound to their cell) | | | `[auth].ad_session_recheck_seconds` | `300` s (*conditional* — a loosening only once `ad_enabled`) | | | `[secret_rotation].enforce_store_key_expiry` | `true` (a calendar-overdue store DEK refuses to start) | @@ -96,11 +95,18 @@ escape-clamp key, defaulting to `enforce` (byte-identical to the former producti **decoupled** from `production_instance` — a PHI *staging* box is now strict by default too. `enforcement` gates every "still refused" clause below; `enforcement = warn` downgrades them all to loud, audited warnings. -`handles_real_patient_data` / `production_instance` default to the value **derived from the active -environment name** (ADR 0148 GIVEN 1: **`dev` → PHI/non-prod**, `staging` → PHI/non-prod, `prod` → PHI/prod); -a custom-named environment must declare them or `serve` fails closed. `handles_real_patient_data` is the -*master data-class lever* — the PHI-only gates below key on it — and now defaults to PHI on every built-in -env (a genuinely-synthetic box must set it `false` explicitly; see its deviation below). +`production_instance` defaults to the value **derived from the active environment name** (`dev` → +non-prod, `staging` → non-prod, `prod` → prod); a custom-named environment must declare it or `serve` +fails closed. It is the production **tier** — it drives the AI data-scope ceiling and the DEBUG-log +refusal, not the serve-gate dial. + +**There is no data-class lever on this page any more.** `handles_real_patient_data` sat beside it and +was retired in [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md): **every instance carries patient data**, and the PHI gates apply +unconditionally. It is refused at load. Each gate it used to relax now has to be reached by its own +switch, which is the point — the retired lever reached all nineteen without naming any of them, and +`security_loosenings()` never named it either, so the serve-time warning that fires for every +deviation on this page did not fire for the widest one the product shipped. **Not all nineteen have a +heading below**; the retired lever's own section names the two that do not, and where to reach them. `audit_all_authorization_decisions` **changed sides on 2026-09-02** (BACKLOG #1277). It used to default `false` and this page called that "a deliberate secure-and-usable default, not a loosening", on the @@ -332,25 +338,32 @@ the call to the Console on 2026-09-02; the Console decided ([ADR 0118](adr/0118- **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 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)). - -### `handles_real_patient_data = false` — declare a genuinely-synthetic (no-ePHI) instance -- **What you lose (nothing — it is an honest scope declaration):** the instance asserts it carries **no real - patient data**, so the ePHI-specific gates (at-rest-encryption requirement, deny-by-default egress, bounded - PHI retention, the PHI transport-hop refusals) relax to their synthetic posture — a no-op on data that is - not PHI. Since [ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) GIVEN 1 - the built-in `dev` / `staging` / `prod` envs all derive **PHI**, so a genuinely-throwaway CI / dev box must - set this **explicitly** — it is no longer the `dev` default. -- **When acceptable:** a CI runner, a local dev box, or a demo that only ever processes synthetic / sample - HL7. **Never** on an instance that touches real patient data — a false declaration silently disables the - ePHI safeguards. -- **Compensating controls:** it is a **loud, audited opt-out** — named by `security_loosenings()`, surfaced in - `GET /security/posture`, and warned at `serve`. Keep it out of any config a PHI instance could inherit. -- **Still refused:** `[store].require_encryption = true` still forces a key even on synthetic; and this is a - **data-class** declaration, **orthogonal to `enforcement`** — it does not lower the AI data-scope ceiling or - re-enable DEBUG-with-PHI logging (both keyed on the retained `production` tier fact, not on `data_class`). + unconditional at **any** enforcement level — `enforcement = warn` does **not** open it — and the unconditional ePHI audit floor is untouched. `enforcement` is **binary** (no `off`), and **nothing silences a + cleartext hop entirely any more**: [ADR 0153](adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md) + removed the data label from that decision and [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) removed the label itself. The + per-connection `cleartext_accepted` declaration is the way to cross one, recorded per hop. + +### `handles_real_patient_data = false` — RETIRED, and refused at load +This section is kept rather than deleted, because the claim it used to make is the reason the lever went. +- **It said:** *"it is a loud, audited opt-out — named by `security_loosenings()`, surfaced in + `GET /security/posture`, and warned at `serve`"*. **Measured, the first and third were false.** + `security_loosenings()` contained no reference to it, and the serve-time loosening warning reads that + registry — so the widest relaxation the product shipped produced no warning line. The posture view did + carry it, in a separate field the console rendered one style-class quieter than a real loosening. +- **What replaced it:** nothing, deliberately. Relax the one you mean — `allow_unencrypted_phi`, + `block_unlisted_outbound`, `allow_keeping_phi_indefinitely`, + `allow_single_factor_admin_when_exposed`, `allow_unverified_alert_smtp_tls`, a per-connection + `cleartext_accepted`, or the `enforcement` dial. **That list is at least, not every:** the retired + lever reached nineteen gates and this page does not carry a heading for each of them. Two it reached + are named here because they have no heading of their own — + `[alerts].security_notifications_required` accepts the pull-only security-event feed instead of a + configured channel, and revocation is attested **process-wide** with the environment variable + `MEFOR_TLS_REVOCATION_ATTESTED`. **There is no per-connection revocation lever.** + `tls_revocation_attested` exists on the outbound model and the connectors read it, but it has no + factory parameter and no `connections.toml` key, so nothing can author it — and + [DEPLOYMENT.md](DEPLOYMENT.md)'s own maintenance rule names that field and forbids offering it as an + operator lever. This page offered it until [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) prompted a re-read. +- **Setting it now fails the start**, with a message naming those switches. See [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md). ### `[store].aad_bind = false` — at-rest values are no longer bound to their cell - **What you lose:** the per-value GCM tag stops covering the `(table, column, row)` cell the value lives @@ -589,7 +602,7 @@ carried from that drive-to-pass, not re-derived here.** | `block_unlisted_outbound` | V14 Data Protection | **AC-4** Information Flow Enforcement · **SC-7(5)** Deny by Default — Allow by Exception | §164.312(e)(1) Transmission Security | | `delete_message_bodies_after_days`, `allow_keeping_phi_indefinitely` | V14 Data Protection | **SI-12** Information Management and Retention | §164.316(b)(2) documentation retention · data-minimization (§164.502(b)) | | `audit_all_authorization_decisions` | V16 Security Logging and Error Handling | **AU-2** Event Logging · **AU-3** Content of Audit Records | §164.312(b) Audit Controls | -| `handles_real_patient_data`, `production_instance` (posture lever) | V13 Configuration (risk-based) | **RA-2** Security Categorization · **AC-6** Least Privilege (risk-based tailoring) | §164.308(a)(1) Risk Analysis / Management | +| `production_instance` (production tier) | V13 Configuration (risk-based) | **RA-2** Security Categorization | §164.308(a)(1) Risk Analysis / Management | | `enforcement` (refuse/warn dial) | V13 Configuration (secure defaults) | **CM-6** Configuration Settings · **CM-7** Least Functionality (secure-by-default) | §164.308(a)(1) Risk Analysis / Management | | `[store].aad_bind` (at-rest cell binding) | V11 Cryptography | **SC-28(1)** Cryptographic Protection · **SI-7** Software, Firmware, and Information Integrity | §164.312(c)(1) Integrity · §164.312(a)(2)(iv) Encryption and Decryption | | `[auth].ad_session_recheck_seconds` (directory revocation propagation) | V7 Session Management · V6 Authentication | **AC-2(3)** Disable Accounts · **AC-12** Session Termination | §164.312(a)(2)(i) Unique User Identification · §164.308(a)(3)(ii)(C) Termination Procedures | @@ -598,11 +611,11 @@ carried from that drive-to-pass, not re-derived here.** | generic-ODBC `DATABASE` TLS unenforced (per-connection, driver-owned) | V12 Secure Communication | **SC-8** Transmission Confidentiality and Integrity · **SC-8(1)** Cryptographic Protection | §164.312(e)(1) Transmission Security · §164.312(e)(2)(ii) Encryption | | `store_principal_over_granted` / `store_principal_privileges_unobserved` (observed store-principal privilege) | V13 Configuration (backend component accounts, 13.2.2) | **AC-6(5)** Privileged Accounts · **AC-6(9)** Log Use of Privileged Functions · **CM-7(5)** Authorized Software / least functionality | §164.312(a)(1) Access Control · §164.308(a)(4) Information Access Management | -> The synthetic-vs-PHI relaxation (a synthetic instance keeps the PHI-only gates relaxed) is **risk-based -> tailoring** keyed on `handles_real_patient_data`: an instance carrying no ePHI is out of scope for the -> ePHI-specific safeguards, which 800-53r5 supports via security categorization (RA-2) and the least- -> privilege / need-to-apply principle (AC-6). The posture view **states** the relaxation so it is never -> silent (ADR 0118 AC-6). +> **There is no longer a synthetic-vs-PHI split to crosswalk.** It was risk-based tailoring keyed on +> `handles_real_patient_data` — an instance carrying no ePHI being out of scope for the ePHI-specific +> safeguards, which 800-53r5 supports via RA-2 and AC-6. The tailoring was sound in principle; what did +> not hold was the control that made it visible. [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) removed the declaration, so every instance +> is categorized as carrying ePHI and each safeguard is relaxed individually or not at all. ### Sources diff --git a/docs/SERVICE.md b/docs/SERVICE.md index 64a695733..7b19888e2 100644 --- a/docs/SERVICE.md +++ b/docs/SERVICE.md @@ -49,15 +49,19 @@ venv interpreter) and the per-connection firewall openings the service needs. ``` `-Environment` is **required** (ADR 0017): it selects which `environments/.toml` value file the -engine resolves and the instance's PHI posture. `serve` refuses to start without it (no silent +engine resolves and the instance's production tier. `serve` refuses to start without it (no silent default), so the install script refuses too — pass `dev`, `staging`, `prod`, or a custom name. -**A custom name must also declare its posture** — `[security].handles_real_patient_data` and -`[security].production_instance` in the service config (`messagefoundry.toml`) — because a free-form -environment name carries no PHI posture to derive one from (only `dev`/`staging`/`prod` do). Without -both, `serve` exits 2 with *"environment '\' has no built-in security posture"*, so the service -registers fine and then dies on every start. (The pre-ADR-0118 spellings `[ai].data_class` / -`[ai].production` are **rejected at config load** — see -[ADR 0118](adr/0118-secure-by-default-security-configuration-section.md).) +**A custom name must also declare its production tier** — `[security].production_instance` in the +service config (`messagefoundry.toml`) — because a free-form environment name carries no tier to +derive one from (only `dev`/`staging`/`prod` do). There is no second declaration to make: every +instance carries patient data ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)). Without it, `serve` exits 2 with *"environment '\' has no built-in security posture"*, so the service +registers fine and then dies on every start. (Both `[ai]` spellings are **rejected at config load**, +and they are rejected differently — the difference is the remedy. `[ai].production` was **relocated** +by [ADR 0118](adr/0118-secure-by-default-security-configuration-section.md), so its refusal carries a +forwarding address: set `[security].production_instance` instead. `[ai].data_class` was **removed** by +[ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) +together with `[security].handles_real_patient_data`, so there is nowhere to forward it and the +refusal names the per-gate switches instead. Delete that line.) `messagefoundry service install` requires the same name via `--env` and passes it straight through. Defaults: diff --git a/docs/Secure_Build_Scorecard_MEFOR.md b/docs/Secure_Build_Scorecard_MEFOR.md index 298ccab9c..db98562ce 100644 --- a/docs/Secure_Build_Scorecard_MEFOR.md +++ b/docs/Secure_Build_Scorecard_MEFOR.md @@ -16,7 +16,7 @@ > **How to read this.** This scorecard grades control **presence + evidence honesty** at HEAD, never runtime efficacy or an external challenge (none has run; see §5). Where this pass found a signal's asserted evidence overstated, the row says so. The rubric's anti-metric rule applies: no release is gated on any single row — only the composite is (see [Secure Build Standards §4.1](Secure_Build_Standards.md#41-the-anti-metric-rule-hard)). -> **⏳ ADR 0148 — pending owner re-sign (2026-07-21).** [ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) landed in code (branch `sec-enforce`): the default env `dev` now derives **PHI** (GIVEN 1), and an explicit `[security].enforcement` dial (`enforce` default | `warn`) replaces the `production` tier as the serve-gate refuse/warn key (GIVEN 2). **Signal #6's `_KNOWN_ENV_POSTURE = dev→SYNTHETIC` statement below is superseded by code** — it must read `dev→PHI` (all three built-in envs derive PHI; a synthetic box now sets `[security].handles_real_patient_data = false` **explicitly**), and the keyless-PHI gate now refuses the **default** path too, *strengthening* this signal. The letter grade + verdict prose are **not re-scored here** (an owner act); this flags the correction the next re-sign folds. See the ASVS-L3 assessment ADR 0148 reframe note (`ASVS-L3-ASSESSMENT-2026-07-20.md`). +> **⏳ ADR 0148 — pending owner re-sign (2026-07-21).** [ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) landed in code (branch `sec-enforce`): the default env `dev` now derives **PHI** (GIVEN 1), and an explicit `[security].enforcement` dial (`enforce` default | `warn`) replaces the `production` tier as the serve-gate refuse/warn key (GIVEN 2). **Signal #6's `_KNOWN_ENV_POSTURE = dev→SYNTHETIC` statement below is superseded by code** — it must read `dev→PHI`, and since [ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) (2026-09-09, BACKLOG #1279) there is **no synthetic posture at all**: `handles_real_patient_data` is removed and refused at load, so every instance derives PHI with no opt-out. The keyless-PHI gate refuses the **default** path too, *strengthening* this signal in both revisions. The letter grade + verdict prose are **not re-scored here** (an owner act); this flags the correction the next re-sign folds. See the ASVS-L3 assessment ADR 0148 reframe note (`ASVS-L3-ASSESSMENT-2026-07-20.md`). --- diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index f5b23eb14..d91635abc 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -123,13 +123,13 @@ python -m messagefoundry serve --config samples/config --db ./messagefoundry.db - `--config` points at the directory of Connection/Router/Handler modules (here `samples/config/`, which includes [IB_ACME_ADT.py](../samples/config/IB_ACME_ADT.py) and its transform [adt.py](../samples/config/adt.py)). - `--db` is the SQLite message store path (created on first run). -- **`--env` is required** — `serve` refuses to start without it. The active environment is a free-form **name** (`dev`/`staging`/`prod`, or a custom name) that does two things: it selects the value file `environments/.toml` that `env("…")` lookups resolve against, and it sets the instance's **PHI posture** (`data_class` / `production`). Built-in names carry a default posture; a custom name must declare it. See [CONFIGURATION.md](CONFIGURATION.md). +- **`--env` is required** — `serve` refuses to start without it. The active environment is a free-form **name** (`dev`/`staging`/`prod`, or a custom name) that does two things: it selects the value file `environments/.toml` that `env("…")` lookups resolve against, and it sets the instance's **production tier** (`[security].production_instance`). Built-in names carry a default tier; a custom name must declare it. There is no data class to declare beside it — every instance carries patient data ([ADR 0186](adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md)). See [CONFIGURATION.md](CONFIGURATION.md). When the engine runs from somewhere other than the repo root (e.g. under the service), anchor the value files with `--project-root ` so `env()` values don't silently resolve empty — see [INSTALL-GUIDE.md](INSTALL-GUIDE.md). **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.** Every instance carries patient data, so `serve` **refuses to start** with no store encryption key configured — on `dev` as much as on `prod`. 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. To run keyless anyway, set `[security].allow_unencrypted_phi = true` — and under the shipped `[security].enforcement = enforce`, `allow_unencrypted_phi_under_strict_enforcement = true` as well. The dial alone does not clear this refusal. Both acks are audited at every start. The full key story is in [PHI.md](PHI.md). Confirm it's up: diff --git a/docs/adr/0118-secure-by-default-security-configuration-section.md b/docs/adr/0118-secure-by-default-security-configuration-section.md index c2c7d55ec..0fdf63c97 100644 --- a/docs/adr/0118-secure-by-default-security-configuration-section.md +++ b/docs/adr/0118-secure-by-default-security-configuration-section.md @@ -19,6 +19,14 @@ > renamed `allow_unencrypted_phi_in_production` → `allow_unencrypted_phi_under_strict_enforcement` (§5). At the > default (`enforce` × PHI) serve is byte-identical to the former production-PHI behaviour. +> **Amended by [ADR 0186](0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) (2026-09-09):** the master posture lever +> `handles_real_patient_data` is **REMOVED**, with `[ai].data_class` and the `DataClass` enum. Every +> instance carries patient data and the PHI gates apply unconditionally; both spellings are refused at +> load. §1's posture-lever row and §3's AC-6 relaxation no longer describe the shipped product. The rest +> of the `[security]` section is unchanged, `production_instance` is retained as the informational tier, +> and every per-gate switch this ADR introduced keeps its behaviour -- they are now the ONLY way to +> relax a PHI gate. + --- ## Context diff --git a/docs/adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md b/docs/adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md index e72e7048a..af1502885 100644 --- a/docs/adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md +++ b/docs/adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md @@ -9,6 +9,15 @@ --- +> **Amended by [ADR 0186](0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) (2026-09-09):** GIVEN 1's explicit synthetic +> opt-out (`[security].handles_real_patient_data = false`) is **REMOVED**. GIVEN 1's direction is kept +> and completed -- all three built-in env names derived PHI, and now nothing can derive anything else: +> the data-class axis is gone entirely, along with `DataClass` and `HopPosture.is_phi`. **GIVEN 2 +> (`[security].enforcement`) is unchanged and is now the sole refuse/warn key**, as is the retained +> informational `production` tier. Read every `data_class` mention below as historical. + +--- + ## Context The engine keys its secure-by-default serve-gate ladder and the ADR 0092 transport-hop authority on **two diff --git a/docs/adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md b/docs/adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md new file mode 100644 index 000000000..4d27b483e --- /dev/null +++ b/docs/adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md @@ -0,0 +1,163 @@ +# ADR 0186 — Retire the synthetic-data declaration: every instance carries patient data + +**Status:** Accepted (2026-09-09) — owner ruling, given directly: *"I don't want to use +`handles_real_patient_data` any more. I want mefor to always take a PHI posture as the default. Users +can adjust individual settings as they need, but not use `handles_real_patient_data = false` as a +combined override."* **BUILT 2026-09-09** under BACKLOG #1279. Completes the direction +[ADR 0153](0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md) started and +[ADR 0148](0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) GIVEN 1 half-took. +Amends [ADR 0118](0118-secure-by-default-security-configuration-section.md) §1/§3 (the posture lever) +and [ADR 0148](0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) GIVEN 1 (the +explicit synthetic opt-out). Retains ADR 0148 GIVEN 2 (`[security].enforcement`) unchanged, and +retains the production **tier** everywhere it is read. + +**Scope:** the **data-class axis** and nothing else. The production tier, the enforcement dial, every +per-gate switch and every per-connection declaration keep their inputs and their behaviour. + +## Context + +`[security].handles_real_patient_data = false` translated to `[ai].data_class = "synthetic"` and, on +that one line, turned off **nineteen** start-up gates. Measured at `0ce6d95cf`, before this change: + +| Gate | Where | Effect when synthetic | +|---|---|---| +| Keyless at-rest encryption | `__main__.py` `_serve` | started with no key; bodies, MRN, patient name plaintext | +| Unrestricted-egress refusal | `__main__.py` `_serve` | started with every destination open | +| Egress deny-by-default auto-flip | `__main__.py` `_serve` | stayed allow-any per transport | +| `--allow-insecure-bind` clamp | `__main__.py` `_serve` | the escape was honored again | +| Proxy attestation (intra-service auth + TLS floor) | `__main__.py` `_serve` | no attestation required | +| Proxy mTLS declared-but-unverified | `tls_policy.proxy_mtls_declared_but_unverified` | silent | +| Admin new-IP step-up advisory | `__main__.py` `_serve` | silent | +| MFA-at-exposure refusal | `__main__.py` `_serve` | single-factor admin over the network | +| Undeclared-proxy MFA warning | `__main__.py` `_serve` | silent | +| Dual-control at exposure | `__main__.py` `_serve` | silent | +| Terminator without a public origin | `__main__.py` `_serve` | started | +| ASVS 12.1.1 TLS-floor probe | `__main__.py` `_serve` | never ran | +| PHI retention bound | `__main__.py` `_serve` | no refusal, no 30-day auto-bound | +| Security-notification channel | `__main__.py` `_serve` | started with no push channel | +| Unauthenticated alert SMTP hop | `__main__.py` `_serve` | cleartext SMTP accepted | +| Memory-encryption declaration at exposure | `__main__.py` `_serve` | silent | +| Deliverable admin notice address | `api/app.py` lifespan | refusal suppressed | +| API PHI-read over an unproven serve hop | `tls_policy.api_phi_hop_disposition` | ALLOW | +| Outbound TLS hop with no revocation check | `tls_policy.revocation_hop_disposition` | ALLOW | + +**Most of those are hard refusals rather than warnings under the shipped `enforcement = enforce`, and +which ones a given instance meets depends on its exposure, not on its environment name.** An earlier +draft of this line put a number on it. That number was not measured, and it understated: the refusing +arms include the keyless at-rest gate (which refuses at any dial), open-egress, the +`--allow-insecure-bind` clamp, proxy attestation, MFA-at-exposure, the terminator-without-an-external- +origin gate, the ASVS 12.1.1 probe, PHI retention on a production tier, the notification channel and +its deliverability check, the alert SMTP hop, the API PHI serve hop and the outbound revocation hop. +The rest warn. Counting them precisely would need a per-topology matrix this ADR does not carry, so +it says which arms refuse instead of asserting a total. + +**Three facts made this the right time.** + +**1. The lever was not the loud, audited opt-out the documentation claimed.** +`docs/SECURITY-LOOSENING.md` said it was *"named by `security_loosenings()`, surfaced in +`GET /security/posture`, and warned at `serve`"*. Measured: `security_loosenings()` spans 364 lines +and contained zero occurrences of `handles_real_patient_data`, `data_class`, `DataClass` or +`synthetic`. The serve-time loosening warning reads that same registry, so **it never fired for the +widest relaxation the product shipped**. The posture view carried it, but as a separate +`synthetic_relaxation` string that the web console rendered in the `muted` class — one line above the +`banner` class real loosenings get. + +`tests/test_security_posture_defaults.py` exempted the field with the reason *"the data-class lever +has its own entry keyed on the derived posture"*. There was no such entry. The exemption was also +unreachable: the completeness loop skips any field whose default is not a `bool`, and this one +defaults to `None`. Two lines of dead code carrying a false statement about a security control — the +compensating-control-on-a-false-premise defect **SDS-3.7** names, sitting in the test that exists to +prevent it. + +**2. Half the removal had already happened, twice, for reasons that generalize.** +ADR 0153 removed `is_phi` from `insecure_hop_disposition` because *"`data_class` is authored in the +same file as the hosts it governs and a typo in it is indistinguishable from a declaration, with +every transport hop in the product as its blast radius."* That argument was never specific to +cleartext hops. ADR 0148 GIVEN 1 then made all three built-in environment names derive PHI, leaving +the label with exactly one job: being an opt-out. + +**3. The stated benefit was already available per-gate.** Every one of the nineteen has its own +switch — `allow_unencrypted_phi`, `block_unlisted_outbound`, `allow_keeping_phi_indefinitely`, +`allow_single_factor_admin_when_exposed`, `allow_unverified_alert_smtp_tls`, +`[alerts].security_notifications_required`, a per-connection `cleartext_accepted` / +`tls_revocation_attested`, or the `[security].enforcement` dial. Each is separately named, separately +audited and separately reported. The lever added no capability; it added a way to reach all nineteen +without naming any of them. + +Per CLAUDE.md §0 there are zero deployments, so removal costs no migration. + +## Decision + +**1. `[security].handles_real_patient_data` and `[ai].data_class` are removed, and the `DataClass` +enum with them.** Every instance carries patient data. The PHI gates apply unconditionally. + +**2. Both spellings are REFUSED at load, not ignored.** `_REMOVED_KEYS` in `config/settings.py` +raises with a message naming the per-gate switches. Refusing matters more for a removed posture +switch than for a misspelled one: ignoring it would start the engine with all nineteen gates ON, +which is the safe direction but a silent contradiction of what the operator's config says — and the +next person to read that file would draw the wrong conclusion about the running instance. + +**3. `HopPosture` loses `is_phi`.** ADR 0153 had already removed it from the widest consumer; the +remaining three (`api_phi_hop_disposition`, `revocation_hop_disposition`, +`proxy_mtls_declared_but_unverified`) lose it here, as do `weakened_tls_escape_permitted` and the two +`wiring_runner` bind predicates. A field that cannot vary is a constant, not a posture dimension. + +**4. Two ADR 0153 scope carve-outs are resolved by subtraction rather than decision.** +`api_phi_hop_disposition` and `forward_hop_disposition` each *restated* the `not is_phi` ALLOW arm +locally, because neither cell is a connection and so neither can carry a per-hop +`cleartext_accepted`. That reasoning stands and is preserved in both docstrings. What it bought was +an arm with no instance left to fire on. **The follow-up 0153 recorded — a `[security]`-level +declaration for these two cells — becomes load-bearing rather than optional:** under `enforce`, an +unproven API serve hop and an unattested plaintext log-forwarding hop now have no per-cell way to say +yes. Filed, not built here. + +**5. The production tier is retained, untouched.** It is a true property of an instance and it drives +the AI data-scope ceiling and the DEBUG-log refusal, neither of which is a PHI gate. +`derived_posture()` and `require_posture()` now return the tier alone. A custom environment name must +still declare it or `serve` fails closed (ADR 0017). + +**6. The wire contract drops `data_class` and `synthetic_relaxation`** from `SecurityPosture`, and +`data_class` from `AiPolicy`. Nothing is deployed, so there is no compatibility shim: a relaxed +control is a named entry in `loosenings`, or it is not reported at all. + +## What this costs, stated plainly + +**CI's SQL Server load leg and the failover load harness both used the declaration to start.** Each +now names the gate it needs instead: + +- **CI (`sqlserver` load leg):** `enforcement: warn`. Its store container's certificate is + self-signed, and `weakened_tls_escape_permitted` clamps `MEFOR_ALLOW_INSECURE_TLS` inert while + enforcing. GitHub starts a `services:` container before any step runs, so a cert generated in a + step cannot be mounted into it, and the store hop carries no per-hop attestation. Restoring + `enforce` needs the job moved onto a `docker run` with a real generated cert — worth doing + deliberately, and unchanged as a residual by this ADR. +- **Failover harness node:** `[security].enforcement = warn`, + `[security].allow_unencrypted_phi = true`, `[security].block_unlisted_outbound = false`. The middle one keeps the benchmark measuring the write path it + has always measured; adding encryption there would change the number, not the risk. + +**Both replacements are narrower than what they replace.** The retired declaration silenced nineteen +gates; `enforcement = warn` downgrades them to warnings and silences none. + +**A stock `serve --env dev` with no store key now refuses.** It refused before this change too, for +any instance that had not opted out — ADR 0148 GIVEN 1 made that the default. What changes is that +the opt-out is gone, so the refusal is no longer escapable in one line. The per-gate path is +`[security].allow_unencrypted_phi = true`, plus +`[security].allow_unencrypted_phi_under_strict_enforcement = true` under the shipped `enforce` +(ADR 0140 — keyless PHI under strict enforcement is never one flag away). + +## Honest residuals + +- **The two non-connection cells named in decision 4 have no acceptance mechanism.** Under `enforce` + they refuse or they do not; there is no way to declare an accepted risk on either. This is a real + gap and it is filed, unallocated — the `[security]`-level declaration ADR 0153 recorded as a + follow-up for the API serve hop, and its `[logging]` sibling for the forwarder. +- **No instrument stops the same shape recurring.** Nothing in the repository would have reported + that `security_loosenings()` did not name its own widest switch; the test that existed to catch it + was structurally incapable of firing. This ADR removes the instance, not the class. + +## Explicitly out of scope + +`AiDataScope` — the AI-assist context axis, whose `synthetic` member is a different concept and +stays. `[security].enforcement` (ADR 0148 GIVEN 2). The production tier. Every per-gate switch. Every +per-connection declaration. `[store].require_encryption`, which still forces a key past +`allow_unencrypted_phi`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5062277e8..9abe4fdf1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -205,3 +205,4 @@ what is withheld and what you can request. | [0183](0183-provision-the-first-administrator-offline-no-default-account-at-first-run.md) | **Provision the first administrator offline; no default account at first run** (BACKLOG #1136, ASVS 6.3.2) -- the verb asks that default user accounts *"are not present in the application or are disabled"*, and neither arm holds: `_ensure_bootstrap_admin` creates an enabled local account literally named `admin` on an empty table, and the disabled arm is **unexpressible at two altitudes** -- `Store.create_user` carries no `disabled` parameter and all three backends hardcode the column rather than binding it. Decision: **the "not present" arm**, reached by `messagefoundry provision-admin --username ` run at the host before the first `serve`; the seeding path then declines on a non-empty table and no default account is ever created. **The way in is filesystem authority over the store, not an account** -- the same host gate ADR 0171 argues for `admin-unlock`, so it grants nothing a network attacker can reach. **The refusal asks for an ENABLED ADMINISTRATOR, not an empty table**, correcting the researched guard: `_upsert_ad_user` assigns no role, so one completed directory sign-in leaves a roleless row and a non-empty table, and an emptiness guard would refuse in exactly the state where the install has no way in. **No `--password` and no `--password-file`** -- unattended provisioning is refused in terms rather than left as a hatch that lands a standing Administrator credential in argv or on disk. **The credential is claimed at birth** (`users.password_claimed_at`), which removes the half-claimed state a restart could land in and keeps an operator-chosen `admin` out of the WP-3 retirement sweep. The row is created with NO password hash, so every interruption point leaves at most a roleless account that is denied everything by default, and a re-run completes it. Rejected: create-disabled plus a claim step (needs a `disabled` parameter across the protocol and three backends, and a claim ceremony is the thing that can strand a headless restart); create-disabled-then-auto-enable-on-first-login (makes the credential file's permissions the real control while the column records a state nothing enforces -- SDS-3.7); requiring `--email` unconditionally (an operator with no relay types a fake address, and the PHI start gate is already the single authority) | **Accepted (2026-09-05)** -- built with the change; 14 tests. **One planted control**: setting the credential with `must_change_password=True` reds exactly the two tests that cover the claim stamp, with `auth.bootstrap_admin_retired` in the captured log against an account an operator had just provisioned. **ASVS 6.3.2 STAYS PARTIAL** -- retiring `_ensure_bootstrap_admin` and the WP-3 lifecycle is out of scope and is what decides the cell (measured at `initialize()`'s 197 call sites across 64 files, plus `bootstrap_expiry_hours`/`bootstrap_warn_hours`, `_emit_bootstrap_admin`, the `bootstrap_admin_expiring` alert, six documents and four IDE files). Severity conditional per CLAUDE.md section 0 -- **zero deployments** | | [0184](0184-identify-a-federated-login-by-the-idp-namespaced-subject-not-by-the-username-it-claims.md) | **Identify a federated login by the IdP-namespaced subject, not by the username it claims** (BACKLOG #1143, ASVS 6.8.1) -- 6.8.1's own mitigation is to identify a user by the IdP's id plus the user's id inside it. **The engine now STORES that pair and still does not IDENTIFY by it.** `oidc_issuer`/`oidc_subject`, the filtered `ux_users_federated_subject` on all three backends, and the keyed lookup all shipped under #1256; measured 2026-09-05 at `a083cdb89` the unique-index probe that returned 0/0/0 in August returns **1/1/1**, control 10/6/8. What remains is ordering: `authenticate_oidc` resolves the directory principal from the token's username claim, the #1015 continuity guard fetches by that username, `_complete_ad_login` fetches by it AGAIN and that is the read the session is issued for, and the pair is consulted only afterwards as an exclusivity veto over an account already chosen. **Decision: select by `(issuer, subject)` at the head of `_complete_ad_login`, then RE-RESOLVE the directory principal from the bound row's own stored username** -- which removes identifier equality from the identification path, literally the verb's parenthetical. `federated_subject` is already a parameter there and already defaults to `None`, so the simple-bind and Kerberos callers take no new branch; no DDL, no migration. **The half-done version is a privilege-transfer bug and is the likely way to get this wrong:** resolve by the pair to row R but carry on with the `AdPrincipal` resolved from the CLAIMED username U, and `_upsert_ad_user` touches a different row while `roles_for_ad_groups` writes U's directory groups onto R. Rejected: a third `AuthProvider` member (measured -- it either breaks hybrid login at `_complete_ad_login`'s provider guard or is written nowhere; the discriminator belongs on the SESSION); grounding the cell on the continuity pair (that is 10.5.2's verb, and `auth/oidc/claims.py` pins the issuer before any guard runs, so the pair cannot do cross-provider work); rescoring `na` because `oidc_enabled` ships `False` (a disabled feature removes the trigger, not the control); and binding on a directory-held immutable attribute as an AUTHENTICATOR (directory-readable, not secret). **THE FLOOR UNDER EVERY CEREMONY IS ENTAILED, WHICH ADR 0142 A.4 STATED AS AN EITHER/OR:** `set_user_federated_subject` has exactly ONE engine caller, the bind-on-first-presentation site (control: five callers for `set_user_roles`), `api/auth_routes.py` has no federated route and the web console has no federated surface -- **the only way to create a binding today is the one the verb forbids**, and 0142's "refuse unbound accounts" says *until an operator binds them*, which is the surface its other option describes. Also priced: that setter takes `issuer: str, subject: str`, so **an unbind is unrepresentable** and any surface needing a clear is a protocol change on three backends | **Proposed (2026-09-05)** -- **DO NOT START THE BUILD.** The first-federated-login ceremony is an owner trust decision and is the one thing blocking it; four other questions sit in *To resolve on acceptance*, including whether the username-keyed `reconcile_directory_sessions` loop is fixed by excluding bound rows or by re-keying its probe to `objectGUID`. Research only: no code, no schema, no vault file touched. **Scope boundary, and it is load-bearing:** this ADR covers the OIDC leg alone and does NOT close the AD `sAMAccountName`-recycle limb that `api/app.py` `_may_access_upload`, `uploads.py` `UploadedFileMeta`, `tests/test_upload_api.py` and ADR 0136 all name BACKLOG #1143 as the fix for | | [0185](0185-retention-levers-for-the-tamper-evident-audit-log-what-each-deletion-shape-costs-verifiability.md) | **Retention levers for the tamper-evident `audit_log`: what each deletion shape costs verifiability** (BACKLOG #1421) -- an options memo, deliberately choosing nothing. #1421's remaining limb is one ruling: which retention lever exists for a table that must stay tamper-evident. Establishes the mechanism from the code rather than from reasoning about audit logs in general -- `audit_row_hash` (`store/store.py:1009`) digests `[prev_hash, ts, actor, action, channel_id, detail]` plus a conditional `client`, the row `id` is NOT in the payload, and `record_audit` reads the head under the store lock, so chain order is `id` order and nothing else. A verifier exists and is reachable three ways (the `Store` protocol, `messagefoundry audit-verify`/`audit-anchor`, and `[integrity].audit_verify_on_start`, which ships `False`). Eight shapes driven at `c57903c2c` against a throwaway store, both controls firing, with a second full walk beside the shipped one because the shipped verifier returns only the FIRST divergent row. **Three findings are not on `main`:** a break is LOCAL and does not spread (successors chain from the STORED hash, so surviving rows keep their evidentiary value); deleting an INTERIOR row leaves the anchor head byte-identical, so an anchor catches it by COUNT and not by hash; and a tombstone that preserves the stored `row_hash` still breaks the walk at that row, so preserving the link bounds the damage without removing it. **The crux:** a delete-then-reseal verifies clean, which is exactly what an attacker who can write the table would produce, so a re-sealing purge would leave a held off-box anchor as the only working control. Six levers costed against chain, build and operator -- hard delete, tombstone, partitioning, archive-and-reseal, archive-first restore-capable, and a write-time bound (the only one whose cost is not paid in verifiability). Separates two things that are not retention levers: the ADR 0055 group committer (#1421 cost 2) and flipping the #1277 default back off | **Proposed (2026-09-05)** -- awaiting the owner ruling #1421 names; no code, no engine behaviour change, no lever chosen. Severity conditional per CLAUDE.md section 0 -- zero deployments, so nothing is growing today; a first deployment WOULD grow the table unbounded at one row per authenticated read. Carries a Builder recommendation marked as separable from the findings: make the anchor operational first, take the write-time bound as the primary lever, use archive-first with the case C1 contract if the table itself must be bounded, and reject both hard delete and re-seal | +| [0186](0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md) | **Retire the synthetic-data declaration -- every instance carries patient data** (BACKLOG #1279) -- `[security].handles_real_patient_data = false` translated to `[ai].data_class = "synthetic"` and turned off **nineteen** start-up gates on one line (keyless at-rest, open-egress + the deny-by-default flip, the `--allow-insecure-bind` clamp, both proxy-attestation gates, MFA-at-exposure, dual-control, both TLS-terminator gates + the 12.1.1 floor probe, PHI retention, the security-notification channel + its deliverability check, the alert SMTP hop, memory-encryption-at-exposure, the API PHI serve hop, the outbound revocation hop). **Removed outright**, with `DataClass`: every instance carries patient data and the PHI gates apply unconditionally. Three findings drove it. (a) It was **not** the loud, audited opt-out the docs claimed -- `security_loosenings()` never named it, so the serve-time loosening warning never fired for the widest relaxation shipped, and the completeness test's exemption for it was both **false in its stated reason and structurally unreachable** (the loop skips non-`bool` defaults; this one defaults `None`). (b) Half the removal had already happened twice -- [ADR 0153](0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md) stripped `is_phi` from the widest consumer on an argument that was never cleartext-specific, and [ADR 0148](0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) GIVEN 1 left the label with opt-out as its only job. (c) Every one of the nineteen already had its **own** named, audited, separately-reported switch. Both spellings are now **REFUSED at load** (not ignored -- a config asserting the gates are off while the engine runs them all is a silent contradiction), with a message naming the per-gate replacements. `HopPosture` loses `is_phi`; `derived_posture()` / `require_posture()` return the production **tier** alone; the wire contract drops `data_class` + `synthetic_relaxation`. Retains ADR 0148 GIVEN 2, the production tier, and every per-gate + per-connection switch. **Resolves two ADR 0153 carve-outs by subtraction and makes its recorded follow-up load-bearing:** the API serve hop and the log forwarder restated the `not is_phi` ALLOW arm locally and now have **no** per-cell way to accept a risk under `enforce` (filed, unallocated -- an honest residual). CI's SQL Server load leg and the failover harness take per-gate relaxations instead, both **narrower** than the nineteen the declaration silenced. #26-clean | Accepted (2026-09-09) -- owner ruling; BUILT same day | diff --git a/harness/load/failover.py b/harness/load/failover.py index e456edc04..f7c2b3baf 100644 --- a/harness/load/failover.py +++ b/harness/load/failover.py @@ -133,11 +133,20 @@ def __init__( self._env.setdefault("MEFOR_API_TLS_KEY_FILE", _key) #: The PEM a client must pin to reach THIS node (EngineClient(cacert=...)). self.cacert = self._env["MEFOR_API_TLS_CERT_FILE"] - # GIVEN 1 (ADR 0148): the default env `dev` now derives PHI, so a bare `serve --env dev` runs the - # secure PHI posture (keyless/egress/retention/notify refusals). This harness node serves the - # SYNTHETIC load graph (no real PHI), so declare the loud opt-out — matching the `--env dev` - # "synthetic-only env" intent in start(). setdefault so a PHI scenario can still override it. - self._env.setdefault("MEFOR_SECURITY_HANDLES_REAL_PATIENT_DATA", "false") + # Every instance carries patient data (BACKLOG #1279), so `serve --env dev` runs the secure PHI + # posture: keyless / egress / retention / notify gates all apply. This node serves the SYNTHETIC + # load graph and measures throughput, so it takes the three PER-GATE relaxations that reproduce + # the posture it measured under before, each named and audited rather than folded into one + # declaration: + # * enforcement=warn -- the gates warn and continue instead of refusing + # * allow_unencrypted_phi -- no store key, so the benchmark measures the same write path it + # always did (adding one here would change the number, not the risk) + # * block_unlisted_outbound=false -- the load graph fans out to loopback sinks chosen at run + # time, which no static allowlist can name + # setdefault throughout, so a scenario that wants the enforcing posture can still override. + self._env.setdefault("MEFOR_SECURITY_ENFORCEMENT", "warn") + self._env.setdefault("MEFOR_SECURITY_ALLOW_UNENCRYPTED_PHI", "true") + self._env.setdefault("MEFOR_SECURITY_BLOCK_UNLISTED_OUTBOUND", "false") self._config_dir = config_dir self._cwd = cwd self._proc: asyncio.subprocess.Process | None = None diff --git a/ide/src/securityEditor.ts b/ide/src/securityEditor.ts index cab69c014..48da43a33 100644 --- a/ide/src/securityEditor.ts +++ b/ide/src/securityEditor.ts @@ -82,8 +82,9 @@ const FIELDS: Field[] = [ desc: "PHI access is ALWAYS audited; this records the grant for every authorization decision on top. On by default (BACKLOG #1277).", insecure: false, risk: "every authenticated READ is authorized but NOT recorded — what an account reached cannot be reconstructed afterwards" }, // ── What this instance handles ────────────────────────────────── - { key: "handles_real_patient_data", label: "Handles real patient data", type: "tristate", group: "What this instance handles", - desc: "The master posture lever. Derived from the environment name when unset (dev → no, staging/prod → yes)." }, + // `handles_real_patient_data` sat here and is retired (BACKLOG #1279): every instance carries + // patient data, so there is no declaration to edit. The engine REFUSES the key at load, so leaving + // a row here would offer an edit that breaks the config it writes. { key: "production_instance", label: "Production instance", type: "tristate", group: "What this instance handles", desc: "Production-tier posture. Derived from the environment name when unset." }, ]; diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index 127720d3d..bf266a186 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -86,8 +86,8 @@ def main(argv: list[str] | None = None) -> int: "--env", default=None, help="active environment NAME (overrides [ai].environment; selects environments/.toml " - "values). Built-in names dev/staging/prod carry a default posture; a custom name also needs " - "[ai].data_class + [ai].production set.", + "values). Built-in names dev/staging/prod carry a default production tier; a custom name also " + "needs [security].production_instance set.", ) serve.add_argument( "--project-root", @@ -1459,20 +1459,22 @@ def _serve(args: argparse.Namespace) -> int: return 2 # Active environment is REQUIRED (ADR 0017): no silent default, so a missing env can never resolve - # another environment's values/secrets. Its security POSTURE (data_class / production) is derived - # for the built-in names dev/staging/prod and must be explicit for a custom name. - from messagefoundry.config.ai_policy import DataClass, SecurityEnforcement + # another environment's values/secrets. Its production TIER is derived for the built-in names + # dev/staging/prod and must be explicit for a custom name. There is no data-class axis to derive: + # every instance carries patient data, so every PHI gate below applies unconditionally + # (BACKLOG #1279). + from messagefoundry.config.ai_policy import SecurityEnforcement if settings.ai.environment is None: print( "error: no active environment set — pass --env or set [ai].environment. It selects " - "environments/.toml and, with [security].handles_real_patient_data/production_instance, " - "the instance's PHI posture.", + "environments/.toml and, with [security].production_instance, the instance's " + "production tier.", file=sys.stderr, ) return 2 try: - data_class, production = settings.ai.require_posture() + production = settings.ai.require_posture() except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return 2 @@ -1515,11 +1517,12 @@ def _serve(args: argparse.Namespace) -> int: ) # PHI-at-rest posture (H3, OWASP *Fail Securely* / SDS §4.3 PW.9 secure-by-default): with no key - # configured, a PHI-carrying instance — gated on data_class == phi, NOT the environment label, so a - # custom-named dev/test box holding near-real PHI is covered the same as prod — REFUSES to start - # (fail-closed). The refusal fires in EVERY environment (dev/staging/prod) once data_class is phi. - # An explicit [security].allow_unencrypted_phi=true is the loud, audited override that lets such an - # instance start keyless (warn). A synthetic/non-PHI instance stays key-free (CI parity), and + # configured the instance REFUSES to start (fail-closed), in EVERY environment. It is not gated on + # the environment label, and since BACKLOG #1279 it is not gated on a data class either: every + # instance carries patient data, so a custom-named dev/test box holding near-real PHI is covered + # exactly as prod is, with no declaration able to exempt it. + # An explicit [security].allow_unencrypted_phi=true is the loud, audited override that lets an + # instance start keyless (warn) — the per-gate switch that replaced the old blanket opt-out, and # [store].require_encryption forces the refusal even for a synthetic instance. A DPAPI-protected key # file (Windows) counts as a configured key; if it's set but unreadable here, open_store fails closed # at startup with the DPAPI error. @@ -1532,66 +1535,65 @@ def _serve(args: argparse.Namespace) -> int: file=sys.stderr, ) return 2 - if data_class is DataClass.PHI: - if not settings.store.allow_unencrypted_phi: - # Secure-by-default: any PHI instance (data_class==phi), in any environment, refuses to - # run keyless. This is the H3 tightening — previously prod refused and non-prod only - # warned (fail-open), but dev/staging routinely hold near-real PHI. - print( - f"error: no MEFOR_STORE_ENCRYPTION_KEY (or [store].encryption_key_file) set on a " - f"PHI instance (environment {env_name!r}, [ai].data_class=phi); refusing to start " - "— PHI bodies and the summary/metadata (MRN + patient name) and " - "error/last_error/detail columns would be stored UNENCRYPTED at rest. Generate a " - "key with `messagefoundry gen-key` (or protect one to a file with `messagefoundry " - "protect-key`) and configure it; or, to deliberately run without at-rest " - "encryption, set [security].allow_unencrypted_phi=true (audited).", - file=sys.stderr, - ) - return 2 - if enforcing and not settings.security.allow_unencrypted_phi_under_strict_enforcement: - # Secure-by-default under STRICT ENFORCEMENT (ADR 0140): keyless PHI under enforcement - # requires a SECOND acknowledgment beyond [security].allow_unencrypted_phi — the highest- - # risk posture (real PHI + strict enforcement) is never one flag away from plaintext at - # rest. Under warn enforcement PHI keeps the single-flag audited override below. - print( - "error: [security].allow_unencrypted_phi=true on a PHI instance under strict " - f"enforcement (environment {env_name!r}), but " - "[security].allow_unencrypted_phi_under_strict_enforcement is not set; refusing to " - "start — PHI bodies and the summary/metadata (MRN + patient name) and " - "error/last_error/detail columns would be stored UNENCRYPTED at rest. Configure a " - "key (MEFOR_STORE_ENCRYPTION_KEY), or set " - "[security].allow_unencrypted_phi_under_strict_enforcement=true to deliberately run " - "keyless under strict enforcement (audited).", - file=sys.stderr, - ) - return 2 - # Explicit, audited override: start keyless on a PHI instance. Emit a loud warning AND a - # WARNING-level audit record (captured by NSSM stdout/SIEM) so the deliberate weakening is - # never silent. (Logging isn't configured yet here, so this goes through the root logger, - # which emits >=WARNING to stderr by default — a durable startup audit line.) Under strict - # enforcement the second ack ([security].allow_unencrypted_phi_under_strict_enforcement=true) - # was verified above, so the AUDIT line names both flags; the warn posture names just the one. - logging.getLogger(__name__).warning( - "AUDIT: starting keyless on a %sPHI instance (environment %r, data_class=phi) because " - "[security].allow_unencrypted_phi=true%s — PHI is stored UNENCRYPTED at rest " - "(at-rest encryption opt-out override).", - "production " if production else "", - env_name, - " + [security].allow_unencrypted_phi_under_strict_enforcement=true" - if enforcing - else "", + if not settings.store.allow_unencrypted_phi: + # Secure-by-default: any instance, in any environment, refuses to run keyless. This is + # the H3 tightening — previously prod refused and non-prod only warned (fail-open), but + # dev/staging routinely hold near-real PHI. + print( + f"error: no MEFOR_STORE_ENCRYPTION_KEY (or [store].encryption_key_file) set on a " + f"PHI instance (environment {env_name!r}); refusing to start " + "— PHI bodies and the summary/metadata (MRN + patient name) and " + "error/last_error/detail columns would be stored UNENCRYPTED at rest. Generate a " + "key with `messagefoundry gen-key` (or protect one to a file with `messagefoundry " + "protect-key`) and configure it; or, to deliberately run without at-rest " + "encryption, set [security].allow_unencrypted_phi=true (audited).", + file=sys.stderr, ) + return 2 + if enforcing and not settings.security.allow_unencrypted_phi_under_strict_enforcement: + # Secure-by-default under STRICT ENFORCEMENT (ADR 0140): keyless PHI under enforcement + # requires a SECOND acknowledgment beyond [security].allow_unencrypted_phi — the highest- + # risk posture (real PHI + strict enforcement) is never one flag away from plaintext at + # rest. Under warn enforcement PHI keeps the single-flag audited override below. print( - f"warning: [security].allow_unencrypted_phi=true — starting a " - f"{'production ' if production else ''}PHI environment " - f"({env_name!r}) keyless; PHI bodies and the summary/metadata (MRN + patient name) and " - "error/last_error/detail columns are stored UNENCRYPTED at rest (only volume " - "encryption protects them). Configure MEFOR_STORE_ENCRYPTION_KEY to encrypt them.", + "error: [security].allow_unencrypted_phi=true on a PHI instance under strict " + f"enforcement (environment {env_name!r}), but " + "[security].allow_unencrypted_phi_under_strict_enforcement is not set; refusing to " + "start — PHI bodies and the summary/metadata (MRN + patient name) and " + "error/last_error/detail columns would be stored UNENCRYPTED at rest. Configure a " + "key (MEFOR_STORE_ENCRYPTION_KEY), or set " + "[security].allow_unencrypted_phi_under_strict_enforcement=true to deliberately run " + "keyless under strict enforcement (audited).", file=sys.stderr, ) + return 2 + # Explicit, audited override: start keyless on a PHI instance. Emit a loud warning AND a + # WARNING-level audit record (captured by NSSM stdout/SIEM) so the deliberate weakening is + # never silent. (Logging isn't configured yet here, so this goes through the root logger, + # which emits >=WARNING to stderr by default — a durable startup audit line.) Under strict + # enforcement the second ack ([security].allow_unencrypted_phi_under_strict_enforcement=true) + # was verified above, so the AUDIT line names both flags; the warn posture names just the one. + logging.getLogger(__name__).warning( + "AUDIT: starting keyless on a %sPHI instance (environment %r) because " + "[security].allow_unencrypted_phi=true%s — PHI is stored UNENCRYPTED at rest " + "(at-rest encryption opt-out override).", + "production " if production else "", + env_name, + " + [security].allow_unencrypted_phi_under_strict_enforcement=true" + if enforcing + else "", + ) + print( + f"warning: [security].allow_unencrypted_phi=true — starting a " + f"{'production ' if production else ''}PHI environment " + f"({env_name!r}) keyless; PHI bodies and the summary/metadata (MRN + patient name) and " + "error/last_error/detail columns are stored UNENCRYPTED at rest (only volume " + "encryption protects them). Configure MEFOR_STORE_ENCRYPTION_KEY to encrypt them.", + file=sys.stderr, + ) - # PHI-at-rest invariant (#186b, ASVS 13.2.4): at-rest encryption is effective-by-default on ANY PHI - # instance (data_class==phi), not only a production one — the keyless gate ABOVE already fails + # PHI-at-rest invariant (#186b, ASVS 13.2.4): at-rest encryption is effective-by-default on ANY + # instance, not only a production one — the keyless gate ABOVE already fails # closed in every environment unless an encryption key is configured or the audited # [security].allow_unencrypted_phi opt-out is set, so by the time control reaches here a PHI instance # necessarily has a key or the explicit opt-out. No further runtime check is added: an executable @@ -1614,49 +1616,48 @@ def _serve(args: argparse.Namespace) -> int: # the flip below turns deny-by-default ON for, so such an instance still starts fail-closed. An # instance that explicitly opted OUT of deny-by-default is deliberately unchanged: it cannot # satisfy this gate on smtp/direct alone, because there the other six transports stay allow-any. - if data_class is DataClass.PHI: - eg = settings.egress - listed = ( - eg.allowed_mllp - or eg.allowed_tcp - or eg.allowed_http - or eg.allowed_db - or eg.allowed_remote - or eg.allowed_file_dirs + eg = settings.egress + listed = ( + eg.allowed_mllp + or eg.allowed_tcp + or eg.allowed_http + or eg.allowed_db + or eg.allowed_remote + or eg.allowed_file_dirs + ) + if "deny_by_default" not in eg.model_fields_set: + listed = listed or eg.allowed_smtp or eg.allowed_direct + egress_open = not eg.deny_by_default and not listed + if egress_open: + # Reaching here WITH allowed_smtp/allowed_direct declared is only possible when + # [security].block_unlisted_outbound was set explicitly (otherwise those two count above), so + # name that override rather than leaving the operator to wonder why a declared allowlist did + # not satisfy the gate. + mail_only_note = ( + " You have declared [egress].allowed_smtp/allowed_direct, but those satisfy this gate " + "only when [security].block_unlisted_outbound is left unset — setting it false opts out " + "of the deny-by-default flip, which would leave every OTHER transport allow-any. Remove " + "that override (or set it true) and a mail-only/Direct-only allowlist is accepted." + if (eg.allowed_smtp or eg.allowed_direct) + else "" ) - if "deny_by_default" not in eg.model_fields_set: - listed = listed or eg.allowed_smtp or eg.allowed_direct - egress_open = not eg.deny_by_default and not listed - if egress_open: - # Reaching here WITH allowed_smtp/allowed_direct declared is only possible when - # [security].block_unlisted_outbound was set explicitly (otherwise those two count above), so - # name that override rather than leaving the operator to wonder why a declared allowlist did - # not satisfy the gate. - mail_only_note = ( - " You have declared [egress].allowed_smtp/allowed_direct, but those satisfy this gate " - "only when [security].block_unlisted_outbound is left unset — setting it false opts out " - "of the deny-by-default flip, which would leave every OTHER transport allow-any. Remove " - "that override (or set it true) and a mail-only/Direct-only allowlist is accepted." - if (eg.allowed_smtp or eg.allowed_direct) - else "" - ) - if enforcing: - print( - f"error: outbound egress is UNRESTRICTED on a " - f"{'production ' if production else ''}PHI instance " - f"({env_name!r}); refusing to start — a transform could send PHI to any " - "destination. Set [security].block_unlisted_outbound=true, or declare the permitted " - f"destinations with per-transport [egress].allowed_* allowlists.{mail_only_note}", - file=sys.stderr, - ) - return 2 + if enforcing: print( - f"warning: outbound egress is UNRESTRICTED in a PHI-carrying environment " - f"({env_name!r}) — a transform may send to any destination. Set " - "[security].block_unlisted_outbound or per-transport [egress].allowed_* allowlists to fail " - "closed.", + f"error: outbound egress is UNRESTRICTED on a " + f"{'production ' if production else ''}PHI instance " + f"({env_name!r}); refusing to start — a transform could send PHI to any " + "destination. Set [security].block_unlisted_outbound=true, or declare the permitted " + f"destinations with per-transport [egress].allowed_* allowlists.{mail_only_note}", file=sys.stderr, ) + return 2 + print( + f"warning: outbound egress is UNRESTRICTED in a PHI-carrying environment " + f"({env_name!r}) — a transform may send to any destination. Set " + "[security].block_unlisted_outbound or per-transport [egress].allowed_* allowlists to fail " + "closed.", + file=sys.stderr, + ) # Egress deny-by-default effective flip (#186c, ASVS 13.2.4/13.2.5): a PRODUCTION PHI instance # defaults to FAIL-CLOSED egress. Unless the operator explicitly set [security].block_unlisted_outbound, turn @@ -1671,38 +1672,37 @@ def _serve(args: argparse.Namespace) -> int: # instance hits that gate's refusal first. settings.egress is the same object later passed to # create_managed_app, so the in-place flip threads through to the wiring_runner egress enforcement # (no forbidden-file edit). - if data_class is DataClass.PHI: - if "deny_by_default" not in settings.egress.model_fields_set: - settings.egress.deny_by_default = True - # configure_logging has not run yet (root lastResort drops < WARNING), so announce on stderr - # like the sibling posture gates rather than logging.info. - print( - f"info: [security].block_unlisted_outbound defaulted ON for a " - f"{'production ' if production else ''}PHI instance " - f"({env_name!r}) — a transport with an empty [egress].allowed_* list now refuses every " - "destination of that type (secure-by-default). Declare the permitted destinations per " - "transport, or set [security].block_unlisted_outbound=false to restore allow-any.", - file=sys.stderr, - ) - elif not settings.egress.deny_by_default: - # Explicit, audited opt-out on a production PHI instance (mirrors allow_unencrypted_phi): - # the operator has chosen the allow-any (empty = unrestricted) egress posture. This audit - # line is WARNING-level so the root lastResort handler still surfaces it before - # configure_logging. - logging.getLogger(__name__).warning( - "AUDIT: [security].block_unlisted_outbound=false on a %sPHI instance (environment %r) — " - "outbound egress uses the allow-any posture (a transport with an empty allowlist may " - "send to ANY destination of that type); the secure-by-default deny is opted out.", - "production " if production else "", - env_name, - ) - print( - f"warning: [security].block_unlisted_outbound=false on a " - f"{'production ' if production else ''}PHI instance ({env_name!r}) " - "— a transport with an empty [egress].allowed_* list may send PHI to ANY destination of " - "that type. Remove the override (or set it true) to fail closed.", - file=sys.stderr, - ) + if "deny_by_default" not in settings.egress.model_fields_set: + settings.egress.deny_by_default = True + # configure_logging has not run yet (root lastResort drops < WARNING), so announce on stderr + # like the sibling posture gates rather than logging.info. + print( + f"info: [security].block_unlisted_outbound defaulted ON for a " + f"{'production ' if production else ''}PHI instance " + f"({env_name!r}) — a transport with an empty [egress].allowed_* list now refuses every " + "destination of that type (secure-by-default). Declare the permitted destinations per " + "transport, or set [security].block_unlisted_outbound=false to restore allow-any.", + file=sys.stderr, + ) + elif not settings.egress.deny_by_default: + # Explicit, audited opt-out on a production PHI instance (mirrors allow_unencrypted_phi): + # the operator has chosen the allow-any (empty = unrestricted) egress posture. This audit + # line is WARNING-level so the root lastResort handler still surfaces it before + # configure_logging. + logging.getLogger(__name__).warning( + "AUDIT: [security].block_unlisted_outbound=false on a %sPHI instance (environment %r) — " + "outbound egress uses the allow-any posture (a transport with an empty allowlist may " + "send to ANY destination of that type); the secure-by-default deny is opted out.", + "production " if production else "", + env_name, + ) + print( + f"warning: [security].block_unlisted_outbound=false on a " + f"{'production ' if production else ''}PHI instance ({env_name!r}) " + "— a transport with an empty [egress].allowed_* list may send PHI to ANY destination of " + "that type. Remove the override (or set it true) to fail closed.", + file=sys.stderr, + ) # Gate #1: DEBUG logging can surface PHI (full message bodies / raw field values) into the general # log. Refuse it fail-closed on a production instance — real PHI flows there. A non-production @@ -1948,11 +1948,11 @@ def _serve(args: argparse.Namespace) -> int: env_base = resolve_values_base_dir(settings.environments.base_dir, cwd=cwd) env_file = env_base / settings.environments.dir / f"{env_name}.toml" # Announce the active environment + posture so an operator can see which env() values resolve and - # the PHI posture in effect (the env is required — there is no silent default). + # the tier in effect (the env is required — there is no silent default). Every instance carries + # patient data (BACKLOG #1279), so there is no data class to report: the PHI gates always apply. logging.getLogger(__name__).info( - "active environment: %s (data_class=%s, production=%s; env() values from %s + MEFOR_VALUE_*)", + "active environment: %s (production=%s; env() values from %s + MEFOR_VALUE_*)", env_name, - data_class.value, production, env_file, ) @@ -1977,7 +1977,7 @@ def _serve(args: argparse.Namespace) -> int: settings.api.host, settings.api.trusted_proxies, ) - elif insecure_bind_ok and not (data_class is DataClass.PHI and enforcing): + elif insecure_bind_ok and not enforcing: print( f"warning: API bound to non-loopback host {settings.api.host!r} with " "--allow-insecure-bind and NO TLS; bearer tokens and PHI cross the network in " @@ -2073,7 +2073,7 @@ def _serve(args: argparse.Namespace) -> int: ) if not settings.api.proxy_tls_floor_declared: posture_b_missing.append("[api].proxy_tls_min_version (attested proxy TLS/KEX floor)") - if posture_b_missing and data_class is DataClass.PHI: + if posture_b_missing: missing_desc = "; ".join(posture_b_missing) if enforcing and not settings.api.is_loopback: print( @@ -2111,7 +2111,6 @@ def _serve(args: argparse.Namespace) -> int: if proxy_mtls_declared_but_unverified( declared=settings.api.proxy_intra_service_auth, client_ca_configured=bool(settings.api.tls_client_ca_file), - is_phi=data_class is DataClass.PHI, ): print( "warning: [api].proxy_intra_service_auth is declared 'mtls' but this engine verifies " @@ -2312,11 +2311,7 @@ def _serve(args: argparse.Namespace) -> int: "reverse-proxy-mTLS guidance in docs/security/OFF-LOOPBACK-DEPLOYMENT.md (ASVS 8.4.2).", file=sys.stderr, ) - if ( - settings.auth.enabled - and not settings.auth.admin_new_ip_step_up - and data_class is DataClass.PHI - ): + if settings.auth.enabled and not settings.auth.admin_new_ip_step_up: # Advisory only — the default deliberately stays False, because a flip would churn # NAT'd hospital networks and, on the shipped loopback bind, would change nothing at # all: _same_host folds 127.0.0.1 and ::1 into one host, so the flipped control still @@ -2423,38 +2418,37 @@ def _serve(args: argparse.Namespace) -> int: else "admin interface reached through a declared reverse proxy " "([api].tls_terminated_upstream)" ) - if data_class is DataClass.PHI: - if enforcing and not settings.security.allow_single_factor_admin_when_exposed: - print( - f"error: {exposure_desc} on a {'production ' if production else ''}PHI " - f"instance ({env_name!r}) with [security].require_mfa off; refusing to start — the " - "Administrator role would authenticate with a single factor over the network. " - "Enable native TOTP MFA with [security].require_mfa=true (WP-14) before exposing the " - "API (on an AD-only deployment it binds directory principals too, each enrolling " - "an engine factor); or set [security].allow_single_factor_admin_when_exposed=true to " - "deliberately permit single-factor admin at exposure (audited).", - file=sys.stderr, - ) - return 2 - if enforcing: - # ADR 0140: single-factor admin at exposure under strict enforcement was explicitly - # acknowledged — emit a loud WARNING-level AUDIT line, then fall through to the shared - # warn posture (permitted-but-audited, never silent). - logging.getLogger(__name__).warning( - "AUDIT: %s on a %sPHI instance (environment %r) with [security].require_mfa " - "off, permitted because [security].allow_single_factor_admin_when_exposed=true — every " - "account in [security].require_mfa_scope is single-factor over the network.", - exposure_desc, - "production " if production else "", - env_name, - ) + if enforcing and not settings.security.allow_single_factor_admin_when_exposed: print( - f"warning: {exposure_desc} in a PHI-carrying " - f"environment ({env_name!r}) with [security].require_mfa off — every account in " - "[security].require_mfa_scope is single-factor over the network. Enable [security].require_mfa=true (WP-14 native TOTP) " - "before exposure.", + f"error: {exposure_desc} on a {'production ' if production else ''}PHI " + f"instance ({env_name!r}) with [security].require_mfa off; refusing to start — the " + "Administrator role would authenticate with a single factor over the network. " + "Enable native TOTP MFA with [security].require_mfa=true (WP-14) before exposing the " + "API (on an AD-only deployment it binds directory principals too, each enrolling " + "an engine factor); or set [security].allow_single_factor_admin_when_exposed=true to " + "deliberately permit single-factor admin at exposure (audited).", file=sys.stderr, ) + return 2 + if enforcing: + # ADR 0140: single-factor admin at exposure under strict enforcement was explicitly + # acknowledged — emit a loud WARNING-level AUDIT line, then fall through to the shared + # warn posture (permitted-but-audited, never silent). + logging.getLogger(__name__).warning( + "AUDIT: %s on a %sPHI instance (environment %r) with [security].require_mfa " + "off, permitted because [security].allow_single_factor_admin_when_exposed=true — every " + "account in [security].require_mfa_scope is single-factor over the network.", + exposure_desc, + "production " if production else "", + env_name, + ) + print( + f"warning: {exposure_desc} in a PHI-carrying " + f"environment ({env_name!r}) with [security].require_mfa off — every account in " + "[security].require_mfa_scope is single-factor over the network. Enable [security].require_mfa=true (WP-14 native TOTP) " + "before exposure.", + file=sys.stderr, + ) # --- the UNDECLARED-proxy residual of the gate above, made visible (BACKLOG #326) --------------- # `instance_exposed` is deliberately narrow, so a set `public_origin` on a loopback bind with no @@ -2471,7 +2465,6 @@ def _serve(args: argparse.Namespace) -> int: and settings.api.public_origin and settings.auth.enabled and not settings.auth.require_mfa - and data_class is DataClass.PHI ): print( "warning: [api].public_origin is set with no declared TLS terminator on a PHI instance " @@ -2492,8 +2485,9 @@ def _serve(args: argparse.Namespace) -> int: # connection with no second sign-off. Key on the SAME exposure signal as the MFA gate above # (admin_exposed = instance_exposed = off-loopback bind OR a declared TLS-terminating proxy), so a # plain loopback default is byte-identical (admin_exposed is False → this never trips, BACKLOG #326 - # preserved that property deliberately) and a synthetic instance stays quiet - # (gated on data_class is PHI). This is WARN-ONLY by design (the reviewed default): dual-control is + # preserved that property deliberately). It used to stay quiet on an instance declared synthetic; + # that declaration is retired (BACKLOG #1279). + # This is WARN-ONLY by design (the reviewed default): dual-control is # off-by-default precisely so a genuine single-operator hospital deployment is never wedged, so # refusing to start on its absence would break a supported topology. # @@ -2501,7 +2495,7 @@ def _serve(args: argparse.Namespace) -> int: # the sec-mfa-on / retention / notifications prod-refuse ladder above, returning 2) instead of # warning is an owner decision — kept WARN-only here until adjudicated; flip by adding the # `if production: ... return 2` arm and an audited [approvals].allow_single_control override. - if admin_exposed and not settings.approvals.enabled and data_class is DataClass.PHI: + if admin_exposed and not settings.approvals.enabled: approvals_exposure_desc = ( f"API bound to non-loopback host {settings.api.host!r}" if not settings.api.is_loopback @@ -2559,12 +2553,7 @@ def _serve(args: argparse.Namespace) -> int: # rather than it being hidden. It is NOT the reason the answer is (a): the reason is that this is # the only end where the control measures its own posture instead of measuring whether someone # happened to configure an unrelated console setting. - if ( - settings.api.tls_terminated_upstream - and data_class is DataClass.PHI - and enforcing - and not settings.api.public_origin - ): + if settings.api.tls_terminated_upstream and enforcing and not settings.api.public_origin: # THE REMEDIATION NAMES THE KEY THE LOADER ACCEPTS, NOT THE FIELD THIS CODE READS # (BACKLOG #1026). `[api].public_origin` is the INTERNAL field; ADR 0118 relocated the # operator-facing key to `[security].web_console_public_address` and REJECTS the old @@ -2595,12 +2584,7 @@ def _serve(args: argparse.Namespace) -> int: # call needs. Before #1026 this comment named three conditions while the gate had four, and the # undocumented fourth was the whole defect -- a reader concluded the probe runs whenever a PHI # instance sits behind a declared terminator under `enforce`, and it did not. - if ( - settings.api.tls_terminated_upstream - and data_class is DataClass.PHI - and enforcing - and settings.api.public_origin - ): + if settings.api.tls_terminated_upstream and enforcing and settings.api.public_origin: from messagefoundry.config.tls_probe import TlsProbeUnavailable, probe_tls_floor try: @@ -2646,135 +2630,134 @@ def _serve(args: argparse.Namespace) -> int: # [security].allow_keeping_phi_indefinitely=true, which downgrades the production refusal to a loud audited # warning (and suppresses the non-production auto-bound). Placed after the exposure gates so an # exposed instance's cleartext/MFA refusals surface first. - if data_class is DataClass.PHI: - # WP243 (#243, ASVS 14.2.7): a NON-PRODUCTION PHI instance auto-bounds each UNSET PHI-body - # retention window to 30 days (secure-by-default), mirroring the egress deny_by_default flip - # above. PRODUCTION PHI is deliberately EXCLUDED so the #186(a) refuse-to-start gate below is - # unchanged (a silent auto-bound there would mask the deliberate fail-closed refusal). Only an - # UNSET window is defaulted (model_fields_set), so an explicit value — including an explicit 0 — - # is respected; the audited keep-forever opt-out is [security].allow_keeping_phi_indefinitely=true. - # settings.retention is the same object later passed to create_managed_app, so the in-place - # default threads through to the RetentionRunner (no forbidden-file edit). - # messages_days moved to [security].delete_message_bodies_after_days (ADR 0118); - # dead_letter_days stays [retention] plumbing — label each window at its real home. - # ASVS 14.2.7: the tier list is GENERATED from the classification in - # config/retention_classification.py, which a drift test holds equal — in both directions — to - # docs/PHI.md §2's Retention column. It used to be a two-element literal here, and the cell - # broke once because a new PHI tier landed and nobody widened it. A wider literal with no - # binding to the classification is the same defect with more characters. - from messagefoundry.config.retention_classification import ( - MIN_PHI_RETENTION_WINDOWS, - PHI_RETENTION_WINDOWS, - auto_bounded_windows, - ) - from messagefoundry.config.retention_classification import ( - unbounded_windows as _unbounded_windows, + # WP243 (#243, ASVS 14.2.7): a NON-PRODUCTION PHI instance auto-bounds each UNSET PHI-body + # retention window to 30 days (secure-by-default), mirroring the egress deny_by_default flip + # above. PRODUCTION PHI is deliberately EXCLUDED so the #186(a) refuse-to-start gate below is + # unchanged (a silent auto-bound there would mask the deliberate fail-closed refusal). Only an + # UNSET window is defaulted (model_fields_set), so an explicit value — including an explicit 0 — + # is respected; the audited keep-forever opt-out is [security].allow_keeping_phi_indefinitely=true. + # settings.retention is the same object later passed to create_managed_app, so the in-place + # default threads through to the RetentionRunner (no forbidden-file edit). + # messages_days moved to [security].delete_message_bodies_after_days (ADR 0118); + # dead_letter_days stays [retention] plumbing — label each window at its real home. + # ASVS 14.2.7: the tier list is GENERATED from the classification in + # config/retention_classification.py, which a drift test holds equal — in both directions — to + # docs/PHI.md §2's Retention column. It used to be a two-element literal here, and the cell + # broke once because a new PHI tier landed and nobody widened it. A wider literal with no + # binding to the classification is the same defect with more characters. + from messagefoundry.config.retention_classification import ( + MIN_PHI_RETENTION_WINDOWS, + PHI_RETENTION_WINDOWS, + auto_bounded_windows, + ) + from messagefoundry.config.retention_classification import ( + unbounded_windows as _unbounded_windows, + ) + + # A FLOOR, not an emptiness check. `if not PHI_RETENTION_WINDOWS` passes for a one-element + # tuple, so a bad merge dropping most entries would leave this gate checking one window while + # reporting success — the precise shape of failure this whole change set exists to remove. + if len(PHI_RETENTION_WINDOWS) < MIN_PHI_RETENTION_WINDOWS: + print( + f"error: the PHI retention classification has shrunk to " + f"{len(PHI_RETENTION_WINDOWS)} windows (floor {MIN_PHI_RETENTION_WINDOWS}); refusing " + "to start rather than gate on a partial classification. This is a build defect, not a " + "configuration one — see messagefoundry/config/retention_classification.py.", + file=sys.stderr, ) + return 2 - # A FLOOR, not an emptiness check. `if not PHI_RETENTION_WINDOWS` passes for a one-element - # tuple, so a bad merge dropping most entries would leave this gate checking one window while - # reporting success — the precise shape of failure this whole change set exists to remove. - if len(PHI_RETENTION_WINDOWS) < MIN_PHI_RETENTION_WINDOWS: + # AUTO-BOUND. Owner ruling 2026-07-30: the three PHI-BODY windows default to 30 days when + # UNSET, on BOTH dials — previously this ran only when `not enforcing`, so on the shipped + # `enforce` posture an unset window took the refusal below instead of a default. + # + # THE SAFETY TRADE IS DELIBERATE AND WORTH STATING: a production PHI instance with an unset + # window used to REFUSE TO START, which forced an operator to choose a number. It now starts + # with 30. What survives is the fail-closed path for an EXPLICIT 0 — choosing keep-forever is + # still refused unless the audited opt-out is set. So "unbounded by accident" is still + # prevented; "unbounded by inattention" becomes "30 days by inattention". + # + # The warn-only windows are NOT auto-bounded, and that is also a ruling rather than an + # omission: `purge_state` and `purge_search_presets` key on timestamps that only move on a + # WRITE, so silently bounding them deletes live operational data a Handler is still reading. + if not settings.retention.allow_unbounded_phi: + defaulted = [ + w + for w in auto_bounded_windows() + if w.field not in getattr(settings, w.reads_from.strip("[]")).model_fields_set + ] + for window in defaulted: + setattr( + getattr(settings, window.reads_from.strip("[]")), + window.field, + window.auto_bound_days, + ) + if defaulted: print( - f"error: the PHI retention classification has shrunk to " - f"{len(PHI_RETENTION_WINDOWS)} windows (floor {MIN_PHI_RETENTION_WINDOWS}); refusing " - "to start rather than gate on a partial classification. This is a build defect, not a " - "configuration one — see messagefoundry/config/retention_classification.py.", + f"info: {', '.join(w.setting for w in defaulted)} defaulted ON (30 days) for a PHI " + f"instance ({env_name!r}) — these PHI tiers are now bounded at rest " + "(secure-by-default, ASVS 14.2.7). Set an explicit window to override, or " + "[security].allow_keeping_phi_indefinitely=true to retain indefinitely.", file=sys.stderr, ) - return 2 - # AUTO-BOUND. Owner ruling 2026-07-30: the three PHI-BODY windows default to 30 days when - # UNSET, on BOTH dials — previously this ran only when `not enforcing`, so on the shipped - # `enforce` posture an unset window took the refusal below instead of a default. - # - # THE SAFETY TRADE IS DELIBERATE AND WORTH STATING: a production PHI instance with an unset - # window used to REFUSE TO START, which forced an operator to choose a number. It now starts - # with 30. What survives is the fail-closed path for an EXPLICIT 0 — choosing keep-forever is - # still refused unless the audited opt-out is set. So "unbounded by accident" is still - # prevented; "unbounded by inattention" becomes "30 days by inattention". - # - # The warn-only windows are NOT auto-bounded, and that is also a ruling rather than an - # omission: `purge_state` and `purge_search_presets` key on timestamps that only move on a - # WRITE, so silently bounding them deletes live operational data a Handler is still reading. + # REFUSE / WARN. `unbounded_windows` skips the tiers where 0 does not mean unbounded + # (`connection_event_retention_hours` INHERITS the body window; `uploads_retention_days` has a + # ge=1 floor so 0 is unrepresentable) and those whose `requires_setting` is unmet — with no + # [logging].log_dir there is nothing for the app-log sweep to sweep. + still_unbounded = _unbounded_windows(settings) + refusable = [w for w in still_unbounded if w.auto_bound_days is not None] + warn_only = [w for w in still_unbounded if w.auto_bound_days is None] + + if warn_only: + # Classified and warned, never refused. Naming the tier AND its protection level is the + # point: an operator who sees "PL-1" knows a full body is involved. + print( + "warning: these classified PHI tiers have no retention window on a PHI instance " + f"({env_name!r}) and will accumulate without bound: " + + ", ".join(f"{w.setting} ({w.level})" for w in warn_only) + + ". They are deliberately NOT defaulted — each keys on a timestamp that only moves on " + "a write, so a silent default would delete data still in use (ASVS 14.2.7).", + file=sys.stderr, + ) + + if refusable: + windows_desc = ", ".join(w.setting for w in refusable) if not settings.retention.allow_unbounded_phi: - defaulted = [ - w - for w in auto_bounded_windows() - if w.field not in getattr(settings, w.reads_from.strip("[]")).model_fields_set - ] - for window in defaulted: - setattr( - getattr(settings, window.reads_from.strip("[]")), - window.field, - window.auto_bound_days, - ) - if defaulted: + if enforcing: print( - f"info: {', '.join(w.setting for w in defaulted)} defaulted ON (30 days) for a PHI " - f"instance ({env_name!r}) — these PHI tiers are now bounded at rest " - "(secure-by-default, ASVS 14.2.7). Set an explicit window to override, or " - "[security].allow_keeping_phi_indefinitely=true to retain indefinitely.", + f"error: a data-retention window is explicitly disabled for {windows_desc} on " + f"a {'production ' if production else ''}PHI instance ({env_name!r}); refusing " + "to start — PHI message bodies would be retained indefinitely (unbounded PHI " + "at rest, ASVS 14.2.4/14.2.7). Set the window(s) to a positive number of days " + "(e.g. 30); or, to deliberately retain forever, set " + "[security].allow_keeping_phi_indefinitely=true (audited).", file=sys.stderr, ) - - # REFUSE / WARN. `unbounded_windows` skips the tiers where 0 does not mean unbounded - # (`connection_event_retention_hours` INHERITS the body window; `uploads_retention_days` has a - # ge=1 floor so 0 is unrepresentable) and those whose `requires_setting` is unmet — with no - # [logging].log_dir there is nothing for the app-log sweep to sweep. - still_unbounded = _unbounded_windows(settings) - refusable = [w for w in still_unbounded if w.auto_bound_days is not None] - warn_only = [w for w in still_unbounded if w.auto_bound_days is None] - - if warn_only: - # Classified and warned, never refused. Naming the tier AND its protection level is the - # point: an operator who sees "PL-1" knows a full body is involved. + return 2 print( - "warning: these classified PHI tiers have no retention window on a PHI instance " - f"({env_name!r}) and will accumulate without bound: " - + ", ".join(f"{w.setting} ({w.level})" for w in warn_only) - + ". They are deliberately NOT defaulted — each keys on a timestamp that only moves on " - "a write, so a silent default would delete data still in use (ASVS 14.2.7).", + f"warning: no data-retention window is configured for {windows_desc} in a " + f"PHI-carrying environment ({env_name!r}) — PHI message bodies accumulate without " + "bound. Set the window(s) to bound PHI at rest (ASVS 14.2.4).", + file=sys.stderr, + ) + elif enforcing: + # Explicit, audited override: unbounded PHI retention under strict enforcement. + logging.getLogger(__name__).warning( + "AUDIT: starting a %sPHI instance (environment %r) with unbounded data " + "retention ([security].allow_keeping_phi_indefinitely=true; %s = 0) — PHI message " + "bodies are retained INDEFINITELY (retention opt-out override).", + "production " if production else "", + env_name, + windows_desc, + ) + print( + f"warning: [security].allow_keeping_phi_indefinitely=true — a " + f"{'production ' if production else ''}PHI instance " + f"({env_name!r}) retains PHI message bodies indefinitely ({windows_desc} unset). " + "Configure a window to bound PHI at rest.", file=sys.stderr, ) - - if refusable: - windows_desc = ", ".join(w.setting for w in refusable) - if not settings.retention.allow_unbounded_phi: - if enforcing: - print( - f"error: a data-retention window is explicitly disabled for {windows_desc} on " - f"a {'production ' if production else ''}PHI instance ({env_name!r}); refusing " - "to start — PHI message bodies would be retained indefinitely (unbounded PHI " - "at rest, ASVS 14.2.4/14.2.7). Set the window(s) to a positive number of days " - "(e.g. 30); or, to deliberately retain forever, set " - "[security].allow_keeping_phi_indefinitely=true (audited).", - file=sys.stderr, - ) - return 2 - print( - f"warning: no data-retention window is configured for {windows_desc} in a " - f"PHI-carrying environment ({env_name!r}) — PHI message bodies accumulate without " - "bound. Set the window(s) to bound PHI at rest (ASVS 14.2.4).", - file=sys.stderr, - ) - elif enforcing: - # Explicit, audited override: unbounded PHI retention under strict enforcement. - logging.getLogger(__name__).warning( - "AUDIT: starting a %sPHI instance (environment %r) with unbounded data " - "retention ([security].allow_keeping_phi_indefinitely=true; %s = 0) — PHI message " - "bodies are retained INDEFINITELY (retention opt-out override).", - "production " if production else "", - env_name, - windows_desc, - ) - print( - f"warning: [security].allow_keeping_phi_indefinitely=true — a " - f"{'production ' if production else ''}PHI instance " - f"({env_name!r}) retains PHI message bodies indefinitely ({windows_desc} unset). " - "Configure a window to bound PHI at rest.", - file=sys.stderr, - ) # --- #188 out-of-band security notifications effective by default (ASVS 6.3.5/6.3.7) ------------- # The per-user security-event push (lockout, password/email/roles change, new-IP admin action) @@ -2790,7 +2773,7 @@ def _serve(args: argparse.Namespace) -> int: # writing). "Effective channel" == notify_security_events on + SMTP host + sender (parity with the # app.py notifier wiring). Skipped when auth is disabled (no accounts to notify — a non-loopback # no-auth serve is already refused elsewhere). - if data_class is DataClass.PHI and settings.auth.enabled: + if settings.auth.enabled: security_channel_ready = bool( settings.auth.notify_security_events and settings.alerts.email_smtp_host @@ -2858,11 +2841,7 @@ def _serve(args: argparse.Namespace) -> int: # # Gated on a CONFIGURED transport: with no email_smtp_host/email_from there is no hop to protect, # and the #188 gate above already owns the "no channel at all" case. - if ( - data_class is DataClass.PHI - and settings.alerts.email_smtp_host - and settings.alerts.email_from - ): + if settings.alerts.email_smtp_host and settings.alerts.email_from: if not settings.alerts.email_use_tls: hop_desc = "[alerts].email_use_tls=false (the SMTP hop is CLEARTEXT)" elif not settings.alerts.email_tls_verify: @@ -2948,8 +2927,9 @@ def _serve(args: argparse.Namespace) -> int: # # WARN BY DEFAULT; REFUSE ONLY ON AN OPT-IN. This is the load-bearing scoping decision and it is # not a softening — it is the only shape that does not hard-stop deployments that boot today: - # * ADR 0148 makes EVERY built-in environment name derive DataClass.PHI, `dev` included, so an - # exposed dev/test instance that declared nothing at all is a PHI instance by derivation; + # * every instance carries patient data (BACKLOG #1279, after ADR 0148 GIVEN 1 made every + # built-in environment name derive PHI), so an exposed dev/test instance that declared + # nothing at all is in scope and nothing can declare it out; # * "exposed" includes the loopback-behind-proxy topology OFF-LOOPBACK-DEPLOYMENT.md actually # RECOMMENDS (it declares tls_terminated_upstream), which the Posture-B gate 400 lines above # deliberately spares from its own refusal for exactly this reason; @@ -2990,9 +2970,7 @@ def _serve(args: argparse.Namespace) -> int: # is exactly how the ASVS 11.7.1 arm and the ASVS 6.3.3 arm came to disagree about whether the same # boot was exposed. memory_declared = settings.security.memory_encryption_operator_declared - memory_undeclared_at_exposure = ( - instance_exposed and data_class is DataClass.PHI and not memory_declared - ) + memory_undeclared_at_exposure = instance_exposed and not memory_declared # Read the platform ONLY when one of the two branches below will consume the answer. A stock # loopback/synthetic start must not pay for a read it discards — on Linux that is a # /proc/cpuinfo read (hundreds of KB on a large host) plus two device stats. @@ -5031,14 +5009,13 @@ def _ai_policy(args: argparse.Namespace) -> int: return 2 ai = settings.ai - data_class, prod = ai.derived_posture() - production = True if prod is None else prod # unresolved posture -> strictest ceiling + prod = ai.derived_posture() + production = True if prod is None else prod # unresolved tier -> strictest ceiling eff = resolve_effective_policy(mode=ai.mode, data_scope=ai.data_scope, production=production) payload = { "mode": eff.mode.value, "data_scope": eff.data_scope.value, "environment": ai.environment, - "data_class": data_class.value if data_class is not None else None, "production": production, "assist_permitted": None, # RBAC is not evaluable offline "reason": eff.reason, diff --git a/messagefoundry/api/_ui_seam.py b/messagefoundry/api/_ui_seam.py index 1968c692a..9fe097b60 100644 --- a/messagefoundry/api/_ui_seam.py +++ b/messagefoundry/api/_ui_seam.py @@ -134,7 +134,7 @@ #: proof is that commit 40a4d5d9 added a REQUIRED ``UploadedFileList.scope`` field the console renders #: unconditionally while touching no seam file at all. Regenerate with #: ``python scripts/webconsole_seam_snapshot.py --write``; never hand-edit it to silence a gate. -ENGINE_UI_SEAM: str = "518e7f18968a2ce7" +ENGINE_UI_SEAM: str = "8753d225e1094901" @dataclass(frozen=True, slots=True) diff --git a/messagefoundry/api/app.py b/messagefoundry/api/app.py index f193123ee..34bf436bc 100644 --- a/messagefoundry/api/app.py +++ b/messagefoundry/api/app.py @@ -224,7 +224,6 @@ from messagefoundry.config.ai_policy import ( AiDataScope, AiMode, - DataClass, resolve_effective_policy, ) from messagefoundry.config.connections_file import CONNECTIONS_FILE_NAME @@ -1625,8 +1624,8 @@ async def ai_policy( ``assist_permitted`` carries the identity-dependent bit (``None`` = RBAC not evaluable, i.e. no/invalid token under enabled auth). Policy reads are not audited in this MVP.""" ai = getattr(request.app.state, "ai", None) or AiSettings() - data_class, prod = ai.derived_posture() - production = True if prod is None else prod # unresolved posture -> strictest ceiling + prod = ai.derived_posture() + production = True if prod is None else prod # unresolved tier -> strictest ceiling eff = resolve_effective_policy( mode=ai.mode, data_scope=ai.data_scope, production=production ) @@ -1635,7 +1634,6 @@ async def ai_policy( mode=eff.mode, data_scope=eff.data_scope, environment=ai.environment, - data_class=data_class, production=production, assist_permitted=permitted, reason=eff.reason, @@ -1660,8 +1658,8 @@ async def ai_chat( Every use is audited on the EXISTING hash-chained ``audit_log`` with **PHI-safe metadata only** — never the prompt, the reply, or the provider key.""" ai = getattr(request.app.state, "ai", None) or AiSettings() - _data_class, prod = ai.derived_posture() - production = True if prod is None else prod # unresolved posture -> strictest ceiling + prod = ai.derived_posture() + production = True if prod is None else prod # unresolved tier -> strictest ceiling # RE-RESOLVE server-side. NEVER trust the IDE-claimed mode/scope — the policy comes from [ai]. eff = resolve_effective_policy( mode=ai.mode, data_scope=ai.data_scope, production=production @@ -1749,11 +1747,12 @@ async def security_posture( # (the lifespan/managed-app stashes the resolved StoreSettings); fall back to defaults if absent. store = getattr(request.app.state, "store_settings", None) or StoreSettings() ai = getattr(request.app.state, "ai", None) or AiSettings() - data_class, production = ai.derived_posture() + production = ai.derived_posture() backend = store.backend.value - # ADR 0118: the effective [security] switch values + active loosenings + the synthetic-relaxation - # notice. security is the resolved SecuritySettings the serve path stashed (defaults on the - # test/embedding path). No secret material — these are booleans/ints only. + # ADR 0118: the effective [security] switch values + active loosenings. security is the + # resolved SecuritySettings the serve path stashed (defaults on the test/embedding path). No + # secret material — these are booleans/ints only. The synthetic-relaxation notice this route + # used to carry went with the declaration it described (BACKLOG #1279). security = getattr(request.app.state, "security", None) or SecuritySettings() # [store]/[auth] carry posture switches too (ADR 0148: one posture, loosen only), so the registry # needs them to report a COMPLETE list. Same stash-or-default pattern as `store` above. @@ -1818,13 +1817,6 @@ async def security_posture( detail=store_privilege.detail, ) ) - synthetic_relaxation = ( - "strict PHI-only controls (at-rest-encryption refusal, deny-by-default egress, bounded " - "retention) are relaxed: this instance is marked synthetic " - "(handles_real_patient_data=false), so it carries no ePHI" - if data_class is not None and data_class is not DataClass.PHI - else None - ) # FIPS-provider attestation of the interpreter's ssl/_hashlib OpenSSL (report-only, #73 / ADR 0120): # metadata (a boolean + version string), never key material, never enforced. fips_mode, openssl_version = fips_attestation() @@ -1855,7 +1847,6 @@ async def security_posture( client=client_ip(request), ) return SecurityPosture( - data_class=data_class, production=production, enforcement=security.enforcement, environment=ai.environment, @@ -1870,7 +1861,6 @@ async def security_posture( loosenings=loosenings, loosenings_scope=loosenings_scope, store_privilege=store_privilege_view, - synthetic_relaxation=synthetic_relaxation, fips_mode=fips_mode, # interpreter ssl/_hashlib OpenSSL FIPS-provider state; None=undeterminable openssl_version=openssl_version, # that OpenSSL's version string (public metadata) kex_groups=kex_groups, # report-only: are the approved KEX groups pinned or inherited (#338)? @@ -5845,10 +5835,12 @@ async def _assert_security_notice_is_deliverable( *, auth_settings: AuthSettings | None, alerts_settings: AlertsSettings | None, - ai_settings: AiSettings | None, security_settings: SecuritySettings | None, ) -> None: - """BACKLOG #1020: refuse to serve a PHI instance whose security notices reach NOBODY. + """BACKLOG #1020: refuse to serve an instance whose security notices reach NOBODY. + + It used to skip a box declared synthetic; that declaration is retired and every instance carries + patient data (BACKLOG #1279), so the check now runs wherever notices are configured at all. The serve gate in ``messagefoundry/__main__.py`` already refuses without a notification channel, but it computes readiness from ``notify_security_events`` + ``email_smtp_host`` + ``email_from`` @@ -5888,9 +5880,6 @@ async def _assert_security_notice_is_deliverable( alerts = alerts_settings or AlertsSettings() if not alerts.security_notifications_required: return # the audited, in-writing opt-out -- the pull-only feed is accepted - data_class, _production = (ai_settings or AiSettings()).derived_posture() - if data_class is not DataClass.PHI: - return for user in await store.list_users(): if user.disabled or not user.notify_email: continue @@ -6523,7 +6512,6 @@ async def _audit_upload_prune(meta: UploadedFileMeta) -> None: store, auth_settings=auth_settings, alerts_settings=alerts_settings, - ai_settings=ai_settings, security_settings=security_settings, ) if not auth.webauthn_available() and await store.any_webauthn_credentials(): diff --git a/messagefoundry/api/models.py b/messagefoundry/api/models.py index 68a04503f..7e2acc126 100644 --- a/messagefoundry/api/models.py +++ b/messagefoundry/api/models.py @@ -47,7 +47,6 @@ from messagefoundry.config.ai_policy import ( AiDataScope, AiMode, - DataClass, SecurityEnforcement, ) @@ -1045,7 +1044,6 @@ class AiPolicy(BaseModel): mode: AiMode data_scope: AiDataScope environment: str | None # the free-form active-environment NAME (ADR 0017) - data_class: DataClass | None = None # PHI posture (synthetic|phi), if resolvable production: bool | None = None # production-tier posture, if resolvable assist_permitted: bool | None reason: str | None = None @@ -1116,14 +1114,13 @@ class SecurityPosture(BaseModel): **No secret material ever appears here** (SECRET-1): ``key_id`` is only the active key's one-way **fingerprint** (the first 16 hex of SHA-256(key)), never key bytes, and ``key_source`` is the - provider *name*, not a credential. ``data_class``/``production`` are the resolved posture; + provider *name*, not a credential. ``production`` is the resolved tier; ``encryption_enabled`` is read from the *live* store cipher (not just config). ``plaintext_columns`` lists any PHI-bearing columns that stay UNENCRYPTED at rest on the active backend — ``[]`` on every backend now (the SQL Server ``error``/``last_error``/``message_events.detail`` residual was retired by H4; SQLite, Postgres, and SQL Server all have full at-rest coverage of the PHI-bearing columns). """ - data_class: DataClass | None = None # resolved PHI posture (synthetic|phi), if resolvable production: bool | None = None # production-tier posture, if resolvable # The security REFUSE/WARN dial (this refactor): enforce (secure default) reproduces the historical # production=True refuse posture; warn reproduces the non-production warn+continue. Decoupled from the @@ -1136,7 +1133,7 @@ class SecurityPosture(BaseModel): key_id: str | None = ( None # active key FINGERPRINT only (first 16 hex of SHA-256(key)); never bytes ) - require_encryption: bool # whether keyless start is refused regardless of data_class + require_encryption: bool # forces the keyless refusal even past allow_unencrypted_phi allow_unencrypted_phi: bool # whether the audited keyless-PHI override is set # PHI-bearing columns NOT encrypted at rest on this backend; empty on every backend (the SQL Server # error/last_error/detail residual was retired by H4) or when encryption is off, where it is N/A. @@ -1159,9 +1156,10 @@ class SecurityPosture(BaseModel): store_privilege: StorePrivilegeView = Field( default_factory=lambda: StorePrivilegeView(status=STORE_PRIVILEGE_NOT_PROBED) ) - # Set WHERE handles_real_patient_data=false: the strict PHI-only controls (at-rest-encryption refusal, - # deny-by-default egress, bounded retention) are relaxed because the instance carries no ePHI (AC-6). - synthetic_relaxation: str | None = None + # `synthetic_relaxation` SAT HERE and is gone with the declaration it described (BACKLOG #1279). + # It reported that the strict PHI controls were relaxed instance-wide. Every instance carries + # patient data now, so there is no such state to report: a relaxed control is a per-gate switch and + # appears in `loosenings` above, named individually. # FIPS-provider attestation (report-only, #73 / ADR 0120). ``fips_mode`` is the FIPS-provider state of # the INTERPRETER's ssl/_hashlib OpenSSL (True/False, or None = undeterminable on a non-OpenSSL build); # ``openssl_version`` is that OpenSSL's version string. Metadata only — NOT secret material (SECRET-1), @@ -1204,8 +1202,7 @@ class SecurityPosture(BaseModel): # The disclaimer that TRAVELS WITH THE ARTIFACT. ADR 0152 designates this endpoint the evidence # artifact for 11.7.1, and every other disclaimer this feature writes lives where an assessor # never looks (comments, docstrings, the ADR, the console HTML). Always populated, on every - # posture, precisely so no reading of this response is missing it. Prose-in-posture has precedent - # on this same model — see ``synthetic_relaxation`` above. + # posture, precisely so no reading of this response is missing it. memory_encryption_note: str | None = None # [security].allowed_client_networks observability. A control nobody can see firing is a control # that gets ripped back out the first time someone cannot reach the console, so these answer "is it diff --git a/messagefoundry/checks.py b/messagefoundry/checks.py index cee1e7f2d..7c41e5bb0 100644 --- a/messagefoundry/checks.py +++ b/messagefoundry/checks.py @@ -36,8 +36,8 @@ A third required check, ``posture``, is **best-effort**: when a ``messagefoundry.toml`` is present (searched from ``config_dir`` upward + the CWD) it loads the service settings and — if an active -environment is set whose security posture is unresolved (a *custom* name with no ``[ai].data_class`` -/ ``[ai].production``) — it FAILS, mirroring ``serve``'s fail-closed ``require_posture()`` so the +environment is set whose production tier is unresolved (a *custom* name with no +``[security].production_instance``) — it FAILS, mirroring ``serve``'s fail-closed ``require_posture()`` so the foot-gun is caught at commit/CI time instead of at runtime. No ``messagefoundry.toml`` → SKIP. A fourth required check, ``build-check``, runs the **posture-stamped** ``build_check_registry`` that @@ -1280,7 +1280,7 @@ def _check_posture( suppress_search: bool = False, ) -> CheckResult: """Catch the ADR-0017 foot-gun at commit/CI time: a CUSTOM active-environment name (not - dev/staging/prod) with no explicit ``[ai].data_class`` / ``[ai].production`` makes ``serve`` fail + dev/staging/prod) with no explicit ``[security].production_instance`` makes ``serve`` fail closed at runtime (``settings.ai.require_posture()``). Mirror that fail-closed check here. Service-toml resolution (ADR 0050 AC-6): an explicit ``service_config`` is used as-is; otherwise, @@ -1328,19 +1328,18 @@ def _check_posture( "posture", ok=True, required=True, skipped=True, detail="no active environment set" ) try: - data_class, production = settings.ai.require_posture() + production = settings.ai.require_posture() except ValueError as exc: - # A custom env name with no explicit posture: serve refuses to start. Fail the gate now, - # naming the missing keys exactly as serve's error does. + # A custom env name with no explicit tier: serve refuses to start. Fail the gate now, + # naming the missing key exactly as serve's error does. return CheckResult("posture", ok=False, required=True, detail=str(exc)) + # No data class is reported because there is no longer one to resolve: every instance carries + # patient data (BACKLOG #1279), so the PHI gates apply to whatever this config describes. return CheckResult( "posture", ok=True, required=True, - detail=( - f"environment {settings.ai.environment!r}: " - f"data_class={data_class.value}, production={production}" - ), + detail=(f"environment {settings.ai.environment!r}: production={production}"), ) diff --git a/messagefoundry/config/ai_policy.py b/messagefoundry/config/ai_policy.py index f54616091..dd5dc190d 100644 --- a/messagefoundry/config/ai_policy.py +++ b/messagefoundry/config/ai_policy.py @@ -16,9 +16,15 @@ The instance's **production** posture flag imposes a ceiling on ``data_scope`` so the same config behaves conservatively on a non-production instance and only reaches ``phi`` on a production instance under a BAA mode. ``mode`` itself is never clamped — a central ``off`` is honored everywhere. Posture -is **decoupled from the environment *name*** (ADR 0017): an instance is ``production`` (and/or -PHI-carrying, see :class:`DataClass`) regardless of whether it is literally named ``prod`` — so an -org can name instances ``poc``/``test``/… while choosing posture explicitly. +is **decoupled from the environment *name*** (ADR 0017): an instance is ``production`` regardless of +whether it is literally named ``prod`` — so an org can name instances ``poc``/``test``/… while +choosing posture explicitly. + +**Every instance carries patient data (BACKLOG #1279).** There is no longer a data-class axis: the +``[security].handles_real_patient_data`` lever and its ``DataClass`` enum are gone, and the PHI +gates apply unconditionally. ``data_scope`` below is a *different* axis — it bounds what context an +AI request may carry, not what the instance holds — so its ``synthetic`` member is unrelated and +stays. This module is **pure** (no I/O) and imports nothing from :mod:`messagefoundry.config.settings` (the dependency is one-way: settings imports these enums, not the reverse, to avoid a cycle). It is @@ -63,18 +69,6 @@ class AiDataScope(str, Enum): # noqa: UP042 PHI = "phi" # real message bodies (only over a BAA + zero-retention provider) -class DataClass(str, Enum): # noqa: UP042 - """Whether an instance handles real PHI, **independent of its (free-form) environment name**. - - Drives the at-rest-encryption + open-egress startup advisories (a synthetic instance stays quiet; - a ``phi`` instance is warned). The AI data-scope ceiling keys off the separate ``production`` flag, - not this. Decoupling the data class from the environment name (ADR 0017) lets an org name instances - freely (``poc``/``test``/…) while choosing posture explicitly. The value is the wire string.""" - - SYNTHETIC = "synthetic" # synthetic/sample data only — relaxed at-rest/egress posture - PHI = "phi" # carries real PHI — encryption + egress advisories apply - - class SecurityEnforcement(str, Enum): # noqa: UP042 """How the serve-gate REFUSE/WARN dial + the ADR 0092 escape-clamp behave, **decoupled** from the instance's production *tier* fact (ADR 0017 / this refactor). @@ -110,7 +104,7 @@ class EffectivePolicy: ``reason`` is a human-readable, ``"; "``-joined note of every clamp applied (``None`` when the requested policy passed through unchanged) — surfaced in the API/CLI so an operator can see *why* the effective scope differs from what was configured. The environment *name* and the posture - (``data_class``/``production``) are carried by the caller's wire model, not here. + (``production``) is carried by the caller's wire model, not here. """ mode: AiMode diff --git a/messagefoundry/config/settings.py b/messagefoundry/config/settings.py index 2de9918d8..14784fec6 100644 --- a/messagefoundry/config/settings.py +++ b/messagefoundry/config/settings.py @@ -51,7 +51,6 @@ from messagefoundry.config.ai_policy import ( AiDataScope, AiMode, - DataClass, SecurityEnforcement, ) from messagefoundry.config.models import ( @@ -104,7 +103,6 @@ "AiSettings", "AiMode", "AiDataScope", - "DataClass", "SecurityEnforcement", "EgressSettings", "ShadowSettings", @@ -272,8 +270,8 @@ def weakened_tls_escape_permitted(posture: HopPosture | None = None) -> bool: """Whether ``MEFOR_ALLOW_INSECURE_TLS`` may permit a weakened / verify-off TLS hop under ``posture``, CLAMPED so an enforcing PHI hop is NEVER relaxed (#200, ADR 0092 decision 2). - The is_phi-blind **weakened-TLS / cleartext-escape** cells route their global-escape check through - here so the blunt escape can no longer silence an **enforcing PHI** refusal (matching the + The **weakened-TLS / cleartext-escape** cells route their global-escape check through + here so the blunt escape can no longer silence an **enforcing** refusal (matching the ``--allow-insecure-bind`` API-bind clamp). That is **at least** the engine<->store TLS gate (:func:`~messagefoundry.store.sqlserver.connection_string` / ``store.postgres._build_ssl``), the MLLP and FTPS ``tls_verify=false`` contexts and the credentialed plain-``ftp`` guard, **and — since #329 —** @@ -286,12 +284,16 @@ def weakened_tls_escape_permitted(posture: HopPosture | None = None) -> bool: be set at all, AND the hop must not be enforcing PHI. ``None`` (a backup utility / embedding / test outside the construction gate) falls back to the **unclamped** escape — byte-identical to pre-#200 — since the enforced serve/reload gate already vetted the real - production posture, so this fallback never loosens the clamp.""" + production posture, so this fallback never loosens the clamp. + + The clamp used to require an enforcing **PHI** hop. Every instance carries patient data now + (BACKLOG #1279), so the second conjunct could not vary and is gone: under ``enforce`` the blunt + escape is inert, full stop.""" if not insecure_tls_allowed(): return False if posture is None: return True - return not (posture.enforcing and posture.is_phi) + return not posture.enforcing def weakened_tls_escape_permitted_here() -> bool: @@ -418,16 +420,16 @@ class StoreSettings(_Section): # (ASVS 11.2.2) until `messagefoundry rotate-key` finishes re-encrypting under the active key. # Secret — env-only (MEFOR_STORE_ENCRYPTION_KEYS_RETIRED). Empty = none. encryption_keys_retired: str = "" - # When true, `serve` refuses to start without an encryption key (any environment, any data_class). - # Off by default. See docs/PHI.md §3. (Independent of the data_class-gated keyless refusal below: - # this forces the refusal even for a synthetic/non-PHI instance.) + # When true, `serve` refuses to start without an encryption key even when the audited opt-out + # below is set. Off by default. See docs/PHI.md §3. require_encryption: bool = False - # Explicit, audited opt-out of the data_class-gated keyless refusal (H3, OWASP *Fail Securely* / SDS - # §4.3 PW.9). By default a PHI-carrying instance (`[ai].data_class == phi`, ANY environment) REFUSES - # to start with no encryption key — secure-by-default. Setting this true is the loud, deliberate - # override that lets such an instance start keyless (it still emits the UNENCRYPTED-at-rest warning - # and the override is audited at startup). It does NOT override `require_encryption=true` (that wins). - # A synthetic/non-PHI instance never needs this — it stays key-free regardless (CI parity). + # Explicit, audited opt-out of the keyless-start refusal (H3, OWASP *Fail Securely* / SDS §4.3 + # PW.9). Every instance carries patient data (BACKLOG #1279), so by default EVERY instance, in + # ANY environment, REFUSES to start with no encryption key — secure-by-default. Setting this true + # is the loud, deliberate override that lets one start keyless (it still emits the + # UNENCRYPTED-at-rest warning and the override is audited at startup). It does NOT override + # `require_encryption=true` (that wins). Under [security].enforcement = enforce it needs a SECOND + # acknowledgment, `allow_unencrypted_phi_under_strict_enforcement` (ADR 0140). allow_unencrypted_phi: bool = False # Windows DPAPI-protected key file (WP-11d, ASVS 13.3.1): a path produced by # `messagefoundry protect-key`. When `encryption_key` is unset and this is set, the active key is @@ -2574,20 +2576,22 @@ def effective_oidc_username_domains(self) -> tuple[str, ...]: #: it must be a safe single path segment). _ENV_NAME_ALLOWED = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") -#: Built-in environment names whose security posture (data_class, production) is derived when -#: ``[ai].data_class`` / ``[ai].production`` are left unset — back-compat with the original -#: dev/staging/prod tiers. A CUSTOM name must set posture explicitly (it is never inferred from a -#: free-form string), so a 'test'/'poc' instance can never default permissive (ADR 0017). -#: GIVEN 1 (ADR 0148): the default env ``dev`` derives **PHI** too — the default/CI path runs the -#: PHI-carrying posture (secure-by-default, so encryption/egress/retention are exercised, not first met -#: in production). A genuinely-synthetic dev/CI box must declare ``[security].handles_real_patient_data -#: = false`` — a loud, audited opt-out. Only ``production`` still differs across the three (prod alone is -#: the production tier, which drives the AI data-scope ceiling + the DEBUG-log refusal, not the security -#: refuse/warn dial — that is ``[security].enforcement``, GIVEN 2). -_KNOWN_ENV_POSTURE: dict[str, tuple[DataClass, bool]] = { - "dev": (DataClass.PHI, False), - "staging": (DataClass.PHI, False), - "prod": (DataClass.PHI, True), +#: Built-in environment names whose **production tier** is derived when ``[security] +#: .production_instance`` is left unset — back-compat with the original dev/staging/prod tiers. A +#: CUSTOM name must set it explicitly (it is never inferred from a free-form string), so a +#: 'test'/'poc' instance can never default permissive (ADR 0017). +#: +#: **This used to carry a data class as well, and no longer does (BACKLOG #1279).** ADR 0148 GIVEN 1 +#: had already made all three names derive PHI; what remained was a per-instance opt-out +#: (``[security].handles_real_patient_data = false``) that silenced the whole PHI gate family at +#: once. That combined override is retired: every instance carries patient data, and an operator who +#: needs a specific gate relaxed uses that gate's own switch. The tier below still differs across the +#: three — prod alone is the production tier, which drives the AI data-scope ceiling and the +#: DEBUG-log refusal, not the security refuse/warn dial (that is ``[security].enforcement``). +_KNOWN_ENV_POSTURE: dict[str, bool] = { + "dev": False, + "staging": False, + "prod": True, } #: BACKLOG #95 -- the ``[ai].provider`` values the engine can actually SERVICE. @@ -2616,9 +2620,10 @@ class AiSettings(_Section): ``environment`` is the **free-form** active-environment name (ADR 0017): it selects ``environments/.toml`` and is what ``current_environment()`` returns. It has **no default** — ``serve`` requires it, so a missing env can never silently resolve another environment's - values/secrets. ``data_class`` / ``production`` are the explicit security posture, **decoupled from - the name**: for the built-in names dev/staging/prod they are derived when unset, but a custom name - must set them (see :meth:`require_posture`).""" + values/secrets. ``production`` is the explicit production **tier**, **decoupled from the name**: + for the built-in names dev/staging/prod it is derived when unset, but a custom name must set it + (see :meth:`require_posture`). There is no data-class axis — every instance carries patient data + (BACKLOG #1279).""" mode: AiMode = AiMode.BYO data_scope: AiDataScope = AiDataScope.CODE_ONLY @@ -2626,10 +2631,9 @@ class AiSettings(_Section): # current_environment() returns. No default — serve requires it (a missing env must never silently # resolve another env's values/secrets). environment: str | None = None - # Explicit security POSTURE, decoupled from the name. Unset is derived from a built-in name - # (ADR 0148 GIVEN 1: dev->phi/non-prod, staging->phi/non-prod, prod->phi/prod); a custom name must - # set them. The refuse/warn dial is [security].enforcement (GIVEN 2), not `production`. - data_class: DataClass | None = None + # Explicit production TIER, decoupled from the name. Unset is derived from a built-in name + # (dev/staging -> non-prod, prod -> prod); a custom name must set it. The refuse/warn dial is + # [security].enforcement (ADR 0148 GIVEN 2), not `production`. production: bool | None = None # --- engine broker (ADR 0135 / BACKLOG #95) ------------------------------------------------ @@ -2682,64 +2686,47 @@ def _valid_environment_name(cls, v: str | None) -> str | None: ) return v - def derived_posture(self) -> tuple[DataClass | None, bool | None]: - """``(data_class, production)`` with built-in-name derivation applied where each is unset. - - Either element may still be ``None`` when a *custom* environment name leaves it unset — callers - that need a definite posture use :meth:`require_posture` (fail-closed) or default the missing - ``production`` to ``True`` (strictest ceiling) for an advisory read.""" - dc, prod = self.data_class, self.production - known = _KNOWN_ENV_POSTURE.get(self.environment or "") - if known is not None: - if dc is None: - dc = known[0] - if prod is None: - prod = known[1] - return dc, prod - - def require_posture(self) -> tuple[DataClass, bool]: - """The fail-closed ``(data_class, production)`` posture; raises ``ValueError`` when a custom or - unset environment name has no explicit posture. Used at ``serve`` so a custom env never defaults - permissive (ADR 0017).""" - dc, prod = self.derived_posture() - if dc is None or prod is None: + def derived_posture(self) -> bool | None: + """The production **tier** with built-in-name derivation applied when it is unset. + + Still ``None`` when a *custom* environment name leaves it unset — callers that need a definite + answer use :meth:`require_posture` (fail-closed) or default it to ``True`` (strictest ceiling) + for an advisory read. + + It no longer returns a data class. Every instance carries patient data (BACKLOG #1279), so + there is nothing left to derive on that axis and no caller has to ask.""" + if self.production is not None: + return self.production + return _KNOWN_ENV_POSTURE.get(self.environment or "") + + def require_posture(self) -> bool: + """The fail-closed production tier; raises ``ValueError`` when a custom or unset environment + name has no explicit tier. Used at ``serve`` so a custom env never defaults permissive + (ADR 0017).""" + prod = self.derived_posture() + if prod is None: raise ValueError( f"environment {self.environment!r} has no built-in security posture (not one of " - "dev/staging/prod); set [security].handles_real_patient_data (true|false) and " - "[security].production_instance (true|false) explicitly" + "dev/staging/prod); set [security].production_instance (true|false) explicitly" ) - return dc, prod + return prod def hop_posture_from_ai(ai: AiSettings, *, enforcement: SecurityEnforcement) -> HopPosture: """The instance's :class:`~messagefoundry.config.tls_policy.HopPosture` for the #200 hop-refusal gate. - Maps the AI section's *derived* ``is_phi`` (built-in dev/staging/prod derivation applied) plus the - explicit ``[security].enforcement`` level onto the ``(is_phi, enforcing)`` the transport cells decide - on. ``is_phi`` keys on ``data_class == phi`` being *explicitly* declared — an **undeclared** - ``data_class`` is **not** PHI, exactly as the keyless-refusal (§3), ``[egress]`` and #906 Posture-B - gates all key on ``data_class == phi`` being set: a bare/default on-prem config carries no PHI - assertion, so its hops stay byte-identical (never newly refused). ``enforcing`` is - ``enforcement is ENFORCE`` (the secure default), which re-keys the REFUSE/WARN dial off the old - production-tier flag onto the explicit enforcement level: at the default it reproduces the historical - ``production=True`` refuse — splitting a declared-PHI hop between ENFORCE-REFUSE and WARN-WARN. The - construction gate stamps the result via ``tls_policy.active_hop_posture`` (ADR 0092).""" - data_class, _production = ai.derived_posture() - if data_class is not None: - # Resolved (a known env or an explicit data_class): PHI only if it is *phi*. - is_phi: bool | None = data_class is DataClass.PHI - elif ai.environment is None: - # Bare/default config — no environment AND no data_class declared. This carries no PHI - # assertion, so it is NOT PHI: its hops stay byte-identical (never newly refused), exactly - # as the keyless-refusal / [egress] / #906 gates all key on data_class == phi being set. - is_phi = False - else: - # A *custom* env is declared but leaves data_class unresolved — the operator asserted a - # non-standard deployment without a posture; fail closed (serve refuses such a start anyway). - is_phi = None - return HopPosture.fail_closed( - is_phi=is_phi, enforcing=(enforcement is SecurityEnforcement.ENFORCE) - ) + Maps the explicit ``[security].enforcement`` level onto the ``enforcing`` the transport cells + decide on. ``enforcing`` is ``enforcement is ENFORCE`` (the secure default), which keys the + REFUSE/WARN dial off the explicit enforcement level rather than the production tier (ADR 0148 + GIVEN 2). The construction gate stamps the result via ``tls_policy.active_hop_posture`` + (ADR 0092). + + **The ``is_phi`` axis is gone (BACKLOG #1279).** It used to key on ``data_class == phi`` being + explicitly declared, which made a bare/default config's hops non-PHI and left the whole family + relaxable by one switch. Every instance now carries patient data, so the only question a hop asks + is whether the instance is enforcing. ``ai`` stays in the signature because the tier it derives is + still read by the callers that report posture.""" + return HopPosture(enforcing=(enforcement is SecurityEnforcement.ENFORCE)) def forward_hop_disposition(log: LoggingSettings, posture: HopPosture) -> HopDisposition: @@ -2761,30 +2748,25 @@ def forward_hop_disposition(log: LoggingSettings, posture: HopPosture) -> HopDis #. loopback collector → ALLOW — the ADR 0080 "point ``tcp``/``udp`` at ``127.0.0.1`` and let a local rsyslog/Vector agent add TLS" deployment is explicitly preserved, byte-identical. - #. synthetic instance (not ``is_phi``) → ALLOW — silent, nothing sensitive rides the hop. Applied - HERE (not by the shared authority, which ADR 0153 stripped of the label) — see below. #. ``forward_hop_attested`` → ALLOW — the acknowledged, reasoned opt-out (a trusted management segment), the ``[logging]`` sibling of a connection's ``tls_hop_attested``. #. the CLAMPED global escape → WARN (never fires under ENFORCE — see :func:`hop_insecure_escape_downgrades`). - #. enforcing PHI → REFUSE. #. else (non-enforcing PHI) → WARN. + #. enforcing → REFUSE. #. else (non-enforcing) → WARN. Callers that have not resolved a posture pass the fail-closed one; ``serve`` supplies :func:`hop_posture_from_ai`. Pure so the gate is unit-testable without standing up ``serve``. - **ADR 0153 leaves this cell keyed on the data label, deliberately** (its *Explicitly out of scope* - table: "Stays keyed on posture; a ``[logging]`` sibling of ``cleartext_accepted`` is a follow-up"). - The forwarder is not a connection, so it has nowhere to carry a per-hop declaration, and refusing - it instead would create a deviation the loosening registry cannot express. The ``not is_phi`` ALLOW - arm 0153 deleted from the shared authority is therefore restated HERE, explicitly, rather than - inherited — the scope limit is a written decision at the one place it applies, not an emergent - property of a signature change.""" + **ADR 0153 left this cell keyed on the data label; BACKLOG #1279 removed the label.** 0153's + scope reasoning stands and is kept: the forwarder is not a connection, so it has nowhere to carry + a per-hop declaration, and refusing outright would create a deviation the loosening registry + cannot express. What that reasoning bought was a restated ``not is_phi`` ALLOW arm, and with every + instance carrying patient data there is no instance left for it to fire on, so it is gone. A + ``[logging]`` sibling of ``cleartext_accepted`` remains the recorded follow-up and is now the only + way this cell could express an acceptance.""" if log.forward_protocol is SyslogProtocol.TLS and log.forward_tls_verify: # Verified, CA-anchored TLS (ADR 0080) — an encrypted+authenticated hop, nothing to gate. return HopDisposition.ALLOW - if not posture.is_phi: - # ADR 0153 scope carve-out — see the docstring. Restated here, not inherited. - return HopDisposition.ALLOW return insecure_hop_disposition( enforcing=posture.enforcing, # An unset forward_host cannot happen with forwarding on (the validator requires it), and the @@ -4024,12 +4006,27 @@ class SecuritySettings(_Section): # field this desugars to. Setting it false is now a LOOSENING and security_loosenings() names it. audit_all_authorization_decisions: bool = True - # ── What this instance handles (the master posture lever) ──────── - # None (the default) = DERIVE from the [ai].environment name (dev→synthetic, staging/prod→phi; a - # custom name must declare a posture or serve fails closed via require_posture — parity with today). - # true/false are explicit overrides. The §1 "= true" in ADR 0118 is the RESOLVED secure position for a - # production instance, not the raw default; a stock dev/staging/prod instance needs no value here. - handles_real_patient_data: bool | None = None # was [ai].data_class = "phi" + # ── What this instance handles ─────────────────────────────────── + # `handles_real_patient_data` USED TO SIT HERE and is retired (BACKLOG #1279). Every instance + # carries patient data, so there is no declaration to make: the PHI gates apply unconditionally. + # An operator who needs a specific one relaxed uses that gate's own switch — allow_unencrypted_phi, + # block_unlisted_outbound, allow_keeping_phi_indefinitely, allow_single_factor_admin_when_exposed, + # allow_unverified_alert_smtp_tls, [alerts].security_notifications_required, a per-connection + # cleartext_accepted, the process-wide MEFOR_TLS_REVOCATION_ATTESTED, or the [security].enforcement + # dial. Each of those is separately named, separately audited and separately reported; the retired + # lever was none of those things, and it silenced nineteen gates at once. Setting it is now REFUSED + # at load with a message naming this decision (see `_REMOVED_KEYS`). + # + # NOT `tls_revocation_attested`, which this comment offered beside cleartext_accepted until it was + # re-read. The field exists on the outbound model and the connectors consume it, but it has no + # factory parameter and no connections.toml key, so an operator cannot author it — and + # docs/DEPLOYMENT.md's maintenance rule names that exact field and forbids offering it as a lever. + # The blanket env var is the only revocation attestation that can actually be set. + # + # The production TIER stays: it is a true property of the instance and it drives the AI + # data-scope ceiling and the DEBUG-log refusal, neither of which is a PHI gate. + # None (the default) = DERIVE from the [ai].environment name (dev/staging -> false, prod -> true); + # a custom name must declare it or serve fails closed via require_posture. production_instance: bool | None = None # was [ai].production # ── Leaving the organization: the ASVS 3.7.3 interstitial ──────── @@ -4406,10 +4403,38 @@ def _reject_unknown_file_keys(file_data: Mapping[str, Any]) -> None: ("retention", "messages_days"): "delete_message_bodies_after_days", ("retention", "allow_unbounded_phi"): "allow_keeping_phi_indefinitely", ("diagnostics", "audit_all_authz"): "audit_all_authorization_decisions", - ("ai", "data_class"): "handles_real_patient_data", ("ai", "production"): "production_instance", } +#: ``(section, key)`` → why it is REFUSED, for a key that was **removed** rather than relocated. A +#: relocated key has somewhere to go and :data:`_RELOCATED_TO_SECURITY` says where; these have +#: nowhere, so the message has to carry the decision instead of a forwarding address. +#: +#: Both spellings of the retired data-class lever are here (BACKLOG #1279). Refusing rather than +#: ignoring matters more for a REMOVED posture switch than for a misspelled one: an operator whose +#: config says ``handles_real_patient_data = false`` believes nineteen gates are off. Ignoring the key +#: would start the engine with all nineteen ON, which is the safe direction but a silent contradiction +#: of what their config says — and the next person to read that file would draw the wrong conclusion +#: about what the running instance is doing. +_REMOVED_KEYS: dict[tuple[str, str], str] = { + ("security", "handles_real_patient_data"): ( + "every instance now carries patient data, so there is no data-class declaration to make " + "(BACKLOG #1279). The PHI gates this used to relax as a group each have their own switch — " + "[security].allow_unencrypted_phi, block_unlisted_outbound, allow_keeping_phi_indefinitely, " + "allow_single_factor_admin_when_exposed, allow_unverified_alert_smtp_tls, " + "[alerts].security_notifications_required, a per-connection cleartext_accepted, the " + "process-wide MEFOR_TLS_REVOCATION_ATTESTED (there is no per-connection revocation lever an " + "operator can author), or the [security].enforcement dial. Relax the one you mean, or " + "delete this line" + ), + ("ai", "data_class"): ( + "the data class was removed, not relocated: every instance now carries patient data " + "(BACKLOG #1279). [ai].data_class had already moved to " + "[security].handles_real_patient_data under ADR 0118, and that key is retired too — delete " + "this line" + ), +} + #: ``[security]`` key → ``(section, field)`` for the switches that map 1:1 onto a settable internal field. #: The non-1:1 switches (network host, at-rest encryption, the posture lever, require_encryption_for_remote) #: are handled explicitly in :func:`_desugar_security`. @@ -4431,7 +4456,19 @@ def _reject_unknown_file_keys(file_data: Mapping[str, Any]) -> None: def _reject_relocated_keys(data: Mapping[str, Any]) -> None: """Raise ``ValueError`` if a relocated posture key is set in its OLD section (ADR 0118 AC-1). The switch moved to ``[security]``; accepting it in two places would defeat the single-canonical-home - goal and could silently disagree with ``[security]``. Checked against file+env (not CLI plumbing).""" + goal and could silently disagree with ``[security]``. Checked against file+env (not CLI plumbing). + + Also refuses the keys in :data:`_REMOVED_KEYS`, which went away entirely rather than moving. That + arm runs FIRST: ``[security].handles_real_patient_data`` is no longer a model field, so without it + the generic unknown-key refusal in :func:`_desugar_security` would fire and offer a spelling + suggestion for a key that is not misspelled.""" + for (section, key), reason in _REMOVED_KEYS.items(): + sect = data.get(section) + if isinstance(sect, dict) and key in sect: + raise ValueError( + f"[{section}].{key} was REMOVED and is no longer accepted: {reason} " + "(see docs/CONFIGURATION.md)." + ) for (section, key), replacement in _RELOCATED_TO_SECURITY.items(): sect = data.get(section) if isinstance(sect, dict) and key in sect: @@ -4528,10 +4565,9 @@ def _set(section: str, key: str, value: Any) -> None: sec.allow_unencrypted_phi or not sec.encrypt_stored_data, ) - # Master posture lever: bool → DataClass string. None (unset) is NOT written, so the posture derives - # from the [ai].environment name exactly as today (parity; custom-unset still fails closed). - if sec.handles_real_patient_data is not None: - _set("ai", "data_class", "phi" if sec.handles_real_patient_data else "synthetic") + # The master posture lever USED TO BE DESUGARED HERE, into [ai].data_class. Both keys are retired + # (BACKLOG #1279) and `_reject_relocated_keys` refuses either spelling before this runs, so there + # is nothing left to translate. The production tier still passes through `_SECURITY_PASSTHROUGH`. def security_loosenings( diff --git a/messagefoundry/config/tls_policy.py b/messagefoundry/config/tls_policy.py index 55983a816..278aa3fd3 100644 --- a/messagefoundry/config/tls_policy.py +++ b/messagefoundry/config/tls_policy.py @@ -381,9 +381,7 @@ def in_process_tls_revocation_refused( return True -def proxy_mtls_declared_but_unverified( - *, declared: str, client_ca_configured: bool, is_phi: bool -) -> bool: +def proxy_mtls_declared_but_unverified(*, declared: str, client_ca_configured: bool) -> bool: """Whether ``serve`` must WARN that the Posture-B mTLS attestation contradicts this engine's config. ``[api].proxy_intra_service_auth = "mtls"`` says the proxy PRESENTS A CLIENT CERTIFICATE on the @@ -407,8 +405,12 @@ def proxy_mtls_declared_but_unverified( value, and :func:`validate_proxy_tls_posture` below is the sibling coherence check on the other Posture-B attestation. Pure predicate so the ``_serve`` gate stays a one-liner and the truth table is testable without a settings load -- the same reason :func:`in_process_tls_revocation_refused` above - is one.""" - return declared == "mtls" and not client_ca_configured and is_phi + is one. + + It used to carry an ``is_phi`` conjunct, so a box declared synthetic never saw the contradiction. + Every instance carries patient data now (BACKLOG #1279), so the diagnostic fires wherever the + declaration and the configuration disagree.""" + return declared == "mtls" and not client_ca_configured def validate_proxy_tls_posture(min_version: str | None, ciphers: str | None) -> None: @@ -1046,30 +1048,29 @@ class InsecureHopRefused(ValueError): class HopPosture: """The instance security posture an insecure-hop decision is keyed on (#200). - ``is_phi`` — the instance carries real PHI (``[ai].data_class == phi``), independent of the - environment name. ``enforcing`` — whether the security REFUSE/WARN dial is at ENFORCE + ``enforcing`` — whether the security REFUSE/WARN dial is at ENFORCE (``[security].enforcement == enforce``, the secure default); it re-keys the ADR 0092 refuse/clamp - dial off the old production-tier flag onto the explicit enforcement level (this refactor). Both are - the *derived* posture (built-in dev/staging/prod derivation applied for ``is_phi``); an unresolved - custom-env ``is_phi`` fails closed to ``True`` via :meth:`fail_closed` — see decision 7 of ADR 0092. - Held in a contextvar for the duration of connector construction (:func:`active_hop_posture`).""" + dial off the old production-tier flag onto the explicit enforcement level (ADR 0148 GIVEN 2). + Held in a contextvar for the duration of connector construction (:func:`active_hop_posture`). + + **``is_phi`` was the other dimension and is gone (BACKLOG #1279).** Every instance carries patient + data, so the answer was about to be ``True`` everywhere; a field that cannot vary is not a posture + dimension, it is a constant with a fail-closed rule attached. ADR 0153 had already removed it from + :func:`insecure_hop_disposition`, the widest consumer; this removes the remaining three. Whether a + hop may be crossed now turns on the hop's own facts (loopback, attested, accepted) and the + enforcement dial — never on a data label the same file could typo.""" - is_phi: bool enforcing: bool @classmethod - def fail_closed(cls, *, is_phi: bool | None, enforcing: bool | None) -> HopPosture: - """Build a posture, defaulting an *unknown* (``None``) dimension to the strict value. - - A custom-env instance may leave ``data_class`` unresolved (``serve`` refuses such a start, but an - offline build-check / embedding may still construct connectors). An unknown dimension defaults to - the fail-closed value — ``is_phi=True`` / ``enforcing=True`` — so an unproven posture never - *relaxes* a hop decision. A fully-declared config passes its real values through unchanged - (decision 7: resolve to the declared posture, not strictest-by-default).""" - return cls( - is_phi=True if is_phi is None else is_phi, - enforcing=True if enforcing is None else enforcing, - ) + def fail_closed(cls, *, enforcing: bool | None) -> HopPosture: + """Build a posture, defaulting an *unknown* (``None``) dial to the strict value. + + An offline build-check or embedding may construct connectors without a resolved enforcement + level. Unknown defaults to ``enforcing=True`` so an unproven posture never *relaxes* a hop + decision; a declared config passes its real value through unchanged (ADR 0092 decision 7: + resolve to the declared posture, not strictest-by-default).""" + return cls(enforcing=True if enforcing is None else enforcing) def is_loopback_hop_host(host: str) -> bool: @@ -1253,25 +1254,29 @@ def phi_read_hop_disposition( identically to the transport cells, and the production-PHI clamp (``audited_opt_out``, supplied already clamped by the caller) stays the single authority for the global escape. - ``posture is None`` (an embedding / test that declared no ``[ai]`` posture, so ``is_phi`` is unknown) - → :attr:`~HopDisposition.ALLOW` — byte-identical to the pre-residual behaviour, so the loopback/dev - default and every non-PHI embedding are untouched. A ``serve_hop_secure`` hop is modelled as the + ``posture is None`` (an embedding / test outside the construction gate, where no enforcement level + was ever stamped) → :attr:`~HopDisposition.ALLOW` — byte-identical to the pre-residual behaviour, + so the loopback/dev default and every embedding are untouched. A ``serve_hop_secure`` hop is modelled as the authority's on-box carve-out (``is_loopback_hop``): a loopback / TLS / proxy-terminated serve hop is not an insecure network exposure, so PHI may cross (the serve-start exposed-gate already vetted it). There is no per-hop attestation for the API serve hop — the serve gate's proxy/TLS declarations are what prove it secure — so ``hop_attested`` is always ``False`` here. - **ADR 0153 leaves this cell keyed on the data label, deliberately** (its *Explicitly out of scope* - table): the API serve hop is not a connection, so it has nowhere to carry a per-hop - ``cleartext_accepted`` declaration, and refusing it instead would create a deviation the loosening - registry cannot express. The ``not is_phi`` ALLOW arm 0153 deleted from the shared authority is - therefore restated HERE, explicitly, rather than inherited — so the scope limit is a written - decision at the one place it applies, not an accident of a signature. A ``[security]``-level - declaration for this cell is the recorded follow-up.""" + **ADR 0153 left this cell keyed on the data label; BACKLOG #1279 removed the label.** 0153's + reasoning for the carve-out stands and is recorded here rather than deleted: the API serve hop is + not a connection, so it has nowhere to carry a per-hop ``cleartext_accepted`` declaration, and + refusing outright would create a deviation the loosening registry cannot express. What that + reasoning bought was a ``not is_phi`` ALLOW arm, and with every instance carrying patient data + there is no longer an instance for it to fire on. So the arm is gone and the escape below is what + remains — ``MEFOR_ALLOW_INSECURE_TLS``, already clamped inert by the caller under ``enforce``. + + **The ``posture is None`` arm stays and is now the only unconditional ALLOW.** It covers an + embedding or test that declared no posture at all, where the serve gate never ran to vet the hop. + A ``[security]``-level declaration for this cell remains the recorded follow-up, and removing the + data label makes it load-bearing rather than optional: under ``enforce`` an unproven serve hop now + has no per-cell way to say yes.""" if posture is None: return HopDisposition.ALLOW - if not posture.is_phi: - return HopDisposition.ALLOW return insecure_hop_disposition( enforcing=posture.enforcing, is_loopback_hop=serve_hop_secure, @@ -1302,7 +1307,6 @@ def phi_read_hop_disposition( def revocation_hop_disposition( *, - is_phi: bool, enforcing: bool, is_loopback_hop: bool, proxy_proven: bool, @@ -1320,13 +1324,15 @@ def revocation_hop_disposition( revocation-checking egress terminator (the outbound analogue of ADR 0078's ``proxy_terminated``). #. ``attested`` → :attr:`~HopDisposition.ALLOW` — the operator attests a revocation-checking PKI backs this hop (per-connection ``tls_revocation_attested`` or the blanket ``MEFOR_TLS_REVOCATION_ATTESTED``). - #. not ``is_phi`` (synthetic instance) → :attr:`~HopDisposition.ALLOW` — no PHI rides the hop. - #. ``enforcing`` → :attr:`~HopDisposition.REFUSE` — an enforcing PHI hop with unchecked revocation. - #. else (non-enforcing PHI — the WARN posture) → :attr:`~HopDisposition.WARN`. + #. ``enforcing`` → :attr:`~HopDisposition.REFUSE` — an enforcing hop with unchecked revocation. + #. else (non-enforcing — the WARN posture) → :attr:`~HopDisposition.WARN`. + + A ``not is_phi`` ALLOW arm sat fourth until BACKLOG #1279. Every instance carries patient data now, + so it could no longer fire and its removal leaves the remaining three relaxations as the whole set. Unlike :func:`insecure_hop_disposition` this carries NO global-escape (``audited_opt_out``) arm — the - ONLY relaxations are the on-box carve-out, a declared revocation-checking terminator, an operator - attestation, or a synthetic instance. This never turns verification off (the caller has already built + ONLY relaxations are the on-box carve-out, a declared revocation-checking terminator and an operator + attestation. This never turns verification off (the caller has already built a verifying context) — it only decides whether the *unchecked-revocation* property of that verified hop is tolerable, so it composes with (never weakens) the #200 cleartext/verify-off refusals.""" if is_loopback_hop: @@ -1335,8 +1341,6 @@ def revocation_hop_disposition( return HopDisposition.ALLOW if attested: return HopDisposition.ALLOW - if not is_phi: - return HopDisposition.ALLOW if enforcing: return HopDisposition.REFUSE return HopDisposition.WARN @@ -1391,7 +1395,6 @@ def capture( def _disposition(self, posture: HopPosture) -> HopDisposition: return revocation_hop_disposition( - is_phi=posture.is_phi, enforcing=posture.enforcing, is_loopback_hop=is_loopback_hop_host(self.host), proxy_proven=self.proxy_proven, @@ -1420,7 +1423,6 @@ def enforce_construction(self) -> None: if ( disposition is HopDisposition.ALLOW and (self.attested or self.proxy_proven) - and posture.is_phi and posture.enforcing and not is_loopback_hop_host(self.host) ): diff --git a/messagefoundry/pipeline/wiring_runner.py b/messagefoundry/pipeline/wiring_runner.py index 30ac27f36..81dd3b170 100644 --- a/messagefoundry/pipeline/wiring_runner.py +++ b/messagefoundry/pipeline/wiring_runner.py @@ -7215,7 +7215,7 @@ def _inbound_insecure_bind_permitted( return False if posture is None: return True # un-postured (direct/embedding) call: preserve the shipped warn (see above) - return not (posture.enforcing and posture.is_phi) + return not posture.enforcing # the `and posture.is_phi` conjunct went with BACKLOG #1279 def _inbound_revocation_gap_permitted(*, attested: bool, posture: HopPosture | None) -> bool: @@ -7229,8 +7229,8 @@ def _inbound_revocation_gap_permitted(*, attested: bool, posture: HopPosture | N An **unstamped** posture (``None``) permits it, for the same reason the sibling does: the check ran outside the ENFORCED gate, so this is a direct / embedding call and must never acquire a new - refusal there. Otherwise it is refused only on an instance that is BOTH enforcing AND PHI -- - every other instance warns and crosses. + refusal there. Otherwise it is refused on an enforcing instance -- a non-enforcing one + warns and crosses. It required enforcing AND PHI until BACKLOG #1279 made every instance PHI. There is deliberately NO blunt process-wide escape here. ``MEFOR_ALLOW_INSECURE_TLS`` governs weakened TLS, and a listener that verifies its peers correctly but does not check revocation is @@ -7240,7 +7240,7 @@ def _inbound_revocation_gap_permitted(*, attested: bool, posture: HopPosture | N return True if posture is None: return True # un-postured (direct/embedding) call: never a new refusal (see above) - return not (posture.enforcing and posture.is_phi) + return not posture.enforcing # the `and posture.is_phi` conjunct went with BACKLOG #1279 def check_inbound_revocation( @@ -7548,9 +7548,9 @@ def check_http_intake_auth(source: Source, name: str, *, posture: HopPosture | N "Note that tls + tls_ca_file alone does NOT satisfy this: it accepts any certificate that CA " "ever signed, which binds no subject. TLS is confidentiality; this gate is authentication." ) - if posture is not None and posture.enforcing and posture.is_phi: + if posture is not None and posture.enforcing: raise WiringError(detail) - log.warning("%s (warned, not refused: this instance is not an enforcing PHI posture)", detail) + log.warning("%s (warned, not refused: this instance is not enforcing)", detail) def check_dimse_tls_exposure( diff --git a/messagefoundry/scaffold.py b/messagefoundry/scaffold.py index 6889294b4..3567f5820 100644 --- a/messagefoundry/scaffold.py +++ b/messagefoundry/scaffold.py @@ -109,15 +109,16 @@ def archive(msg): # type: ignore[no-untyped-def] # next two lines together (TLS is then required) — listen_address alone is refused as contradictory: # local_access_only = false # listen_address = "0.0.0.0" -# handles_real_patient_data = true # does this instance carry REAL PHI? (drives at-rest + egress advisories) # production_instance = true # production tier? (drives the prod-DEBUG refusal + the AI data-scope ceiling) +# EVERY instance carries patient data (ADR 0186) - there is no data-class switch to set here, and the +# retired one is REFUSED at load. To relax a specific PHI gate, name that gate's own switch. # block_unlisted_outbound = true # lock outbound destinations down (recommended for Test/Prod) [ai] # The active-environment NAME — REQUIRED (also passable as `serve --env `). Free-form: name -# instances dev/staging/test/prod/poc/... Built-in names dev/staging/prod carry a default posture; a -# CUSTOM name MUST also set handles_real_patient_data + production_instance in [security] above -# (posture is never inferred from the name). +# instances dev/staging/test/prod/poc/... Built-in names dev/staging/prod carry a default production +# tier; a CUSTOM name MUST also set production_instance in [security] above (the tier is never +# inferred from the name). That is the only posture declaration there is. environment = "dev" [environments] @@ -337,10 +338,12 @@ def archive(msg): # type: ignore[no-untyped-def] ## Environments & posture The active environment is **required** and **free-form** — name instances `dev`/`staging`/`test`/`prod`/`poc`/… -Built-in names `dev`/`staging`/`prod` carry a default security posture; a **custom** name must set -`[security].handles_real_patient_data` (does this instance carry REAL PHI?) and +Built-in names `dev`/`staging`/`prod` carry a default production tier; a **custom** name must set `[security].production_instance` in `messagefoundry.toml` -- the `[ai]` spellings these replaced are -REFUSED by the loader (ADR 0118), as the generated config file itself says. One reviewed config +REFUSED by the loader (ADR 0118), as the generated config file itself says. There is no second +declaration to make: **every instance carries patient data** and the PHI gates apply unconditionally +(ADR 0186). The retired data-class lever is refused at load too; relax the individual gate you mean +instead. One reviewed config commit is deployed to every instance; each instance picks its environment at runtime (`--env` or `[ai].environment`), so a Test instance never resolves Prod values. diff --git a/messagefoundry/store/postgres.py b/messagefoundry/store/postgres.py index 8d2e062bb..b84861b76 100644 --- a/messagefoundry/store/postgres.py +++ b/messagefoundry/store/postgres.py @@ -806,7 +806,6 @@ def _refuse_store_revocation(*, host: str, posture: HopPosture | None) -> None: if posture is None: return disposition = revocation_hop_disposition( - is_phi=posture.is_phi, enforcing=posture.enforcing, is_loopback_hop=is_loopback_hop_host(host), proxy_proven=False, diff --git a/messagefoundry/transports/database.py b/messagefoundry/transports/database.py index 34a49b375..b47fd4a0e 100644 --- a/messagefoundry/transports/database.py +++ b/messagefoundry/transports/database.py @@ -124,7 +124,7 @@ def _audit_attested_weakened_tls(cell: str) -> None: refusal (#200 decision 3 — attestation is AUDITED when it crosses a prod-PHI hop). No-op on a non-prod / non-PHI / unstamped posture (nothing was suppressed there).""" posture = current_hop_posture() - if posture is not None and posture.is_phi and posture.enforcing: + if posture is not None and posture.enforcing: logger.warning( "%s: weakened TLS permitted by per-connection tls_hop_attested on a production-PHI " "instance (operator attests the hop is secure by other means)", diff --git a/messagefoundry/transports/rest.py b/messagefoundry/transports/rest.py index 38fb570db..ed35095bd 100644 --- a/messagefoundry/transports/rest.py +++ b/messagefoundry/transports/rest.py @@ -365,7 +365,7 @@ def _current_hop_posture_fail_closed() -> HopPosture: send-time recompute which runs past that scope) sees ``None`` and fails closed — treats the hop as production PHI so an unproven posture never *relaxes* a refusal (ADR 0092 decision 7).""" posture = current_hop_posture() - return HopPosture(is_phi=True, enforcing=True) if posture is None else posture + return HopPosture(enforcing=True) if posture is None else posture def _shipped_strict_disposition( diff --git a/messagefoundry_webconsole/__init__.py b/messagefoundry_webconsole/__init__.py index 518b67dfc..373c1faf3 100644 --- a/messagefoundry_webconsole/__init__.py +++ b/messagefoundry_webconsole/__init__.py @@ -44,7 +44,7 @@ # If cross-seam support is ever genuinely wanted, re-widen this set AND add the CI matrix that # installs the MIN and MAX supported engine builds — the claim and its test land together, or not # at all. -SUPPORTED_ENGINE_SEAMS: frozenset[str] = frozenset({"518e7f18968a2ce7"}) +SUPPORTED_ENGINE_SEAMS: frozenset[str] = frozenset({"8753d225e1094901"}) #: The vendored static assets shipped in THIS wheel (mounted at /ui/static by :func:`mount_ui`). STATIC_DIR = Path(__file__).parent / "static" diff --git a/messagefoundry_webconsole/pages/monitoring.py b/messagefoundry_webconsole/pages/monitoring.py index c6bd67c0a..79d561e63 100644 --- a/messagefoundry_webconsole/pages/monitoring.py +++ b/messagefoundry_webconsole/pages/monitoring.py @@ -389,7 +389,6 @@ def status( ["Encryption at rest", _yn(posture.encryption_enabled)], ["Key source", posture.key_source], ["Key fingerprint", _opt(posture.key_id)], - ["Data class", _opt(posture.data_class)], ["Production", _yn(posture.production)], ["Environment", _opt(posture.environment)], # FIPS-provider attestation (report-only, #73 / ADR 0120). Scoped wording: this is the @@ -483,17 +482,11 @@ def _sec(key: str, none_label: str = "—") -> object: ["Delete message bodies after (days)", _sec("delete_message_bodies_after_days")], ["Allow keeping PHI indefinitely", _sec("allow_keeping_phi_indefinitely")], ["Audit all authz decisions", _sec("audit_all_authorization_decisions")], - [ - "Handles real patient data", - _sec("handles_real_patient_data", "(derived from environment)"), - ], ["Production instance", _sec("production_instance", "(derived from environment)")], ], adjustable=False, ) security_section: list[object] = [el("h2", "Security posture"), security_tbl] - if posture.synthetic_relaxation: - security_section.append(el("p", posture.synthetic_relaxation, class_="muted")) if posture.loosenings: security_section.append( el("p", "Protections loosened from the secure defaults:", class_="banner") diff --git a/packaging/messagefoundry-webconsole/tests/test_webui.py b/packaging/messagefoundry-webconsole/tests/test_webui.py index 3f50ae8df..693d3775b 100644 --- a/packaging/messagefoundry-webconsole/tests/test_webui.py +++ b/packaging/messagefoundry-webconsole/tests/test_webui.py @@ -390,10 +390,17 @@ def test_serve_ui_offloopback_requires_tls( monkeypatch.chdir(tmp_path) monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEY", generate_key()) monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) - # GIVEN 1 (ADR 0148): dev derives PHI now, so declare synthetic — this reaches the stricter /ui - # exposure gate (the subject) instead of the PHI cleartext-bind clamp / egress refusals. + # The /ui exposure gate is the subject, so the gates AHEAD of it are stood down by name. This + # said `handles_real_patient_data = false` until BACKLOG #1279 retired it; the key is refused + # at load now, which made this test refuse for the wrong reason while still exiting 2. + # + # The four lines are inlined rather than imported from `tests/_phi_gate_provisions.py`: this is a + # SEPARATE distributable package and nothing here imports from the engine repo's test tree. That + # constant's docstring is the explanation of what each line gives up. (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n" + 'security.enforcement = "warn"\n' + "security.block_unlisted_outbound = true\n" + "alerts.security_notifications_required = false\n" 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' "security.serve_web_console = true\n", encoding="utf-8", diff --git a/tests/_phi_gate_provisions.py b/tests/_phi_gate_provisions.py new file mode 100644 index 000000000..8f6acc35e --- /dev/null +++ b/tests/_phi_gate_provisions.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""What a `serve` fixture must PROVIDE to start clean, now that every instance carries patient data. + +BACKLOG #1279 retired `[security].handles_real_patient_data = false`. One line used to buy a quiet +`serve --env dev` in a test that was probing something else entirely -- log levels, path anchoring, +console mounting -- and it did so by turning off nineteen start-up gates at once. + +There is no such line now, so a fixture names the gates it needs. That is more typing and it is the +point: a test that reads :data:`PHI_GATE_PROVISIONS_TOML` can see exactly which protections its +scenario is standing down, and a reviewer can tell at a glance whether the test under it is still +measuring what its name says. + +**This is a TEST-FIXTURE convenience, never a recommended operator configuration.** Three of the four +entries are audited loosenings that `security_loosenings()` reports and the serve gate warns about; +`docs/SECURITY-LOOSENING.md` is the operator-facing account of what each costs. A deployment reaches +for at most the one it needs. + +Use :data:`PHI_GATE_PROVISIONS_TOML` when the fixture writes a `messagefoundry.toml`, and +:func:`setenv_phi_gate_provisions` when it drives the same settings through the environment. + +The bundles below are COMPOSED from the per-gate parts rather than written out one by one. Three +near-identical string literals is the drift this module exists to prevent: an edit lands in one, the +others keep the old line, and the difference between two bundles stops being the difference their +names claim. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - typing only + import pytest + +#: Satisfies the unrestricted-egress refusal by declaring deny-by-default rather than an allowlist, +#: since a fixture's destinations are usually assigned at run time. +_EGRESS = "security.block_unlisted_outbound = true\n" + +#: The at-rest gate's audited opt-out, BOTH acks, because under the shipped ``enforcement = enforce`` +#: keyless PHI is deliberately never one flag away (ADR 0140). A fixture that can just as easily set a +#: store key should do that instead. +_AT_REST_ACKS = ( + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" +) + +#: Accepts the pull-only security-event feed, so a fixture does not have to stand up an SMTP transport +#: it never reads. +_ALERTS_OPT_OUT = "alerts.security_notifications_required = false\n" + +#: Dotted keys throughout, so a fixture can concatenate this and still add its own `[section]` +#: headers without TOML redefining a table. +PHI_GATE_PROVISIONS_TOML = _EGRESS + _AT_REST_ACKS + _ALERTS_OPT_OUT + +#: The same, minus the `alerts.` line, for a fixture that declares its own `[alerts]` TABLE. TOML +#: refuses to declare a table twice, and a dotted `alerts.x` key counts as declaring it -- so a +#: fixture that configures a real SMTP transport must take this one and satisfy the notification gate +#: the honest way. Splitting the constant rather than dropping the line from both keeps the +#: distinction visible at the call site instead of leaving it to whoever debugs the TOML error. +PHI_GATE_PROVISIONS_NO_ALERTS_TOML = _EGRESS + _AT_REST_ACKS + +# NO BUNDLE SUITS A TEST WHOSE SUBJECT IS THE AT-REST GATE, and none should be added. Both bundles +# carry `allow_unencrypted_phi_under_strict_enforcement`, so a test asserting that a MISSING ack +# refuses cannot take either -- it would provision away its own scenario and pass on whatever gate +# fired next. A third "no acks" bundle looks like the fix and is not: such a test still has to spell +# out the exact at-rest flags it is measuring, so the bundle saves nothing and invites the next author +# to reach for a shared constant here when the point is that this file's constants do not apply. See +# the `keyless-prod-phi-single-flag-refuses` row in tests/test_checks_gate_parity.py. + +#: The same four, as the environment variables the loader reads. Kept beside the TOML deliberately: +#: two spellings of one list drift, and a fixture that sets three of four gets a refusal whose message +#: names a gate the author was not thinking about. +PHI_GATE_PROVISIONS_ENV: dict[str, str] = { + "MEFOR_SECURITY_BLOCK_UNLISTED_OUTBOUND": "true", + "MEFOR_SECURITY_ALLOW_UNENCRYPTED_PHI": "true", + "MEFOR_SECURITY_ALLOW_UNENCRYPTED_PHI_UNDER_STRICT_ENFORCEMENT": "true", + "MEFOR_ALERTS_SECURITY_NOTIFICATIONS_REQUIRED": "false", +} + + +def setenv_phi_gate_provisions(monkeypatch: pytest.MonkeyPatch) -> None: + """Set :data:`PHI_GATE_PROVISIONS_ENV` on the environment for one test.""" + for name, value in PHI_GATE_PROVISIONS_ENV.items(): + monkeypatch.setenv(name, value) diff --git a/tests/golden/webconsole_seam.snapshot b/tests/golden/webconsole_seam.snapshot index 3ebc55cec..dd655728f 100644 --- a/tests/golden/webconsole_seam.snapshot +++ b/tests/golden/webconsole_seam.snapshot @@ -7,7 +7,7 @@ # by hand (BACKLOG #1220) - so a newly rendered DTO is covered with nobody editing a list. ## ENGINE_UI_SEAM -518e7f18968a2ce7 +8753d225e1094901 ## dataclass messagefoundry.api._ui_seam.UiDeps engine_seam @@ -216,7 +216,7 @@ messagefoundry.api.models.ReloadResult: degraded, dry_run, failures, handlers, i messagefoundry.api.models.SearchPresetCreateRequest: criteria, name messagefoundry.api.models.SearchPresetCriteria: channel_id, content, control_id, field_path, field_value, limit, message_type, status, target messagefoundry.api.models.SecurityLoosening: risk, switch -messagefoundry.api.models.SecurityPosture: allow_unencrypted_phi, backend, client_address_monoculture, client_denied_last, client_network_denials, data_class, encryption_enabled, enforcement, environment, fips_mode, kex_groups, key_id, key_source, loosenings, loosenings_scope, memory_encryption_note, memory_encryption_operator_declared, memory_encryption_readout_contradicts_declaration, memory_encryption_readout_source, memory_encryption_self_reported_active, memory_encryption_self_reported_capability, memory_encryption_self_reported_mechanism, openssl_version, plaintext_columns, production, require_encryption, security, store_privilege, synthetic_relaxation +messagefoundry.api.models.SecurityPosture: allow_unencrypted_phi, backend, client_address_monoculture, client_denied_last, client_network_denials, encryption_enabled, enforcement, environment, fips_mode, kex_groups, key_id, key_source, loosenings, loosenings_scope, memory_encryption_note, memory_encryption_operator_declared, memory_encryption_readout_contradicts_declaration, memory_encryption_readout_source, memory_encryption_self_reported_active, memory_encryption_self_reported_capability, memory_encryption_self_reported_mechanism, openssl_version, plaintext_columns, production, require_encryption, security, store_privilege messagefoundry.api.models.ServiceStatusInfo: enabled, service_name, state messagefoundry.api.models.StatsResetRequest: all, targets messagefoundry.api.models.StatsResetTarget: channel_id, destination, role @@ -231,7 +231,6 @@ messagefoundry.api.models.UploadedMessageSummary: control_id, index, message_typ messagefoundry.api.models.UploadedMessagesResult: file_id, filename, matched, messages, scanned, total_messages, truncated ## enum members reachable from those DTOs -messagefoundry.config.ai_policy.DataClass: PHI, SYNTHETIC messagefoundry.config.ai_policy.SecurityEnforcement: ENFORCE, WARN ## Literal value sets on those DTOs diff --git a/tests/test_ai_policy.py b/tests/test_ai_policy.py index 37fe6b6d5..6054e130f 100644 --- a/tests/test_ai_policy.py +++ b/tests/test_ai_policy.py @@ -4,7 +4,7 @@ ``resolve_effective_policy`` is pure, so the bulk here is a direct truth-table + an exhaustive sweep over every (mode x scope x production) asserting the invariants. The posture (``production`` / -``data_class``) is **decoupled from the environment name** (ADR 0017), so the settings tests also cover +``production``) is **decoupled from the environment name** (ADR 0017), so the settings tests also cover free-form names, known-name posture derivation, and the fail-closed custom-name path. The endpoint test mirrors the existing API patterns (httpx ASGI transport, ``allow_no_auth`` for the system identity, an ``AuthService`` + login for the token-bearing / tokenless cases).""" @@ -25,7 +25,6 @@ from messagefoundry.config.ai_policy import ( AiDataScope, AiMode, - DataClass, EffectivePolicy, resolve_effective_policy, ) @@ -195,8 +194,9 @@ def test_ai_settings_defaults() -> None: assert ai.mode is AiMode.BYO assert ai.data_scope is AiDataScope.CODE_ONLY assert ai.environment is None # no default — serve requires it (no silent PROD) - assert ai.data_class is None assert ai.production is None + # There is no `data_class` to default: every instance carries patient data (BACKLOG #1279). + assert not hasattr(ai, "data_class") # `provider` is READ (it addresses the broker and is recorded in the per-use audit) but never # dispatched on; `model`/`baa_attested`/`endpoint` are forward-compat. See #95. assert ai.provider == "claude" @@ -217,38 +217,36 @@ def test_ai_settings_default_on_service_settings( @pytest.mark.parametrize( ("name", "expected"), - [ - ("dev", (DataClass.PHI, False)), - ("staging", (DataClass.PHI, False)), - ("prod", (DataClass.PHI, True)), - ], + [("dev", False), ("staging", False), ("prod", True)], ) -def test_known_name_posture_derived(name: str, expected: tuple[DataClass, bool]) -> None: - # GIVEN 1 (ADR 0148): the built-in names now all derive PHI when data_class is unset (dev is - # PHI-by-default too); only the production tier still differs (prod alone is production=True). +def test_known_name_posture_derived(name: str, expected: bool) -> None: + # Only the production TIER is derived from the name. The data class went with BACKLOG #1279 -- + # ADR 0148 GIVEN 1 had already made all three names derive PHI, and now nothing can vary it. ai = AiSettings(environment=name) - assert ai.derived_posture() == expected - assert ai.require_posture() == expected + assert ai.derived_posture() is expected + assert ai.require_posture() is expected def test_custom_name_requires_explicit_posture() -> None: # A custom env name has no built-in posture: derived_posture leaves it unresolved, and the # fail-closed require_posture raises so a custom instance never defaults permissive (ADR 0017). ai = AiSettings(environment="poc") - assert ai.derived_posture() == (None, None) + assert ai.derived_posture() is None with pytest.raises(ValueError, match="no built-in security posture"): ai.require_posture() def test_custom_name_with_explicit_posture_resolves() -> None: - ai = AiSettings(environment="poc", data_class=DataClass.PHI, production=False) - assert ai.require_posture() == (DataClass.PHI, False) + ai = AiSettings(environment="poc", production=False) + assert ai.require_posture() is False def test_explicit_posture_overrides_known_name() -> None: - # Explicit fields win over the built-in derivation (a 'prod'-named but synthetic/non-prod box). - ai = AiSettings(environment="prod", data_class=DataClass.SYNTHETIC, production=False) - assert ai.require_posture() == (DataClass.SYNTHETIC, False) + # The explicit tier wins over the built-in derivation (a 'prod'-named but non-prod box). The + # data class has no such escape any more: a 'prod'-named box cannot declare itself synthetic, + # and neither can any other (BACKLOG #1279). + ai = AiSettings(environment="prod", production=False) + assert ai.require_posture() is False def test_environment_name_must_be_a_safe_token() -> None: @@ -266,8 +264,9 @@ def test_ai_env_vars_load(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> No "MEFOR_AI_MODE": "managed_claude_baa", "MEFOR_AI_DATA_SCOPE": "phi", "MEFOR_AI_ENVIRONMENT": "test", # a custom name parses fine - # Posture moved to [security] (ADR 0118); env keys desugar into ai.data_class/ai.production. - "MEFOR_SECURITY_HANDLES_REAL_PATIENT_DATA": "true", + # The tier moved to [security] (ADR 0118) and desugars into ai.production. Its + # data-class sibling is retired and now REFUSED at load (BACKLOG #1279) -- see + # tests/test_security_config.py for that arm. "MEFOR_SECURITY_PRODUCTION_INSTANCE": "true", "MEFOR_AI_BAA_ATTESTED": "true", "MEFOR_AI_ENDPOINT": "https://broker.example/internal", @@ -276,9 +275,8 @@ def test_ai_env_vars_load(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> No assert s.ai.mode is AiMode.MANAGED_CLAUDE_BAA assert s.ai.data_scope is AiDataScope.PHI assert s.ai.environment == "test" - assert s.ai.data_class is DataClass.PHI # str -> enum coercion assert s.ai.production is True # str -> bool coercion - assert s.ai.require_posture() == (DataClass.PHI, True) + assert s.ai.require_posture() is True assert s.ai.baa_attested is True assert s.ai.endpoint == "https://broker.example/internal" @@ -293,7 +291,7 @@ def test_ai_settings_from_toml(tmp_path: Path) -> None: assert s.ai.mode is AiMode.OFF assert s.ai.data_scope is AiDataScope.SYNTHETIC assert s.ai.environment == "staging" # a free-form string, not an enum - assert s.ai.require_posture() == (DataClass.PHI, False) # derived from the built-in name + assert s.ai.require_posture() is False # tier derived from the built-in name # --- GET /ai/policy endpoint ------------------------------------------------- @@ -315,8 +313,7 @@ def _client(app: object) -> httpx.AsyncClient: async def test_ai_policy_open_app_reflects_settings_and_grants_assist(engine: Engine) -> None: # allow_no_auth -> the system identity holds every permission, so assist_permitted is True, and - # the policy reflects the attached [ai] settings (a non-production 'dev' instance). GIVEN 1 - # (ADR 0148): dev now derives data_class=phi (PHI-by-default), while production stays False. + # the policy reflects the attached [ai] settings (a non-production 'dev' instance). ai = AiSettings(mode=AiMode.BYO, data_scope=AiDataScope.SYNTHETIC, environment="dev") app = create_app(engine, ai_settings=ai, allow_no_auth=True) async with _client(app) as c: @@ -324,8 +321,8 @@ async def test_ai_policy_open_app_reflects_settings_and_grants_assist(engine: En assert body["mode"] == "byo" assert body["data_scope"] == "synthetic" assert body["environment"] == "dev" - assert body["data_class"] == "phi" assert body["production"] is False + assert "data_class" not in body # retired with the declaration (BACKLOG #1279) assert body["assist_permitted"] is True assert body["reason"] is None diff --git a/tests/test_alert_smtp_tls.py b/tests/test_alert_smtp_tls.py index ed1a204d4..fc682592c 100644 --- a/tests/test_alert_smtp_tls.py +++ b/tests/test_alert_smtp_tls.py @@ -36,6 +36,7 @@ ) from messagefoundry.pipeline.alert_sinks import EmailTransport from messagefoundry.pipeline.security_notify import SecurityEventNotifier +from tests._phi_gate_provisions import PHI_GATE_PROVISIONS_NO_ALERTS_TOML class _RecordingSMTP: @@ -305,9 +306,12 @@ def _prod_phi_toml(*, alerts_lines: str = "", security_lines: str = "") -> str: for the wrong reason. Extra `[security]` lines are spliced in as dotted keys BEFORE the first table header: appended after `[alerts]` they would land IN `[alerts]`, load clean, and silently do nothing.""" + # The NO_ALERTS variant, because this builder declares the `[alerts]` TABLE below and TOML + # refuses to declare one twice -- a dotted `alerts.x` key counts as declaring it. The + # notification gate is satisfied here by a real transport rather than by the opt-out. return ( - "security.block_unlisted_outbound = true\n" - "security.delete_message_bodies_after_days = 30\n" + PHI_GATE_PROVISIONS_NO_ALERTS_TOML + + "security.delete_message_bodies_after_days = 30\n" + security_lines + "[retention]\ndead_letter_days = 30\n" + '[alerts]\nemail_smtp_host = "smtp.example.org"\nemail_from = "sec@example.org"\n' @@ -373,19 +377,36 @@ def test_the_acknowledgment_permits_the_start_and_audits_it( assert "warning:" in capsys.readouterr().err -def test_a_synthetic_instance_is_not_gated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # No PHI, no refusal — the gate is keyed on data_class, like every sibling in the ladder. +def test_a_dev_instance_is_gated_exactly_as_prod_is( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # THE INVERSE OF THE TEST THIS REPLACES (BACKLOG #1279). It asserted that a box declared + # synthetic was NOT gated, on the reasoning that the gate keys on data_class like every sibling + # in the ladder. The data class is gone, so the whole ladder keys on exposure and the + # enforcement dial -- and an unverified SMTP hop refuses on `dev` exactly as it does on `prod`. # - # The posture is declared EXPLICITLY rather than inferred from `--env dev`. Measured: the samples - # config resolves `dev` to data_class=phi, so an env-name-based version of this test failed with - # rc=2 — and had the gate been (wrongly) keyed on the env NAME instead of the derived data_class, - # that version would have passed while proving nothing. + # The env name is still varied deliberately: it was never what the gate read, and pinning that + # it STILL is not what the gate reads is the half of the original test worth keeping. + rc = _serve( + tmp_path, + monkeypatch, + _prod_phi_toml(alerts_lines="email_tls_verify = false\n"), + env="dev", + ) + assert rc == 2 + + +def test_the_dial_is_what_stands_the_smtp_gate_down_now( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # ...and with the data class gone, the two ways past it are the per-gate ack (covered above) + # and the enforcement dial. Neither is silent: both are named by `security_loosenings()`. rc = _serve( tmp_path, monkeypatch, _prod_phi_toml( alerts_lines="email_tls_verify = false\n", - security_lines="security.handles_real_patient_data = false\n", + security_lines='security.enforcement = "warn"\n', ), env="dev", ) diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index c3cffe2d3..bf40f0ac5 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -17,7 +17,6 @@ from messagefoundry.auth.ldap import AdPrincipal from messagefoundry.auth.service import AuthService from messagefoundry.auth.tokens import hash_token -from messagefoundry.config.ai_policy import DataClass from messagefoundry.config.models import RetryPolicy from messagefoundry.config.settings import AiSettings, AuthSettings, StoreSettings from messagefoundry.pipeline import Engine @@ -1447,7 +1446,7 @@ async def test_security_posture_keyless_reports_off(engine: Engine) -> None: # The default engine fixture opens a keyless SQLite store → encryption off, no key_id, sqlite backend. service = await _service(engine) await _add(service, "vw", Role.VIEWER) - ai = AiSettings(environment="staging", data_class=DataClass.PHI, production=False) + ai = AiSettings(environment="staging", production=False) store = StoreSettings(allow_unencrypted_phi=True) async with _posture_client(engine, service, ai_settings=ai, store_settings=store) as c: vw = _auth((await _login(c, "vw")).json()["token"]) @@ -1455,7 +1454,9 @@ async def test_security_posture_keyless_reports_off(engine: Engine) -> None: assert body["encryption_enabled"] is False assert body["key_id"] is None assert body["backend"] == "sqlite" - assert body["data_class"] == "phi" and body["production"] is False + assert body["production"] is False + # `data_class` left the wire model with the declaration it reported (BACKLOG #1279). + assert "data_class" not in body assert body["environment"] == "staging" assert body["allow_unencrypted_phi"] is True and body["require_encryption"] is False assert body["plaintext_columns"] == [] # encryption off → N/A @@ -1522,7 +1523,7 @@ async def test_security_posture_encrypted_exposes_fingerprint_not_key_bytes( service = AuthService(enc_engine.store, AuthSettings(require_mfa=False)) await service.initialize() await _add(service, "vw", Role.VIEWER) - ai = AiSettings(environment="prod", data_class=DataClass.PHI, production=True) + ai = AiSettings(environment="prod", production=True) store_settings = StoreSettings(encryption_key=key_b64, key_provider="env") async with _posture_client( enc_engine, service, ai_settings=ai, store_settings=store_settings diff --git a/tests/test_api_security_posture.py b/tests/test_api_security_posture.py index 06a87432d..6c853f0c7 100644 --- a/tests/test_api_security_posture.py +++ b/tests/test_api_security_posture.py @@ -20,7 +20,6 @@ from messagefoundry.config.settings import ( AiSettings, AuthSettings, - DataClass, SecuritySettings, StoreSettings, ) @@ -87,16 +86,11 @@ async def _token(c: httpx.AsyncClient, username: str) -> dict[str, str]: async def test_posture_reports_security_and_has_no_write_route(engine: Engine) -> None: service = await _service(engine) await _add_viewer(service, "vw") - # A synthetic instance with two protections deliberately loosened. GIVEN 1 (ADR 0148): dev now - # derives PHI, so a synthetic instance declares the opt-out explicitly (handles_real_patient_data - # =False, which desugars to [ai].data_class=synthetic) — this is the path that reports the synthetic - # relaxation + loosenings. This test builds the app directly (no _desugar_security), so the synthetic - # posture is set on BOTH the AiSettings (drives the relaxation notice) and the SecuritySettings - # (reported in the posture's security dict). - ai = AiSettings(environment="dev", data_class=DataClass.SYNTHETIC) - security = SecuritySettings( - require_mfa=False, block_unlisted_outbound=False, handles_real_patient_data=False - ) + # An instance with two protections deliberately loosened. There is no third, instance-wide + # declaration to make any more (BACKLOG #1279): a relaxed control is a named switch, and this + # route reports each one individually or not at all. + ai = AiSettings(environment="dev") + security = SecuritySettings(require_mfa=False, block_unlisted_outbound=False) app, client = _app_and_client(engine, service, ai_settings=ai, security_settings=security) async with client as c: @@ -108,7 +102,7 @@ async def test_posture_reports_security_and_has_no_write_route(engine: Engine) - assert ( sec["require_sign_in"] is True and sec["local_access_only"] is True ) # secure defaults kept - assert sec["handles_real_patient_data"] is False # explicit synthetic opt-out (GIVEN 1) + assert "handles_real_patient_data" not in sec # retired (BACKLOG #1279) # ...the active loosenings each name the risk (AC-4/AC-5)... loosen = {row["switch"]: row["risk"] for row in body["loosenings"]} @@ -118,9 +112,11 @@ async def test_posture_reports_security_and_has_no_write_route(engine: Engine) - and "any destination" in loosen["block_unlisted_outbound"] ) - # ...and the synthetic-relaxation notice is stated (AC-6): the PHI-only gates are relaxed on synthetic. - assert body["synthetic_relaxation"] is not None - assert "synthetic" in body["synthetic_relaxation"] and "relaxed" in body["synthetic_relaxation"] + # ...and NOTHING reports an instance-wide relaxation, because none can exist. The field that + # used to say the PHI gates were relaxed wholesale went with the declaration (BACKLOG #1279), + # so `loosenings` above is the complete account of what this instance gave up. + assert "synthetic_relaxation" not in body + assert "data_class" not in body # AC-5: NO endpoint writes a security setting — every /security route is read-only (GET/HEAD/OPTIONS). write_methods = {"POST", "PUT", "PATCH", "DELETE"} @@ -133,17 +129,17 @@ async def test_posture_reports_security_and_has_no_write_route(engine: Engine) - ) -async def test_posture_phi_instance_has_no_relaxation_and_no_loosenings(engine: Engine) -> None: - # A PHI instance with all-secure defaults: no synthetic relaxation, no loosenings reported. +async def test_posture_on_secure_defaults_reports_no_loosenings(engine: Engine) -> None: + # All-secure defaults: nothing reported. Every instance carries patient data (BACKLOG #1279), + # so this is now the ONLY quiet posture -- there is no second, quieter one a declaration buys. service = await _service(engine) await _add_viewer(service, "vw") - ai = AiSettings(environment="prod") # prod → phi + ai = AiSettings(environment="prod") _app, client = _app_and_client( engine, service, ai_settings=ai, security_settings=SecuritySettings() ) async with client as c: body = (await c.get("/security/posture", headers=await _token(c, "vw"))).json() - assert body["synthetic_relaxation"] is None assert body["loosenings"] == [] @@ -167,7 +163,7 @@ async def test_posture_reports_production_ack_switches(engine: Engine) -> None: loosen = {row["switch"] for row in body["loosenings"]} assert "allow_single_factor_admin_when_exposed" in loosen assert "allow_unencrypted_phi_under_strict_enforcement" in loosen - assert body["data_class"] == "phi" and body["production"] is True + assert body["production"] is True async def test_posture_surfaces_enforcement_level(engine: Engine) -> None: diff --git a/tests/test_api_tls.py b/tests/test_api_tls.py index 84d4b71db..57e7a4500 100644 --- a/tests/test_api_tls.py +++ b/tests/test_api_tls.py @@ -175,7 +175,10 @@ def test_serve_allows_non_loopback_bind_with_tls( # GIVEN 1 (ADR 0148): declare synthetic so the PHI egress/retention/notify gates stay quiet — the # TLS bind-guard is the subject here. (tmp_path / "messagefoundry.toml").write_text( - f"security.handles_real_patient_data = false\n" + f"security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" f'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' f'[api]\ntls_cert_file = "{cert.as_posix()}"\n' f'tls_key_file = "{key.as_posix()}"\n', @@ -203,7 +206,10 @@ def test_serve_mtls_with_cert_map_swaps_in_shim_protocol( monkeypatch.setattr("uvicorn.run", lambda *a, **k: captured.update(k)) # GIVEN 1 (ADR 0148): declare synthetic so the PHI gates stay quiet — the mTLS shim wiring is under test. (tmp_path / "messagefoundry.toml").write_text( - f"security.handles_real_patient_data = false\n" + f"security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" f'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' f'[api]\ntls_cert_file = "{cert.as_posix()}"\n' f'tls_key_file = "{key.as_posix()}"\ntls_client_ca_file = "{cert.as_posix()}"\n' @@ -231,7 +237,10 @@ def test_serve_mtls_without_cert_map_keeps_stock_protocol( monkeypatch.setattr("uvicorn.run", lambda *a, **k: captured.update(k)) # GIVEN 1 (ADR 0148): declare synthetic so the PHI gates stay quiet — the stock-protocol path is under test. (tmp_path / "messagefoundry.toml").write_text( - f"security.handles_real_patient_data = false\n" + f"security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" f'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' f'[api]\ntls_cert_file = "{cert.as_posix()}"\n' f'tls_key_file = "{key.as_posix()}"\ntls_client_ca_file = "{cert.as_posix()}"\n', @@ -251,7 +260,11 @@ def test_serve_loopback_without_a_certificate_now_mints_and_serves_tls( monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEY", generate_key()) monkeypatch.setattr("uvicorn.run", lambda *a, **k: captured.update(k)) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\nsecurity.local_access_only = true\n", + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" + "security.local_access_only = true\n", encoding="utf-8", ) assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 0 @@ -332,12 +345,16 @@ def test_serve_allows_non_loopback_with_upstream_tls( monkeypatch.chdir(tmp_path) monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEY", generate_key()) monkeypatch.setattr("uvicorn.run", lambda *a, **k: captured.update(k)) - # GIVEN 1 (ADR 0148): declare synthetic so the PHI gates stay quiet — the upstream-TLS exposed-gate - # is the subject here. + # The upstream-TLS exposed gate is the subject here, so everything around it is pre-satisfied + # per-gate. Since BACKLOG #1279 a dev box cannot declare its way past the Posture-B attestations + # either, and an undeclared one would refuse before this gate is reached. (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n" + "security.block_unlisted_outbound = true\n" + "alerts.security_notifications_required = false\n" 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' - '[api]\ntls_terminated_upstream = true\ntrusted_proxies = ["10.0.0.7"]\n', + 'security.enforcement = "warn"\n' + '[api]\ntls_terminated_upstream = true\ntrusted_proxies = ["10.0.0.7"]\n' + 'proxy_intra_service_auth = "network"\nproxy_tls_min_version = "1.2"\n', encoding="utf-8", ) assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 0 @@ -356,7 +373,11 @@ def test_serve_forwarded_allow_ips_empty_when_no_proxy( monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEY", generate_key()) monkeypatch.setattr("uvicorn.run", lambda *a, **k: captured.update(k)) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\nsecurity.local_access_only = true\n", + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" + "security.local_access_only = true\n", encoding="utf-8", ) assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 0 @@ -427,7 +448,7 @@ def _posture_b_toml( intra: str = "none", floor: str | None = None, enforcement: str | None = None, - synthetic: bool = False, + relax_phi_gates: bool = False, loopback: bool = False, public_origin: str | None = "https://mefor.example.org", ) -> None: @@ -454,7 +475,18 @@ def _posture_b_toml( # there, which is exactly why the attestation gate had to move onto the declaration. body = ( (f'security.enforcement = "{enforcement}"\n' if enforcement else "") - + ("security.handles_real_patient_data = false\n" if synthetic else "") + # `synthetic=True` used to write `handles_real_patient_data = false` here and relax the whole + # PHI family on one line. BACKLOG #1279 retired that, so the flag writes the per-gate acks a + # keyless fixture needs. Egress is declared unconditionally below, so it is not repeated here. + + ( + # NO `alerts.` line here: `_SECURE_ALERTS` below declares the `[alerts]` TABLE, and TOML + # refuses to declare one twice. This fixture satisfies the notification gate the honest + # way, with a configured SMTP transport, so the opt-out would be redundant as well. + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + if relax_phi_gates + else "" + ) + ( "" if loopback @@ -588,25 +620,27 @@ def test_serve_still_refuses_posture_b_off_loopback_after_the_loopback_widening( assert "refusing to serve on a production PHI" in capsys.readouterr().err -def test_serve_posture_b_loopback_synthetic_is_quiet( +def test_serve_posture_b_loopback_warns_rather_than_staying_silent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - # The widening must not make a synthetic loopback dev box noisy — byte-identical, as for the - # keyless/MFA gates. - _posture_b_toml(tmp_path, intra="none", floor=None, synthetic=True, loopback=True) + # This asserted BYTE-IDENTICAL SILENCE for a loopback dev box that declared itself synthetic. + # BACKLOG #1279 retired the declaration, so the Posture-B attestation gate now reaches it -- + # and on the LOOPBACK arm it warns rather than refusing, which is the property that matters: + # the recommended proxy-in-front topology still starts. + _posture_b_toml(tmp_path, intra="none", floor=None, relax_phi_gates=True, loopback=True) assert _run_posture_b(tmp_path, monkeypatch, env="dev", key=False) == 0 - assert "proxy_intra_service_auth" not in capsys.readouterr().err + assert "proxy_intra_service_auth" in capsys.readouterr().err -def test_serve_posture_b_synthetic_is_quiet( +def test_serve_posture_b_offloopback_refuses_on_dev_too( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - # A synthetic instance (dev) stays quiet on the Posture-B posture (byte-identical — parity with the - # keyless / MFA gates), even with both attestations undeclared. GIVEN 1 (ADR 0148): dev derives PHI - # now, so declare the synthetic opt-out explicitly. - _posture_b_toml(tmp_path, intra="none", floor=None, synthetic=True) - assert _run_posture_b(tmp_path, monkeypatch, env="dev", key=False) == 0 - assert "proxy_intra_service_auth" not in capsys.readouterr().err + # THE INVERSE, and the pair with the loopback case above is the useful part: OFF-loopback the + # same undeclared attestations now REFUSE on a dev box, where they used to be silent. Exposure + # decides the severity; the environment name never did and the data class no longer can. + _posture_b_toml(tmp_path, intra="none", floor=None, relax_phi_gates=True) + assert _run_posture_b(tmp_path, monkeypatch, env="dev", key=False) == 2 + assert "proxy_intra_service_auth" in capsys.readouterr().err # --- BACKLOG #1181 (ASVS 12.3.5): the one attestation the engine can check against its own config -- @@ -638,13 +672,17 @@ def test_serve_says_nothing_about_client_certs_when_the_declaration_is_not_mtls( assert "verifies no client certificate" not in capsys.readouterr().err -def test_serve_mtls_coherence_warning_is_quiet_on_a_synthetic_instance( +def test_serve_mtls_coherence_warning_now_reaches_a_dev_instance( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - """Byte-identical on a synthetic box, exactly like the two attestation arms above.""" - _posture_b_toml(tmp_path, intra="mtls", floor="1.2", synthetic=True) + """The diagnostic used to skip a box declared synthetic; BACKLOG #1279 removed the label. + + It is a pure coherence check -- the declaration says the proxy presents a client certificate + and this engine verifies none -- so a data class was never a reason to suppress it. It still + WARNS and never refuses, because a sidecar can legitimately terminate the mTLS in front.""" + _posture_b_toml(tmp_path, intra="mtls", floor="1.2", relax_phi_gates=True) assert _run_posture_b(tmp_path, monkeypatch, env="dev", key=False) == 0 - assert "verifies no client certificate" not in capsys.readouterr().err + assert "verifies no client certificate" in capsys.readouterr().err def test_serve_loopback_emits_no_new_stderr( @@ -657,7 +695,26 @@ def test_serve_loopback_emits_no_new_stderr( monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEY", generate_key()) monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\nsecurity.local_access_only = true\n", + # A KEY is configured above, so this fixture takes NO at-rest ack -- and must not, because the + # ack is audited and would put a line on the very stream this test asserts is empty. Same + # reason the retention windows are set EXPLICITLY rather than left to the secure-by-default + # auto-bound: since BACKLOG #1279 that default reaches every instance, and it announces itself. + "security.block_unlisted_outbound = true\n" + # SATISFIED, not opted out. `security_notifications_required = false` is itself an audited + # loosening and warns, which on this test's own terms is a line on the stream it asserts is + # empty. Configuring the transport is the honest way past the gate and stays silent. + 'alerts.email_smtp_host = "smtp.example.org"\n' + 'alerts.email_from = "sec@example.org"\n' + "security.delete_message_bodies_after_days = 30\n" + "retention.dead_letter_days = 30\n" + "retention.reference_snapshot_days = 30\n" + # The two PL-2 tiers the engine deliberately does NOT default, because each keys on a + # timestamp that only moves on a write, so a silent default would delete data still in use. + # They warn when unset, which on this test's own terms is a line on a stream it asserts is + # empty -- so they are set here rather than the assertion being loosened. + "retention.state_max_age_days = 30\n" + "retention.search_preset_days = 30\n" + "security.local_access_only = true\n", encoding="utf-8", ) assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 0 @@ -1684,7 +1741,12 @@ def test_no_value_of_the_declaration_changes_the_listener(tmp_path: Path) -> Non def _posture_probe_toml( - tmp_path: Path, *, public_origin: str | None, serve_ui: bool, synthetic: bool = False + tmp_path: Path, + *, + public_origin: str | None, + serve_ui: bool, + enforcement: str = "enforce", + relax_phi_gates: bool = False, ) -> None: """The declared-terminator PHI posture under `enforce`, with the console and `public_origin` varied independently -- which is the pair the defect coupled.""" @@ -1692,8 +1754,8 @@ def _posture_probe_toml( tmp_path, intra="mtls", floor="1.2", - enforcement="enforce", - synthetic=synthetic, + enforcement=enforcement, + relax_phi_gates=relax_phi_gates, public_origin=public_origin, ) path = tmp_path / "messagefoundry.toml" @@ -1738,13 +1800,18 @@ def test_the_refusal_does_not_depend_on_the_console( assert "web_console_public_address" in capsys.readouterr().err -def test_a_non_phi_instance_is_not_refused( +def test_a_non_enforcing_instance_is_not_refused( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """POSITIVE CONTROL, and the one that stops this becoming a blanket refusal: the scope is the - posture the requirement is about. A synthetic-data instance must still start with no - `public_origin`, or the refusal is measuring something other than its own posture.""" - _posture_probe_toml(tmp_path, public_origin=None, serve_ui=False, synthetic=True) + posture the requirement is about, so SOME posture must still start with no `public_origin`. + + That used to be a synthetic-data instance. BACKLOG #1279 removed the data class, so the scope + narrowing this control exercises is now the enforcement dial -- which is the only one left, and + therefore the only thing that can prove the refusal is scoped at all.""" + _posture_probe_toml( + tmp_path, public_origin=None, serve_ui=False, enforcement="warn", relax_phi_gates=True + ) rc = _run_posture_b(tmp_path, monkeypatch, env="prod") assert rc != 2 or "public_origin" not in capsys.readouterr().err diff --git a/tests/test_checks.py b/tests/test_checks.py index 533a373dc..42ea0d754 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -588,7 +588,10 @@ def test_posture_fails_custom_env_without_posture(tmp_path: Path) -> None: report = run_checks(cfg, run_lint=False) posture = next(r for r in report.results if r.name == "posture") assert posture.required and not posture.ok and not posture.skipped - assert "handles_real_patient_data" in posture.detail and "production_instance" in posture.detail + # The refusal named BOTH posture keys until BACKLOG #1279 removed the data class. A custom env + # still has to declare its production tier, and that is now the whole of what it must declare. + assert "production_instance" in posture.detail + assert "handles_real_patient_data" not in posture.detail assert report.ok is False # a required check failed -> the gate fails @@ -605,7 +608,7 @@ def test_posture_ok_custom_env_with_explicit_posture(tmp_path: Path) -> None: # A custom name is fine once posture is set explicitly (decoupled from the name, ADR 0017). cfg = _config_repo( tmp_path, - "security.handles_real_patient_data = false\nsecurity.production_instance = false\n" + "security.block_unlisted_outbound = true\nsecurity.production_instance = false\n" '[ai]\nenvironment = "poc"\n', ) report = run_checks(cfg, run_lint=False) diff --git a/tests/test_checks_gate_parity.py b/tests/test_checks_gate_parity.py index f196019fd..327f14325 100644 --- a/tests/test_checks_gate_parity.py +++ b/tests/test_checks_gate_parity.py @@ -11,12 +11,17 @@ from __future__ import annotations +import tomllib from pathlib import Path import pytest from messagefoundry.__main__ import main from messagefoundry.checks import run_checks +from tests._phi_gate_provisions import ( + PHI_GATE_PROVISIONS_NO_ALERTS_TOML, + PHI_GATE_PROVISIONS_TOML, +) SAMPLES_CONFIG = Path(__file__).resolve().parents[1] / "samples" / "config" @@ -85,20 +90,21 @@ def _serve( #: pre-refactor gate that must still fire through [security]; each ALLOW (0) must still start. No entry #: uses a legacy key — the whole point is that the new keys reproduce the old decisions. _MATRIX: list[tuple[str, str, str, bool, int]] = [ - # keyless refusals (data_class-gated) — sourced from handles_real_patient_data / the env name. + # keyless refusals. These were data_class-gated; since BACKLOG #1279 every instance is in scope, + # so the only thing that varies is whether the per-gate ack is present. ("keyless-prod-phi-refuses", "", "prod", False, 2), ("keyless-staging-phi-refuses", "", "staging", False, 2), - # GIVEN 1 (ADR 0148): dev derives PHI now, so a synthetic dev box declares the opt-out explicitly. + # A dev box declares no data class; it takes the per-gate acks like any other (BACKLOG #1279). ( - "keyless-synthetic-dev-allows", - "security.handles_real_patient_data = false\n", + "keyless-dev-with-acks-allows", + PHI_GATE_PROVISIONS_TOML, "dev", False, 0, ), ( "keyless-declared-phi-on-dev-refuses", - "security.handles_real_patient_data = true\n", + "", "dev", False, 2, @@ -117,6 +123,15 @@ def _serve( # Pre-clear egress/retention/alerts so the ONLY remaining refusal is the ADR-0140 keyless-prod # branch — exit 2 here discriminates that branch (deleting it would flip this row to ALLOW), # unlike a bare single-flag config whose exit 2 the later open-egress gate would also produce. + # + # THIS ROW MUST NEVER TAKE A SHARED PROVISIONS BUNDLE, and no subtraction of one works either. + # Its whole scenario is a MISSING second ack, and both bundles carry + # `allow_unencrypted_phi_under_strict_enforcement` — the very flag whose absence is under test. + # Taking one provisions the refusal away, so the row would pass on whatever gate fired next, or + # on none. It once took `PHI_GATE_PROVISIONS_TOML` and duplicated the dotted + # `security.allow_unencrypted_phi` key, which made the TOML unparseable; `TOMLDecodeError` + # subclasses `ValueError`, the config load returns 2, and the row expected 2 — so it passed + # without ever reaching this gate. Spell the four lines out here. "keyless-prod-phi-single-flag-refuses", "security.allow_unencrypted_phi = true\n" "security.block_unlisted_outbound = true\n" @@ -127,10 +142,10 @@ def _serve( ), ( "keyless-prod-phi-both-acks-allows", - "security.allow_unencrypted_phi = true\n" - "security.allow_unencrypted_phi_under_strict_enforcement = true\n" - "security.block_unlisted_outbound = true\n" - "security.delete_message_bodies_after_days = 30\n" + _RETENTION_DL + _ALERTS, + PHI_GATE_PROVISIONS_NO_ALERTS_TOML + + "security.delete_message_bodies_after_days = 30\n" + + _RETENTION_DL + + _ALERTS, "prod", False, 0, @@ -139,8 +154,8 @@ def _serve( "mfa-off-exposed-prod-phi-single-factor-ack-allows", 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' "security.require_mfa = false\nsecurity.allow_single_factor_admin_when_exposed = true\n" - "security.block_unlisted_outbound = true\n" - "security.delete_message_bodies_after_days = 30\n" + + PHI_GATE_PROVISIONS_NO_ALERTS_TOML + + "security.delete_message_bodies_after_days = 30\n" + _MEMORY_ENCRYPTION + _PUBLIC_ADDRESS + _PROXY @@ -175,12 +190,13 @@ def _serve( 2, ), ( - # GIVEN 1 (ADR 0148): dev derives PHI now; declare synthetic so this stays the non-PHI escape - # path (a PHI cleartext off-loopback bind is clamped-refused under enforce — the prod-clamp row - # below covers that). - "cleartext-offloopback-dev-escape-allows", - "security.handles_real_patient_data = false\n" - 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' + # The escape is CLAMPED INERT while enforcing. That clamp used to need enforcing AND PHI, and + # this row escaped it by declaring the box synthetic; BACKLOG #1279 left the dial as the only + # key, so the dial is what opens this path. The prod-clamp row below still covers the refusal. + "cleartext-offloopback-warn-escape-allows", + 'security.enforcement = "warn"\n' + + PHI_GATE_PROVISIONS_TOML + + 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' "security.require_encryption_for_remote = false\n", "dev", True, @@ -268,7 +284,7 @@ def _serve( # dev derives PHI now, so the synthetic posture is declared explicitly. ( "synthetic-loopback-default-allows", - "security.handles_real_patient_data = false\n", + PHI_GATE_PROVISIONS_TOML, "dev", True, 0, @@ -276,6 +292,23 @@ def _serve( ] +@pytest.mark.parametrize("label,toml,env,key,expected", _MATRIX, ids=[m[0] for m in _MATRIX]) +def test_every_matrix_row_is_valid_toml( + label: str, toml: str, env: str, key: bool, expected: int +) -> None: + """A row that does not PARSE still exits 2, so a REFUSE row passes without reaching its gate. + + `__main__` catches `ValueError` from the config load and returns 2, and `TOMLDecodeError` + subclasses `ValueError`. That makes a malformed row indistinguishable from the refusal it claims + to measure. One row concatenated a bundle that already carried the same dotted key and sat green + for the life of the matrix; 19 of 20 rows parsed and the 20th passed anyway. + + This control is cheap because it does not start anything -- it only asserts the fixture is the + document the row's author thought they wrote. + """ + tomllib.loads(toml) + + @pytest.mark.parametrize("label,toml,env,key,expected", _MATRIX, ids=[m[0] for m in _MATRIX]) def test_gate_parity_through_security_keys( tmp_path: Path, @@ -317,13 +350,17 @@ def test_checks_mirror_posture_parity_through_security_keys(tmp_path: Path) -> N # exactly the fail-closed require_posture() that serve refuses on. fail = _posture(_config_repo(tmp_path / "a", '[ai]\nenvironment = "poc"\n')) assert fail.required and not fail.ok and not fail.skipped # type: ignore[attr-defined] - assert "handles_real_patient_data" in fail.detail # type: ignore[attr-defined] + # It named BOTH posture keys until BACKLOG #1279 removed the data class. Asserting the ABSENCE of + # the retired one matters as much as the presence of the survivor: a remediation naming a key the + # loader refuses costs an operator a restart cycle to discover, and reads as authoritative. + assert "production_instance" in fail.detail # type: ignore[attr-defined] + assert "handles_real_patient_data" not in fail.detail # type: ignore[attr-defined] # The SAME custom env with the posture set via [security] resolves — the mirror passes. ok = _posture( _config_repo( tmp_path / "b", - "security.handles_real_patient_data = false\nsecurity.production_instance = false\n" + "security.block_unlisted_outbound = true\nsecurity.production_instance = false\n" '[ai]\nenvironment = "poc"\n', ) ) diff --git a/tests/test_ci_leg_data_class.py b/tests/test_ci_leg_data_class.py index ac090ea93..16fe59ea4 100644 --- a/tests/test_ci_leg_data_class.py +++ b/tests/test_ci_leg_data_class.py @@ -1,33 +1,37 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 MessageFoundry Organization and contributors -"""A CI leg that weakens TLS must declare itself synthetic — the two are mutually exclusive. +"""A CI leg that weakens TLS must also stop enforcing — the two are mutually exclusive. -THE DEFECT THIS EXISTS FOR. The `load test (smoke, sqlserver)` legs were RED for four consecutive +THE DEFECT THIS EXISTS FOR. The ``load test (smoke, sqlserver)`` legs were RED for four consecutive nights (2026-07-27..30) on:: ValueError: SQL Server TLS is weakened (trust_server_certificate=true or encrypt=false) The job *did* set ``MEFOR_ALLOW_INSECURE_TLS=1``, and had since the history reset. What changed is that -ADR 0148 (GIVEN 1) remapped the built-in ``dev`` env from SYNTHETIC to **PHI** — and under enforcing -PHI that escape is clamped **inert** by design (#200 / ADR 0092 decision 2, -``weakened_tls_escape_permitted``: ``not (posture.enforcing and posture.is_phi)``). +ADR 0148 (GIVEN 1) remapped the built-in ``dev`` env from SYNTHETIC to **PHI** — and under an enforcing +PHI posture that escape is clamped **inert** by design (#200 / ADR 0092 decision 2, +``weakened_tls_escape_permitted``). -So the combination the job asked for is impossible on purpose: +So the combination the job asked for is impossible on purpose:: - trust_server_certificate=true + data_class=phi + enforcement=enforce -> REFUSED, always + trust_server_certificate=true + enforcement=enforce -> REFUSED, always Nothing caught it, because a nightly is not a PR context and the ``CI gate`` roll-up correctly treats those PR-skipped legs as a pass. This test makes the incompatibility fail LOUDLY at PR time instead. -THE RULE. If a serve-based CI job sets ``MEFOR_STORE_TRUST_SERVER_CERTIFICATE=true`` (a self-signed -service container — the only practical option for a ``services:`` block, which starts before any step -runs and so cannot be handed a cert generated in a step), then it MUST also declare -``MEFOR_SECURITY_HANDLES_REAL_PATIENT_DATA=false``. That is the mechanism ADR 0148 designed for exactly -this case and ``docs/SECURITY-LOOSENING.md`` names as acceptable for "a CI runner ... that only ever -processes synthetic / sample HL7" — an honest scope declaration, not a workaround. +THE RULE, AND WHAT RE-KEYED IT. If a serve-based CI job sets +``MEFOR_STORE_TRUST_SERVER_CERTIFICATE=true`` (a self-signed service container — the only practical +option for a ``services:`` block, which starts before any step runs and so cannot be handed a cert +generated in a step), then it MUST also set ``MEFOR_SECURITY_ENFORCEMENT=warn``. -The alternative — keeping the PHI declaration — requires a REAL certificate, which means moving the job -off ``services:`` onto a ``docker run`` with a generated cert mounted and trusted. Worth doing +**That used to be ``MEFOR_SECURITY_HANDLES_REAL_PATIENT_DATA=false``, and BACKLOG #1279 retired it.** +The clamp reads ``enforcing`` alone now: every instance carries patient data, so declaring one +synthetic is neither possible nor, if it were, the narrow thing to do here. The replacement is +*strictly narrower* — the retired declaration silenced nineteen start-up gates, and ``warn`` downgrades +them to warnings and silences none. The clamp itself is unchanged and so is the failure it produces. + +The alternative — keeping ``enforce`` — requires a REAL certificate, which means moving the job off +``services:`` onto a ``docker run`` with a generated cert mounted and trusted. Worth doing deliberately; not as a side effect of unbreaking a nightly. """ @@ -36,9 +40,9 @@ import pytest _TRUST = "MEFOR_STORE_TRUST_SERVER_CERTIFICATE" -_SYNTHETIC = "MEFOR_SECURITY_HANDLES_REAL_PATIENT_DATA" +_ENFORCEMENT = "MEFOR_SECURITY_ENFORCEMENT" +_RETIRED = "MEFOR_SECURITY_HANDLES_REAL_PATIENT_DATA" _TRUTHY = {"true", "1", "yes", "on"} -_FALSEY = {"false", "0", "no", "off"} def _serve_steps_with_env(workflow: str) -> list[tuple[str, str, dict]]: @@ -53,8 +57,8 @@ def _serve_steps_with_env(workflow: str) -> list[tuple[str, str, dict]]: Without this narrowing the first draft of this test flagged six `sqlserver-store` steps that set ``MEFOR_STORE_TRUST_SERVER_CERTIFICATE=true`` and are perfectly green — they run the pytest suites, - never `serve`. Demanding a synthetic declaration there would have been a false positive that - "fixing" makes the repo *less* honest: those legs genuinely exercise the PHI-shaped store path. + never `serve`. Demanding a relaxation there would have been a false positive that "fixing" makes + the repo *less* honest: those legs genuinely exercise the PHI-shaped store path. """ yaml = pytest.importorskip("yaml") from tests._workflow_contexts import WORKFLOWS @@ -70,12 +74,12 @@ def _serve_steps_with_env(workflow: str) -> list[tuple[str, str, dict]]: return out -def test_a_weakened_tls_leg_declares_itself_synthetic() -> None: +def test_a_weakened_tls_leg_stops_enforcing() -> None: """The incompatibility, made mechanical. - Under PHI + enforce the ``MEFOR_ALLOW_INSECURE_TLS`` escape is inert, so a leg that trusts a - self-signed server certificate can never start. Requiring the synthetic declaration alongside it - turns a four-night silent breakage into a PR-time failure. + While enforcing, the ``MEFOR_ALLOW_INSECURE_TLS`` escape is inert, so a leg that trusts a + self-signed server certificate can never start. Requiring ``enforcement=warn`` alongside it turns a + four-night silent breakage into a PR-time failure. """ offenders: list[str] = [] checked = 0 @@ -83,16 +87,16 @@ def test_a_weakened_tls_leg_declares_itself_synthetic() -> None: if str(env.get(_TRUST, "")).strip().lower() not in _TRUTHY: continue checked += 1 - declared = str(env.get(_SYNTHETIC, "")).strip().lower() - if declared not in _FALSEY: + declared = str(env.get(_ENFORCEMENT, "")).strip().lower() + if declared != "warn": offenders.append( f"ci.yml:{job} — step {step!r} sets {_TRUST}=true but " - f"{_SYNTHETIC}={declared or ''}" + f"{_ENFORCEMENT}={declared or ''}" ) # Liveness: if no leg trusts a self-signed cert any more, say so rather than pass on an empty scan. print( - f"[ci-leg-data-class] examined {checked} serve step(s) that trust a self-signed server certificate" + f"[ci-leg-tls-posture] examined {checked} serve step(s) that trust a self-signed server certificate" ) assert checked > 0, ( f"no `messagefoundry serve` step in ci.yml sets {_TRUST}=true any more. If those legs were reworked onto a real " @@ -100,32 +104,55 @@ def test_a_weakened_tls_leg_declares_itself_synthetic() -> None: "letting it pass by finding nothing." ) assert not offenders, ( - "a CI leg trusts a self-signed server certificate without declaring itself synthetic:\n " + "a CI leg trusts a self-signed server certificate while still enforcing:\n " + "\n ".join(offenders) - + f"\nUnder PHI + enforce the MEFOR_ALLOW_INSECURE_TLS escape is INERT by design (ADR 0092 " + + "\nWhile enforcing, the MEFOR_ALLOW_INSECURE_TLS escape is INERT by design (ADR 0092 " "decision 2), so that leg cannot start at all — it fails with 'SQL Server TLS is weakened'. " - f"Either set {_SYNTHETIC}=false (ADR 0148's opt-out for a throwaway CI box that only processes " - "synthetic HL7 — see docs/SECURITY-LOOSENING.md), or give the leg a real certificate, which " - "means moving it off a `services:` container." + f"Either set {_ENFORCEMENT}=warn, or give the leg a real certificate, which means moving it " + "off a `services:` container." ) def test_the_escape_alone_is_not_mistaken_for_a_fix() -> None: - """``MEFOR_ALLOW_INSECURE_TLS`` present without the data-class declaration is the exact trap. + """``MEFOR_ALLOW_INSECURE_TLS`` present without the dial turned down is the exact trap. It reads like the fix — it is even what the error message names first — and it is what the job had set, unchanged, for the entire four nights it was failing. The variable is necessary but NOT - sufficient once the instance derives PHI. + sufficient while the instance enforces. """ for job, step, env in _serve_steps_with_env("ci.yml"): if "MEFOR_ALLOW_INSECURE_TLS" not in env: continue if str(env.get(_TRUST, "")).strip().lower() not in _TRUTHY: continue - declared = str(env.get(_SYNTHETIC, "")).strip().lower() - assert declared in _FALSEY, ( + declared = str(env.get(_ENFORCEMENT, "")).strip().lower() + assert declared == "warn", ( f"ci.yml:{job} — step {step!r} sets MEFOR_ALLOW_INSECURE_TLS with {_TRUST}=true but does " - f"not declare {_SYNTHETIC}=false. The escape is clamped inert under enforcing PHI, so on " - "its own it does nothing here — this is the configuration that failed silently for four " - "nights while looking correct." + f"not set {_ENFORCEMENT}=warn. The escape is clamped inert while enforcing, so on its own " + "it does nothing here — this is the configuration that failed silently for four nights " + "while looking correct." ) + + +def test_no_leg_still_sets_the_retired_declaration() -> None: + """The retired key is refused at LOAD, so a leg that still sets it fails at start, not at review. + + This is the migration's own tripwire and it is deliberately wider than the two tests above: it + covers every serve step, not only the TLS-weakened ones, because ``handles_real_patient_data`` was + a convenience anywhere a CI box wanted a quiet start. A leg that kept it would die on + ``[security].handles_real_patient_data was REMOVED``, which is a clear message but arrives a + nightly too late (BACKLOG #1279). + """ + steps = _serve_steps_with_env("ci.yml") + assert steps, "no `messagefoundry serve` step found in ci.yml — this guard would scan nothing" + offenders = [ + f"ci.yml:{job} — step {step!r} still sets {_RETIRED}" + for job, step, env in steps + if _RETIRED in env + ] + assert not offenders, ( + "a CI leg sets a config key the loader REFUSES:\n " + + "\n ".join(offenders) + + f"\n{_RETIRED} was removed in BACKLOG #1279 — every instance carries patient data. Set the " + "individual switch the leg actually needs (see the refusal message, which names them)." + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 008a2c49d..4fa51c2c1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -478,21 +478,28 @@ def test_serve_require_encryption_overrides_keyless_override( assert "require_encryption" in capsys.readouterr().err -def test_serve_quiet_in_dev_without_key( +def test_serve_keyless_in_dev_starts_but_is_never_quiet( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - # GIVEN 1 (ADR 0148): dev now derives PHI, so a genuinely-synthetic box declares the opt-out - # explicitly (handles_real_patient_data=false). A synthetic instance keyless start is allowed and - # quiet — the H3 refusal is gated on data_class==phi. (CI parity: synthetic stays key-free.) + # RENAMED FROM `test_serve_quiet_in_dev_without_key`, and the rename is the finding. A dev box + # used to start keyless AND SILENT by declaring itself synthetic. BACKLOG #1279 retired that, + # so the same box starts only on the per-gate ack -- and that ack is audited at every start. + # Quiet was the property worth losing: an accepted risk that stops being visible has stopped + # being accepted. monkeypatch.chdir(tmp_path) monkeypatch.delenv("MEFOR_STORE_ENCRYPTION_KEY", raising=False) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n", encoding="utf-8" + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n", + encoding="utf-8", ) monkeypatch.setattr("messagefoundry.api.create_managed_app", lambda **kw: object()) monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 0 - assert "UNENCRYPTED at rest" not in capsys.readouterr().err + captured = capsys.readouterr() + assert "UNENCRYPTED at rest" in captured.err + captured.out def test_serve_keyless_custom_phi_env_refuses( @@ -503,8 +510,7 @@ def test_serve_keyless_custom_phi_env_refuses( monkeypatch.chdir(tmp_path) monkeypatch.delenv("MEFOR_STORE_ENCRYPTION_KEY", raising=False) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = true\nsecurity.production_instance = false\n" - '[ai]\nenvironment = "test"\n', + 'security.production_instance = false\n[ai]\nenvironment = "test"\n', encoding="utf-8", ) monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) @@ -522,8 +528,7 @@ def test_serve_keyless_poc_phi_env_refuses_decoupled_from_name( monkeypatch.chdir(tmp_path) monkeypatch.delenv("MEFOR_STORE_ENCRYPTION_KEY", raising=False) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = true\nsecurity.production_instance = false\n" - '[ai]\nenvironment = "poc"\n', + 'security.production_instance = false\n[ai]\nenvironment = "poc"\n', encoding="utf-8", ) monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) @@ -541,8 +546,7 @@ def test_serve_keyless_poc_phi_env_refuses_when_production_true( monkeypatch.chdir(tmp_path) monkeypatch.delenv("MEFOR_STORE_ENCRYPTION_KEY", raising=False) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = true\nsecurity.production_instance = true\n" - '[ai]\nenvironment = "poc"\n', + 'security.production_instance = true\n[ai]\nenvironment = "poc"\n', encoding="utf-8", ) monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) @@ -606,7 +610,10 @@ def test_serve_custom_env_requires_explicit_posture( monkeypatch.chdir(tmp_path) monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "test"]) == 2 - assert "handles_real_patient_data" in capsys.readouterr().err + # The refusal named BOTH posture keys until BACKLOG #1279 removed the data class. A custom env + # still has to declare its production tier, and that is now the whole of what it must declare. + err = capsys.readouterr().err + assert "production_instance" in err and "handles_real_patient_data" not in err def test_serve_custom_env_with_posture_starts( @@ -616,7 +623,10 @@ def test_serve_custom_env_with_posture_starts( monkeypatch.chdir(tmp_path) monkeypatch.delenv("MEFOR_STORE_ENCRYPTION_KEY", raising=False) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\nsecurity.production_instance = false\n" + "security.block_unlisted_outbound = true\nsecurity.production_instance = false\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" '[ai]\nenvironment = "test"\n', encoding="utf-8", ) @@ -636,7 +646,10 @@ def test_serve_refuses_non_loopback_bind_by_default( # GIVEN 1 (ADR 0148): declare synthetic so the PHI gates stay quiet and only the bind gate decides. monkeypatch.chdir(tmp_path) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n" + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n', encoding="utf-8", ) @@ -652,10 +665,15 @@ def test_serve_allows_non_loopback_bind_with_flag( monkeypatch.chdir(tmp_path) monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEY", generate_key()) # silence the at-rest warning monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) # don't actually serve - # GIVEN 1 (ADR 0148): declare synthetic so the enforce clamp doesn't refuse the PHI cleartext bind - # — this test is about the --allow-insecure-bind flag path on a synthetic instance. + # The flag is CLAMPED INERT while enforcing (ADR 0092 decision 2). That clamp used to require + # enforcing AND PHI, and this case escaped it by declaring the box synthetic; since BACKLOG #1279 + # the dial is the only key left, so the dial is what this fixture turns down. (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n" + 'security.enforcement = "warn"\n' + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n', encoding="utf-8", ) @@ -678,7 +696,10 @@ def test_serve_loopback_bind_needs_no_flag( # GIVEN 1 (ADR 0148): declare synthetic so the PHI egress/retention/notify gates stay quiet and # only the loopback-bind (no-flag) behavior is under test. (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\nsecurity.local_access_only = true\n", + "security.block_unlisted_outbound = true\nsecurity.local_access_only = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n", encoding="utf-8", ) assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 0 @@ -742,7 +763,10 @@ def test_serve_auth_off_on_unexposed_loopback_still_starts( 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.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" "security.local_access_only = true\n" "security.require_sign_in = false\n", encoding="utf-8", @@ -755,15 +779,21 @@ def test_serve_auth_off_on_unexposed_loopback_still_starts( 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. + # BACKLOG #1013: the arm is inert under auth ON even when exposed. A 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), so the auth-off arm must not fire. + # + # The dial is at warn because the terminator-without-an-external-origin refusal has NO loopback + # carve-out, and since BACKLOG #1279 no declaration exempts a dev box from it. Different subject. 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.enforcement = "warn"\n' + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" "security.local_access_only = true\n" "[api]\n" "tls_terminated_upstream = true\n" @@ -786,7 +816,7 @@ def test_serve_insecure_bind_clamp_keys_on_enforcement_not_tier( monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) base = ( 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' - "security.handles_real_patient_data = true\nsecurity.block_unlisted_outbound = true\n" + "security.block_unlisted_outbound = true\n" ) # default enforce → a *staging* PHI cleartext bind is REFUSED at the bind gate (not just prod). (tmp_path / "messagefoundry.toml").write_text(base, encoding="utf-8") @@ -844,7 +874,7 @@ def _expose_toml( *, require_mfa: bool = False, enforcement: str | None = None, - synthetic: bool = False, + relax: str | None = None, public_origin: str | None = None, ) -> None: """A non-loopback bind (exposed via a declared TLS-terminating proxy) with egress locked down. @@ -863,17 +893,24 @@ def _expose_toml( # enforcement=warn reproduces the historical non-production dial (the gate WARNS + starts) — the # security REFUSE/WARN dial is decoupled from the production tier, so a staging-warn case sets it. enforce_line = f'security.enforcement = "{enforcement}"\n' if enforcement else "" - # GIVEN 1 (ADR 0148): dev now derives PHI, so the synthetic-dev exposure case declares the opt-out - # explicitly to keep the PHI gates (keyless/egress/MFA) relaxed the way the old dev default did. - synthetic_line = "security.handles_real_patient_data = false\n" if synthetic else "" + # `synthetic=True` used to write `handles_real_patient_data = false` here and relax the whole + # PHI family at once. BACKLOG #1279 retired that, so the flag now stands down the ONE gate a + # caller means: `relax` names it, and the caller that wants the MFA advisory silent asks for + # `require_mfa` rather than for a data class. + relax_line = relax or "" # Pass every non-MFA exposure gate (Posture-B declarations + egress deny-by-default + the #186/#188 # secure retention + SMTP-alert channels) so require_mfa is the ONLY posture under test. (tmp_path / "messagefoundry.toml").write_text( - enforce_line + synthetic_line + "security.local_access_only = false\n" + enforce_line + relax_line + "security.local_access_only = false\n" 'security.listen_address = "0.0.0.0"\n' f"security.require_mfa = {auth}\n" "security.block_unlisted_outbound = true\n" "security.delete_message_bodies_after_days = 30\n" + # The at-rest gate, acked here with the rest of the non-MFA plumbing. These callers run + # keyless, and since BACKLOG #1279 there is no data-class declaration to exempt them -- so + # without both acks every case in this block would refuse before reaching the gate it names. + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" # ADR 0152 rung 2: an EXPOSED PHI instance without an in-use data-protection declaration # WARNS at every start (it refuses only behind require_memory_encryption_declaration). # Declared here with the rest of the non-MFA plumbing so no case in this file is reading @@ -977,20 +1014,48 @@ def test_serve_warns_exposed_without_mfa_in_staging( assert "refusing to start" not in err # warned, did not refuse -def test_serve_quiet_exposed_without_mfa_in_synthetic_dev( +def test_serve_exposed_without_mfa_refuses_on_dev_too( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - # A synthetic instance (dev) stays quiet on the MFA posture (parity with keyless/egress gates). - # GIVEN 1 (ADR 0148): dev now derives PHI, so synthetic is declared explicitly (synthetic=True). + # THE INVERSE OF THE TEST THIS REPLACES. It asserted that a dev box declared synthetic stayed + # quiet on the MFA-at-exposure posture. BACKLOG #1279 retired the declaration, so an exposed + # dev box with require_mfa off now takes the same refusal a prod box takes -- which is the + # point of the change, not a side effect of it. monkeypatch.chdir(tmp_path) - _expose_toml(tmp_path, synthetic=True) + _expose_toml(tmp_path) monkeypatch.setattr("messagefoundry.api.create_managed_app", lambda **kw: object()) monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) - assert ( - main(["serve", "--config", str(SAMPLES_CONFIG), "--allow-insecure-bind", "--env", "dev"]) - == 0 + assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 2 + err = capsys.readouterr().err + assert "require_mfa" in err and "refusing to start" in err + + +def test_serve_exposed_without_mfa_is_permitted_by_the_per_gate_ack( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # The replacement for the retired declaration is the switch that names THIS gate, and it leaves + # every other one live. That asymmetry is the whole argument for the removal, so it is what the + # assertion below actually measures. + # + # IT DOES NOT ASSERT A CLEAN START, deliberately. `_expose_toml` exposes via a declared + # terminator, and the terminator-without-an-external-origin refusal fires AFTER the MFA gate has + # already been satisfied -- so a zero exit would be measuring that later gate, not this ack. + # Gating on the exit code here is the instrument answering the adjacent question (SDS-3.8). + monkeypatch.chdir(tmp_path) + _expose_toml( + tmp_path, + relax="security.allow_single_factor_admin_when_exposed = true\n", ) - assert "require_mfa" not in capsys.readouterr().err # synthetic → no MFA advisory + monkeypatch.setattr("messagefoundry.api.create_managed_app", lambda **kw: object()) + monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) + main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) + captured = capsys.readouterr() + both = captured.err + captured.out + # Permitted, and audited -- never silent. The AUDIT rides the logging path (stdout); the + # refusal it replaces printed to stderr, so this reads both rather than guessing. + assert "allow_single_factor_admin_when_exposed" in both + # ...and the MFA gate's own REFUSAL is gone, which is the half that proves the ack worked. + assert "with [security].require_mfa off; refusing to start" not in both def test_serve_exposed_with_mfa_on_starts_in_prod( @@ -1159,23 +1224,30 @@ def test_serve_warns_exposed_without_approvals_in_staging( assert "require_mfa off" not in err # the MFA gate stayed silent (pre-satisfied) -def test_serve_quiet_exposed_without_approvals_in_synthetic_dev( +def test_serve_exposed_without_approvals_warns_on_dev_too( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - # A synthetic instance (dev, data_class != PHI) stays quiet on the approvals posture, parity with - # the keyless / MFA / retention gates. GIVEN 1 (ADR 0148): dev derives PHI now, so declare the - # synthetic opt-out explicitly to keep the PHI gates relaxed. + # THE INVERSE OF THE TEST THIS REPLACES (BACKLOG #1279). An exposed dev box used to stay quiet + # on the dual-control posture by declaring itself synthetic; it now gets the same ASVS 2.3.5 + # warning an exposed staging box gets. Still WARN-only on every tier -- the removal widened who + # the advisory reaches, and changed nothing about its severity. rc = _dualctl_serve( tmp_path, monkeypatch, - "security.handles_real_patient_data = false\n" + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" + 'security.enforcement = "warn"\n' "security.local_access_only = false\n" - 'security.listen_address = "0.0.0.0"\n' - "security.block_unlisted_outbound = true\n", + 'security.listen_address = "0.0.0.0"\n', env="dev", ) + # The dial is at warn because this fixture binds off-loopback with no TLS, and the cleartext + # bind clamp refuses before the approvals advisory is reached. The advisory is the subject. assert rc == 0 - assert "approvals" not in capsys.readouterr().err + err = capsys.readouterr().err + assert "[approvals].enabled off" in err and "single caller's authority" in err def test_serve_loopback_prod_quiet_on_approvals( @@ -1380,15 +1452,19 @@ def test_serve_ui_upstream_with_public_origin_starts( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: # The refusal's happy path: one config line satisfies it (the error message names it). - # GIVEN 1 (ADR 0148): declare synthetic so the PHI retention/notify gates stay quiet — the /ui - # ladder is the subject here. + # The /ui ladder is the subject here, so the gates around it are stood down by NAME. The dial + # goes to warn because a declared terminator under `enforce` dials the ASVS 12.1.1 TLS-floor + # probe at the origin below, and that probe is a different test's business. rc = _l5b_serve( tmp_path, monkeypatch, - "security.handles_real_patient_data = false\n" + 'security.enforcement = "warn"\n' + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" "security.serve_web_console = true\n" 'security.web_console_public_address = "https://mefor.example.org"\n' - "security.block_unlisted_outbound = true\n" '[api]\ntls_terminated_upstream = true\ntrusted_proxies = ["10.0.0.2"]\n', ) assert rc == 0 @@ -1417,14 +1493,14 @@ def test_serve_ui_warns_on_undeclared_proxy_signal( ) -> None: # public_origin set on an unprotected loopback instance = the undeclared-proxy heuristic: # WARN (cookie ships without Secure until the posture is declared) but still start. - # GIVEN 1 (ADR 0148): declare synthetic so the PHI retention/notify gates stay quiet. + # The undeclared-proxy heuristic is the subject; the gates around it are stood down by name. rc = _l5b_serve( tmp_path, monkeypatch, - "security.handles_real_patient_data = false\n" + 'security.enforcement = "warn"\n' + "security.block_unlisted_outbound = true\n" "security.serve_web_console = true\n" - 'security.web_console_public_address = "https://mefor.example.org"\n' - "security.block_unlisted_outbound = true\n", + 'security.web_console_public_address = "https://mefor.example.org"\n', ) assert rc == 0 err = capsys.readouterr().err @@ -1689,7 +1765,11 @@ def test_serve_ui_default_on_loopback_mounts_ui( # GIVEN 1 (ADR 0148): declare synthetic so the bare loopback serve stays quiet on the PHI gates and # only the ADR 0143 default-on console behavior is under test. (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n", encoding="utf-8" + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n", + encoding="utf-8", ) monkeypatch.setattr( "messagefoundry.api.create_managed_app", lambda **kw: captured.update(kw) or object() @@ -1710,7 +1790,11 @@ def _bare_loopback_serve(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> int monkeypatch.chdir(tmp_path) monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEY", "x" * 44) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n", encoding="utf-8" + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n", + encoding="utf-8", ) monkeypatch.setattr("messagefoundry.api.create_managed_app", lambda **kw: object()) monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) @@ -1786,11 +1870,13 @@ def test_serve_ui_default_on_offloopback_degrades_json_only( # ADR 0143: the console defaults ON for LOOPBACK binds only. A DEFAULT-on (not explicitly requested) # console on an off-loopback bind AUTO-DEGRADES to JSON-only rather than tripping the /ui exposure # refusal — so a previously-working off-loopback JSON serve is not turned into a start failure. - # GIVEN 1 (ADR 0148): declare synthetic so the PHI retention/notify gates stay quiet. + # The dial is at warn: a declared terminator under `enforce` refuses without an external origin, + # and since BACKLOG #1279 no declaration exempts a dev box from that. The /ui degrade is the + # subject here, so the gate around it is stood down by name rather than by a data label. rc = _l5b_serve( tmp_path, monkeypatch, - "security.handles_real_patient_data = false\n" + 'security.enforcement = "warn"\n' "security.block_unlisted_outbound = true\n" '[api]\ntls_terminated_upstream = true\ntrusted_proxies = ["10.0.0.2"]\n', ) @@ -1810,8 +1896,10 @@ def test_serve_ui_default_on_public_origin_degrades_json_only( rc = _l5b_serve( tmp_path, monkeypatch, - "security.handles_real_patient_data = false\n" "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" 'security.web_console_public_address = "https://ops.example.com"\n', ) assert rc == 0 @@ -1922,8 +2010,7 @@ def test_serve_auto_bounds_retention_on_loopback_phi( rc, captured = _run_secure_serve( tmp_path, monkeypatch, - 'security.enforcement = "warn"\nsecurity.handles_real_patient_data = true\n' - "security.block_unlisted_outbound = true\n" + _SECURE_ALERTS, + 'security.enforcement = "warn"\nsecurity.block_unlisted_outbound = true\n' + _SECURE_ALERTS, env="dev", ) assert rc == 0 @@ -1952,20 +2039,24 @@ def test_serve_retention_respects_explicit_zero_in_staging( assert "PHI message bodies accumulate without bound" in capsys.readouterr().err -def test_serve_retention_quiet_in_synthetic_dev( +def test_serve_retention_auto_bounds_on_dev_too( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - # A synthetic instance (dev) is exempt from the retention gate — byte-identical keyless start. - # GIVEN 1 (ADR 0148): dev derives PHI now, so declare the synthetic opt-out explicitly. + # THE INVERSE OF THE TEST THIS REPLACES (BACKLOG #1279). No instance is exempt from the + # retention gate any more, so a non-production dev box takes the secure-by-default treatment + # its staging sibling always took: each UNSET PHI-body window is auto-bounded to 30 days + # rather than left unbounded. It still starts -- the auto-bound is a default, not a refusal. rc, _ = _run_secure_serve( tmp_path, monkeypatch, - "security.handles_real_patient_data = false\n", + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n", env="dev", key=False, ) assert rc == 0 - assert "retention" not in capsys.readouterr().err.lower() def test_serve_allow_unbounded_phi_override_starts_in_prod( @@ -2056,9 +2147,7 @@ def test_serve_auto_denies_egress_on_loopback_phi( rc, captured = _run_secure_serve( tmp_path, monkeypatch, - 'security.enforcement = "warn"\nsecurity.handles_real_patient_data = true\n' - + _SECURE_RETENTION - + _SECURE_ALERTS, + 'security.enforcement = "warn"\n' + _SECURE_RETENTION + _SECURE_ALERTS, env="dev", ) assert rc == 0 @@ -2066,22 +2155,36 @@ def test_serve_auto_denies_egress_on_loopback_phi( assert "block_unlisted_outbound defaulted ON for a PHI instance" in capsys.readouterr().err -def test_serve_egress_flip_skipped_for_synthetic_dev( +def test_serve_egress_flip_respects_an_explicit_opt_out_on_dev( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - # Byte-identical guard (TRAP-D): a NON-PHI (synthetic dev) serve is untouched by the WP243 - # broadening — deny_by_default stays False and no flip notice is emitted. GIVEN 1 (ADR 0148): dev - # derives PHI now, so declare the synthetic opt-out explicitly to keep the flip skipped. + # This test used to prove the WP243 flip SKIPPED a synthetic dev box. BACKLOG #1279 removed the + # data class, so nothing is skipped by classification -- and what it now pins is the property + # that still matters and is easy to lose: an EXPLICIT `block_unlisted_outbound = false` is + # respected rather than overridden by the flip. The audited opt-out is the only way to the + # allow-any posture now, and it is reported as a loosening. rc, captured = _run_secure_serve( tmp_path, monkeypatch, - 'security.handles_real_patient_data = false\n[egress]\nallowed_mllp = ["10.0.0.5"]\n', + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" + 'security.block_unlisted_outbound = false\n[egress]\nallowed_mllp = ["10.0.0.5"]\n', env="dev", - key=False, # synthetic — the flip keys on declared PHI + key=False, ) assert rc == 0 assert captured["egress_settings"].deny_by_default is False # type: ignore[attr-defined] - assert "defaulted ON" not in capsys.readouterr().err + # The flip did not run (it is presence-gated on the explicit value), and the opt-out is + # AUDITED -- the flip's own notice is absent, the deliberate choice is not. + # + # The absence check names the FULL notice, not the phrase "defaulted ON". Since BACKLOG #1279 the + # retention auto-bound reaches every instance and its notice contains that phrase too, so the + # short form would be matching a different gate's output -- passing or failing for reasons that + # have nothing to do with egress (SDS-3.8). + captured_out = capsys.readouterr() + assert "[security].block_unlisted_outbound defaulted ON" not in captured_out.err + assert "block_unlisted_outbound" in captured_out.err + captured_out.out # --- #188 out-of-band security-notification channel effective by default ------------------------- @@ -2164,7 +2267,10 @@ def test_serve_notify_quiet_in_synthetic_dev( rc, _ = _run_secure_serve( tmp_path, monkeypatch, - "security.handles_real_patient_data = false\n", + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n", env="dev", key=False, ) @@ -2188,7 +2294,10 @@ def test_serve_require_encryption_starts_with_configured_key( # GIVEN 1 (ADR 0148): declare synthetic so the PHI egress/retention/notify gates stay quiet and # only the require_encryption presence guard is under test. (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n[store]\nrequire_encryption = true\n", + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" + "security.block_unlisted_outbound = true\n[store]\nrequire_encryption = true\n", encoding="utf-8", ) monkeypatch.setattr("messagefoundry.api.create_managed_app", lambda **kw: object()) diff --git a/tests/test_client_network_allowlist.py b/tests/test_client_network_allowlist.py index 4e13d7403..e28783a6e 100644 --- a/tests/test_client_network_allowlist.py +++ b/tests/test_client_network_allowlist.py @@ -719,12 +719,19 @@ def _serve(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, toml: str, *, env: s _EXPOSED = ( - "security.handles_real_patient_data = false\n" + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" "security.local_access_only = false\n" 'security.listen_address = "0.0.0.0"\n' - "security.block_unlisted_outbound = true\n" "security.delete_message_bodies_after_days = 30\n" + # The Posture-B attestations. A box declared synthetic used to skip this gate; BACKLOG + # #1279 retired the declaration, so a declared terminator needs both or the start refuses, + # before these tests reach the allow-list they are about. + 'security.enforcement = "warn"\n' '[api]\ntls_terminated_upstream = true\ntrusted_proxies = ["10.0.0.1"]\n' + 'proxy_intra_service_auth = "network"\nproxy_tls_min_version = "1.2"\n' ) @@ -756,8 +763,10 @@ def test_default_loopback_serve_emits_nothing_new( ) -> None: """Byte-identity at the default: the shipped loopback posture gains no warning from this feature.""" toml = ( - "security.handles_real_patient_data = false\n" "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" "security.delete_message_bodies_after_days = 30\n" ) assert _serve(tmp_path, monkeypatch, toml, env="dev") == 0 diff --git a/tests/test_config_anchoring.py b/tests/test_config_anchoring.py index c8471ba29..08af03d68 100644 --- a/tests/test_config_anchoring.py +++ b/tests/test_config_anchoring.py @@ -238,7 +238,14 @@ def _serve_capturing_store_path( path resolution, with no PHI-gate noise.""" import messagefoundry.api as api_mod - monkeypatch.setenv("MEFOR_SECURITY_HANDLES_REAL_PATIENT_DATA", "false") + # The PER-GATE provisions a keyless dev serve needs since BACKLOG #1279 retired the one-line + # synthetic declaration these tests used to lean on: egress declared, and the at-rest gate + # acknowledged twice (ADR 0140 -- keyless PHI under `enforce` is never one flag away). Keeps + # this module focused on path resolution with no PHI-gate noise, as it always was. + monkeypatch.setenv("MEFOR_SECURITY_BLOCK_UNLISTED_OUTBOUND", "true") + monkeypatch.setenv("MEFOR_SECURITY_ALLOW_UNENCRYPTED_PHI", "true") + monkeypatch.setenv("MEFOR_SECURITY_ALLOW_UNENCRYPTED_PHI_UNDER_STRICT_ENFORCEMENT", "true") + monkeypatch.setenv("MEFOR_ALERTS_SECURITY_NOTIFICATIONS_REQUIRED", "false") captured: dict[str, object] = {} def _fake_app(*, store_settings: object, **_kw: object) -> object: @@ -354,7 +361,14 @@ def _run_serve_stubbed(monkeypatch: pytest.MonkeyPatch, argv: list[str]) -> int: anchoring diagnostics, not the security posture.""" import messagefoundry.api as api_mod - monkeypatch.setenv("MEFOR_SECURITY_HANDLES_REAL_PATIENT_DATA", "false") + # The PER-GATE provisions a keyless dev serve needs since BACKLOG #1279 retired the one-line + # synthetic declaration these tests used to lean on: egress declared, and the at-rest gate + # acknowledged twice (ADR 0140 -- keyless PHI under `enforce` is never one flag away). Keeps + # this module focused on path resolution with no PHI-gate noise, as it always was. + monkeypatch.setenv("MEFOR_SECURITY_BLOCK_UNLISTED_OUTBOUND", "true") + monkeypatch.setenv("MEFOR_SECURITY_ALLOW_UNENCRYPTED_PHI", "true") + monkeypatch.setenv("MEFOR_SECURITY_ALLOW_UNENCRYPTED_PHI_UNDER_STRICT_ENFORCEMENT", "true") + monkeypatch.setenv("MEFOR_ALERTS_SECURITY_NOTIFICATIONS_REQUIRED", "false") monkeypatch.setattr(api_mod, "create_managed_app", lambda **_kw: object()) import uvicorn diff --git a/tests/test_connection_tls_loosenings.py b/tests/test_connection_tls_loosenings.py index 98a25f498..8df31956b 100644 --- a/tests/test_connection_tls_loosenings.py +++ b/tests/test_connection_tls_loosenings.py @@ -28,7 +28,9 @@ environment = "dev" [security] -handles_real_patient_data = false +block_unlisted_outbound = true +allow_unencrypted_phi = true +allow_unencrypted_phi_under_strict_enforcement = true """ #: An outbound holding an expiry bridge open, and a generic-ODBC DATABASE pair — one outbound with no diff --git a/tests/test_dicomweb.py b/tests/test_dicomweb.py index 6f1078832..51dd8862e 100644 --- a/tests/test_dicomweb.py +++ b/tests/test_dicomweb.py @@ -304,7 +304,7 @@ def test_dicomweb_cleartext_http_nonloopback_allowed_when_accepted( # decision (decision 5). The per-connection declaration is what crosses it now — loudly, and # recorded in the audit trail, instead of a process-wide env var nobody sees in review. monkeypatch.delenv("MEFOR_ALLOW_INSECURE_TLS", raising=False) - with active_hop_posture(HopPosture(is_phi=True, enforcing=True)): + with active_hop_posture(HopPosture(enforcing=True)): dest = build_destination( Destination( name="OB", diff --git a/tests/test_docs_cite_no_refused_config_keys.py b/tests/test_docs_cite_no_refused_config_keys.py index 717a4665a..db8bd0fca 100644 --- a/tests/test_docs_cite_no_refused_config_keys.py +++ b/tests/test_docs_cite_no_refused_config_keys.py @@ -32,13 +32,22 @@ import pytest -from messagefoundry.config.settings import _RELOCATED_TO_SECURITY +from messagefoundry.config.settings import _RELOCATED_TO_SECURITY, _REMOVED_KEYS + +#: Every key the loader refuses, whichever way it got there. BACKLOG #1279 added the second +#: table: a REMOVED key fails at load exactly like a relocated one, and its message cannot name a +#: replacement spelling, so a doc that presents one is strictly worse to copy from. +_REFUSED_KEYS: tuple[tuple[str, str], ...] = tuple(_RELOCATED_TO_SECURITY) + tuple(_REMOVED_KEYS) REPO = pathlib.Path(__file__).resolve().parents[1] DOCS = REPO / "docs" # A line is exempt when it is talking ABOUT the refusal rather than instructing the reader. -_DISCLAIMS = re.compile(r"refus|relocat|moved to|no longer|rejected|\[security\]", re.IGNORECASE) +# `removed|retired` joined the list with BACKLOG #1279: a key that went away is documented in the +# past tense, and those are the two words that tense reaches for. +_DISCLAIMS = re.compile( + r"refus|relocat|moved to|no longer|rejected|removed|retired|\[security\]", re.IGNORECASE +) # TOML values only: quoted string, LOWERCASE bool, or bare number. Capitalised True is Python. _VALUE = r'("[^"]*"|true|false|\d+)(?![\w])' @@ -49,7 +58,7 @@ def _citations(text: str) -> list[tuple[int, str, str]]: for lineno, line in enumerate(text.splitlines(), 1): if _DISCLAIMS.search(line): continue - for section, key in _RELOCATED_TO_SECURITY: + for section, key in _REFUSED_KEYS: match = re.search(rf"(? None: Without this, deleting or renaming _RELOCATED_TO_SECURITY makes every test below pass vacuously -- a green suite over nothing, which is the failure this whole item is about. """ - assert len(_RELOCATED_TO_SECURITY) >= 15, _RELOCATED_TO_SECURITY + # 15 until BACKLOG #1279 moved ([ai], data_class) out of the relocation table and into + # _REMOVED_KEYS -- it relocated to nothing. The COMBINED floor is what the scan depends on, so + # that is what is asserted; both tables are required to be non-empty so neither can vanish. + assert len(_RELOCATED_TO_SECURITY) >= 14, _RELOCATED_TO_SECURITY + assert len(_REMOVED_KEYS) >= 2, _REMOVED_KEYS + assert len(_REFUSED_KEYS) >= 16, _REFUSED_KEYS def test_the_scanner_catches_a_deliberately_bad_line() -> None: @@ -76,7 +90,7 @@ def test_the_scanner_catches_a_deliberately_bad_line() -> None: def test_the_scanner_does_not_flag_a_line_documenting_the_refusal() -> None: """NEGATIVE CONTROL, and it is the one that has already burned somebody. Flagging this line would send a builder to 'fix' the only place the doc states the rule correctly.""" - section, key = next(iter(_RELOCATED_TO_SECURITY)) + section, key = next(iter(_REFUSED_KEYS)) documented = f"The `[{section}].{key} = true` TOML spelling is refused at load.\n" assert not _citations(documented) @@ -101,6 +115,14 @@ def test_the_scanner_does_not_flag_a_python_keyword_argument() -> None: # IT SELF-PRUNES, WHICH IS WHAT KEEPS A BASELINE FROM ROTTING INTO A SUPPRESSION LIST: fixing a # file below its number FAILS until you lower the number, and fixing it entirely FAILS until you # delete the row. The list can only shrink, and it cannot silently stop matching reality. +# +# BACKLOG #1279 WIDENED THE SCAN to `_REMOVED_KEYS` and the count went UP, which is the ratchet +# working rather than failing. The rows it added are all HISTORICAL: an accepted ADR that records +# what a since-removed key did, and closed ledger rows quoting the same. Those cannot be rewritten +# to a live spelling, because there is no live spelling -- the key relocated to nothing. A ratchet +# whose only remedy is to delete a decision record is the wrong instrument, so they are baselined. +# What the widening DOES catch is the case it was added for: a NEW doc telling a reader to write +# one, which fails immediately, at zero, like any other new citation. _BASELINE: dict[str, int] = { "docs/adr/0014-alerting-rules-engine.md": 1, "docs/adr/0022-fhir-resource-codec-rest-client.md": 1, @@ -108,14 +130,23 @@ def test_the_scanner_does_not_flag_a_python_keyword_argument() -> None: "docs/adr/0049-turnkey-dr-backup-restore-verify.md": 1, "docs/adr/0056-engine-managed-vip-failover.md": 1, "docs/adr/0096-cluster-leader-preference-and-non-promotable-standby.md": 1, + # #1279: was 2. The three added are the retired posture lever, quoted in this ADR's own + # amendment banner and in the two config blocks that show what the section looked like. + "docs/adr/0118-secure-by-default-security-configuration-section.md": 5, + # #1279 rows below: each records what the removed key did, in a decision record. "docs/adr/0115-asvs-l3-drive-to-pass-secure-by-default-flips-and-residual-closure.md": 1, - "docs/adr/0118-secure-by-default-security-configuration-section.md": 2, + "docs/adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md": 2, + "docs/adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md": 2, + # The owner's ruling, quoted verbatim in the status line. CLAUDE.md forbids rewriting a + # quotation, and the sentence retiring the key necessarily contains the key. + "docs/adr/0186-retire-the-synthetic-data-declaration-every-instance-carries-patient-data.md": 1, "docs/adr/0135-engine-brokered-ai-assistance-customer-managed-llm-egress-with-per-use-audit.md": 1, "docs/adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md": 2, "docs/adr/0151-operator-surface-source-network-allow-list-security-allowed-client-networks.md": 1, - "docs/adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md": 2, "docs/archive/backlog/BACKLOG-CLOSED.md": 2, - "docs/BACKLOG.md": 2, + # #1279: was 2. The two added are in item 1279 itself -- the row that ASKED for the removal, + # naming the key it wanted gone, and the closing banner recording that it went. + "docs/BACKLOG.md": 4, "docs/CLOUD-PHI-HIPAA.md": 1, "docs/CLUSTERING.md": 1, "docs/CONFIGURATION.md": 3, diff --git a/tests/test_email_destination.py b/tests/test_email_destination.py index 2344df51d..4b29396f5 100644 --- a/tests/test_email_destination.py +++ b/tests/test_email_destination.py @@ -266,9 +266,9 @@ async def test_cleartext_send_path_when_escaped(monkeypatch: pytest.MonkeyPatch) # cross an ENFORCING production-PHI cleartext hop, and the body is decided by the same # InsecureHopGuard gradient raw-TCP / X12 / plaintext-DIMSE / anonymous-FTP consume. -PROD_PHI = HopPosture(is_phi=True, enforcing=True) -STAGING_PHI = HopPosture(is_phi=True, enforcing=False) # PHI, dial at warn -SYNTHETIC = HopPosture(is_phi=False, enforcing=True) # not is_phi → always ALLOW +PROD_PHI = HopPosture(enforcing=True) +STAGING_PHI = HopPosture(enforcing=False) # PHI, dial at warn +SYNTHETIC = HopPosture(enforcing=True) # not is_phi → always ALLOW def _cleartext_dest( @@ -597,7 +597,7 @@ def test_tls_verify_false_refused_on_enforcing_phi_even_with_escape( # weakened_tls_escape_permitted_here() and not the raw insecure_tls_allowed(). monkeypatch.setenv(INSECURE_TLS_ESCAPE_ENV, "1") with ( - active_hop_posture(HopPosture(enforcing=True, is_phi=True)), + active_hop_posture(HopPosture(enforcing=True)), pytest.raises(ValueError, match="tls_verify=false"), ): EmailDestination(_dest(tls_verify=False)) diff --git a/tests/test_fhir_transport.py b/tests/test_fhir_transport.py index a4c82e9bb..0a000f767 100644 --- a/tests/test_fhir_transport.py +++ b/tests/test_fhir_transport.py @@ -130,7 +130,7 @@ def test_fhir_cleartext_http_nonloopback_allowed_when_accepted( # decision (decision 5). The per-connection declaration is what crosses it now — loudly, and # recorded in the audit trail, instead of a process-wide env var nobody sees in review. monkeypatch.delenv("MEFOR_ALLOW_INSECURE_TLS", raising=False) - with active_hop_posture(HopPosture(is_phi=True, enforcing=True)): + with active_hop_posture(HopPosture(enforcing=True)): dest = build_destination( Destination( name="OB", diff --git a/tests/test_hop_refusal_329.py b/tests/test_hop_refusal_329.py index 4b6537647..1664150de 100644 --- a/tests/test_hop_refusal_329.py +++ b/tests/test_hop_refusal_329.py @@ -40,9 +40,9 @@ from messagefoundry.transports.remotefile import _SftpClient # Mirror tests/test_hop_refusal_serve_clamp.py so the two suites decide against the same postures. -PROD_PHI = HopPosture(is_phi=True, enforcing=True) -STAGING_PHI = HopPosture(is_phi=True, enforcing=False) -SYNTHETIC = HopPosture(is_phi=False, enforcing=False) # dev / synthetic instance (no PHI) +PROD_PHI = HopPosture(enforcing=True) +STAGING_PHI = HopPosture(enforcing=False) +SYNTHETIC = HopPosture(enforcing=False) # dev / synthetic instance (no PHI) @pytest.fixture diff --git a/tests/test_hop_refusal_db_inbound.py b/tests/test_hop_refusal_db_inbound.py index 6dc966e50..d9de343ae 100644 --- a/tests/test_hop_refusal_db_inbound.py +++ b/tests/test_hop_refusal_db_inbound.py @@ -51,9 +51,9 @@ SAMPLES_CONFIG = Path(__file__).resolve().parents[1] / "samples" / "config" -PROD_PHI = HopPosture(is_phi=True, enforcing=True) -STAGING_PHI = HopPosture(is_phi=True, enforcing=False) -DEV = HopPosture(is_phi=False, enforcing=False) +PROD_PHI = HopPosture(enforcing=True) +STAGING_PHI = HopPosture(enforcing=False) +DEV = HopPosture(enforcing=False) _WEAK_DB = {"server": "s", "database": "d", "encrypt": False} @@ -247,7 +247,7 @@ def test_generic_refused_on_an_enforcing_synthetic_instance() -> None: # ADR 0153 removed is_phi from the authority, so the refusal keys on the enforcement dial alone -- # an enforcing instance refuses this hop whether or not it declares itself PHI-carrying. with ( - active_hop_posture(HopPosture(is_phi=False, enforcing=True)), + active_hop_posture(HopPosture(enforcing=True)), pytest.raises(InsecureHopRefused), ): DatabaseDestination(_generic_dest()) @@ -493,7 +493,7 @@ def test_serve_prod_phi_refuses_cleartext_even_with_flag( assert "enforcement=enforce" in err and "cannot relax a PHI cleartext bind" in err -def test_serve_dev_synthetic_honors_flag( +def test_serve_dev_honors_flag_under_warn_enforcement( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: pytest.importorskip( @@ -505,10 +505,15 @@ def test_serve_dev_synthetic_honors_flag( monkeypatch.chdir(tmp_path) monkeypatch.setenv("MEFOR_STORE_ENCRYPTION_KEY", generate_key()) monkeypatch.setattr("uvicorn.run", lambda *a, **k: None) - # GIVEN 1 (ADR 0148): dev derives PHI now, so declare synthetic explicitly — this test proves the - # --allow-insecure-bind flag is honored on a synthetic instance (the PHI clamp is tested elsewhere). + # The flag is CLAMPED INERT while enforcing. That clamp used to need enforcing AND PHI, and + # this test escaped it by declaring the box synthetic; BACKLOG #1279 left the dial as the only + # key. This proves the flag is HONORED where it can be -- the clamp itself is tested elsewhere. (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n" + 'security.enforcement = "warn"\n' + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n', encoding="utf-8", ) diff --git a/tests/test_hop_refusal_http.py b/tests/test_hop_refusal_http.py index b2fb231ed..8ea6d402b 100644 --- a/tests/test_hop_refusal_http.py +++ b/tests/test_hop_refusal_http.py @@ -62,9 +62,9 @@ "DICOMweb": (ConnectorType.DICOMWEB, DICOMweb, "https://pacs.example.org/dicom-web"), } -_STAGING = HopPosture(is_phi=True, enforcing=False) # non-prod PHI (staging/dev) -_PROD = HopPosture(is_phi=True, enforcing=True) # production PHI -_SYNTHETIC = HopPosture(is_phi=False, enforcing=False) # no PHI on the wire +_STAGING = HopPosture(enforcing=False) # non-prod PHI (staging/dev) +_PROD = HopPosture(enforcing=True) # production PHI +_SYNTHETIC = HopPosture(enforcing=False) # no PHI on the wire _CELLS = list(_CLEARTEXT) diff --git a/tests/test_hop_refusal_log_forwarding.py b/tests/test_hop_refusal_log_forwarding.py index a1e1c6636..5e476cbcd 100644 --- a/tests/test_hop_refusal_log_forwarding.py +++ b/tests/test_hop_refusal_log_forwarding.py @@ -40,9 +40,10 @@ SAMPLES_CONFIG = Path(__file__).resolve().parents[1] / "samples" / "config" -PROD_PHI = HopPosture(is_phi=True, enforcing=True) -STAGING_PHI = HopPosture(is_phi=True, enforcing=False) # PHI, dial at warn -SYNTHETIC = HopPosture(is_phi=False, enforcing=True) # not is_phi → always ALLOW +PROD_PHI = HopPosture(enforcing=True) +STAGING_PHI = HopPosture(enforcing=False) # PHI, dial at warn +# Pre-#1279 this was the blanket carve-out ("not is_phi → ALLOW"). It is enforcing, so it REFUSES. +SYNTHETIC = HopPosture(enforcing=True) REMOTE = "10.0.0.5" # RFC 1918, non-loopback (never resolves; treated as off-box) LOOPBACK = "127.0.0.1" @@ -105,8 +106,16 @@ def test_loopback_collector_allowed_so_the_local_agent_deployment_survives() -> assert forward_hop_disposition(_log(forward_host=LOOPBACK), PROD_PHI) is HopDisposition.ALLOW -def test_synthetic_instance_plaintext_collector_allowed_silently() -> None: - assert forward_hop_disposition(_log(), SYNTHETIC) is HopDisposition.ALLOW +def test_the_synthetic_allow_arm_is_gone_and_the_hop_now_refuses() -> None: + # ADR 0153 left this cell keyed on the data label because the forwarder is not a connection and + # so cannot carry a per-hop `cleartext_accepted`. BACKLOG #1279 removed the label, so the arm it + # bought has no instance left to fire on: an enforcing box with a plaintext collector REFUSES. + # + # THIS IS THE RESIDUAL ADR 0186 RECORDS, made executable. Under `enforce` this cell now has NO + # per-hop way to accept the risk -- only `forward_hop_attested` (below), which is an assertion + # that the hop is secure by other means, not an acceptance that it is not. The `[logging]` + # sibling of `cleartext_accepted` is the recorded follow-up and is unbuilt. + assert forward_hop_disposition(_log(), SYNTHETIC) is HopDisposition.REFUSE def test_non_enforcing_phi_plaintext_collector_warns_and_crosses() -> None: diff --git a/tests/test_hop_refusal_rawtcp.py b/tests/test_hop_refusal_rawtcp.py index 38d2dce34..af370155c 100644 --- a/tests/test_hop_refusal_rawtcp.py +++ b/tests/test_hop_refusal_rawtcp.py @@ -42,10 +42,10 @@ # The three postures. Only `enforcing` reaches the cleartext authority now (ADR 0153) — `is_phi` is # retained on HopPosture for the revocation / inbound-bind / verify-off gates, which still read it. -PROD_PHI = HopPosture(is_phi=True, enforcing=True) -STAGING_PHI = HopPosture(is_phi=True, enforcing=False) +PROD_PHI = HopPosture(enforcing=True) +STAGING_PHI = HopPosture(enforcing=False) # Pre-0153 this was the blanket carve-out ("not is_phi → ALLOW"). It is enforcing, so it now REFUSES. -SYNTHETIC = HopPosture(is_phi=False, enforcing=True) +SYNTHETIC = HopPosture(enforcing=True) REMOTE = "10.0.0.5" # a non-loopback host (never resolves; treated as remote/off-box) LOOPBACK = "127.0.0.1" diff --git a/tests/test_hop_refusal_residuals.py b/tests/test_hop_refusal_residuals.py index 1af78e4c0..babbbd088 100644 --- a/tests/test_hop_refusal_residuals.py +++ b/tests/test_hop_refusal_residuals.py @@ -32,7 +32,6 @@ from messagefoundry.config.settings import ( INSECURE_TLS_ESCAPE_ENV, AiSettings, - DataClass, EgressSettings, ) from messagefoundry.config.tls_policy import ( @@ -51,9 +50,9 @@ from messagefoundry.pipeline.wiring_runner import RegistryRunner from messagefoundry.store import MessageStore -PROD_PHI = HopPosture(is_phi=True, enforcing=True) -STAGING_PHI = HopPosture(is_phi=True, enforcing=False) -SYNTHETIC = HopPosture(is_phi=False, enforcing=False) +PROD_PHI = HopPosture(enforcing=True) +STAGING_PHI = HopPosture(enforcing=False) +SYNTHETIC = HopPosture(enforcing=False) # Non-routable / documentation hosts only (leak-gate): RFC 5737 TEST-NET-1 + RFC 2606 .example. REMOTE_DB = "192.0.2.10" @@ -94,10 +93,34 @@ def test_phi_read_disposition_prod_phi_secure_hop_allows() -> None: ) -def test_phi_read_disposition_synthetic_insecure_hop_allows() -> None: - # A synthetic instance carries no PHI, so an insecure serve hop is not refused (no false-close). +def test_the_synthetic_allow_arm_is_gone_from_the_api_serve_hop() -> None: + # ADR 0153 restated the `not is_phi -> ALLOW` arm HERE rather than inheriting it, because the + # API serve hop is not a connection and cannot carry a per-hop `cleartext_accepted`. BACKLOG + # #1279 removed the label, so the arm has no instance left to fire on. + # + # SYNTHETIC is a NON-enforcing posture here, so it WARNS rather than refusing -- which is the + # useful half of this test: the removal did not turn every previously-quiet hop into a hard + # stop, it moved each one to whatever the enforcement dial says. assert ( phi_read_hop_disposition(SYNTHETIC, serve_hop_secure=False, audited_opt_out=False) + is HopDisposition.WARN + ) + + +def test_the_api_serve_hop_refuses_when_enforcing_with_no_per_cell_escape() -> None: + # The other half, and the residual ADR 0186 records: under `enforce` this cell has NO per-hop + # acceptance mechanism at all. `audited_opt_out` arrives already clamped by the caller, so the + # only ways across are a proven-secure serve hop or an unstamped posture. + assert ( + phi_read_hop_disposition(PROD_PHI, serve_hop_secure=False, audited_opt_out=False) + is HopDisposition.REFUSE + ) + assert ( + phi_read_hop_disposition(PROD_PHI, serve_hop_secure=True, audited_opt_out=False) + is HopDisposition.ALLOW + ) + assert ( + phi_read_hop_disposition(None, serve_hop_secure=False, audited_opt_out=False) is HopDisposition.ALLOW ) @@ -171,14 +194,27 @@ async def test_api_prod_phi_secure_hop_serves_raw_view(engine: Engine) -> None: assert r.json()["raw"] == ADT -async def test_api_synthetic_insecure_hop_is_byte_identical(engine: Engine) -> None: +async def test_api_dev_insecure_hop_refuses_exactly_as_prod_does(engine: Engine) -> None: mid = await _seed(engine) - # A synthetic (dev) instance over an insecure hop is UNAFFECTED — no PHI to protect, byte-identical. - # GIVEN 1 (ADR 0148): dev derives PHI now, so the synthetic posture is declared explicitly. - async with _client( - engine, ai=AiSettings(environment="dev", data_class=DataClass.SYNTHETIC), secure=False - ) as c: - assert (await c.get(f"/messages/{mid}")).status_code == 200 + # THE INVERSE OF THE TEST THIS REPLACES (BACKLOG #1279). A dev instance over an unproven serve + # hop used to be byte-identical to no gate at all, because it declared itself synthetic. There + # is no such declaration now, so `dev` takes the same 403 `prod` takes three tests above -- and + # the PHI body still never leaves. + async with _client(engine, ai=AiSettings(environment="dev"), secure=False) as c: + r = await c.get(f"/messages/{mid}") + assert r.status_code == 403 + assert "PHI read refused" in r.json()["detail"] + assert ADT not in r.text + + +async def test_api_dev_secure_hop_still_serves(engine: Engine) -> None: + mid = await _seed(engine) + # ...and the loopback default every developer actually runs is untouched, which is what keeps + # the refusal above a gate on EXPOSURE rather than a gate on the environment name. + async with _client(engine, ai=AiSettings(environment="dev"), secure=True) as c: + r = await c.get(f"/messages/{mid}") + assert r.status_code == 200 + assert r.json()["raw"] == ADT async def test_api_no_ai_posture_is_byte_identical(engine: Engine) -> None: @@ -369,7 +405,7 @@ def _write_config( module = _CONFIG_MODULE_ACCEPTED if accepted else _CONFIG_MODULE (cfg / "feed.py").write_text(module, encoding="utf-8") # GIVEN 1 (ADR 0148): dev derives PHI now, so a synthetic instance declares the opt-out explicitly. - synthetic_line = "security.handles_real_patient_data = false\n" if synthetic else "" + synthetic_line = "security.block_unlisted_outbound = true\n" if synthetic else "" (tmp_path / "messagefoundry.toml").write_text( synthetic_line + _TOML.format(env=env), encoding="utf-8" ) diff --git a/tests/test_hop_refusal_revocation.py b/tests/test_hop_refusal_revocation.py index 4b713ca1c..6e67f3e45 100644 --- a/tests/test_hop_refusal_revocation.py +++ b/tests/test_hop_refusal_revocation.py @@ -10,7 +10,8 @@ REFUSED at construction / ``messagefoundry check`` / dry-run (store: at open) unless revocation is attested (per-connection ``tls_revocation_attested`` or the blanket ``MEFOR_TLS_REVOCATION_ATTESTED`` env). -Loopback / synthetic (non-PHI) / attested hops are byte-identical; a non-production PHI hop WARNs. It +Loopback / attested / proxy-proven hops are byte-identical; a non-enforcing hop WARNs. The fourth +relaxation, a synthetic instance, went with BACKLOG #1279 -- every instance carries patient data. It COMPOSES with #200: #200 refuses the CLEARTEXT / verify-off hop, so revocation fires ONLY on a VERIFYING hop — the two gates key on disjoint conditions and never double-refuse one hop. """ @@ -36,10 +37,11 @@ from messagefoundry.transports.email import EmailDestination from messagefoundry.transports.mllp import MLLPDestination -# The three postures the gradient keys on (the AI-derived is_phi/production). -PROD_PHI = HopPosture(is_phi=True, enforcing=True) -STAGING_PHI = HopPosture(is_phi=True, enforcing=False) -SYNTHETIC = HopPosture(is_phi=False, enforcing=True) # not is_phi → always ALLOW +# The postures the gradient keys on. `is_phi` went with BACKLOG #1279 -- only the dial is left. +PROD_PHI = HopPosture(enforcing=True) +STAGING_PHI = HopPosture(enforcing=False) +# Pre-#1279 this was the blanket carve-out ("not is_phi → ALLOW"). It is enforcing, so it REFUSES. +SYNTHETIC_NOW_ENFORCING = HopPosture(enforcing=True) REMOTE = "10.0.0.5" # a non-loopback host (never resolves; treated as remote/off-box) LOOPBACK = "127.0.0.1" @@ -59,14 +61,12 @@ def _no_blanket_env(monkeypatch: pytest.MonkeyPatch) -> None: def test_revocation_hop_disposition_matrix() -> None: def disp( *, - is_phi: bool, enforcing: bool, is_loopback_hop: bool = False, proxy_proven: bool = False, attested: bool = False, ) -> HopDisposition: return revocation_hop_disposition( - is_phi=is_phi, enforcing=enforcing, is_loopback_hop=is_loopback_hop, proxy_proven=proxy_proven, @@ -74,17 +74,19 @@ def disp( ) # loopback → ALLOW (on-box, not a network exposure) even on enforcing-PHI. - assert disp(is_phi=True, enforcing=True, is_loopback_hop=True) is HopDisposition.ALLOW + assert disp(enforcing=True, is_loopback_hop=True) is HopDisposition.ALLOW # a proven revocation-checking terminator → ALLOW. - assert disp(is_phi=True, enforcing=True, proxy_proven=True) is HopDisposition.ALLOW + assert disp(enforcing=True, proxy_proven=True) is HopDisposition.ALLOW # attested → ALLOW. - assert disp(is_phi=True, enforcing=True, attested=True) is HopDisposition.ALLOW - # synthetic (no PHI) → ALLOW. - assert disp(is_phi=False, enforcing=True) is HopDisposition.ALLOW - # enforcing PHI, unproven → REFUSE. - assert disp(is_phi=True, enforcing=True) is HopDisposition.REFUSE - # non-enforcing PHI → WARN (crosses, loud-logged). - assert disp(is_phi=True, enforcing=False) is HopDisposition.WARN + assert disp(enforcing=True, attested=True) is HopDisposition.ALLOW + # A fourth ALLOW arm sat here -- `not is_phi`, the synthetic instance -- and went with BACKLOG + # #1279. Every instance carries patient data, so it had no input left to fire on and this row, + # which used to ALLOW, now falls through to the refusal below. + # + # enforcing, unproven → REFUSE. + assert disp(enforcing=True) is HopDisposition.REFUSE + # non-enforcing → WARN (crosses, loud-logged). + assert disp(enforcing=False) is HopDisposition.WARN # --- the guard: construction gate + unstamped no-op + attestation audit ------------------------------ @@ -106,13 +108,11 @@ def test_guard_refuses_prod_phi_remote() -> None: _guard(REMOTE).enforce_construction() -def test_guard_allows_loopback_synthetic_nonprod_attested() -> None: +def test_guard_allows_loopback_nonprod_attested() -> None: with active_hop_posture(PROD_PHI): _guard(LOOPBACK).enforce_construction() # on-box _guard(REMOTE, attested=True).enforce_construction() # attested _guard(REMOTE, proxy_proven=True).enforce_construction() # proven terminator - with active_hop_posture(SYNTHETIC): - _guard(REMOTE).enforce_construction() # no PHI with active_hop_posture(STAGING_PHI): _guard(REMOTE).enforce_construction() # non-prod PHI → WARN (constructs) @@ -153,12 +153,10 @@ def test_mllp_tls_verify_refuses_prod_phi_remote() -> None: MLLPDestination(mllp_cfg(REMOTE)) -def test_mllp_tls_verify_allows_attested_loopback_synthetic_nonprod() -> None: +def test_mllp_tls_verify_allows_attested_loopback_nonprod() -> None: with active_hop_posture(PROD_PHI): MLLPDestination(mllp_cfg(REMOTE, revocation_attested=True)) MLLPDestination(mllp_cfg(LOOPBACK)) - with active_hop_posture(SYNTHETIC): - MLLPDestination(mllp_cfg(REMOTE)) with active_hop_posture(STAGING_PHI): MLLPDestination(mllp_cfg(REMOTE)) # non-prod PHI → WARN, constructs @@ -177,12 +175,14 @@ def test_mllp_sets_revocation_guard_only_on_verify_path() -> None: # verify-ON TLS hop carries a revocation guard; a cleartext (tls off) hop does not (its cleartext # #200 guard handles it — the two guards are disjoint, never both set). # - # SYNTHETIC used to suppress BOTH gates. Since ADR 0153 the data label no longer relaxes the - # CLEARTEXT one, so the cleartext leg carries an explicit declaration instead. The revocation gate - # (ADR 0078) still reads the label and is deliberately OUT of 0153's scope — which is precisely what - # makes the asymmetry in this test the thing worth pinning. - with active_hop_posture(SYNTHETIC): - verified = MLLPDestination(mllp_cfg(REMOTE)) + # A synthetic declaration used to suppress BOTH gates. ADR 0153 took the data label off the + # CLEARTEXT one (so the cleartext leg carries an explicit declaration instead) and BACKLOG #1279 + # took it off the revocation one too, so the asymmetry this test pinned is gone: BOTH gates now + # read the hop's own facts. What is still worth pinning is that only ONE guard is set per hop -- + # a verify-ON hop carries the revocation guard, a cleartext hop carries the #200 guard, never both. + # The verified leg is attested so it constructs; the gate itself is covered above. + with active_hop_posture(SYNTHETIC_NOW_ENFORCING): + verified = MLLPDestination(mllp_cfg(REMOTE, revocation_attested=True)) cleartext = MLLPDestination( Destination( name="OB", @@ -273,12 +273,6 @@ def test_https_verified_warns_but_builds_staging(cell: str) -> None: _build_https(_HTTPS[cell]) # non-prod PHI → WARN, constructs -@pytest.mark.parametrize("cell", _HTTP_CELLS) -def test_https_verified_allows_synthetic(cell: str) -> None: - with active_hop_posture(SYNTHETIC): - _build_https(_HTTPS[cell]) # no PHI on the wire - - @pytest.mark.parametrize("cell", _HTTP_CELLS) def test_https_verified_unstamped_is_noop(cell: str) -> None: _build_https(_HTTPS[cell]) # no stamped posture → byte-identical @@ -310,10 +304,9 @@ def test_store_verify_refuses_prod_phi_remote() -> None: _build_ssl(_pg(), posture=PROD_PHI) -def test_store_verify_allows_loopback_synthetic_nonprod() -> None: +def test_store_verify_allows_loopback_nonprod() -> None: assert _build_ssl(_pg(server=LOOPBACK), posture=PROD_PHI) is True # on-box - assert _build_ssl(_pg(), posture=SYNTHETIC) is True # no PHI - assert _build_ssl(_pg(), posture=STAGING_PHI) is True # non-prod PHI → WARN, returns verifying + assert _build_ssl(_pg(), posture=STAGING_PHI) is True # non-enforcing → WARN, returns verifying def test_store_verify_unstamped_is_noop() -> None: @@ -358,14 +351,41 @@ def test_email_tls_refuses_prod_phi_remote() -> None: EmailDestination(email_cfg(REMOTE)) -def test_email_tls_allows_attested_loopback_synthetic_nonprod() -> None: +def test_email_tls_allows_attested_loopback_nonprod() -> None: with active_hop_posture(PROD_PHI): EmailDestination(email_cfg(REMOTE, revocation_attested=True)) EmailDestination(email_cfg(LOOPBACK)) - with active_hop_posture(SYNTHETIC): - EmailDestination(email_cfg(REMOTE)) with active_hop_posture(STAGING_PHI): - EmailDestination(email_cfg(REMOTE)) # non-prod PHI → WARN, constructs + EmailDestination(email_cfg(REMOTE)) # non-enforcing → WARN, constructs + + +def test_the_synthetic_arm_is_gone_and_those_hops_now_refuse() -> None: + """BACKLOG #1279: the `not is_phi -> ALLOW` arm went, so its inputs REFUSE. + + Pinned once, on every cell that carried a SYNTHETIC arm before, rather than left implicit in six + deleted lines. Each of these constructed silently on an instance declared synthetic; each refuses + now, because there is no declaration that reaches this gate any more. The only relaxations left are + the three the disposition still names: on-box, a proven terminator, an operator attestation. + """ + with active_hop_posture(SYNTHETIC_NOW_ENFORCING): + with pytest.raises(InsecureHopRefused, match="revocation"): + _guard(REMOTE).enforce_construction() + with pytest.raises(InsecureHopRefused, match="revocation"): + MLLPDestination(mllp_cfg(REMOTE)) + with pytest.raises(InsecureHopRefused, match="revocation"): + EmailDestination(email_cfg(REMOTE)) + for cell in _HTTP_CELLS: + with pytest.raises(InsecureHopRefused, match="revocation"): + _build_https(_HTTPS[cell]) + # The store hop takes its posture as an argument rather than off the contextvar, so it is asserted + # separately rather than dropped -- it carried a SYNTHETIC arm too. + with pytest.raises(ValueError, match="revocation"): + _build_ssl(_pg(), posture=SYNTHETIC_NOW_ENFORCING) + # ...and the three real relaxations still cross it, so this is a tightening rather than a wall. + with active_hop_posture(SYNTHETIC_NOW_ENFORCING): + _guard(LOOPBACK).enforce_construction() + _guard(REMOTE, attested=True).enforce_construction() + _guard(REMOTE, proxy_proven=True).enforce_construction() def test_email_tls_unstamped_is_noop() -> None: diff --git a/tests/test_hop_refusal_serve_clamp.py b/tests/test_hop_refusal_serve_clamp.py index ac0775488..a057050d3 100644 --- a/tests/test_hop_refusal_serve_clamp.py +++ b/tests/test_hop_refusal_serve_clamp.py @@ -34,12 +34,12 @@ from messagefoundry.store import MessageStore from messagefoundry.store.sqlserver import connection_string -PROD_PHI = HopPosture(is_phi=True, enforcing=True) -STAGING_PHI = HopPosture(is_phi=True, enforcing=False) -SYNTHETIC = HopPosture(is_phi=False, enforcing=False) # dev / synthetic instance (no PHI) +PROD_PHI = HopPosture(enforcing=True) +STAGING_PHI = HopPosture(enforcing=False) +SYNTHETIC = HopPosture(enforcing=False) # dev / synthetic instance (no PHI) # ADR 0153: an ENFORCING instance that merely carries a synthetic label. Pre-0153 the label alone # allowed every cleartext hop; now only `enforcing` reaches the authority, so this one refuses. -SYNTHETIC_ENFORCING = HopPosture(is_phi=False, enforcing=True) +SYNTHETIC_ENFORCING = HopPosture(enforcing=True) REMOTE = "10.0.0.5" # a non-loopback host (never resolves; treated as off-box) diff --git a/tests/test_hop_refusal_wiring.py b/tests/test_hop_refusal_wiring.py index 5ee71437a..005d71668 100644 --- a/tests/test_hop_refusal_wiring.py +++ b/tests/test_hop_refusal_wiring.py @@ -7,7 +7,7 @@ import pytest -from messagefoundry.config.ai_policy import AiMode, DataClass, SecurityEnforcement +from messagefoundry.config.ai_policy import AiMode, SecurityEnforcement from messagefoundry.config.models import ConnectorType, Destination, Source from messagefoundry.config.settings import ( AiSettings, @@ -167,49 +167,41 @@ def test_cleartext_acceptance_is_destination_only() -> None: assert "cleartext_accepted" in Destination.model_fields -# --- decision 7: the [ai]->HopPosture mapping (is_phi from data_class; enforcing from [security]) -- +# --- decision 7: the [ai]->HopPosture mapping (enforcing from [security]; there is no other axis) -- _ENFORCE = SecurityEnforcement.ENFORCE _WARN = SecurityEnforcement.WARN -def test_hop_posture_from_ai_builtin_names_is_phi_mapping() -> None: - # is_phi keys on data_class (GIVEN 1, ADR 0148: dev/staging/prod all derive phi now); enforcing - # keys on the enforcement level, DECOUPLED from the tier — at the ENFORCE default all carry - # enforcing=True. - dev = AiSettings(mode=AiMode.BYO, environment="dev") - assert hop_posture_from_ai(dev, enforcement=_ENFORCE) == HopPosture(is_phi=True, enforcing=True) - staging = AiSettings(mode=AiMode.BYO, environment="staging") - assert hop_posture_from_ai(staging, enforcement=_ENFORCE) == HopPosture( - is_phi=True, enforcing=True - ) - prod = AiSettings(mode=AiMode.BYO, environment="prod") - assert hop_posture_from_ai(prod, enforcement=_ENFORCE) == HopPosture( - is_phi=True, enforcing=True - ) +def test_hop_posture_from_ai_builtin_names_all_map_to_the_enforcement_dial() -> None: + # The mapping is now one-dimensional. The data-class axis went with BACKLOG #1279, so no + # environment name and no declaration can produce a posture other than the dial's. + for name in ("dev", "staging", "prod"): + ai = AiSettings(mode=AiMode.BYO, environment=name) + assert hop_posture_from_ai(ai, enforcement=_ENFORCE) == HopPosture(enforcing=True) def test_hop_posture_from_ai_enforcement_drives_enforcing() -> None: - # enforcement=warn reproduces the historical non-production dial (enforcing=False) on every tier; - # is_phi is unchanged. This is the dial DECOUPLING: the tier no longer sets the refuse/warn bit. - for env, is_phi in (("dev", True), ("staging", True), ("prod", True)): + # enforcement=warn reproduces the historical non-production dial (enforcing=False) on every tier. + # This is the dial DECOUPLING: the tier does not set the refuse/warn bit, and since #1279 nothing + # else contributes to the posture at all. + for env in ("dev", "staging", "prod"): ai = AiSettings(mode=AiMode.BYO, environment=env) - assert hop_posture_from_ai(ai, enforcement=_WARN) == HopPosture( - is_phi=is_phi, enforcing=False - ) - assert hop_posture_from_ai(ai, enforcement=_ENFORCE) == HopPosture( - is_phi=is_phi, enforcing=True - ) + assert hop_posture_from_ai(ai, enforcement=_WARN) == HopPosture(enforcing=False) + assert hop_posture_from_ai(ai, enforcement=_ENFORCE) == HopPosture(enforcing=True) -def test_hop_posture_from_ai_explicit_posture_overrides_name() -> None: - ai = AiSettings(mode=AiMode.BYO, environment="poc", data_class=DataClass.PHI, production=True) - assert hop_posture_from_ai(ai, enforcement=_ENFORCE) == HopPosture(is_phi=True, enforcing=True) +def test_hop_posture_from_ai_explicit_tier_does_not_change_the_hop_posture() -> None: + # The production tier is a real property and it is still declarable, but it is not a hop input: + # an explicitly-production custom env produces the same posture as anything else at this dial. + ai = AiSettings(mode=AiMode.BYO, environment="poc", production=True) + assert hop_posture_from_ai(ai, enforcement=_ENFORCE) == HopPosture(enforcing=True) def test_hop_posture_from_ai_custom_unresolved_fails_closed() -> None: - # A custom env with no explicit data_class -> is_phi unknown -> strictest (fail-closed True). + # A custom env that resolves no tier at all still yields the strict posture -- the fail-closed + # property this test has always pinned, now carried by the dial alone. ai = AiSettings(mode=AiMode.BYO, environment="poc") - assert hop_posture_from_ai(ai, enforcement=_ENFORCE) == HopPosture(is_phi=True, enforcing=True) + assert hop_posture_from_ai(ai, enforcement=_ENFORCE) == HopPosture(enforcing=True) # --- decision 7 wiring: build_check_registry stamps the posture during connector construction --- @@ -229,7 +221,7 @@ def fake_build_destination(dest: Destination) -> object: reg = Registry() reg.add_outbound(build_outbound_connection("OB", File(directory="."))) - posture = HopPosture(is_phi=True, enforcing=True) + posture = HopPosture(enforcing=True) wr.build_check_registry( reg, inbound_bind_host="127.0.0.1", @@ -460,7 +452,7 @@ def _mtls(**overrides: object) -> Source: ) -_PHI_ENFORCING = HopPosture(is_phi=True, enforcing=True) +_PHI_ENFORCING = HopPosture(enforcing=True) def test_mtls_without_a_crl_is_refused_on_an_enforcing_phi_instance() -> None: @@ -483,12 +475,16 @@ def test_the_per_connection_attestation_passes() -> None: check_inbound_revocation(_mtls(attested=True), "IB", posture=_PHI_ENFORCING) -def test_a_non_phi_instance_warns_rather_than_refusing() -> None: - check_inbound_revocation(_mtls(), "IB", posture=HopPosture(is_phi=False, enforcing=True)) +def test_an_enforcing_instance_refuses_where_a_non_phi_one_used_to_warn() -> None: + # This asserted that a NON-PHI instance warned and crossed. BACKLOG #1279 removed the data + # label from the predicate, so the same input is now judged on the enforcement dial alone -- + # and at `enforce` an mTLS listener that checks no revocation is refused. + with pytest.raises(WiringError, match="revocation"): + check_inbound_revocation(_mtls(), "IB", posture=HopPosture(enforcing=True)) def test_a_non_enforcing_instance_warns_rather_than_refusing() -> None: - check_inbound_revocation(_mtls(), "IB", posture=HopPosture(is_phi=True, enforcing=False)) + check_inbound_revocation(_mtls(), "IB", posture=HopPosture(enforcing=False)) def test_an_unstamped_posture_never_acquires_a_new_refusal() -> None: diff --git a/tests/test_http_auth.py b/tests/test_http_auth.py index 49503750c..5098510d4 100644 --- a/tests/test_http_auth.py +++ b/tests/test_http_auth.py @@ -139,8 +139,8 @@ def test_oauth2_cc_unparseable_token_response_raises_delivery_error() -> None: # escape set (the escape is inert for prod-PHI), non-prod / attested is permitted (as the delivery cells # do), and the default (unstamped → fail-closed prod-PHI, e.g. the two tests above) still refuses. -_PROD_PHI = HopPosture(is_phi=True, enforcing=True) -_STAGING_PHI = HopPosture(is_phi=True, enforcing=False) # non-prod PHI +_PROD_PHI = HopPosture(enforcing=True) +_STAGING_PHI = HopPosture(enforcing=False) # non-prod PHI def test_oauth2_cleartext_token_endpoint_refused_on_prod_phi_even_with_escape( diff --git a/tests/test_inbound_http_intake_auth.py b/tests/test_inbound_http_intake_auth.py index 8696997ce..6bd5ed209 100644 --- a/tests/test_inbound_http_intake_auth.py +++ b/tests/test_inbound_http_intake_auth.py @@ -487,9 +487,11 @@ def test_global_limit_trip_does_not_deny_authenticated_peer() -> None: # --- D7: the posture-keyed peer-control gate ---------------------------------------------------- -PROD_PHI = HopPosture(is_phi=True, enforcing=True) -STAGING_PHI = HopPosture(is_phi=True, enforcing=False) # PHI, but the dial is at warn -SYNTHETIC = HopPosture(is_phi=False, enforcing=True) # no real PHI +PROD_PHI = HopPosture(enforcing=True) +STAGING_PHI = HopPosture(enforcing=False) # PHI, but the dial is at warn +# Pre-#1279 this was the synthetic carve-out. It is ENFORCING, so it now refuses like PROD_PHI -- +# named for what it was so the arm below reads as the tightening it is. +SYNTHETIC_NOW_ENFORCING = HopPosture(enforcing=True) def _http_source(**settings: Any) -> Source: @@ -506,9 +508,12 @@ def test_offloopback_without_effective_peer_control_refused_by_posture() -> None with pytest.raises(WiringError, match="no effective peer control"): check_http_intake_auth(exposed, "IB_HTTP", posture=PROD_PHI) - # Warn, don't refuse, outside an enforcing PHI posture — and never crash the engine. + # Warn, don't refuse, outside an enforcing posture — and never crash the engine. check_http_intake_auth(exposed, "IB_HTTP", posture=STAGING_PHI) - check_http_intake_auth(exposed, "IB_HTTP", posture=SYNTHETIC) + # The synthetic arm that sat here went with BACKLOG #1279: that posture is enforcing, so it now + # takes the same refusal PROD_PHI takes above rather than warning past it. + with pytest.raises(WiringError, match="no effective peer control"): + check_http_intake_auth(exposed, "IB_HTTP", posture=SYNTHETIC_NOW_ENFORCING) # posture=None is a direct/embedding call: a NEW refusal must not start firing for those. check_http_intake_auth(exposed, "IB_HTTP", posture=None) diff --git a/tests/test_listener_tls_exposure.py b/tests/test_listener_tls_exposure.py index d04ec7670..b96140698 100644 --- a/tests/test_listener_tls_exposure.py +++ b/tests/test_listener_tls_exposure.py @@ -93,7 +93,10 @@ def test_serve_refuses_inprocess_tls_offloopback_without_attestation( monkeypatch.chdir(tmp_path) monkeypatch.delenv("MEFOR_TLS_REVOCATION_ATTESTED", raising=False) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n" # GIVEN 1 (ADR 0148): dev derives PHI now + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" # GIVEN 1 (ADR 0148): dev derives PHI now 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' '[api]\ntls_cert_file = "cert.pem"\n', encoding="utf-8", @@ -113,7 +116,10 @@ def test_serve_inprocess_tls_offloopback_attested_starts( monkeypatch.setenv("MEFOR_TLS_REVOCATION_ATTESTED", "1") _mock_start(monkeypatch) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n" # GIVEN 1 (ADR 0148): dev derives PHI now + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" # GIVEN 1 (ADR 0148): dev derives PHI now 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' '[api]\ntls_cert_file = "cert.pem"\n', encoding="utf-8", @@ -131,7 +137,10 @@ def test_serve_loopback_inprocess_tls_starts( monkeypatch.delenv("MEFOR_TLS_REVOCATION_ATTESTED", raising=False) _mock_start(monkeypatch) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n" # GIVEN 1 (ADR 0148): dev derives PHI now + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" # GIVEN 1 (ADR 0148): dev derives PHI now 'security.local_access_only = true\n[api]\ntls_cert_file = "cert.pem"\n', encoding="utf-8", ) @@ -148,9 +157,14 @@ def test_serve_proxy_terminated_offloopback_starts( monkeypatch.delenv("MEFOR_TLS_REVOCATION_ATTESTED", raising=False) _mock_start(monkeypatch) (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n" # GIVEN 1 (ADR 0148): dev derives PHI now + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" # GIVEN 1 (ADR 0148): dev derives PHI now 'security.local_access_only = false\nsecurity.listen_address = "0.0.0.0"\n' - '[api]\ntls_terminated_upstream = true\ntrusted_proxies = ["10.0.0.7"]\n', + 'security.enforcement = "warn"\n' + '[api]\ntls_terminated_upstream = true\ntrusted_proxies = ["10.0.0.7"]\n' + 'proxy_intra_service_auth = "network"\nproxy_tls_min_version = "1.2"\n', encoding="utf-8", ) assert main(["serve", "--config", str(SAMPLES_CONFIG), "--env", "dev"]) == 0 diff --git a/tests/test_logging.py b/tests/test_logging.py index 605155462..e66809fc1 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -158,7 +158,14 @@ def test_serve_applies_log_level(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) # serve imports these lazily, so patch them at the source (looked up at call time). # GIVEN 1 (ADR 0148): dev derives PHI now, so declare synthetic (env opt-out) to keep PHI gates quiet. - monkeypatch.setenv("MEFOR_SECURITY_HANDLES_REAL_PATIENT_DATA", "false") + # The PER-GATE provisions a keyless dev serve needs since BACKLOG #1279 retired the one-line + # synthetic declaration these tests used to lean on: egress declared, and the at-rest gate + # acknowledged twice (ADR 0140 -- keyless PHI under `enforce` is never one flag away). Keeps + # this module focused on path resolution with no PHI-gate noise, as it always was. + monkeypatch.setenv("MEFOR_SECURITY_BLOCK_UNLISTED_OUTBOUND", "true") + monkeypatch.setenv("MEFOR_SECURITY_ALLOW_UNENCRYPTED_PHI", "true") + monkeypatch.setenv("MEFOR_SECURITY_ALLOW_UNENCRYPTED_PHI_UNDER_STRICT_ENFORCEMENT", "true") + monkeypatch.setenv("MEFOR_ALERTS_SECURITY_NOTIFICATIONS_REQUIRED", "false") monkeypatch.setattr("messagefoundry.api.create_managed_app", lambda **kw: object()) monkeypatch.setattr(uvicorn, "run", lambda app, **kw: captured.update(kw)) @@ -584,7 +591,14 @@ def test_serve_wires_off_box_forwarder_and_logs_enabled( monkeypatch.chdir(tmp_path) # GIVEN 1 (ADR 0148): dev derives PHI now, so declare synthetic to keep the PHI gates quiet. (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n" + # The PER-GATE provisions a keyless dev serve needs since BACKLOG #1279 retired the + # one-line synthetic declaration these fixtures leaned on. Each names the gate it + # relaxes; together they are the quiet start this module wants while it probes + # something else entirely. + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" '[logging]\nforward_enabled = true\nforward_host = "127.0.0.1"\nforward_port = 5514\n' 'forward_protocol = "udp"\nforward_format = "text"\n', encoding="utf-8", @@ -1398,10 +1412,17 @@ def recvfrom(self, _n: int) -> tuple[bytes, Any]: def _write_timesync_toml(tmp_path: Any, *, fail_closed: bool) -> None: - # GIVEN 1 (ADR 0148): dev derives PHI now, so declare synthetic to keep the PHI gates quiet — these - # tests probe the clock-sync gate, not the security posture. + # These tests probe the clock-sync gate, not the security posture, so the PHI gates are + # satisfied per-gate below rather than declared away (BACKLOG #1279 retired that option). body = ( - "security.handles_real_patient_data = false\n" + # The PER-GATE provisions a keyless dev serve needs since BACKLOG #1279 retired the + # one-line synthetic declaration these fixtures leaned on. Each names the gate it + # relaxes; together they are the quiet start this module wants while it probes + # something else entirely. + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" '[logging]\nrequire_time_sync = true\nntp_peer = "ntp.example.test"\n' "time_sync_max_skew_seconds = 1.0\n" ) diff --git a/tests/test_memory_encryption_readout.py b/tests/test_memory_encryption_readout.py index cad18fbdc..e458eeb7b 100644 --- a/tests/test_memory_encryption_readout.py +++ b/tests/test_memory_encryption_readout.py @@ -379,7 +379,15 @@ def _counting_readout() -> MemoryEncryptionReadout: "messagefoundry.config.memory_encryption.platform_memory_encryption_readout", _counting_readout, ) - rc = _serve(tmp_path, monkeypatch, "security.handles_real_patient_data = false\n", env="dev") + rc = _serve( + tmp_path, + monkeypatch, + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n", + env="dev", + ) assert rc == 0 captured = capsys.readouterr() assert "memory" not in (captured.out + captured.err).lower() @@ -537,18 +545,22 @@ def test_a_positive_readout_does_not_discharge_the_declaration( assert "amd-sev-snp" in err -def test_exposed_synthetic_instance_is_silent( +def test_exposed_instance_is_no_longer_silenced_by_a_declaration( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: + # THE INVERSE OF THE TEST THIS REPLACES. It asserted silence on the strength of a synthetic + # declaration; BACKLOG #1279 retired it, so an exposed instance with no in-use data-protection + # declaration gets the ASVS 11.7.1 warning. Still a WARNING, not a refusal -- that needs + # [security].require_memory_encryption_declaration, which the sibling test above covers. _readout(monkeypatch, capability=False, active=False, source="test:negative") rc = _serve( tmp_path, monkeypatch, - exposed_prod_phi("security.handles_real_patient_data = false\n"), + exposed_prod_phi(), env="prod", ) assert rc == 0 - assert "11.7.1" not in capsys.readouterr().err + assert "11.7.1" in capsys.readouterr().err # --- rung 2: the contradiction case ------------------------------------------------------------- @@ -622,7 +634,15 @@ def test_contradiction_is_silent_when_nobody_declared( """A negative read-out on its own contradicts nothing — the contradiction branch is reachable only once the operator has opted in, which is why it costs no byte-identity.""" _readout(monkeypatch, capability=True, active=False, source="test:neg") - rc = _serve(tmp_path, monkeypatch, "security.handles_real_patient_data = false\n", env="dev") + rc = _serve( + tmp_path, + monkeypatch, + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n", + env="dev", + ) assert rc == 0 assert "contradict" not in capsys.readouterr().err.lower() diff --git a/tests/test_oidc_auth_params_advisory.py b/tests/test_oidc_auth_params_advisory.py index 3d527cb6f..a31933876 100644 --- a/tests/test_oidc_auth_params_advisory.py +++ b/tests/test_oidc_auth_params_advisory.py @@ -58,7 +58,7 @@ def _toml(tmp_path: Path, **auth: str) -> Path: # assertion in this file read a "settings did not load" detail instead of the advisory. '[store]\nbackend = "sqlite"\n\n' '[ai]\nenvironment = "dev"\n\n' - "[security]\nhandles_real_patient_data = false\n" + "[security]\nblock_unlisted_outbound = true\n" 'web_console_public_address = "https://mefor.example.invalid"\n\n' f"[auth]\n{body}\n", encoding="utf-8", @@ -174,7 +174,7 @@ def test_oidc_disabled_reports_that_and_stops(tmp_path: Path) -> None: path = tmp_path / "messagefoundry.toml" path.write_text( '[store]\nbackend = "sqlite"\n\n[ai]\nenvironment = "dev"\n\n' - "[security]\nhandles_real_patient_data = false\n", + "[security]\nblock_unlisted_outbound = true\n", encoding="utf-8", ) result = _check_oidc_auth_params(tmp_path, service_config=path) diff --git a/tests/test_outbound_forward_proxy.py b/tests/test_outbound_forward_proxy.py index f6755fcaa..b1804c0ba 100644 --- a/tests/test_outbound_forward_proxy.py +++ b/tests/test_outbound_forward_proxy.py @@ -57,10 +57,8 @@ def _rsa_pem() -> str: # ADR 0153: the data label no longer relaxes a cleartext hop, so the permissive posture for these # PROXY-behaviour tests is the non-enforcing dial (which still WARNs, never refuses). Renamed from # _SYNTHETIC so nothing here reads as if the label were still doing the work. -_WARN_DIAL = HopPosture(is_phi=False, enforcing=False) -_PROD = HopPosture( - is_phi=True, enforcing=True -) # production PHI → the cleartext-proxy-hop guard bites +_WARN_DIAL = HopPosture(enforcing=False) +_PROD = HopPosture(enforcing=True) # enforcing → the cleartext-proxy-hop guard bites PROXY = "http://proxy.example.com:3128" LOOPBACK_PROXY = "http://127.0.0.1:3128" # a local auth proxy (cntlm) — allowed under any posture diff --git a/tests/test_relocated_key_messages.py b/tests/test_relocated_key_messages.py index 5638d4b8d..7666dc0a4 100644 --- a/tests/test_relocated_key_messages.py +++ b/tests/test_relocated_key_messages.py @@ -23,7 +23,7 @@ import collections import pathlib -from messagefoundry.config.settings import _RELOCATED_TO_SECURITY +from messagefoundry.config.settings import _RELOCATED_TO_SECURITY, _REMOVED_KEYS _ENGINE = pathlib.Path(__file__).resolve().parents[1] / "messagefoundry" @@ -31,7 +31,9 @@ # equality so a fix that REMOVES one does not red the test -- PR 593 removes two from __main__.py. _BUDGET: dict[tuple[str, str], int] = { # Describe the posture that triggered a refusal; the remediation is elsewhere or absent. - ("messagefoundry/__main__.py", "[ai].data_class"): 2, + # The ([ai].data_class, 2) row that sat here is GONE with BACKLOG #1279: the key left the + # relocation map (it was removed, not relocated) and both sites in __main__.py went with it, so + # the row could never be read again -- a budget entry for a spelling the scanner no longer knows. ("messagefoundry/__main__.py", "[ai].production"): 2, ("messagefoundry/__main__.py", "[api].host"): 3, ("messagefoundry/__main__.py", "[api].public_origin"): 6, @@ -186,3 +188,18 @@ def test_the_two_fixed_refusals_name_the_key_the_loader_accepts() -> None: f"the scaffolded README tells a new operator to set [{section}].{key}, which the loader " f"REFUSES; the config file it generates alongside says so. Name [security].{replacement}." ) + + # BACKLOG #1279: the loop above reads the RELOCATED map, so it is blind to a key that was + # REMOVED -- and a removed key is worse to scaffold, because the refusal cannot name a + # replacement spelling to redirect the operator to. + # + # It matches the BARE key, not the `[section].key` form the loop above uses, because the + # scaffolder emits a `messagefoundry.toml` as well as a README: a commented + # `# handles_real_patient_data = true` line under a `[security]` header is an instruction, and + # uncommenting it fails the next start. That is how two such lines survived the first pass. + for _section, removed_key in _REMOVED_KEYS: + assert removed_key not in scaffold_src, ( + f"the scaffolder emits {removed_key!r}, which the loader REFUSES outright. It was removed " + "rather than relocated, so there is no replacement spelling to point at -- say what the " + "operator should do instead." + ) diff --git a/tests/test_rest_transport.py b/tests/test_rest_transport.py index ae7c477e6..035985bbc 100644 --- a/tests/test_rest_transport.py +++ b/tests/test_rest_transport.py @@ -324,7 +324,7 @@ def test_rest_verify_tls_false_allowed_with_escape(monkeypatch: pytest.MonkeyPat hop is encrypted-but-unauthenticated, not cleartext, so it is explicitly out of that scope and decides exactly as the MLLP/FTPS ``tls_verify=false`` cells do.""" monkeypatch.setenv("MEFOR_ALLOW_INSECURE_TLS", "1") - with active_hop_posture(HopPosture(is_phi=True, enforcing=False)): + with active_hop_posture(HopPosture(enforcing=False)): dest = _dest(verify_tls=False) # builds a no-verify opener; no exception assert dest._opener is not None @@ -340,7 +340,7 @@ def test_rest_verify_tls_false_not_relaxed_by_a_cleartext_declaration( from MLLP, which decides the same question through ``weakened_tls_escape_permitted_here()``.""" monkeypatch.delenv("MEFOR_ALLOW_INSECURE_TLS", raising=False) with ( - active_hop_posture(HopPosture(is_phi=True, enforcing=True)), + active_hop_posture(HopPosture(enforcing=True)), pytest.raises(ValueError, match="verify_tls=false"), ): _dest( @@ -370,7 +370,7 @@ def test_rest_credentials_over_cleartext_http_allowed_when_accepted( # decision (decision 5). The per-connection declaration is what crosses it now — loudly, and # recorded in the audit trail, instead of a process-wide env var nobody sees in review. monkeypatch.delenv("MEFOR_ALLOW_INSECURE_TLS", raising=False) - with active_hop_posture(HopPosture(is_phi=True, enforcing=True)): + with active_hop_posture(HopPosture(enforcing=True)): dest = build_destination( Destination( name="OB", @@ -429,7 +429,7 @@ def test_rest_cleartext_http_nonloopback_allowed_when_accepted( # decision (decision 5). The per-connection declaration is what crosses it now — loudly, and # recorded in the audit trail, instead of a process-wide env var nobody sees in review. monkeypatch.delenv("MEFOR_ALLOW_INSECURE_TLS", raising=False) - with active_hop_posture(HopPosture(is_phi=True, enforcing=True)): + with active_hop_posture(HopPosture(enforcing=True)): dest = build_destination( Destination( name="OB", diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index c6e329a51..e1eabf6d4 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -50,7 +50,12 @@ def test_scaffold_writes_the_skeleton(tmp_path: Path) -> None: # real guard is the round trip in test_the_config_init_writes_is_accepted_by_the_loader_that_reads_it. toml = (repo / "messagefoundry.toml").read_text() assert 'environment = "dev"' in toml - assert "handles_real_patient_data" in toml and "production_instance" in toml + # `handles_real_patient_data` sat beside `production_instance` here until BACKLOG #1279 retired it. + # The ABSENCE is now the assertion, and it is the same class of bug as the comment above records: + # asserting the presence of a name the loader refuses keeps a test green while `init` emits an + # unloadable config. tests/test_relocated_key_messages.py carries the general form of this guard. + assert "production_instance" in toml + assert "handles_real_patient_data" not in toml # D11: the .gitignore must ignore the one-time bootstrap admin credential the engine writes next # to the store, so it is never committed gitignore = (repo / ".gitignore").read_text() diff --git a/tests/test_security_cli.py b/tests/test_security_cli.py index 2124a697e..456e44da5 100644 --- a/tests/test_security_cli.py +++ b/tests/test_security_cli.py @@ -33,7 +33,10 @@ def test_show_defaults_when_absent(tmp_path: Path, capsys: pytest.CaptureFixture assert data["set"] == [] and data["loosenings"] == [] assert data["values"]["require_mfa"] is True and data["values"]["local_access_only"] is True assert data["defaults"]["block_unlisted_outbound"] is True - assert data["values"]["handles_real_patient_data"] is None # unset → derived from environment + # sat beside this and is retired (BACKLOG #1279); the model no longer + # carries the field, so cannot report it and must not invent it. + assert "handles_real_patient_data" not in data["values"] + assert data["values"]["production_instance"] is None # unset → derived from environment def test_set_writes_security_and_preserves_other_sections( diff --git a/tests/test_security_config.py b/tests/test_security_config.py index c756c7805..c1093b040 100644 --- a/tests/test_security_config.py +++ b/tests/test_security_config.py @@ -16,7 +16,6 @@ import pytest from messagefoundry.__main__ import main -from messagefoundry.config.ai_policy import DataClass from messagefoundry.config.settings import ( AlertsSettings, AuthSettings, @@ -81,6 +80,19 @@ def _serve( return main(argv) +#: What a stock instance must PROVIDE to start clean, now that every instance carries patient +#: data (BACKLOG #1279). Before that, one line -- `handles_real_patient_data = false` -- stood in +#: for all of it. Dotted keys throughout so a test can add its own `[section]` headers without +#: TOML redefining a table. +_PHI_PROVISIONS = ( + "security.block_unlisted_outbound = true\n" + "security.delete_message_bodies_after_days = 30\n" + "retention.dead_letter_days = 30\n" + 'alerts.email_smtp_host = "smtp.example.org"\n' + 'alerts.email_from = "sec@example.org"\n' +) + + # --- AC-1: [security] is the canonical, sole home; legacy keys no longer accepted ----------------- @@ -98,7 +110,6 @@ def test_security_section_is_canonical(tmp_path: Path) -> None: "security.sign_out_after_idle_minutes = 15\n" "security.max_session_hours = 8\n" "security.allow_unencrypted_phi = true\n" - "security.handles_real_patient_data = true\n" "security.production_instance = false\n", ) assert s.auth.enabled is False and s.auth.require_mfa is False @@ -108,11 +119,10 @@ def test_security_section_is_canonical(tmp_path: Path) -> None: assert s.diagnostics.audit_all_authz is True assert s.auth.session_idle_timeout_minutes == 15 and s.auth.session_absolute_hours == 8 assert s.store.allow_unencrypted_phi is True - assert s.ai.data_class is DataClass.PHI and s.ai.production is False + assert s.ai.production is False # ...and the legacy scattered keys are REJECTED in their old sections (file OR env). legacy = [ - ('[ai]\ndata_class = "phi"\n', "handles_real_patient_data"), ('[api]\nhost = "0.0.0.0"\n', "local_access_only"), ("[api]\nserve_ui = true\n", "serve_web_console"), ("[auth]\nenabled = false\n", "require_sign_in"), @@ -126,9 +136,23 @@ def test_security_section_is_canonical(tmp_path: Path) -> None: for toml, replacement in legacy: with pytest.raises(ValueError, match=replacement): _load(tmp_path, toml) - # env form is rejected too (MEFOR_AI_DATA_CLASS moved). - with pytest.raises(ValueError, match="handles_real_patient_data"): - _load(tmp_path, "", environ={"MEFOR_AI_DATA_CLASS": "phi"}) + # The two RETIRED keys are refused too, and with a DIFFERENT message: they were removed + # rather than relocated (BACKLOG #1279), so there is no forwarding address to name. Both + # spellings, and the env form of each, because a config asserting the PHI gates are off + # while the engine runs them all is a silent contradiction the next reader resolves wrongly. + for toml in ( + '[ai]\ndata_class = "phi"\n', + "security.handles_real_patient_data = false\n", + ): + with pytest.raises(ValueError, match="was REMOVED"): + _load(tmp_path, toml) + for var in ("MEFOR_AI_DATA_CLASS", "MEFOR_SECURITY_HANDLES_REAL_PATIENT_DATA"): + with pytest.raises(ValueError, match="was REMOVED"): + _load(tmp_path, "", environ={var: "phi"}) + # ...and the refusal names the per-gate switches that replaced it, so an operator who wanted + # ONE of the nineteen gates relaxed can find the one they actually meant. + with pytest.raises(ValueError, match="allow_unencrypted_phi"): + _load(tmp_path, "security.handles_real_patient_data = false\n") def test_web_console_on_by_default(tmp_path: Path) -> None: @@ -259,12 +283,13 @@ def test_loosening_warns_and_prod_phi_refuses( # The serve-time consolidated warning fires naming the loosened switch (AC-4). It rides the logging # path (post-configure_logging), which routes to stdout — the gate REFUSE messages print to stderr. - # GIVEN 1 (ADR 0148): dev derives PHI now, so declare synthetic to keep the PHI gates quiet — the - # loosening WARNING is the subject here. + # Every instance carries patient data (BACKLOG #1279), so this dev serve SATISFIES the PHI gates + # rather than declaring itself out of them -- the loosening WARNING is the subject here, and it + # must name require_mfa and nothing else. rc = _serve( tmp_path, monkeypatch, - "security.handles_real_patient_data = false\nsecurity.require_mfa = false\n", + _PHI_PROVISIONS + "security.require_mfa = false\n", env="dev", ) assert rc == 0 @@ -311,14 +336,15 @@ def test_production_acks_are_loosenings_when_set() -> None: assert _loosenings(SecuritySettings()) == [] # acks off => nothing named -# --- AC-6: handles_real_patient_data=false relaxes the PHI-only gates (and it is posture-visible) -- +# --- BACKLOG #1279: no declaration relaxes the PHI gates; only the per-gate switch does ----------- -def test_synthetic_relaxation_visible( +def test_no_declaration_relaxes_the_phi_gates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - # A synthetic instance (handles_real_patient_data=false) relaxes the PHI-only gates: it starts keyless, - # quietly, with no at-rest-encryption / egress / retention refusal. + # This test is the INVERSE of the one it replaces. `handles_real_patient_data = false` used to + # start a keyless dev box quietly; it is now refused at load, so the keyless gate fires and the + # operator is told which switch to reach for instead. rc = _serve( tmp_path, monkeypatch, @@ -326,21 +352,30 @@ def test_synthetic_relaxation_visible( env="dev", key=False, ) - assert rc == 0 + assert rc == 2 err = capsys.readouterr().err - assert "UNENCRYPTED at rest" not in err and "refusing to start" not in err + assert "was REMOVED" in err and "allow_unencrypted_phi" in err - # The SAME config marked as real patient data does NOT relax — the keyless at-rest gate fires. This - # posture split is what the read-only GET /security/posture view surfaces (AC-5). + # A bare dev box with no key refuses on the keyless gate itself -- the gate the declaration + # used to silence, now reachable by every instance. + rc = _serve(tmp_path, monkeypatch, "", env="dev", key=False) + assert rc == 2 + assert "UNENCRYPTED at rest" in capsys.readouterr().err + + # The PER-GATE ack is what starts it, and unlike the retired lever it relaxes ONE gate and says + # so: the AUDIT line fires and every other gate stays live. Under the shipped `enforce` it takes + # the second acknowledgment too (ADR 0140), which is the point -- keyless PHI under strict + # enforcement is never one flag away. rc = _serve( tmp_path, monkeypatch, - "security.handles_real_patient_data = true\n", + _PHI_PROVISIONS + + "security.allow_unencrypted_phi = true\n" + + "security.allow_unencrypted_phi_under_strict_enforcement = true\n", env="dev", key=False, ) - assert rc == 2 - assert "UNENCRYPTED at rest" in capsys.readouterr().err + assert rc == 0 # --- security enforcement dial (this refactor): decoupled REFUSE/WARN from the production tier ------- @@ -415,9 +450,8 @@ def test_debug_logging_gate_keys_on_tier_not_enforcement( rc = _serve( tmp_path, monkeypatch, - "security.handles_real_patient_data = false\n" + debug, + _PHI_PROVISIONS + debug, env="dev", - key=False, ) assert rc == 0 assert "DEBUG logging is refused" not in capsys.readouterr().err diff --git a/tests/test_security_doc_drift.py b/tests/test_security_doc_drift.py index 46381ebf3..bb5b4cbeb 100644 --- a/tests/test_security_doc_drift.py +++ b/tests/test_security_doc_drift.py @@ -1775,9 +1775,17 @@ def test_startup_dual_control_arm_is_documented_as_warn_only() -> None: assert ServiceSettings().security.enforcement is SecurityEnforcement.ENFORCE, ( "[security].enforcement no longer defaults to ENFORCE; the refuse/warn wording is stale." ) - assert _KNOWN_ENV_POSTURE["staging"][1] is False and _KNOWN_ENV_POSTURE["dev"][1] is False, ( + # _KNOWN_ENV_POSTURE became a plain name -> production-tier map when BACKLOG #1279 removed the + # data class; it used to be a (DataClass, bool) tuple, hence the retired [1] subscript. + assert _KNOWN_ENV_POSTURE["staging"] is False and _KNOWN_ENV_POSTURE["dev"] is False, ( "dev/staging are no longer non-production; the 'includes dev and staging' clause is stale." ) - assert all(_KNOWN_ENV_POSTURE[env][0].value == "phi" for env in ("dev", "staging")), ( - "dev/staging no longer derive PHI; the refusing-arms row's clause is stale." + # The clause this used to check -- "dev/staging derive PHI" -- is no longer derivable, because it + # is no longer derived: BACKLOG #1279 made EVERY instance carry patient data, so there is nothing + # in `_KNOWN_ENV_POSTURE` to read it off. What the doc's refusing-arms row now depends on is that + # no data-class axis exists to exempt anything, so that is what is asserted. + from messagefoundry.config.settings import SecuritySettings as _Sec + + assert "handles_real_patient_data" not in _Sec.model_fields, ( + "the data-class lever is back; the refusing-arms row's 'every instance' clause is stale." ) diff --git a/tests/test_security_notice_deliverability.py b/tests/test_security_notice_deliverability.py index 9cf07eba8..885a31df1 100644 --- a/tests/test_security_notice_deliverability.py +++ b/tests/test_security_notice_deliverability.py @@ -25,7 +25,6 @@ from messagefoundry.auth import Role from messagefoundry.auth.service import AuthService from messagefoundry.config.settings import ( - AiSettings, AlertsSettings, AuthSettings, SecurityEnforcement, @@ -33,7 +32,6 @@ ) from messagefoundry.store.store import MessageStore -_PHI = AiSettings(data_class="phi") _ENFORCE = SecuritySettings(enforcement=SecurityEnforcement.ENFORCE) _WARN = SecuritySettings(enforcement=SecurityEnforcement.WARN) @@ -79,15 +77,15 @@ async def _call( store: MessageStore, *, security: SecuritySettings = _ENFORCE, - ai: AiSettings = _PHI, auth: AuthSettings | None = None, alerts: AlertsSettings | None = None, ) -> None: + # The `ai` argument went with BACKLOG #1279: the gate used to skip an instance declared + # synthetic, and there is no such instance now. Its preconditions are down to two. await _assert_security_notice_is_deliverable( store, auth_settings=auth or AuthSettings(notify_security_events=True), alerts_settings=alerts or AlertsSettings(security_notifications_required=True), - ai_settings=ai, security_settings=security, ) @@ -185,7 +183,6 @@ async def test_warn_enforcement_does_not_refuse() -> None: @pytest.mark.parametrize( ("label", "kwargs"), [ - ("not a PHI instance", {"ai": AiSettings(data_class="synthetic")}), ( "notices switched off", {"auth": AuthSettings(notify_security_events=False)}, @@ -196,7 +193,7 @@ async def test_warn_enforcement_does_not_refuse() -> None: ), ], ) -async def test_the_gate_is_silent_outside_its_three_preconditions( +async def test_the_gate_is_silent_outside_its_two_preconditions( label: str, kwargs: dict[str, object] ) -> None: # Each precondition is a separate reason the question does not arise. Asserted individually @@ -223,7 +220,6 @@ def _phi_app(tmp_path: Path, *, security: SecuritySettings) -> FastAPI: email_smtp_host="smtp.example.test", email_from="alerts@example.test", ), - ai_settings=_PHI, security_settings=security, ) diff --git a/tests/test_serve_shard_unified_store_guard.py b/tests/test_serve_shard_unified_store_guard.py index 543d91192..7c9bee2a1 100644 --- a/tests/test_serve_shard_unified_store_guard.py +++ b/tests/test_serve_shard_unified_store_guard.py @@ -46,10 +46,20 @@ # `[cluster]` is not involved here, but a server-DB backend needs its connection essentials to pass # settings validation. Nothing is dialed: create_managed_app is stubbed, so no store is ever opened. -_SQLITE_TOML = "security.handles_real_patient_data = false\n" +# +# Both bodies carried `handles_real_patient_data = false` until BACKLOG #1279 retired it. These +# fixtures need a `serve` that REACHES create_managed_app, so they now satisfy the PHI gates +# per-gate instead. `tests/_phi_gate_provisions.py` documents what each line stands down. +_PHI_GATES = ( + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" +) +_SQLITE_TOML = _PHI_GATES _POSTGRES_TOML = ( - "security.handles_real_patient_data = false\n" - '[store]\nbackend = "postgres"\nserver = "127.0.0.1"\ndatabase = "mf"\nusername = "mf"\n' + _PHI_GATES + + '[store]\nbackend = "postgres"\nserver = "127.0.0.1"\ndatabase = "mf"\nusername = "mf"\n' ) diff --git a/tests/test_settings.py b/tests/test_settings.py index 7f9fe0d0d..d43155afd 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -252,8 +252,7 @@ def test_an_engine_written_config_still_loads(tmp_path: Path) -> None: "block_unlisted_outbound = true\n" "serve_web_console = true\n" "local_access_only = true\n" - "delete_message_bodies_after_days = 30\n" - "handles_real_patient_data = false\n", + "delete_message_bodies_after_days = 30\n", ) s = load_settings( config_path=cfg, diff --git a/tests/test_shard_recovery_engine.py b/tests/test_shard_recovery_engine.py index b2b5a3a78..42777bb68 100644 --- a/tests/test_shard_recovery_engine.py +++ b/tests/test_shard_recovery_engine.py @@ -330,7 +330,10 @@ def test_serve_refuses_shard_with_cluster_enabled( (tmp_path / "messagefoundry.toml").write_text( # GIVEN 1 (ADR 0148): dev derives PHI now, so declare synthetic to keep the PHI gates quiet — # the shard+cluster mutual-exclusion refusal is the subject here. - "security.handles_real_patient_data = false\n" + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" # [cluster].enabled requires a server-DB backend (+ its connection essentials) at settings # validation; the refusal under test fires before any store is opened, so nothing is dialed. '[store]\nbackend = "postgres"\nserver = "127.0.0.1"\ndatabase = "mf"\n' diff --git a/tests/test_smart_backend.py b/tests/test_smart_backend.py index e228d20ed..0fc23a499 100644 --- a/tests/test_smart_backend.py +++ b/tests/test_smart_backend.py @@ -328,7 +328,7 @@ def test_cleartext_token_url_is_decided_by_the_one_authority( monkeypatch.setenv("MEFOR_ALLOW_INSECURE_TLS", "1") with ( - active_hop_posture(HopPosture(is_phi=True, enforcing=True)), + active_hop_posture(HopPosture(enforcing=True)), pytest.raises(SmartAuthError, match="cleartext"), ): SmartBackendTokenProvider( @@ -345,7 +345,7 @@ def test_cleartext_token_url_crosses_on_a_per_connection_declaration( from messagefoundry.config.tls_policy import HopPosture, active_hop_posture monkeypatch.delenv("MEFOR_ALLOW_INSECURE_TLS", raising=False) - with active_hop_posture(HopPosture(is_phi=True, enforcing=True)): + with active_hop_posture(HopPosture(enforcing=True)): provider = SmartBackendTokenProvider( token_url="http://auth.example/token", client_id="c", @@ -372,7 +372,7 @@ def test_token_provider_from_settings_reads_the_declaration(rsa_pem: str) -> Non "cleartext_reason": "legacy IdP has no TLS listener", "cleartext_connection": "OB_LEGACY", } - with active_hop_posture(HopPosture(is_phi=True, enforcing=True)): + with active_hop_posture(HopPosture(enforcing=True)): provider = token_provider_from_settings(settings) assert provider is not None diff --git a/tests/test_smart_scope_advisory.py b/tests/test_smart_scope_advisory.py index e8fa11449..2b0ea7b41 100644 --- a/tests/test_smart_scope_advisory.py +++ b/tests/test_smart_scope_advisory.py @@ -34,7 +34,9 @@ environment = "dev" [security] -handles_real_patient_data = false +block_unlisted_outbound = true +allow_unencrypted_phi = true +allow_unencrypted_phi_under_strict_enforcement = true """ #: Every shape the check has an opinion about, plus every shape it must stay quiet on, in one graph. diff --git a/tests/test_soap_transport.py b/tests/test_soap_transport.py index 53388cbea..311163bb7 100644 --- a/tests/test_soap_transport.py +++ b/tests/test_soap_transport.py @@ -252,7 +252,7 @@ def test_soap_cleartext_http_nonloopback_allowed_when_accepted( # decision (decision 5). The per-connection declaration is what crosses it now — loudly, and # recorded in the audit trail, instead of a process-wide env var nobody sees in review. monkeypatch.delenv("MEFOR_ALLOW_INSECURE_TLS", raising=False) - with active_hop_posture(HopPosture(is_phi=True, enforcing=True)): + with active_hop_posture(HopPosture(enforcing=True)): dest = build_destination( Destination( name="OB", diff --git a/tests/test_static_credential_db_hops.py b/tests/test_static_credential_db_hops.py index 364e91368..51ddfea93 100644 --- a/tests/test_static_credential_db_hops.py +++ b/tests/test_static_credential_db_hops.py @@ -41,7 +41,9 @@ environment = "dev" [security] -handles_real_patient_data = false +block_unlisted_outbound = true +allow_unencrypted_phi = true +allow_unencrypted_phi_under_strict_enforcement = true """ #: One graph carrying every shape the reader has an opinion about and every shape it must stay quiet diff --git a/tests/test_tls_policy.py b/tests/test_tls_policy.py index 5fefb29d7..7e22375fa 100644 --- a/tests/test_tls_policy.py +++ b/tests/test_tls_policy.py @@ -301,32 +301,44 @@ def test_in_process_tls_revocation_refused_matrix( @pytest.mark.parametrize("declared", ["none", "mtls", "network", "shared_secret"]) @pytest.mark.parametrize("client_ca_configured", [False, True]) -@pytest.mark.parametrize("is_phi", [False, True]) def test_proxy_mtls_declared_but_unverified_matrix( - declared: str, client_ca_configured: bool, is_phi: bool + declared: str, client_ca_configured: bool ) -> None: - # WARN on exactly one combination: the declaration says the proxy presents a certificate, the - # engine verifies none, and the instance carries PHI. Everything else is byte-identical silence. - expected = declared == "mtls" and not client_ca_configured and is_phi + # WARN on exactly one combination: the declaration says the proxy presents a certificate and the + # engine verifies none. Everything else is byte-identical silence. + # + # There used to be a third axis. `is_phi` went with BACKLOG #1279 -- every instance carries + # patient data, so a box could no longer be declared out of this diagnostic, and the matrix is + # eight cells rather than sixteen. That is a WIDENING: the four cells this predicate used to stay + # silent on (mtls, no client CA, synthetic) now warn. + expected = declared == "mtls" and not client_ca_configured assert ( proxy_mtls_declared_but_unverified( - declared=declared, client_ca_configured=client_ca_configured, is_phi=is_phi + declared=declared, client_ca_configured=client_ca_configured ) is expected ) -def test_proxy_mtls_predicate_warns_on_exactly_one_of_sixteen() -> None: +def test_proxy_mtls_predicate_warns_on_exactly_one_of_eight() -> None: """The matrix above derives `expected` from the same rule the predicate implements, so on its own it would pass for a predicate that always returned False. This counts the True cells.""" warned = [ - (d, ca, phi) + (d, ca) for d in ("none", "mtls", "network", "shared_secret") for ca in (False, True) - for phi in (False, True) - if proxy_mtls_declared_but_unverified(declared=d, client_ca_configured=ca, is_phi=phi) + if proxy_mtls_declared_but_unverified(declared=d, client_ca_configured=ca) ] - assert warned == [("mtls", False, True)] + assert warned == [("mtls", False)] + + +def test_proxy_mtls_predicate_no_longer_takes_a_data_label() -> None: + """BACKLOG #1279, pinned the way ADR 0153 pinned its own removal: on the SIGNATURE. + + Asserting the eight cells above would not catch an `is_phi=True`-defaulted parameter being + reintroduced, because every call site would keep passing.""" + params = set(inspect.signature(proxy_mtls_declared_but_unverified).parameters) + assert params == {"declared", "client_ca_configured"} @pytest.mark.parametrize("val", ["1", "true", "TRUE", "Yes", "on"]) @@ -537,21 +549,23 @@ def test_enforce_insecure_hop_allow_is_noop() -> None: def test_hop_posture_fail_closed_defaults_unknown_to_strict() -> None: - assert HopPosture.fail_closed(is_phi=None, enforcing=None) == HopPosture( - is_phi=True, enforcing=True - ) + assert HopPosture.fail_closed(enforcing=None) == HopPosture(enforcing=True) # A fully-declared posture passes through unchanged (not strictest-by-default). - assert HopPosture.fail_closed(is_phi=False, enforcing=False) == HopPosture( - is_phi=False, enforcing=False - ) - assert HopPosture.fail_closed(is_phi=True, enforcing=None) == HopPosture( - is_phi=True, enforcing=True - ) + assert HopPosture.fail_closed(enforcing=False) == HopPosture(enforcing=False) + + +def test_hop_posture_carries_only_the_enforcement_dial() -> None: + """BACKLOG #1279 on the SIGNATURE, for the same reason ADR 0153 pinned its own removal there. + + A reintroduced `is_phi` with a `True` default would keep every call site passing while handing + the data label back its influence over three dispositions.""" + assert set(inspect.signature(HopPosture.fail_closed).parameters) == {"enforcing"} + assert set(HopPosture.__dataclass_fields__) == {"enforcing"} def test_active_hop_posture_stamps_and_restores() -> None: assert current_hop_posture() is None - posture = HopPosture(is_phi=True, enforcing=True) + posture = HopPosture(enforcing=True) with active_hop_posture(posture): assert current_hop_posture() is posture # nesting restores the outer value on exit diff --git a/tests/test_webconsole_absent.py b/tests/test_webconsole_absent.py index e5c9825bb..451de5e2e 100644 --- a/tests/test_webconsole_absent.py +++ b/tests/test_webconsole_absent.py @@ -194,7 +194,11 @@ def _serve_with_console_absent( # GIVEN 1 (ADR 0148): dev derives PHI now, so declare synthetic to keep the PHI gates quiet — these # tests probe the ADR 0143 console soft-degrade / hard-refuse contract, not the security posture. (tmp_path / "messagefoundry.toml").write_text( - "security.handles_real_patient_data = false\n" + toml, encoding="utf-8" + "security.block_unlisted_outbound = true\n" + "security.allow_unencrypted_phi = true\n" + "security.allow_unencrypted_phi_under_strict_enforcement = true\n" + "alerts.security_notifications_required = false\n" + toml, + encoding="utf-8", ) real_find_spec = ilu.find_spec