diff --git a/docs/ANTIVIRUS-FIREWALL.md b/docs/ANTIVIRUS-FIREWALL.md index 20e15a45..650fdae0 100644 --- a/docs/ANTIVIRUS-FIREWALL.md +++ b/docs/ANTIVIRUS-FIREWALL.md @@ -28,7 +28,6 @@ If an exclusion or rule is not justified below for *your* deployment, do not add | Rollback journal | **Does not exist** — the store runs `PRAGMA journal_mode=WAL` unconditionally (no setting disables it), so no `-journal` sidecar appears in any supported deployment | | Logs | `C:\ProgramData\MessageFoundry\logs\service.out.log`, `...\service.err.log` (rotated at ~10 MiB) | | API bind (default) | `127.0.0.1:8765` (loopback) | -| Bootstrap secret | `C:\ProgramData\MessageFoundry\bootstrap-admin.txt` (owner-only `0o600`, alongside the DB) | > The service you see in `services.msc` and Task Manager is `messagefoundry.exe`, but the process that actually owns the listening/connecting sockets is the venv **`python.exe`** it launches. That distinction matters for program-scoped firewall rules below. @@ -52,9 +51,8 @@ Exclude these specific paths (substitute your real `DataDir`, repo path, and con | `C:\ProgramData\MessageFoundry\messagefoundry.db` | SQLite message store | Live, constantly-written queue/inbox/outbox | Lock contention, **WAL corruption**, quarantine → engine fail-closed | | `C:\ProgramData\MessageFoundry\messagefoundry.db-wal` | WAL sidecar | Write-ahead log, written on every commit | Corruption of in-flight messages; lost/duplicated delivery | | `C:\ProgramData\MessageFoundry\messagefoundry.db-shm` | Shared-memory index | WAL coordination file | WAL breakage; failed opens | -| `C:\ProgramData\MessageFoundry` (DataDir folder) | Data dir | Holds DB trio, `bootstrap-admin.txt`, etc. | Misc. data-at-rest quarantine | +| `C:\ProgramData\MessageFoundry` (DataDir folder) | Data dir | Holds the DB trio, etc. | Misc. data-at-rest quarantine | | `C:\ProgramData\MessageFoundry\logs\` | Service logs | High-frequency append + rotation | Rotation failures, scan thrash | -| `C:\ProgramData\MessageFoundry\bootstrap-admin.txt` | One-time bootstrap admin secret (`0o600`) | Owner-only secret, written next to the DB | **Also exclude from AV cloud-sample upload** — never let it leave the host | | **`[store].encryption_key_file`** (operator-configured path — **may sit outside DataDir**) | DPAPI-wrapped store encryption key (for `key_provider=dpapi`/`auto`) | Decrypted into memory at startup to unlock the store | **Quarantine/lock → `DpapiError` (fail-closed) → store key cannot be provisioned → engine won't start and PHI at rest is unreadable.** Exclude its *actual* path. | | Connector **key/cert files** referenced by path (operator-configured, **may be anywhere on disk**) | JWS signing key (`signing.py` `private_key`), SMART Backend Services key (`smart_private_key` PEM file path), SOAP mTLS `client_cert_file` / `client_key_file` | Read on demand for outbound signing / SMART token / mTLS | Quarantine → broken outbound **signing / SMART auth / mutual-TLS delivery** to those partners. *(Inline-PEM-via `env()` deployments have no file to exclude.)* | | File-connector **inbound (source) directory** + its `.processed` and `.error` subdirs | Watched intake dir (default poll **1.0 s**) and move-aside targets | Constant directory polling + file moves | Scan thrash, file-move races, quarantined intake | @@ -103,7 +101,6 @@ Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\messagefoundry.db Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\messagefoundry.db-wal' Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\messagefoundry.db-shm' Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\logs' -Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\bootstrap-admin.txt' Add-MpPreference -ExclusionPath 'C:\Path\To\MessageFoundry\.venv' Add-MpPreference -ExclusionPath 'C:\ProgramData\MessageFoundry\bin\nssm.exe' # DPAPI store key + connector key/cert files — exclude their ACTUAL configured paths: @@ -119,7 +116,7 @@ Add-MpPreference -ExclusionProcess 'C:\Path\To\MessageFoundry\.venv\Scripts\mess Add-MpPreference -ExclusionProcess 'C:\ProgramData\MessageFoundry\bin\nssm.exe' ``` -**Third-party EDR (CrowdStrike, SentinelOne, Defender for Endpoint, Sophos, etc.):** create the equivalent **file/folder exclusions** and **process/executable exclusions** for the same paths and processes through your management console, and ensure `bootstrap-admin.txt` and any key/cert files are excluded from **cloud sample submission / upload**, not just on-access scanning. +**Third-party EDR (CrowdStrike, SentinelOne, Defender for Endpoint, Sophos, etc.):** create the equivalent **file/folder exclusions** and **process/executable exclusions** for the same paths and processes through your management console, and ensure any key/cert files are excluded from **cloud sample submission / upload**, not just on-access scanning. --- @@ -209,7 +206,7 @@ After applying exclusions and firewall rules: - [ ] **API health on loopback** — `Invoke-WebRequest http://127.0.0.1:8765/health` succeeds **without** any inbound firewall rule (proves loopback needs none). - [ ] **MLLP round-trip** — send a synthetic test message to a configured inbound port and confirm an `AA` ACK and a `PROCESSED` disposition. - [ ] **DB sidecars intact** — `messagefoundry.db`, `messagefoundry.db-wal`, and `messagefoundry.db-shm` all present and untouched; no `-journal` file (expected — WAL mode). -- [ ] **Clean quarantine log** — AV/EDR quarantine history shows **no** MessageFoundry DB, sidecar, key/cert, `bootstrap-admin.txt`, or interpreter detections. +- [ ] **Clean quarantine log** — AV/EDR quarantine history shows **no** MessageFoundry DB, sidecar, key/cert, or interpreter detections. - [ ] **Outbound reaches partners** — a test delivery to each configured downstream succeeds, and each is covered by both a firewall rule **and** the matching `[egress]` allowlist entry. --- diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 300238c1..2d69331b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -551,9 +551,7 @@ document ([SECURITY-DOCS-POLICY.md](SECURITY-DOCS-POLICY.md)). | `password_breach_corpus_file` | path | — | optional path to a **larger offline breach corpus** that augments the bundled top-10k list (ASVS 6.2.12): a plaintext list **or** an HIBP-style SHA-1 hash export (`HASH[:count]` lines, auto-detected). Fully offline — still no live HIBP call. Use a curated subset, not the full ~40 GB HIBP set (it is loaded into memory). A path, not a secret | | `lockout_threshold` | int | 5 | failed logins before lock (per account) | | `lockout_minutes` | int | 15 | lockout duration | -| `bootstrap_expiry_hours` | int | 72 | the first-run bootstrap admin is auto-disabled once a second administrator exists, and — while still unclaimed (never password-changed) — this many hours after creation. `0` = no time expiry | -| `bootstrap_warn_hours` | int | 24 | **(ASVS 6.4.5):** how long *before* that deadline to remind an operator that the still-unclaimed bootstrap credential is about to be retired — a **`bootstrap_admin_expiring`** [`[alerts]`](#alerts) event, raised once while `now` is inside `[expiry − this, expiry)`. Advisory only (it disables nothing); meaningful only when `bootstrap_expiry_hours > 0`. The deadline itself is also written into `bootstrap-admin.txt` at issuance. | -| `initial_password_expiry_hours` | int | 72 | **(ASVS 6.4.1):** an admin-issued initial/reset credential (a `must_change_password` temp password) that is never claimed **expires** this many hours after it was set. Without it an unused reset password grants an authenticated session indefinitely — and the one action it permits is to *set the password*, i.e. account takeover. Keyed on `password_changed_at`; a user who set their own password has `must_change_password = false` and is unaffected, and the bootstrap admin has its own `bootstrap_expiry_hours` path (exempt). `0` = no expiry (not recommended on a PHI instance) | +| `initial_password_expiry_hours` | int | 72 | **(ASVS 6.4.1):** an admin-issued initial/reset credential (a `must_change_password` temp password) that is never claimed **expires** this many hours after it was set. Without it an unused reset password grants an authenticated session indefinitely — and the one action it permits is to *set the password*, i.e. account takeover. Keyed on `password_changed_at`; a user who set their own password has `must_change_password = false` and is unaffected — including the first administrator, which `messagefoundry admin-create` creates with the operator's own password. `0` = no expiry (not recommended on a PHI instance) | | `login_rate_limit_enabled` | bool | `true` | in-process sliding-window limiter on the **sign-in surface** — `/auth/login`, `/auth/negotiate`, `/auth/mfa-verify` plus the four console entry routes (`POST /ui/login`, `GET /ui/sso`, `GET /ui/oidc/start`, `GET /ui/oidc/callback`) — in front of the per-account lockout. The **same flag** also constructs the per-actor **credential-ceremony** limiter covering `/me/password`, `/me/reauth`, `/me/mfa/confirm` (+ the console re-auth routes); turning it off removes **both** (see [SECURITY.md](SECURITY.md) "Route → limiter map"). | | `login_rate_limit_per_ip` | int | 10 | max attempts per client IP per window (`0` disables). **One number, two limiters:** it is also the per-**actor** budget of the credential-**ceremony** limiter (`/me/password`, `/me/reauth`, `/me/mfa/confirm` + the console re-auth routes) — the `_per_ip` name is historical, and retuning it retunes both | | `login_rate_limit_global` | int | 60 | max attempts across all clients per window (`0` disables). Sign-in window only — the ceremony limiter has **no** global dimension (`glob=0`) | @@ -1032,7 +1030,7 @@ silences an event you didn't name. Matching is pure config (no code/`eval`). | Key | Type | Default | Notes | |---|---|---|---| -| `event_type` | str | `any` | match this event. The validator (`AlertRule._check_event_type`) accepts `any` plus exactly these **18**, and **rejects anything else at config load**, so a typo is loud rather than a rule that never matches: `backup_failed`, `bootstrap_admin_expiring`, `cert_expiry`, `connection_error`, `connection_stopped`, `content_match`, `dr_activated`, `gcm_invocations`, `integrity_drift`, `lane_stuck`, `leadership_acquired`, `message_stall`, `queue_buildup`, `rcsi_off_degraded`, `saturation`, `secret_rotation`, `storage_threshold`, `update_available`. Note the **event names are shorter than the prose names** used elsewhere in this file — the secret-rotation reminder is routed as `secret_rotation`, not `secret_rotation_due` | +| `event_type` | str | `any` | match this event. The validator (`AlertRule._check_event_type`) accepts `any` plus exactly these **17**, and **rejects anything else at config load**, so a typo is loud rather than a rule that never matches: `backup_failed`, `cert_expiry`, `connection_error`, `connection_stopped`, `content_match`, `dr_activated`, `gcm_invocations`, `integrity_drift`, `lane_stuck`, `leadership_acquired`, `message_stall`, `queue_buildup`, `rcsi_off_degraded`, `saturation`, `secret_rotation`, `storage_threshold`, `update_available`. Note the **event names are shorter than the prose names** used elsewhere in this file — the secret-rotation reminder is routed as `secret_rotation`, not `secret_rotation_due` | | `connection` | str (glob) | `*` | glob over the connection name (e.g. `OB_*`, `IB_ACME_*`) | | `min_depth` | int | _unset_ | `queue_buildup` only — match only when pending depth is at/over this | | `min_oldest_seconds` | num | _unset_ | `queue_buildup` only — …or the oldest pending message has waited at least this long | @@ -1496,7 +1494,7 @@ and a PHI weakening under **strict enforcement** (`enforcement = enforce`, the d | `encrypt_stored_data` | bool | `true` | PHI encrypted at rest (key from the environment) | | `allow_unencrypted_phi` | bool | `false` | audited escape: start a PHI instance with **no** key | | `allow_unencrypted_phi_under_strict_enforcement` | bool | `false` | the **second acknowledgment** required to start a PHI instance keyless under strict enforcement ([ADR 0140](adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md)). Under `enforcement = enforce`, `allow_unencrypted_phi = true` on its own is **not** enough — `serve` still refuses to start (exit 2) unless this is also set, so the highest-risk posture (real PHI + strict enforcement) is never one flag away from plaintext at rest. Under `enforcement = warn` the single `allow_unencrypted_phi` flag still governs. With both set the instance starts with PHI bodies, summary/metadata and the error columns **unencrypted at rest**, and the startup AUDIT line names **both** flags. A **loosening** — `security_loosenings()` reports it, so it is never silent | -| `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** local account, *not* only Administrators, so a non-interactive **local** bearer-token service account becomes MFA-pending and cannot enrol unattended. **There are two remedies, not three.** Either make it a **directory (AD/Kerberos) principal** — those are out of scope under either value, their factor delegated to the directory — or set `require_mfa_scope = "administrators"` (itself reported as a loosening, and it leaves every local Administrator in scope) — see that row below. **mTLS is *not* the third.** 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. Directory identities being out of scope also means an AD-only deployment is safe **for its AD users**; its local bootstrap admin and any local service accounts are still in scope | +| `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** local account, *not* only Administrators, so a non-interactive **local** bearer-token service account becomes MFA-pending and cannot enrol unattended. **There are two remedies, not three.** Either make it a **directory (AD/Kerberos) principal** — those are out of scope under either value, their factor delegated to the directory — or set `require_mfa_scope = "administrators"` (itself reported as a loosening, and it leaves every local Administrator in scope) — see that row below. **mTLS is *not* the third.** 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. Directory identities being out of scope also means an AD-only deployment is safe **for its AD users**; its local administrator accounts and any local service accounts are still in scope | | `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` | | `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` | diff --git a/docs/EARLY-ADOPTER-GUIDE.md b/docs/EARLY-ADOPTER-GUIDE.md index 93afcd69..c380152c 100644 --- a/docs/EARLY-ADOPTER-GUIDE.md +++ b/docs/EARLY-ADOPTER-GUIDE.md @@ -291,18 +291,20 @@ account. Service defaults: name `MessageFoundry`, data dir `C:\ProgramData\Messa > upgrade is an explicit, reviewable act. *(A contributor running the **editable** install instead > serves whatever branch is checked out — treat that checkout as the release artifact; see §13.)* -### 4.5 First-run admin bootstrap +### 4.5 Create the first administrator -Auth is **enabled by default**. On the first start against an empty store, MEFOR creates a one-time -bootstrap admin (`admin`) and writes its password to an **owner-only `bootstrap-admin.txt`** next to -the store (only the file *location* is logged — never the password). Then: +Auth is **enabled by default**, and a fresh store has **no accounts at all** — no default username, no +generated password, nothing written to disk (ASVS 6.3.2). Create the first administrator yourself, on +the server: -1. Log in as `admin`; you are **forced to change the password** on first use. -2. **Create a second real administrator** promptly. -3. **Delete `bootstrap-admin.txt`.** +1. `messagefoundry admin-create --username --email ` — it prompts for the + password (no echo, confirmed) and holds it to your `[auth]` policy. +2. Sign in to the console at `/ui` with it. +3. **Create a second administrator** promptly, so no single lost credential strands the estate. -The bootstrap account auto-retires once a second admin exists, or — while still unclaimed — 72h after -creation. +The command works whether the engine is running or stopped, and it is also the recovery path if every +administrator is ever locked out — which is why the server and the store file need the same protection +as any credential. ### 4.6 Verify it runs @@ -392,8 +394,8 @@ Full references: **[SECURITY.md](SECURITY.md)**, **[PHI.md](PHI.md)**, and **[DE app-encrypted and rely on volume encryption. - [ ] **Run under a least-privilege account** (the virtual account from §4.4) and lock down the store directory and any File-connector spill directories. **Treat backups as PHI.** -- [ ] **Finish the bootstrap-admin handoff** (§4.5): change the password, create a second admin, - delete `bootstrap-admin.txt`. +- [ ] **Create the first administrator and a second one** (§4.5), each with an email address so the + out-of-band security notices have a mailbox. - [ ] **For Active Directory:** use **LDAPS** with a trusted CA, never set `MEFOR_ALLOW_INSECURE_TLS` in production, and configure the directory's lockout/complexity policy (the engine's account lockout covers local accounts only). AD/Entra MFA is enforced by your directory; **local diff --git a/docs/PHI.md b/docs/PHI.md index 7e5f9edb..299bf0b7 100644 --- a/docs/PHI.md +++ b/docs/PHI.md @@ -969,7 +969,7 @@ with materially different PHI profiles, so they get their own rows; stream 4 is | **5. `audit_log` table** (SQLite, Postgres, SQL Server) | who / what / **where-from** / when of auth + PHI *access* and admin actions — plus, when the opt-in `[security].audit_all_authorization_decisions` is on (**default `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, which multiplies this stream's volume — `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** (deleting rows would break the chain; HIPAA expects ~6 years) | `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 | -| **8. `alert_instance` table — default on wherever an `[alerts]` notifier exists** | resolvable operator alerts: `connection_stopped`, `queue_buildup`, `lane_stuck`, `message_stall`, `saturation`, `connection_error`, `content_match`, `storage_threshold`, `cert_expiry`, `secret_rotation`, `bootstrap_admin_expiring` (the UNCLAIMED first-run bootstrap admin nearing its auto-disable deadline — ASVS 6.4.5; its payload carries only the ISO deadline plus whole hours remaining, never the password or any secret), `integrity_drift`, `update_available`, `backup_failed`, `rcsi_off_degraded`, `leadership_acquired`, `dr_activated`, `gcm_invocations` (the per-key AES-GCM invocation bound crossing its 2^31 soft warn — ASVS 11.3.4; its payload carries a one-way `key_id` fingerprint plus counters, never key bytes) The three reachable **inverse** signals — `connection_restored`, `leadership_lost`, `dr_released` — are never rows here: `_record_state` routes an inverse through `_AUTO_RESOLVE` to `resolve_alert_instances_for`, never to `upsert_alert_instance`. (A fourth mapped key, `connection_started`, is emitted by no code path today.) | rows: `event_type`, `connection`, `severity`, `status`, `first_seen`, `last_seen`, `count`, `reason`, `acked_by`, `acked_at`, `resolved_at`, `suspended_until`, `escalation_tier` | the store database, **all three backends** | the operator alert list — acknowledge / resolve / suspend. Durable state is recorded **before** any suppression or throttle return, so a muted alert still leaves a record | `GET /alerts/active` under **`monitoring:diagnose`** (**not** a PHI permission) with the same per-channel scope; ack/resolve/suspend/**resume** are POSTs on the same tier, and the separate read-only `GET /alerts/rules` view sits on its own gate | shares the connection-event window; **only RESOLVED instances are DELETEd**, by `resolved_at` — an open or acknowledged condition is never aged out from under an operator | **`reason` is free text** taken from the event's `detail`/`reason`/`label`: `safe_text(reason)[:200]` then cipher-encrypted (AAD `("alert_instance","reason",event_type,connection)` — the de-dup grain, so one AAD covers both the INSERT and the re-fire UPDATE). `content_match` is **PHI-free by contract**: the sink method takes no value parameter, only the connection, an operator label and an optional rule id | +| **8. `alert_instance` table — default on wherever an `[alerts]` notifier exists** | resolvable operator alerts: `connection_stopped`, `queue_buildup`, `lane_stuck`, `message_stall`, `saturation`, `connection_error`, `content_match`, `storage_threshold`, `cert_expiry`, `secret_rotation`, `integrity_drift`, `update_available`, `backup_failed`, `rcsi_off_degraded`, `leadership_acquired`, `dr_activated`, `gcm_invocations` (the per-key AES-GCM invocation bound crossing its 2^31 soft warn — ASVS 11.3.4; its payload carries a one-way `key_id` fingerprint plus counters, never key bytes) The three reachable **inverse** signals — `connection_restored`, `leadership_lost`, `dr_released` — are never rows here: `_record_state` routes an inverse through `_AUTO_RESOLVE` to `resolve_alert_instances_for`, never to `upsert_alert_instance`. (A fourth mapped key, `connection_started`, is emitted by no code path today.) | rows: `event_type`, `connection`, `severity`, `status`, `first_seen`, `last_seen`, `count`, `reason`, `acked_by`, `acked_at`, `resolved_at`, `suspended_until`, `escalation_tier` | the store database, **all three backends** | the operator alert list — acknowledge / resolve / suspend. Durable state is recorded **before** any suppression or throttle return, so a muted alert still leaves a record | `GET /alerts/active` under **`monitoring:diagnose`** (**not** a PHI permission) with the same per-channel scope; ack/resolve/suspend/**resume** are POSTs on the same tier, and the separate read-only `GET /alerts/rules` view sits on its own gate | shares the connection-event window; **only RESOLVED instances are DELETEd**, by `resolved_at` — an open or acknowledged condition is never aged out from under an operator | **`reason` is free text** taken from the event's `detail`/`reason`/`label`: `safe_text(reason)[:200]` then cipher-encrypted (AAD `("alert_instance","reason",event_type,connection)` — the de-dup grain, so one AAD covers both the INSERT and the re-fire UPDATE). `content_match` is **PHI-free by contract**: the sink method takes no value parameter, only the connection, an operator label and an optional rule id | | **9. `response` rows with `kind='ack_sent'` — DEFAULT ON** (`[diagnostics].response_sent = true`) | the ACK/NAK the engine returned to an inbound sender, under a sentinel destination `\x1fack:` | rows: `ack_code` (`AA`/`AE`/`AR`/`CA`/`CE`/`CR`), `ack_phase` (`decode`/`parse`/`strict`/`ingest`), `outcome`, `body`, `detail` | the store database | "what did we actually reply, and why" — the operator's answer to a sender disputing an ACK | `GET /messages/{id}/responses` under `messages:read` + `require_phi_read`; the `body` only for a caller who also holds `messages:view_raw`; every read writes a `response.read` audit row | `body`, `detail` and `resp_headers` are set to `NULL` in place by `purge_message_bodies` on the message-body window, on all three backends | **PHI fail-safe:** the ACK **body** is stored **only when the store cipher is active** — on a keyless store it is `NULL` rather than plaintext — and every NAK passes no body at all, so the offending field value is never persisted. The disposition metadata (`ack_code`/`ack_phase`/`outcome`) is non-PHI and always captured; `detail` is `safe_text`-scrubbed, 200-char bounded and encrypted | | **10. `[alerts]` webhook transport** (off by default — `webhook_url` unset) | one HTTPS POST per alert, carrying every non-underscore event key as JSON | JSON | the operator's webhook endpoint (Slack/Teams/PagerDuty/custom) | operator notification | **`https` only** — a plaintext `http://` webhook URL is refused at construction unless the `MEFOR_ALLOW_INSECURE_TLS` escape is set (and then a warning is logged); since #329 this path routes that escape through the clamped `weakened_tls_escape_permitted(posture)` (the instance posture threaded from the API lifespan), so on an enforcing-PHI instance the escape is inert and a cleartext webhook POST stays refused — the same clamp as the connectors, no longer the raw escape. Redirects are refused; an optional `webhook_allowed_hosts` egress allowlist gates the host | the endpoint's | **carries the alert's `detail`/`reason` free text** (`safe_exc()`-scrubbed at the emit sites, but **not** re-run through `safe_text` on this path). Internal `_`-prefixed keys (per-rule recipients, rule id, cooldown) are stripped before send, so recipient addresses never cross the wire | | **11. `[alerts]` SMTP transport — operator alert list** (off unless `email_smtp_host` + `email_from` + ≥1 `email_to`) | one email per alert; default subject `[MessageFoundry] `, default body every non-underscore event key as `k: v` | plain text (always kept — never HTML-only); optional HTML alternative | the operators' mailboxes | operator notification | `smtp_allowed_hosts` egress allowlist; the SMTP password comes from `MEFOR_ALERTS_EMAIL_PASSWORD` or a `[secrets]` provider, never the config file; per-send timeout `email_timeout` | the mail system's | carries the same `detail`/`reason` free text as the webhook. #138 operator templates are constrained to a **closed non-PHI variable allowlist** validated fail-closed at config load. **Transport posture:** `send_plain_email` builds an explicit **verifying** context (chain + hostname + strict RFC 5280, TLS 1.2 floor) via `tls_policy.build_smtp_tls_context()` and passes it to `starttls()`, anchored to the OS roots, `[alerts].email_tls_ca_file`, or `[tls].internal_ca_file` — the same factory the EMAIL and DIRECT *message destinations* use, so all three SMTP cells now share one policy ([#323](archive/backlog/BACKLOG-CLOSED.md#323-smtp-tls-is-unverified-on-all-three-send-paths), closed 2026-08-02). Before that this call passed **no** context and Python's stdlib default applied (`ssl._create_stdlib_context` **is** `ssl._create_unverified_context` — `CERT_NONE`, `check_hostname = False`), leaving the hop encrypted but unauthenticated. There is still **no hop gradient or attestation on this path** — unlike the connectors, this cell is constructed outside the `active_hop_posture` scope, so its deviations (`email_use_tls = false`, or `email_tls_verify = false`) are gated by a `[security].allow_unverified_alert_smtp_tls` **acknowledgment switch at the serve gate** rather than by the clamped escape: on an enforcing PHI instance `serve` refuses to start without it, and permits + `AUDIT`-logs the start with it. Both deviations are named by `security_loosenings()` and reported by `messagefoundry check`'s `alert-smtp-tls` advisory | @@ -1204,7 +1204,7 @@ For operators standing up the engine (see also [SERVICE.md](SERVICE.md)): - [ ] **Never run at `DEBUG`** in production. - [ ] **Treat backups as PHI** — encrypt and access-control them; never copy `*.db` or File-connector output to source control, tickets, or shared drives. -- [ ] **Change the bootstrap admin password immediately** (see [SECURITY.md](SECURITY.md)). +- [ ] **Create the first administrator with `messagefoundry admin-create`** (see [SECURITY.md](SECURITY.md)) — a fresh store has no account until you make one. - [ ] **Supply secrets via env**, never the TOML (`MEFOR_STORE_PASSWORD`, `MEFOR_AUTH_AD_BIND_PASSWORD`, future `MEFOR_STORE_ENCRYPTION_KEY`). - [ ] **Never feed real PHI to `dryrun`/`generate`** or redirect their output to shared locations (§7). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index a4fad12c..e0b4d295 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -42,23 +42,36 @@ network in cleartext, so it's refused). So there is no way to be accidentally se unauthenticated full access — or to silently void the loopback assumption with a stray `[api].host` edit (SYS-1). -### First-run bootstrap admin - -On first start against an empty store, the engine creates a single **bootstrap admin** -(username `admin`, role `Administrator`) with a random one-time password **generated through the -active password policy**. The password is **written to an owner-only file** (`bootstrap-admin.txt`, -next to the store) — **never to the log** — and only the file's location is logged, so the credential -doesn't land in NSSM's broadly-readable stdout capture. Sign in with it, change the password -immediately (enforced — the account is flagged `must_change_password`), and delete the file. After any -user exists, no further bootstrap occurs. - -**Auto-retirement (WP-3).** The bootstrap account exists only to seed the first real admin, so it -self-retires while still **unclaimed** (never password-changed): it is **disabled once a second -administrator exists**, and — if left unclaimed — **disabled `[auth].bootstrap_expiry_hours` after -creation** (default 72 h; `0` disables the timer). Once you change its password it becomes a normal -admin account and is never auto-disabled, so a single-admin deployment can't be locked out. A retired -bootstrap login is refused like any other invalid credential and the retirement is audited -(`auth.bootstrap_admin_retired`). +### The first administrator (ASVS 6.3.2 — no default account) + +**A fresh store has no users.** Starting the engine seeds the built-in roles and creates **no +account**: there is no default username, no machine-generated credential, and no credential file +written next to the store. Nothing privileged exists until an operator decides it should. + +Create the first administrator **on the box**, with the engine stopped or running: + +``` +messagefoundry admin-create --username --email
+``` + +It prompts for the password (no echo, confirmed) — or reads one line from stdin under +`--password-stdin` for an unattended install. **The password is never an argv flag**, because argv is +readable by other accounts on the host. The password is held to the deployment's own `[auth]` policy, +so the CLI is not a laxer second path into the same account store; the account is created with the +`Administrator` role, `must_change_password` clear (the operator chose the password, so there is no +second party a forced rotation would protect), and an audited `user.created` row. Every account after +the first comes from `POST /users` / the console, which is where role assignment belongs. + +**Set `--email`.** The out-of-band security notices (lockout, password/roles change, sign-in after +failures) need a mailbox; without one only the audited `GET /me/security-events` feed records them. +The command says so at creation time. + +An earlier design created an implicit first-run `admin` account with a one-time password in an +owner-only `bootstrap-admin.txt`, auto-retired on a timer. It was retired outright rather than +patched: **6.3.2 wants that account not to exist**, and while it existed the engine had to ship a +standing credential in a file for someone to find, plus a retirement timer, an expiry warning alert +and a login carve-out to manage its lifetime. None of that is needed once the account is never +created. (BACKLOG #1020, closed as superseded by 6.3.2 rather than as built.) ### Admin password reset (WP-L3-12, ASVS 6.4.6) @@ -328,7 +341,7 @@ tuple: they act only on the caller's own account. | `GET` | `/me/mfa` | `require` | | | `POST` | `/me/mfa/enroll` | `require_reauth_only_action` (action `mfa_enroll`) | password-only step-up — the MFA gate is skipped so a required-but-unenrolled user cannot deadlock | | `POST` | `/me/mfa/confirm` | `require_reauth_only_action` (action `mfa_confirm`) | per-actor ceremony limiter; password-only step-up | -| `DELETE` | `/me/mfa` | `require_step_up_action` (action `mfa_disable`) | step-up bound to the disable action (current factor + a fresh password). ⚠️ **No last-factor guard** — this is the TOTP path (`disable_mfa`), and it does **not** refuse when it would leave the account with zero enrolled factors. The passkey removal path does refuse; see BACKLOG #1022 for the asymmetry | +| `DELETE` | `/me/mfa` | `require_step_up_action` (action `mfa_disable`) | step-up bound to the disable action (current factor + a fresh password). **400** when TOTP is the account's last enrolled factor and MFA is still required — the same refusal the passkey-removal path makes, so the zero-factor state is unreachable by either removal order (ADR 0068 AC-10) | | `GET` | `/me/sessions` | `require` | | | `GET` | `/me/security-events` | `require` | | | `DELETE` | `/me/sessions/{session_id}` | `require_reauth_only` | password-only step-up | @@ -736,13 +749,15 @@ dual-control approval above (the requester re-verifies; an independent approver Local accounts can enroll a native **RFC 6238 TOTP** second factor (ASVS 6.3.3): `POST /me/mfa/enroll` returns a setup key + `otpauth://` URI for an authenticator app, `POST /me/mfa/confirm` activates it and returns the **single-use recovery codes** (shown once), and `POST /auth/mfa-verify` satisfies a session's -second factor with a TOTP code or a recovery code. `DELETE /me/mfa` disables it; an administrator clears a +second factor with a TOTP code or a recovery code. `DELETE /me/mfa` disables it — **refused when TOTP is +your last enrolled factor and MFA is still required for you** ("enroll another factor first"), the same +refusal the passkey-removal path makes; an administrator clears a lost authenticator via `POST /users/{id}/reset-mfa` (which also revokes the user's sessions). With `[auth].require_mfa` on — **the default since BACKLOG #187 (secure-by-default, including the loopback bind)** — the **Administrator** role must satisfy MFA before any step-up operation (the gate returns `403` + `X-MFA-Required` until verified); other users may opt in by enrolling. A required-but-unenrolled admin is never locked out — the enroll/confirm routes sit behind an action-bound **password** step-up, -not the MFA gate, so the bootstrap admin enrolls then satisfies it. The documented org opt-out is +not the MFA gate, so the first administrator enrolls then satisfies it. The documented org opt-out is `[auth].require_mfa = false`. **AD/Kerberos MFA is delegated to the directory** (Entra Conditional Access / an MFA proxy) — a directory login is never prompted for an engine TOTP and is MFA-satisfied at issuance. The TOTP secret is stored **encrypted at rest** (the store cipher) and recovery codes are @@ -1123,7 +1138,6 @@ one-to-one — that is why the bind/exposure posture occupies two rows and the A | Declared token class of a federated assertion | the `typ` JOSE header, and the presence of an `events` claim, on a **signature-verified** JWS | `typ` present and — normalised `.strip().lower()` then `application/`-stripped — not `jwt`, so `at+jwt` (RFC 9068 access token), `logout+jwt` and `secevent+jwt` are refused while an **absent** `typ` is allowed (RFC 7519 §5.1 makes the header advisory); or the claim set carries `events`, i.e. an RFC 8417 security event token. Every such token is minted by the **same issuer under the same key**, so no signature or key rung distinguishes it | **DENY** the sign-in — `ClaimsError("wrong_token_type")` at the key-selection rung, `ClaimsError("unexpected_events_claim")` ahead of the nonce compare (a logout token carries no nonce, so a later check would misreport it as a browser-binding failure) | on | (no knob) | | Federated authentication-context claims (`amr` / `acr`) | the `amr` list / `acr` string of a **signature-verified** `id_token` | `oidc_require_mfa_claim` on **and** neither an `amr` value in `[auth].oidc_mfa_amr_values` (default `["mfa"]`) nor an `acr` in `oidc_required_acr_values` (default `[]`, so the `amr` arm alone decides) | **DENY** the sign-in — `ClaimsError("mfa_claim_missing")`. An IdP **assertion**, never a proof | on, `["mfa"]` / `[]` | `[auth].oidc_require_mfa_claim`, `oidc_mfa_amr_values`, `oidc_required_acr_values` | | UPN suffix of the federated username claim | the suffix after the FIRST `@` of the username claim | `oidc_username_strip_domain` on (default) **and** the suffix is not in `oidc_allowed_username_domains` (or `[auth].ad_domain`). With stripping **off** the claim is used verbatim and no suffix check runs | **DENY** the sign-in — `ClaimsError("username_domain_not_allowed")` | on | `[auth].oidc_allowed_username_domains`, `oidc_username_strip_domain` | -| Bootstrap-admin age × admin population | `users.created_at` for the built-in bootstrap account × whether a second enabled Administrator exists | still unclaimed (`must_change_password` set) **and** (`now ≥ created_at + bootstrap_expiry_hours × 3600` **or** another enabled admin exists); `0` = no time expiry | **DENY** — the account is disabled, **all** its sessions revoked, `auth.bootstrap_admin_retired` audited. A *claimed* (password-changed) bootstrap account is never touched | 72 h | `[auth].bootstrap_expiry_hours` | | Browser `Origin` at the WebSocket handshake | the `Origin` header on the upgrade | absent (a native client) → allowed; present → must be an exact member of the list, whose default `[]` rejects **every** browser Origin | **DENY** before `accept()`, so the route never runs | `[]` | `[api].ws_allowed_origins` | | Cross-site request signal on a `/ui` state change | `Sec-Fetch-Site` (preferred) else `Origin` vs our own origin (`[api].public_origin` is authoritative when set; `Host` is the fallback) | `Sec-Fetch-Site` ∈ {cross-site, same-site}, or a non-matching `Origin` | **DENY** 403 — defence-in-depth over the `SameSite=Strict` cookie, deliberately token-free | on | `[api].public_origin` | @@ -1574,8 +1588,8 @@ Every enforced limit, with both dimensions stated even where one is hard-coded o **and** globally" is the requirement's own wording. **Enforcement scope is stated per row, because it is not uniform.** The four sliding-window limiters (sign-in, credential ceremony, PHI read, admin write) and the two pending-flow caches are **in-process, per API process** — N engine shards multiply -*those* budgets by N. The account lockout, the concurrent-session cap and the bootstrap-admin timer are -**store-backed** (`record_login_failure` / `enforce_session_cap` / `set_user_disabled` against the one +*those* budgets by N. The account lockout and the concurrent-session cap are +**store-backed** (`record_login_failure` / `enforce_session_cap` against the one unified store), so they are **shared** by every API process and are **not** multiplied by N. The request-body cap is **stateless** — a per-request test that carries no budget at all. An exposed or multi-host deployment must additionally front the API with a proxy/WAF limiter and TLS. @@ -1588,7 +1602,6 @@ multi-host deployment must additionally front the API with a proxy/WAF limiter a | PHI reads | `[auth].phi_read_rate_limit_enabled`, `phi_read_rate_limit_per_actor`, `phi_read_rate_limit_global`, `phi_read_rate_limit_window_seconds` | on / 120 / **0 = off** / 60.0 s | 60 s | **yes** (120) | off by default | no | **in-process** — 7 JSON routes via `require_phi_read`, 4 bulk-PHI step-up GETs charged at admission, 5 `/ui` views via `require_ui(phi=True)`, and 3 further `/ui` GETs that inherit the charge by delegating into the handler body | 429 + `Retry-After: 10`, logged | | Admin writes | `[auth].admin_write_rate_limit_enabled`, `admin_write_rate_limit_per_actor`, `admin_write_rate_limit_window_seconds` | on / 12 / 1.0 s | 1.0 s | **yes** (12) | no (`glob=0`) | no | **in-process** — **non-GET only**, JSON API only, via `require_step_up` **and** `require_paced`; **no `/ui` route charges it** | 429 + `Retry-After: 1`, logged | | Concurrent sessions | `[auth].max_sessions_per_user` | 5 (`0` = unlimited) | — | **yes** | no | no | **store-backed** — every login | the user's oldest session is revoked | -| Bootstrap-admin lifetime | `[auth].bootstrap_expiry_hours` | 72 h (`0` = no timer) | — | n/a | n/a | n/a | **store-backed** — the unclaimed bootstrap account | disabled + audited | | Request body | `[store].max_upload_bytes` (the `/uploads` routes only) | 1 MiB elsewhere | per request | no | no | no | **stateless** — every route, in ASGI middleware | **413** over the cap, **400** on ambiguous CL+TE framing or an invalid `Content-Length`, **411** on a chunked body | | OIDC pending flows | `[auth].oidc_flow_cache_max` (global), `DEFAULT_PER_IP_CAP` (per-IP, no knob), `oidc_flow_ttl_seconds` | 512 / 16 / 300 s | 300 s TTL | no | **yes** (512) | **yes** (16) | **in-process** — `GET /ui/oidc/start` — reject-when-full, never evict | 303 → `/ui/login?e=rate_limited`, WARNING-logged, **never** audited | | WebAuthn pending ceremonies | `GLOBAL_PENDING_CAP`, `PER_USER_PENDING_CAP`, `CHALLENGE_TTL_SECONDS` (module constants, no knobs) | 4096 / 16 / 120 s | 120 s TTL | **yes** (16) | **yes** (4096) | no | **in-process** — every passkey registration + assertion ceremony | per-user: evicts that user's **own** oldest pending ceremony (silent); global: `ChallengeCacheFullError` naming the cause + the `admin_reset_mfa` recovery path | @@ -1779,8 +1792,9 @@ runs bulk AES-256-GCM. #198 closes the **application-code-feasible** half and ac - **Person/entity authentication** (required) — local argon2id and/or AD bind; lockout on brute force. - **Audit controls** (required) — durable, user-attributed audit trail (append-only via the store API). - **Automatic logoff** (addressable) — idle + absolute session timeouts. -- **Emergency access** (required) — the bootstrap admin provides break-glass; treat its credential as - a sealed secret. +- **Emergency access** (required) — `messagefoundry admin-create` on the box mints an administrator + without needing an existing session, so a locked-out estate is recoverable by whoever holds the + host. Protect the host and the store accordingly: filesystem access to them IS this break-glass. --- diff --git a/docs/VERSION-CONTROL.md b/docs/VERSION-CONTROL.md index 06f89c7a..e2e97814 100644 --- a/docs/VERSION-CONTROL.md +++ b/docs/VERSION-CONTROL.md @@ -156,7 +156,7 @@ credentials, and the design keeps secrets out of the repo entirely: versioned `environments/.toml` files hold only **non-secret** per-environment values. - **No real PHI in the repo.** Test fixtures are **synthetic only**. Real message bodies live in the engine's message store, never in git. See [PHI.md](PHI.md). -- **`*.db`, `.env`, captures, and `bootstrap-admin.txt`** are git-ignored by the scaffold. +- **`*.db`, `.env` and captures** are git-ignored by the scaffold. This holds regardless of where the repo is stored — a private on-prem remote does not change what may be committed. When in doubt, review the diff before you commit. diff --git a/docs/adr/0034-static-analysis-triage-policy-accepted-risk-register.md b/docs/adr/0034-static-analysis-triage-policy-accepted-risk-register.md index bf2aab56..54b0f327 100644 --- a/docs/adr/0034-static-analysis-triage-policy-accepted-risk-register.md +++ b/docs/adr/0034-static-analysis-triage-policy-accepted-risk-register.md @@ -33,7 +33,7 @@ A scanner re-runs on every publish, and a finding dismissed only via a per-alert - `py/overly-permissive-file` — the FILE outbound's cross-filesystem **copy fallback** created delivered files `0o644` (world-readable) while the `mkstemp` temp and the `os.link`/`os.replace` paths all yield `0o600`; tightened the fallback to `0o600`. **Accepted risk (1, `won't fix`):** -- `py/clear-text-storage-sensitive-data` — the **one-time bootstrap-admin password** is written in cleartext to an **owner-only** file (`_secure_file` → `chmod 0o600` / NTFS owner-only DACL), the log records only its location, and server-side `must_change_password` forces rotation at first login. Conveying a first-run credential to the operator requires writing it somewhere; an owner-only, force-rotated file is the chosen, compensated mechanism. Revisit if the bootstrap flow changes. +- `py/clear-text-storage-sensitive-data` — **WITHDRAWN 2026-08-10; the accepted risk no longer exists.** It covered the one-time bootstrap-admin password written in cleartext to an owner-only file, compensated by `0o600`/NTFS owner-only perms plus forced first-login rotation. BACKLOG #1020 retired the implicit first-run account outright (ASVS 6.3.2), so nothing writes a credential to disk: `messagefoundry admin-create` takes the operator's own password on a no-echo prompt or stdin and never persists it in cleartext. The finding it accepted has no site left to fire on. Recorded rather than deleted, because a register that silently loses a row cannot be reconciled against a re-scan. **Dismissed as false positive (11) / used in tests (2)** — class rationale: - *Protocol-/format-mandated hashing* — `weak-sensitive-data-hashing` on SHA-256 of a high-entropy session token (not a low-entropy password), SHA-1 for HaveIBeenPwned breach-corpus interop (`usedforsecurity=False`), and SHA-1 mandated by the WS-Security UsernameToken Digest profile. @@ -83,7 +83,7 @@ Scorecard runs on the same mirror and surfaced **48 findings**. These are **repo **Positive** — one durable, reviewable record; future scans converge instead of re-litigating; the single accepted risk is logged and revisitable; the dataflow-verification gate (AC-4) is written down, not folklore. -**Negative / risks** — a register can go stale: it MUST be updated whenever new findings are triaged, or it misleads. The accepted risk (#5) remains a cleartext-at-rest credential — mitigated by owner-only perms + forced first-login rotation, but a residual to revisit if the bootstrap flow changes. +**Negative / risks** — a register can go stale: it MUST be updated whenever new findings are triaged, or it misleads. The cleartext-at-rest credential accepted as #5 was withdrawn on 2026-08-10 when the account that produced it was retired (see the amended row above); that is the shape of maintenance this register needs. **Out of scope** — enabling GHAS on the private repo; pursuing the *proper* Docker/Fuzzing/badge hardening above (deferred, not warranted now); and the operational mirror **publish** that re-runs CodeQL/Scorecard and auto-closes the fixed/stale findings (`publish.ps1`, owner-run). diff --git a/docs/adr/0068-browser-webauthn-passkeys-offloopback.md b/docs/adr/0068-browser-webauthn-passkeys-offloopback.md index 43d484da..c01bf516 100644 --- a/docs/adr/0068-browser-webauthn-passkeys-offloopback.md +++ b/docs/adr/0068-browser-webauthn-passkeys-offloopback.md @@ -135,9 +135,14 @@ TOTP-specific (a WebAuthn-only user's TOTP code gets "not enrolled", never a loc **No WebAuthn recovery codes** — they are phishable knowledge secrets that undercut the phishing-resistant tier. Recovery = enroll ≥2 passkeys (UI nudge) / keep TOTP alongside / -`admin_reset_mfa`, which is **extended to also delete all WebAuthn credentials**. Deleting the -**last remaining second factor while MFA is required is refused** ("enroll another factor first"); -TOTP-disable keeps its existing behavior this lane (parity follow-up recorded). Documented +`admin_reset_mfa`, which is **extended to also delete all WebAuthn credentials**. Removing the +**last remaining second factor while MFA is required is refused** ("enroll another factor first"). +This lane guarded the passkey-delete route only, recording a parity follow-up for TOTP-disable; that +follow-up **landed under BACKLOG #1022** (in [`docs/BACKLOG.md`](../BACKLOG.md) until archived, then +`docs/archive/backlog/BACKLOG-CLOSED.md`), so `disable_mfa` now carries the same guard, keyed the +same way. The refusal is stated over the resulting **state**, not over one route: with the guard on +one side only, a user holding TOTP plus one passkey could delete the passkey (permitted while TOTP +remained) and then disable TOTP, reaching zero enrolled factors by ordering alone. Documented consequence: a passkey-only local user cannot satisfy the TOTP-shaped JSON `/auth/mfa-verify`, so desktop-console step-up actions become unavailable to them (enroll-page warning; owner-accepted). An extra-less install with enrolled credentials gets a **startup advisory** naming @@ -283,9 +288,14 @@ advisory-only, restated). - **AC-9** — WHEN any new `/ui` POST (enroll, verify, delete, `/ui/reauth/webauthn`) receives a cross-site request, THE SYSTEM SHALL reject it with 403. → `tests/test_webui.py::test_all_admin_posts_reject_cross_site` -- **AC-10** — IF deleting a WebAuthn credential would remove the user's last second factor WHILE MFA - is required for them, THEN THE SYSTEM SHALL refuse with "enroll another factor first". - → `tests/test_webauthn.py::test_last_factor_delete_refused_while_required` +- **AC-10** — IF removing a second factor — **either** deleting a WebAuthn credential **or** disabling + TOTP — would leave the user with no enrolled factor WHILE MFA is required for them, THEN THE SYSTEM + SHALL refuse with "enroll another factor first". Scoping this to the passkey route alone forbade one + *path* to the zero-factor state rather than the state itself, so the guard is stated over the state + and both removal orders are covered (BACKLOG #1022 widened it). + → `tests/test_webauthn.py::test_last_factor_delete_refused_while_required` (passkey route), + `tests/test_mfa.py::test_disable_mfa_refused_when_totp_is_the_last_factor` (TOTP route), + `tests/test_mfa.py::test_zero_factor_state_is_unreachable_by_either_removal_order` (the state) - **AC-11** — WHEN `admin_reset_mfa` runs, THE SYSTEM SHALL delete the user's WebAuthn credentials alongside TOTP state (existing session-revoke semantics unchanged). → `tests/test_webauthn.py::test_admin_reset_mfa_clears_webauthn_credentials` diff --git a/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md b/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md index f3f2618d..ece8672d 100644 --- a/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md +++ b/docs/adr/0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md @@ -271,3 +271,20 @@ prevent. silently revert ADR 0035's SEC-005/CWE-918 machine-scoping. **Refusing to build the form is the security decision.** - **Paint "signed out" with a warning background** — rejected: it is the author's harmless steady state; a permanently amber item gets ignored, and then it cannot warn when something is actually broken. + + +## Amendment (2026-08-10) — the fork hazard no longer includes an account (BACKLOG #1020) + +Nothing decided here changes; two factual premises it rests on do, and a reader would otherwise act on +stale text. BACKLOG #1020 retired the implicit first-run bootstrap Administrator (ASVS 6.3.2 — no +default accounts), so: + +- **§5's fork hazard is now "a brand-new EMPTY database", with no account at all.** The forked engine is + one nobody can sign into until an operator runs `messagefoundry admin-create` against it. That is a + *worse* accident to have silently, not a lesser one, so the guard's reason stands unweakened. +- **The "auto-retired bootstrap admin" explain-only case is gone**, along with `[auth].bootstrap_expiry_hours`. + There is no auto-retiring account and no such setting to look up. The RBAC-403 and TLS explain-only + cases are unaffected. + +Item #1020 lives in [`docs/BACKLOG.md`](../BACKLOG.md) until archived, then +`docs/archive/backlog/BACKLOG-CLOSED.md`. diff --git a/docs/adr/0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md b/docs/adr/0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md index 6129e315..2757fed5 100644 --- a/docs/adr/0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md +++ b/docs/adr/0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md @@ -170,3 +170,16 @@ running here" is the normal state of an authoring checkout. (`CMD.openEngineSetup`) and `CMD.startEngine` SHALL NOT be offered from the pill; WHEN `hasStore == true` the plain Start is unchanged. → `engine-control.test.ts` (rewritten store-less assertion + the known-CMD sweep), `engine-setup.test.ts` (content-model commands ⊆ `Object.values(CMD)`; dev-engine button === `CMD.startEngine`). + + +## Amendment (2026-08-10) — the store-less confirm no longer promises an admin account (BACKLOG #1020) + +The guard, its gating and its acceptance criteria are unchanged. What changed is what the guard is +warning about: BACKLOG #1020 retired the implicit first-run bootstrap Administrator (ASVS 6.3.2), so a +store-less start creates a **new EMPTY database with no accounts** — an engine nobody can sign into +until `messagefoundry admin-create` runs against it. The modal, the setup page copy and the two tests +that pin them now say exactly that; leaving the old wording would have had the IDE promise an account +the engine no longer creates. + +Item #1020 lives in [`docs/BACKLOG.md`](../BACKLOG.md) until archived, then +`docs/archive/backlog/BACKLOG-CLOSED.md`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 000fad53..75b0f356 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -141,9 +141,9 @@ what is withheld and what you can request. | [0107](0107-phase-4-is-closed-transaction-reduction-is-a-measured-dead-end.md) | **Phase 4 is CLOSED — transaction reduction is a measured dead end.** The P0 falsifier [ADR 0099](0099-phase-4-group-commit-amortize-the-per-event-transaction-cost.md) pre-registered has run and returned **ABANDON**. Inline stage-fusion ([ADR 0057](0057-inline-step-a-fast-path.md)) **works** — it cut `committed_txns/msg` **10.47 → 7.49** (−28.5%; manipulation check passed, disarmed-arm trap avoided at `H=D=dests=1`) — and **buys nothing**: throughput moved **−0.56%**, inside the pre-registered NULL band and below either arm's replicate spread. **That null IS the verdict** (pre-registered primary A/B). **Arm E** adds the number worth remembering: sweeping H∈{1,2,4,8} on the unmodified split path, a **~3× swing in committed transactions (9.89→29.20/msg) moves throughput only −11.7%** → **elasticity −0.115** — the txn→throughput coupling is real but **far too weak to be a lever**. ⚠️ **CORRECTION (same-day, adversarial verify):** an earlier draft claimed arm E proves *"F2 cannot clear the bar at any shape"* and that F2's ceiling *"lands inside B5's rejected band"* — **both FALSE.** F2's arm-E ceiling at H=8 is **+13.2%**, which is **ABOVE** the +8% PROCEED bar and above B5's +6.5…+10% band; even net of the *measured* H=1 give-back (−4.49 pts) it is **+8.75%**. **The data does NOT exclude F2 clearing the bar at high H** — and **F2 cannot be measured without being built** (the fusion gate is `len(names)==1`, so inline fusion is H=1-only *by construction*). We therefore **decline on cost/risk/evidence, NOT on a proof of impossibility**: fusion buys nothing at the only measurable shape; the deratings that would sink it are argued, not measured; ADR 0071 B5 is the precedent (6× fewer round-trips → +6.5…+10%, NO-GO); and F2 is a large permanent 3-backend surface. Decisions: no F2/F3; **ADR 0057 ⛔ DO NOT PROMOTE, default-OFF permanently**; state the conclusion precisely (**NOT** "the wall is per-message" — transactions *do* matter, just far too weakly); **F1 survives on latency/cleanliness merit only, never a throughput claim**; **do NOT open a fifth store-side falsifier** (four negative: C5/C6/C7/P0). **Frontier: the ENGINE side has never been ATTRIBUTED** — note shardcert already publishes per-shard PIDs for an external per-PID CPU capture that nobody has ever taken | Accepted (2026-07-13) — closes options; authorizes no build | | [0108](0108-steps-view-accumulator-send-fan-out-copy-on-send-authoring.md) | **Steps-view accumulator Send fan-out** — author multi-destination, copy-on-Send fan-out ([ADR 0104](0104-copy-on-send-outbound-message-model-recognition-first-handler-message-type-and-hl7-field-picker.md)) as first-class mid-body **actions**, never a `Send`-in-a-`return`. The owner rejected both `return Send(...)` and the named-sends `return [a, b]` form: a send should read as an action, not the function's return. **Decision:** recognize + author the **accumulator idiom** `sends = []; sends.append(Send("OB", msg)); ...transform...; sends.append(Send("OB2", msg)); return sends` — each `sends.append(Send(...))` is an editable `send` row (additive `appended:true`) positioned AT the append; the `sends = []` init + bare `return sends` footer are managed **read-only scaffold** (`scaffold:"collector_init"/"return_collector"`, kind stays `code`); deleting every append leaves an empty accumulator = **FILTERED**, no name-scrub. The three legacy returned forms stay **byte-identical**; the palette's **Send** item repoints `template:"send"` → `op:"insert_send"`, plus a per-row **+ dest** `add_destination` button. **No engine runtime change** for this ADR — pure recognizer + rewrite + view (see the ADR's §2 invariant for the `_partition` behaviour it rests on, amended 2026-08-04 by BACKLOG #341; do not restate it here). **Honesty gate:** an append is a send row only where its collector is a *clean delivering accumulator* (single top-level `[]` init, top-level `return NAME`, bound nowhere else); a discarded/aliased/rebound/closure-local append degrades to a read-only `code` row. IDE keys insert-after suppression on a new `isReturnRow` (append allows after; return/footer suppresses) across the six position sites + the CSP mirror. Adversarial pass folded in 10 fixes (stale import index → `_leading_import_end`; ruff quote escape; delivering-accumulator gate; nested-guard convert; non-empty-tuple convert refused; nested-anchor placement) — see the ADR's §7 for each rationale. Extends [ADR 0076](0076-typed-action-vocabulary-action-list-lens.md)/[ADR 0089](0089-recognition-first-lens-native-idioms.md); repoints the [ADR 0106](0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) Send item; #26-clean | Accepted (2026-07-14) — owner-directed; engine + IDE built, adversarially verified; **amended 2026-08-04** (two `_partition` rationales corrected by BACKLOG #341 — nothing built changed) | | [0109](0109-at-rest-encryption-fail-closed-on-an-undeclared-phi-posture.md) | At-rest encryption fail-closed on an **undeclared PHI posture** — default an undeclared/underived `[ai].data_class` to **PHI** so a keyless store refuses to start (reusing the built `__main__.py:980-1004` PHI serve gate) unless the operator declares `data_class=synthetic`, configures a key, or sets the audited `[store].allow_unencrypted_phi`. Makes cleartext-at-rest an explicit, audited opt-out at every posture (prod is already fail-closed); keyless→keyed reads stay back-compat. Closes [Secure Build Scorecard](../Secure_Build_Scorecard_MEFOR.md) gap #4 (→ B+ → A−). Layer-2 first-run auto-key deferred to a follow-on ADR. **Tier S2×P2 ⇒ T3.** Extends [ADR 0002](0002-phase2-transport-security-and-strong-auth.md); audited-escape precedent [ADR 0036](0036-windows-config-source-trust.md) | **Rejected (2026-07-14)** — premise refuted on code review: `serve` already fails closed for every PHI posture via `require_posture()` (env mandatory; `staging`/`prod`→PHI; custom-env-undeclared raises); cleartext only for declared/derived synthetic (no real PHI). No code shipped; scorecard gap #4 corrected | -| [0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) | **IDE engine-link doctor — the status bar tells the truth about the promote target** (BACKLOG #232). The `MEFOR: ` item painted a green check whenever *anything* answered `GET /health`: `classifyProbe` folded **every** HTTP status — **including a 401** — into `"reachable"`, and the probe **discarded the body**. `AuthSettings.enabled` defaults True, so a plain `serve` = engine up + IDE holding **no session** + every authenticated call 401ing + **a confident green check** (a live probe of the owner's engine returned `{"status":"ok","version":null}` — `/health` discloses `version` only to an authenticated caller, WP-L3-07). The click's menu diagnosed nothing, and its `Open engine URL in browser` opened the **bare** engine URL — a live **404** (there is no `/` route). **Decision:** green means *"the IDE can USE this engine"*, not *"a socket answered"* — a closed link-state union (`unreachable{code}`/`foreign`/`signedOut`/`unverified`/`blocked{reason}`/`drifted`/`ok`) in the vscode-free model; the tokenless poll reads the `/health` body (`version: null` ⇒ **signedOut**; a version present ⇒ **`unverified`, NEVER green** — `optional_identity` applies no RBAC and no must-change gate, and `/auth/me` is must-change-**exempt**, so neither endpoint can prove a session is usable); a green check is **EARNED** only by a **user-initiated** deep probe of a non-exempt protected route (`GET /config/provenance` — `monitoring:read`, no step-up, yields `drift` free) and it **DECAYS**. **The periodic poll MUST stay TOKENLESS** — `identity_for_token(..., activity=True)` refreshes the session idle clock, so a bearer on a 15s timer would make the engine's 30-min idle timeout unreachable forever (CWE-613). Surface is **native chrome, NOT a webview**: a `MarkdownString` hover (the primary diagnosis — renders at the item, needs no command dispatch), a state-gated QuickPick (`title:`, never a `placeHolder:`), a `$(sync~spin)` flip at the click site, and the extension's **first engine `LogOutputChannel`** (URLs/status/errno/duration/verdict — **never a body, never a token**). **Boundary made executable:** the IDE renders and repairs the **LINK**, never the **WORKLOAD** — container poverty + the model emitting **command ids, never data** + **two frozen CI allowlists** (an `EngineLink` field list with no connection/message/queue/count/rate field, and a probe-endpoint list of only `/health`, `/ai/policy`, `/config/provenance`; `/messages`/`/connections`/`/stats` break the build). Exposes `engineSignIn`/`engineSignOut` (already-written flows that were unreachable; `signIn()` inherits ADR 0035's pre-prompt `assertTargetAllowed()` refusal). **Declined:** a webview panel; a bearer on the poll; a "Reload config" button (`require_step_up` + 300s window while `withAuth` never retries a 403); inline change-password/MFA (deep-link to `/ui/login` — the Console owns credentials); "Start local engine" (a terminal cwd'd at a worktree forks a brand-new store + bootstrap admin). Extends [ADR 0100](0100-ide-native-surface-polish-and-open-to-messagefoundry-startup-experience-backlog-221.md) (supersedes its "opens engine settings" record) / [ADR 0035](0035-ide-extension-workspace-trust-and-scope.md); bounded by [ADR 0065](0065-web-ops-dashboard.md); #26-clean | Accepted (2026-07-14) — owner-directed; built + verified against the live engine (IDE v0.0.28) | +| [0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) | **IDE engine-link doctor — the status bar tells the truth about the promote target** (BACKLOG #232). The `MEFOR: ` item painted a green check whenever *anything* answered `GET /health`: `classifyProbe` folded **every** HTTP status — **including a 401** — into `"reachable"`, and the probe **discarded the body**. `AuthSettings.enabled` defaults True, so a plain `serve` = engine up + IDE holding **no session** + every authenticated call 401ing + **a confident green check** (a live probe of the owner's engine returned `{"status":"ok","version":null}` — `/health` discloses `version` only to an authenticated caller, WP-L3-07). The click's menu diagnosed nothing, and its `Open engine URL in browser` opened the **bare** engine URL — a live **404** (there is no `/` route). **Decision:** green means *"the IDE can USE this engine"*, not *"a socket answered"* — a closed link-state union (`unreachable{code}`/`foreign`/`signedOut`/`unverified`/`blocked{reason}`/`drifted`/`ok`) in the vscode-free model; the tokenless poll reads the `/health` body (`version: null` ⇒ **signedOut**; a version present ⇒ **`unverified`, NEVER green** — `optional_identity` applies no RBAC and no must-change gate, and `/auth/me` is must-change-**exempt**, so neither endpoint can prove a session is usable); a green check is **EARNED** only by a **user-initiated** deep probe of a non-exempt protected route (`GET /config/provenance` — `monitoring:read`, no step-up, yields `drift` free) and it **DECAYS**. **The periodic poll MUST stay TOKENLESS** — `identity_for_token(..., activity=True)` refreshes the session idle clock, so a bearer on a 15s timer would make the engine's 30-min idle timeout unreachable forever (CWE-613). Surface is **native chrome, NOT a webview**: a `MarkdownString` hover (the primary diagnosis — renders at the item, needs no command dispatch), a state-gated QuickPick (`title:`, never a `placeHolder:`), a `$(sync~spin)` flip at the click site, and the extension's **first engine `LogOutputChannel`** (URLs/status/errno/duration/verdict — **never a body, never a token**). **Boundary made executable:** the IDE renders and repairs the **LINK**, never the **WORKLOAD** — container poverty + the model emitting **command ids, never data** + **two frozen CI allowlists** (an `EngineLink` field list with no connection/message/queue/count/rate field, and a probe-endpoint list of only `/health`, `/ai/policy`, `/config/provenance`; `/messages`/`/connections`/`/stats` break the build). Exposes `engineSignIn`/`engineSignOut` (already-written flows that were unreachable; `signIn()` inherits ADR 0035's pre-prompt `assertTargetAllowed()` refusal). **Declined:** a webview panel; a bearer on the poll; a "Reload config" button (`require_step_up` + 300s window while `withAuth` never retries a 403); inline change-password/MFA (deep-link to `/ui/login` — the Console owns credentials); "Start local engine" (a terminal cwd'd at a worktree forks a brand-new store + bootstrap admin). Extends [ADR 0100](0100-ide-native-surface-polish-and-open-to-messagefoundry-startup-experience-backlog-221.md) (supersedes its "opens engine settings" record) / [ADR 0035](0035-ide-extension-workspace-trust-and-scope.md); bounded by [ADR 0065](0065-web-ops-dashboard.md); #26-clean | Accepted (2026-07-14) — owner-directed; built + verified against the live engine (IDE v0.0.28) — **amended 2026-08-10**: the §5 fork hazard is now an EMPTY database with no account, and the auto-retiring bootstrap admin it cites is gone (BACKLOG #1020) | | [0111](0111-not-deployed-connections.md) | **Connection present but not deployed** (BACKLOG #233) — a first-class `deployed: bool = True` on **both** connection models so a config can carry a real, reviewed, dark connection (retired partner, superseded duplicate send, a relay pulled from prod) that is **never wired, never started, never queued to, and whose `env()` is never resolved**. Removes DEGRADED-on-**every**-boot, which is indistinguishable from a real regression — a permanent alarm is a disabled alarm. **Key finding:** `auto_start=False` already dodges `resolve_env_settings` on the *cold serve* path (both boot gates return before `_source_config`/`_dest_config`), **but `_build_check_connectors` (`wiring_runner.py:5181`/`:5186`) loops EVERY inbound and EVERY outbound with NO gate** — so `messagefoundry check` (the **required** commit gate), every reload, every promote and every `connection upsert` still explode, and one unresolvable connection blocks edits to *every other* connection in the file. **Honoring the flag there IS the feature.** `deployed=False` **WINS over `auto_start`** (deploying is a *config* change, not a runtime action → `start`/`restart` 409). **Three-way distinction that must never be conflated:** **SIMULATED** (#15 — built, receives rows, suppresses egress, finalizes `PROCESSED`) vs **PARKED** ([ADR 0048](0048-third-tier-disaster-recovery-standby.md)/[ADR 0095](0095-connection-lifecycle-scheduler-and-credential-fault-stop.md) — rows **RETAINED**, queued, retried) vs **NOT DEPLOYED** (no row is ever created). Enforced at the **`transform_one` Send-materialization seam** (`pipeline/dryrun.py:403-416`) — structurally mirroring [ADR 0084](0084-accepts-router-seam.md)'s `accepts=` seam for the router half — which covers the split, [ADR 0057](0057-inline-step-a-fast-path.md) inline and [ADR 0071](0071-cut-executor-round-trips-b5.md) fused paths plus dry-run/`check`/Test Bench in **one** edit; plus a **separate** 409 guard on the [ADR 0090](0090-resend-a-stored-message-to-an-alternate-outbound-connection.md) resend/edit-resend path, which inserts an outbound row directly and bypasses `transform_one` entirely. **Count-and-log (CLAUDE.md §12) preserved** by a per-destination `message_events` row **added to `_AUDIT_FLOOR_EVENTS`** (else the #63 verbosity gate evaporates it at `errors`/`off`, re-creating the accept-and-drop) + a **7th `MessageStatus.NOT_DEPLOYED`** used only when **every** selected destination was declined — because the finalizer decides `FILTERED` **by absence**, merely dropping the target would silently report "the handler filtered this" with **zero code changes and zero test failures** (the trap). **At-least-once ([ADR 0001](0001-staged-pipeline-architecture.md)) preserved:** the decline is keyed on the **REGISTRY FLAG** (a property of the graph), never on live runner state, so a re-run re-derives an identical delivery set. Connection **stays in `Registry.outbound`** (the orphan sweep keys on the registry — removing it would dead-letter already-queued rows). No DDL, no migration, no schema-hash change. **Not built here:** the `ide/` form controls (three TS field enumerations — deferred to avoid colliding with in-flight [ADR 0106](0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) work; the flag works via hand-edited TOML + code-first without them) and **BACKLOG #234** (`_SCALAR_FIELDS` is a 12-key whitelist with no passthrough → a GUI save silently strips `priority`/`schedule`/`shard`/`metadata`/`batch`/`stall`/`dead_letter_days`; a **pre-existing** data-loss bug, filed only). Extends [ADR 0007](0007-gui-manageable-connections-toml.md) | Accepted (2026-07-14) — owner-directed; built (#233) | -| [0112](0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md) | **IDE engine lifecycle from the status-bar pill — guarded start / stop / restart** — the `MEFOR: ` pill may now **run** the engine, not just hand over the command. **Supersedes [ADR 0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) §5's "Start local engine" decline** (everything else in 0110 stands — the link-state model, the tokenless poll, the earned/decaying green, and the **LINK-not-WORKLOAD boundary §4**, left byte-for-byte intact). §5 declined Start because a `createTerminal` cwd'd at a worktree would fork a **brand-new empty DB + bootstrap admin**; the copy-only v1 also **did not fix the actual failure** — the clipboard's bare `python -m messagefoundry serve …`, pasted into a fresh terminal, resolves `python` to the PATH shim (on Windows the Store shim), which lacks the deps → the reported **`ModuleNotFoundError: No module named 'pydantic'`**. **Decision:** Start runs the **exact blessed command** (`serve --config `, **no `--db`/`--env`/`--port`** — the service TOML stays the sole authority; ADR 0110 rejected `engineEnv` for this reason) but with `python` = `pythonPath()` (the auto-detected workspace `.venv`), launched `createTerminal({shellPath,shellArgs})` = argv, no shell re-parse. The §5 fork is **neutralised, not reintroduced**: Start is gated `canControl` = **loopback** (M-29) + **trusted** (SEC-004/CWE-426) + a workspace, and **never silently creates a store** — a run dir with no service TOML and no `*.db` gets a modal "creates a NEW database + bootstrap admin" confirm (`runDirHasEngine`). **Stop/Restart act ONLY on a terminal this IDE started** (`exitStatus === undefined`), never a port-kill — parallel worktrees can share a port. **"Set up environment"** bootstraps `.venv` + install for the fresh-clone (missing-deps) case. **The ADR 0110 §4 boundary is untouched:** lifecycle actions are **command ids** from the pure `planActions` (new optional `EngineControlContext`, default no-control → every existing call site unchanged); **no `EngineLink` field, no probe endpoint, no setting** added — the two frozen CI allowlists and the settings-scope invariant stay green. Adversarial self-review caught + fixed two shell defects (a post-`dispose` re-probe touching a disposed item; a `startEngine` reentrancy window double-launching). Node-side suite green (348, +20 for this change); tsc + esbuild clean. Supersedes [ADR 0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) §5; extends [ADR 0035](0035-ide-extension-workspace-trust-and-scope.md); #26-clean | Accepted (2026-07-15) — owner-directed; built (IDE extension v0.0.29) — Amended 2026-07-16: store-less pill leads with a guided setup page (BACKLOG #238; ratified, built (IDE extension v0.0.32), Plan-12 `ide-238-setup`) | +| [0112](0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md) | **IDE engine lifecycle from the status-bar pill — guarded start / stop / restart** — the `MEFOR: ` pill may now **run** the engine, not just hand over the command. **Supersedes [ADR 0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) §5's "Start local engine" decline** (everything else in 0110 stands — the link-state model, the tokenless poll, the earned/decaying green, and the **LINK-not-WORKLOAD boundary §4**, left byte-for-byte intact). §5 declined Start because a `createTerminal` cwd'd at a worktree would fork a **brand-new empty DB + bootstrap admin**; the copy-only v1 also **did not fix the actual failure** — the clipboard's bare `python -m messagefoundry serve …`, pasted into a fresh terminal, resolves `python` to the PATH shim (on Windows the Store shim), which lacks the deps → the reported **`ModuleNotFoundError: No module named 'pydantic'`**. **Decision:** Start runs the **exact blessed command** (`serve --config `, **no `--db`/`--env`/`--port`** — the service TOML stays the sole authority; ADR 0110 rejected `engineEnv` for this reason) but with `python` = `pythonPath()` (the auto-detected workspace `.venv`), launched `createTerminal({shellPath,shellArgs})` = argv, no shell re-parse. The §5 fork is **neutralised, not reintroduced**: Start is gated `canControl` = **loopback** (M-29) + **trusted** (SEC-004/CWE-426) + a workspace, and **never silently creates a store** — a run dir with no service TOML and no `*.db` gets a modal "creates a NEW database + bootstrap admin" confirm (`runDirHasEngine`). **Stop/Restart act ONLY on a terminal this IDE started** (`exitStatus === undefined`), never a port-kill — parallel worktrees can share a port. **"Set up environment"** bootstraps `.venv` + install for the fresh-clone (missing-deps) case. **The ADR 0110 §4 boundary is untouched:** lifecycle actions are **command ids** from the pure `planActions` (new optional `EngineControlContext`, default no-control → every existing call site unchanged); **no `EngineLink` field, no probe endpoint, no setting** added — the two frozen CI allowlists and the settings-scope invariant stay green. Adversarial self-review caught + fixed two shell defects (a post-`dispose` re-probe touching a disposed item; a `startEngine` reentrancy window double-launching). Node-side suite green (348, +20 for this change); tsc + esbuild clean. Supersedes [ADR 0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md) §5; extends [ADR 0035](0035-ide-extension-workspace-trust-and-scope.md); #26-clean | Accepted (2026-07-15) — owner-directed; built (IDE extension v0.0.29) — Amended 2026-07-16: store-less pill leads with a guided setup page (BACKLOG #238; ratified, built (IDE extension v0.0.32), Plan-12 `ide-238-setup`) — Amended 2026-08-10: the store-less confirm promises an EMPTY database with no accounts, not a bootstrap admin (BACKLOG #1020) | | [0113](0113-windows-tray-service-manager-stdlib-ctypes-tokenless.md) | **Windows tray service-manager — stdlib ctypes, tokenless** (BACKLOG #239) — a Windows notification-area app for the NSSM engine service: shows engine status at a glance, opens the console (`/ui`) / VS Code / service log, and start/stop/restarts the service via per-action UAC elevation. **No PySide6 — no Qt** (owner-chosen over a `QSystemTrayIcon` design), so §10's "no new PySide6 operator surfaces" is **untouched** — this is the "tiny standalone tray/service-manager" [#103](../archive/backlog/BACKLOG-CLOSED.md#103-retire-the-pyside6-desktop-console-in-favor-of-the-web-console-p3-owner-decision) sanctioned, an ADR **ratification** not an amendment. **Not a second console:** tokenless `GET /health` + local SCM state **only** — no token ever, no message/queue/count field (a frozen snapshot-field allowlist + a `/health`-stays-tokenless test enforce it, mirroring the IDE `ENGINE_LINK_FIELDS`/`POLL_PLAN` doctrine). Built on stdlib `ctypes` (`Shell_NotifyIcon` + owned message pump) + `apiclient` (**zero** new locked deps). **Elevation writes no new privileged code:** reuse the shipped, injection-hardened `messagefoundry.service.control_service` (System32 `cmd`/`net`, single UAC prompt even for restart), **patched** `ShellExecuteW`→`ShellExecuteExW`+`GetExitCodeProcess` so a UAC-cancel (`ERROR_CANCELLED` 1223) is distinguishable — never elevating the user-writable venv interpreter. Nine-state machine (incl. `WEDGED`/`FOREIGN`/`RUNNING_UNMANAGED`/`UNKNOWN`) over `QueryServiceStatusEx` (`dwCheckPoint`/`dwWaitHint`) with monotonic grace windows; local-single-box scope (**remote** → monitor-only; *amended 2026-07-22* — a **local https** engine is fully managed and its cert verified against the OS trust store, no `verify=False` path; scheme ≠ locality). Ratifies the `messagefoundry.service`/`service_status` client-import carve-out. Extends [ADR 0088](0088-apiclient-service-cli-extraction.md); mirrors [ADR 0110](0110-ide-engine-link-doctor-the-status-bar-tells-the-truth-about-the-promote-target.md)/[ADR 0112](0112-ide-engine-lifecycle-from-the-status-bar-pill-guarded-start-stop-restart.md); bounded by [ADR 0065](0065-web-ops-dashboard.md); #26-clean | Accepted (2026-07-16) — owner chose the no-Qt ctypes spine + accepted the §10 clarification; design + plan complete (25-agent workflow, adversarially verified); build phased, pushes/PR owner-approved | | [0114](0114-phase-4-claim-path-call-complexity-reduction-driver-interface-redesign-ingress-routed-reset-fold.md) | **Phase-4 claim-path call-complexity reduction — driver-interface redesign + the INGRESS/ROUTED reset fold** — the frozen D1 verdict locates ~9.7 ms/call of the pooled `claim_fifo_heads` cost (18.0-18.2 ms at the N=4 240-offered pin) **inside the ODBC/TDS driver, per-call, ~2/3 fixed (6.5-7.0 ms), call-complexity-shaped** (the same driver runs a trivial 7-statement batch in 0.335 ms), so the remedy axis is what the call *carries*: three sub-levers, **one default-OFF SqlServerStore-only flag each** (`fifo_claim_fold_reset` / `fifo_claim_proc` / `fifo_claim_prepared`; PG/SQLite provably untouched). **C (fold, measured 1.87-1.91 ms/call, licensed ceiling +8.0% ingress+routed):** the finally-guard's `SET LOCK_TIMEOUT -1` + write-less commit#2 fold into the batch's confirmed-clean success path at INGRESS/ROUTED only (H2-noop code-confirmed + runtime-guarded); the shielded B1/M-6 guard is retained **verbatim on every non-clean exit** (1222, kept≠claimed, cancellation, any error). **A (proc-ification):** TWO lane-family versioned procs (`mefor_claim_fifo_heads_cid_v1`/`_dst_v1` — the lane column is a code literal), fixed-arity 9-param CALL with one JSON lanes parameter, bodies = the batch verbatim (no TRY/CATCH, no txn statements, no reset outside the conditional `@fold_reset` tail — `SET LOCK_TIMEOUT`'s session persistence past proc exit is load-bearing at outbound), guarded DDL that can never break a flag-OFF open, and a startup gate hashing `OBJECT_DEFINITION` + compat ≥ 130 that degrades loudly to the batch. **B (stable text + retained prepared cursor):** the non-DDL fallback, fail-closed-coupled to the fold, on store-owned dedicated connections reconciled with EF-6/STORE-3 — structural feasibility itself is a gate question. FIFO-always semantics preserved exactly; commit-amortization stays D2-scoped-out; **no throughput projections** — pre-registered ms/call bench gates (fixed 240-pin, replicate pairs, invariant battery, total accept/kill rules) are the only forward-looking numbers; the composed 6.35-6.64 ms/call removal (claim 18.8-19.0 → 12.2-12.65) is a target evaluated only at the post-build re-measure; the certification run remains the arbiter | Proposed (2026-07-16) — owner D0-accept + D2-GO (TO-ENGINE-033); design of record, build ships later, all flags default OFF, each flipped only after its own pre-registered bench gate | | [0115](0115-asvs-l3-drive-to-pass-secure-by-default-flips-and-residual-closure.md) | **ASVS L3 drive-to-Pass — secure-by-default flips and residual closure** (BACKLOG #242–#246). The 2026-07-16 ASVS re-score left **50/51 Partials + 2 Fails**; almost every Partial is a *shipped* control scored Partial because it is opt-in / off-by-default / delegated. Owner chose to **drive-to-Pass** rather than leave them all as accepted residuals — but a naïve "flip every default on" would break a valid deployment (a dev box with no SMTP collector, a partner with no JWS verifier, a single-operator loopback install). **Decision:** a **secure-by-default-where-safe, runbook-instructed-otherwise** posture applied per control — (1) flip the global default ON only where it is already gated on the PHI posture so a synthetic/CI box stays byte-identical (bounded retention 14.2.4/14.2.7, egress deny-by-default 13.2.4/13.2.5, cleartext-egress refusal 12.2.1); (2) **instruct** the control in `OFF-LOOPBACK-DEPLOYMENT.md` (+ a fail-closed prod-PHI serve gate where refusing to start is defensible) where a global flip would break a valid install (approvals 2.3.5, JWS signing 4.1.5, off-box forwarding 16.4.3, WebAuthn 6.3.3/6.5.7/6.7.2) — so the *documented deployment* earns Pass while loopback/no-collector/partner-less defaults are unchanged; (3) **build the small last-mile controls** (AEAD context-binding 11.3.3, AES-GCM invocation counter 11.3.4, time-sync enforce 16.2.2, log-all-authz 16.3.2, keyed audit chain default 16.4.2, magic-byte validation 5.2.2, extended pacing 2.4.2, action-bound step-up 7.5.1/7.5.2); (4) **refresh the drifted inventories** (11.1.2, 13.1.1, 13.1.4); (5) **formally accept** the genuinely delegated residuals (WP #246 — proxy-TLS 12.1.x/12.3.x the engine cannot inspect because it terminates no browser TLS, org-delegated backend creds 13.2.1/13.2.2, SMART AS enforcement 10.4.16, AV 5.4.3) into the register. **Out of scope (stay signed residuals):** the runtime sandbox (15.2.5), HSM key custody (13.3.1/13.3.3), the ECH/in-use-memory platform gaps (12.1.5/11.7.2), and the tolerant-HL7 accepted deviations (2.2.1/2.2.3). Each flip/build amends its owning feature ADR (0018 signing, 0080 forwarding, 0068 WebAuthn, 0014 approvals/notify, 0019 AAD/nonce, 0004 magic-byte, 0077 step-up, 0105 served-filename); no secure posture changes without its ADR record updated in the same work. Plan + per-cell mapping: `ASVS-REMEDIATION-2026-07.md`. Drives `ASVS-L3-ASSESSMENT-2026-07-16.md`; residuals owned in `ASVS-L3-RISK-ACCEPTANCE-REGISTER.md`; #26-clean | Accepted (2026-07-16) — owner-directed scope decision (drive-to-Pass); builds phased across BACKLOG #242–#246, pushes/PR owner-approved | diff --git a/docs/testing/FEATURE-COVERAGE-PLAN.md b/docs/testing/FEATURE-COVERAGE-PLAN.md index 163c9833..5ead7795 100644 --- a/docs/testing/FEATURE-COVERAGE-PLAN.md +++ b/docs/testing/FEATURE-COVERAGE-PLAN.md @@ -1069,7 +1069,7 @@ Coverage summary: 18 of 22 features are well-covered on the primary functional + | AUTHN-14 | Session inventory + targeted revoke (list, own-by-id 404, others-keep-current, admin revoke-all) | 0002 | test_api_auth (4 session tests) | covered | none material | med | S | | AUTHN-15 | Step-up re-verification — session window (reauth pw/AD-rebind, MFA gate, admin-write pacing, new-IP force + re-anchor) | 0077,0002 | test_step_up, test_admin_new_ip, test_console_step_up | covered | none material | high | S | | AUTHN-16 | Action-bound step-up (0077 single-use per-action grants, login/verify_mfa never mint, no-deadlock, opt-out, X-Step-Up-Action) | 0077 | test_step_up (6 action tests), test_console_step_up::test_action_step_up_binds_purpose_into_reauth | covered | ha: process-local grant restart/multi-node re-prompt not asserted | high | S | -| AUTHN-17 | Bootstrap admin (create, policy pw, expiry/supersession retire, unclaimed guard, 0600 file, symlink refusal, not logged) | 0002 | test_auth_service (5), test_bootstrap_admin_perms (4), test_auth_hardening::test_bootstrap_password_written_to_file_not_log | covered | none material | high | S | +| AUTHN-17 | First administrator: `admin-create` provisions one on a fresh store (policy password, audited, Administrator role); starting the engine provisions NOTHING | 0002 | test_admin_create_cli (9), test_auth_service::test_initialize_seeds_roles_and_creates_no_account | covered | none material | high | S | | AUTHN-18 | mTLS client-cert -> Identity (require_service_cert allow-list, deny-by-default, PHI-fenced, identity_for_username) | 0002 | tests/test_api_tls.py | partial | evidence in transport-TLS sibling; no auth-suite test pins CN/SAN mapping matrix | high | M | | AUTHN-19 | Kerberos/IdP session-lifetime coordination | 0079 | (none) | none | DESIGN-ONLY / DEFERRED — unbuilt; AD-disable/CA-revoke doesn't propagate before absolute/idle timeout | med | L | | AUTHN-20 | Security-event notifications + self feed (isolated best-effort notify, caller-scoped PHI-free feed) | 0002 | test_auth_service (notifier tests), test_api_auth::test_security_events_feed_is_scoped_to_caller/_phi_free | covered | overlaps audit sibling | med | S | diff --git a/docs/testing/WIN2025-TEST-PLAN.md b/docs/testing/WIN2025-TEST-PLAN.md index c75f5a6d..dc2ba998 100644 --- a/docs/testing/WIN2025-TEST-PLAN.md +++ b/docs/testing/WIN2025-TEST-PLAN.md @@ -324,7 +324,7 @@ These are the rows CI structurally cannot reach and `verify` reports MANUAL. Eac These are the eight gap-map tests (Tiers 1–3), **S2.1–S2.8** mapping 1:1 to gap-map #1–#8. Each attacks a path that `verify`/`check`/CI reports green (or simply never exercises) but that is unproven under real Windows Server 2025 service identity, real outbound delivery, real crash, real TLS, or real adversarial input. **Tier 1 (S2.1, S2.2) runs first.** Tools used: the functional harness Receive/Compose/Monitor tabs (GUI, desktop session) and the Qt-free headless `--scenario` path, plus `verify` where it applies. All require the box `serve`-ing the matching graph. -> **Auth note for headless harness runs (applies to all of Sections 2–4).** S2.8/S1.AC-API prove the API binds loopback and **requires auth**. Against that production-posture (auth-on) engine the load runner validates the token via `/auth/me` and the scenario runner polls auth-gated `/messages`/`/dead-letters` — so **every headless `--scenario`/`--load`/`--failover` invocation must pass `--token `** (mint a bearer for the service-identity engine via the console/API auth route, or via the runbook's bootstrap-admin flow). The failover orchestrator is the one exception: it spawns its own nodes with `MEFOR_SECURITY_REQUIRE_SIGN_IN=false`, so it needs no `--token`. If you prefer, serve the test engine with auth disabled for the harness runs and re-enable it for the S2.8 attack; the commands below assume auth-on + `--token`. +> **Auth note for headless harness runs (applies to all of Sections 2–4).** S2.8/S1.AC-API prove the API binds loopback and **requires auth**. Against that production-posture (auth-on) engine the load runner validates the token via `/auth/me` and the scenario runner polls auth-gated `/messages`/`/dead-letters` — so **every headless `--scenario`/`--load`/`--failover` invocation must pass `--token `** (mint a bearer for the service-identity engine via the console/API auth route, for an administrator created with `messagefoundry admin-create`). The failover orchestrator is the one exception: it spawns its own nodes with `MEFOR_SECURITY_REQUIRE_SIGN_IN=false`, so it needs no `--token`. If you prefer, serve the test engine with auth disabled for the harness runs and re-enable it for the S2.8 attack; the commands below assume auth-on + `--token`. ### S2.1 (gap #1) — Healthy message → PROCESSED UNDER the NSSM service account @@ -1536,7 +1536,6 @@ These have no runnable PASS gate the harness can assert; a human observes and st | S4.10 (manual half) | Live DB-restart drill | Bounce the SQL Server/PostgreSQL service mid-flight; engine recovers, no loss | B6 | | S2.2 (remedy) | DPAPI boundary remedy confirmation | Chosen remedy (machine-scope DPAPI / env-var key / mint-as-service) lets the svc identity decrypt the key | S2.2 | | S4.9 (timing) | Windows port-rebind recovery timing | Record seconds for a killed listener to rebind `:2600` (host-variable; the box-owned number) | S4.9 | -| M-BOOTSTRAP | `bootstrap-admin.txt` handling | One-time admin password written to repo root on first `serve`; rotate + secure/delete after capture (D11) | runbook | ### F. Phase-2 (customer-network) backlog diff --git a/docs/testing/master-test-plan/01-environments-data-and-tooling.md b/docs/testing/master-test-plan/01-environments-data-and-tooling.md index df81684c..3fa2b0eb 100644 --- a/docs/testing/master-test-plan/01-environments-data-and-tooling.md +++ b/docs/testing/master-test-plan/01-environments-data-and-tooling.md @@ -252,8 +252,7 @@ deliberately fail-closed: operator-local profiles tuned to a specific deployment's volume. - `scripts/security/scan-tokens.local.txt` — the real customer/vendor token list. Only the **synthetic** `.example` is ever committed. -- `.env`, `.env.*`, `*.key`, `*.pem`, `*.pfx`, `secrets/`, `/docker/secrets.env`, `/docker/tls/`, - `bootstrap-admin.txt`. +- `.env`, `.env.*`, `*.key`, `*.pem`, `*.pfx`, `secrets/`, `/docker/secrets.env`, `/docker/tls/`. Two CI contexts back this up: **`forbidden-content (customer/PHI leak guard)`** ([`security.yml:367`](../../../.github/workflows/security.yml), running diff --git a/docs/testing/master-test-plan/10-auth-rbac-and-active-directory.md b/docs/testing/master-test-plan/10-auth-rbac-and-active-directory.md index 81785d36..6d5b56d8 100644 --- a/docs/testing/master-test-plan/10-auth-rbac-and-active-directory.md +++ b/docs/testing/master-test-plan/10-auth-rbac-and-active-directory.md @@ -15,7 +15,7 @@ This chapter covers the whole authentication/authorization core and, specificall - **Active Directory** — LDAPS simple-bind and nested-group resolution (`auth/ldap.py:86-282`), Kerberos/SPNEGO for both the JSON `POST /auth/negotiate` leg and the browser `GET /ui/sso` RFC 4559 flow (`auth/ldap.py:285-364`, `messagefoundry_webconsole/routes/sso.py`), AD group → role and AD group → per-connection scope maps, domain-join dependency, keytab/SPN preflight and its legible degradation, referrals / multi-domain / nested groups, AD outage and slow-LDAP behaviour, disabled / locked / expired-password accounts, service-account rights, LDAPS posture and channel binding. - **Federated SSO** — the OIDC authorization-code + PKCE relying party (ADR 0142, `auth/oidc/`, `auth/oidc_http.py`, `messagefoundry_webconsole/routes/oidc.py`). - **RBAC** — the 27-entry `Permission` catalog and 6 fixed `Role`s (`auth/permissions.py`, counted from the AST: `Permission=27`, `Role=6`), ADR 0045 custom roles, and the per-connection authorization scope. -- **Sessions & re-proof** — opaque tokens, idle/absolute/cap/rotation/inventory, TOTP + recovery codes, WebAuthn passkeys, session-window and ADR 0077 action-bound step-up, the new-client-IP re-anchor, bootstrap admin. +- **Sessions & re-proof** — opaque tokens, idle/absolute/cap/rotation/inventory, TOTP + recovery codes, WebAuthn passkeys, session-window and ADR 0077 action-bound step-up, the new-client-IP re-anchor, first-administrator provisioning. - **Audit** — the hash-chained tamper-evident `audit_log`, the ADR 0150 client address as a conditional 7th chain element, and the off-box tee. - **The AD lab environment** itself, without which none of the directory legs can be tested for real. @@ -48,9 +48,10 @@ This chapter covers the whole authentication/authorization core and, specificall | Evidence | What it proves | |---|---| -| `tests/test_auth_service.py` (29) | Login paths, AD role sync, lockout notification + security events, bootstrap-admin lifecycle | +| `tests/test_auth_service.py` (26) | Login paths, AD role sync, lockout notification + security events, first-administrator provisioning (a fresh store has zero users) | +| `tests/test_admin_create_cli.py` (9) | `messagefoundry admin-create`: a fresh store reaches an authenticated, USERS_MANAGE-gated API session by the operator route, and no implicit account appears without it | | `tests/test_api_auth.py` (61, 1402 lines) | End-to-end API auth: login/logout/me, session routes, users CRUD, forced first-login rotation, caller-scoped PHI-free security-event feed, ADR-0150 client reaching the audit row (`:1361`) | -| `tests/test_auth_hardening.py` (24) | Unknown-user timing equalizer, lockout window reset, LDAPS posture / RFC 4515 escaping / disabled-account / local-account conflict, session reaper, bootstrap password written to file not log, WS audit, HTTP grant/deny precision helper | +| `tests/test_auth_hardening.py` (24) | Unknown-user timing equalizer, lockout window reset, LDAPS posture / RFC 4515 escaping / disabled-account / local-account conflict, session reaper, WS audit, HTTP grant/deny precision helper | | `tests/test_auth_core.py` (13) + `tests/test_auth_store.py` (7) | argon2 roundtrip + rejections, password-policy rules, token uniqueness and hash-only storability, auth store tables | | `tests/test_auth_entry_hardening.py` (9) | Sliding-window limiter per-IP + global + monotonic clock; entry routes fail closed | | `tests/test_auth_session_lifecycle.py` (9) | Idle/absolute expiry, backward-clock revoke, activity-only refresh, Kerberos reject audited, AD role change revokes other sessions | @@ -73,7 +74,7 @@ This chapter covers the whole authentication/authorization core and, specificall | `tests/test_security_doc_drift.py` (~40) | The route-map meta-guard: every engine and `/ui` route appears in `docs/SECURITY.md` with its exact permission set **and** gate wrapper, both directions, with planted-mutation self-tests; permission catalogue == enum; role matrix == `BUILTIN_ROLE_PERMISSIONS`; ungated allow-lists pinned | | `tests/test_security_doc_rate_limits.py`, `tests/test_docs_security_pathways.py` | Every rate-limit setting documented with its shipped default; `/auth/providers` reports what is CONFIGURED, not what is reachable | | `tests/test_trust_anchors.py` (27) | SHA-256 anchor pin match/mismatch (refuses at both enforcement levels), `icacls` + POSIX writability detection, dormant when unconfigured, baseline/changed/pin-mismatch audit rows | -| `tests/test_bootstrap_admin_perms.py` (4), `tests/test_dr_rbac.py` (4), `tests/test_field_authz*.py` | Bootstrap file permissions / symlink refusal, `DR_OPERATE` gating, field-level PHI redaction + metadata drift guard | +| `tests/test_dr_rbac.py` (4), `tests/test_field_authz*.py` | `DR_OPERATE` gating, field-level PHI redaction + metadata drift guard | | `tests/test_approvals.py` (9) | Dual-control maker-checker release for gated high-value actions (ASVS 2.3.5) | | `tests/test_admin_new_ip.py` (11) | New-client-IP force-step-up, re-anchor on reauth and on `verify_mfa`, dedupe, loopback equivalence, never overrides RBAC, default-OFF no-op | | `tests/test_verify_federation.py` (17) | Offline `verify --section federation`: pinned endpoints as MANUAL, secret resolution + TLS-context build PASS/FAIL, a captured `id_token` replayed through the real ladder with a verdict per rung | @@ -170,7 +171,6 @@ Nine of the eighteen P0 rows sit behind the AD campaign gate, and that gate is * | AUTH-41 | Derived drift guard over the two hand-maintained gate exemption sets | Negative/Security | pytest | dev-PC | n/a | T | P2 | Every entry of `_MUST_CHANGE_EXEMPT_PATHS` and `_MFA_EXEMPT_ROUTES` resolves to a live `(method, path)` in `create_app()`'s route table; a stale entry fails; a newly added entry without a reviewed reason string fails; the existing shape assertions in `tests/test_mfa_access_gate.py:145` still hold | | AUTH-42 | Per-connection scope enforcement on the server backends | Cross-backend | pytest | container-CI | x2 | T | P1 | On SQL Server and PostgreSQL, a scoped identity gets 200 on its own connection's list/detail/replay/purge/connection-control/graph-edges and 403 on another's — the same six assertions `tests/test_channel_rbac.py` makes on SQLite | | AUTH-43 | mTLS client-cert → `Identity` mapping matrix (ADR 0083) | Negative/Security | pytest | dev-PC | SQLite | T | P1 | Unmapped subject → no identity (401); a CN spoofing a pinned DNS SAN → no identity; a mapped but **disabled** account → 401; `require_service_cert` refuses at **app build** to gate any route asking for `MESSAGES_VIEW_SUMMARY`/`_VIEW_RAW`; a cert identity can never satisfy a step-up or MFA gate | -| AUTH-44 | `bootstrap-admin.txt` handling on the box | Usability | manual | W2025-box | SQLite | T | P2 | Captured on first `serve`, then rotated and the file deleted or ACL'd to the service identity only; the password never appears in the general log. (Closes `docs/testing/WIN2025-TEST-PLAN.md` Appendix E **`W25:M-BOOTSTRAP`** — record there) | | AUTH-45 | Real roaming FIDO2 hardware key: enroll + assert, Chrome **and** Firefox | Functional | browser | browser-matrix | SQLite | T | P1 | Against an off-loopback engine with `[api].public_origin` set: enrollment behind the password re-proof succeeds and the credential lands in the store with a sign count; a subsequent assertion satisfies the MFA leg; the RP id derived from `public_origin` matches or the ceremony fails **legibly** (not a 500); both browsers recorded separately | | AUTH-46 | Windows Hello platform authenticator (scope decision pending — see Q11) | Functional | browser | browser-matrix | SQLite | C | P2 | If in scope: a platform authenticator enrolls and asserts on the same engine, and the credential is distinguishable from the roaming key in `GET /me/mfa`. If out of scope: recorded as a declined dimension with the reason | | AUTH-47 | TOTP enrollment with a real authenticator app | Functional | manual | W2025-box | SQLite | T | P2 | A current code admits; a wrong code and a code from the previous 30 s step are both rejected (`totp_skew_steps=0`); enrollment is audited. (Closes `docs/testing/WIN2025-TEST-PLAN.md` **`W25:S1.AC-MFA`** — record there) | @@ -186,7 +186,7 @@ Nine of the eighteen P0 rows sit behind the AD campaign gate, and that gate is * | AUTH-57 | Off-box collector actually receives the teed rows from the box | Functional | manual | W2025-box | x3 | T | P2 | The collector shows the run window's auth + PHI-access rows with matching `row_hash` values and non-NULL client addresses; no PHI body is present in any received record | | AUTH-58 | Trust-anchor preflight against the real AD FS / AD CS PEMs | Negative/Security | pytest + manual | dev-PC, AD-lab | SQLite | T | P2 | A configured `ad_tls_ca_cert_pin` / `oidc_tls_ca_cert_pin` that does not match the PEM **refuses at both enforcement levels**; a group-/world-writable anchor refuses at `[security].enforcement = enforce` and warns + audits at `warn`; an anchor change writes exactly one `auth.trust_anchor` row; with no anchor configured the preflight is dormant (zero rows) | | AUTH-59 | Engine-shard locality of the in-process auth budgets, fail-safe direction | Negative/Security | pytest | dev-PC | SQLite | T | P1 | Two `AuthService` instances over **one unified store**: an ADR 0077 action grant minted on A is **not** honoured on B (B re-prompts, never passes); a WebAuthn challenge started on A cannot be completed on B; a reconcile strike recorded on A is not visible to B. Every miss is a re-prompt, never a bypass | -| AUTH-60 | Engine-shard: the store-backed controls are **not** multiplied | Negative/Security | pytest | dev-PC | SQLite | T | P1 | Across the same two instances: the account lockout counter (5/15 min), the per-user session cap (5) and the bootstrap-admin expiry are shared — 3 failures on A plus 2 on B locks the account; a 6th session on B evicts A's oldest. Confirms the split stated at `docs/SECURITY.md:1538-1541` | +| AUTH-60 | Engine-shard: the store-backed controls are **not** multiplied | Negative/Security | pytest | dev-PC | SQLite | T | P1 | Across the same two instances: the account lockout counter (5/15 min) and the per-user session cap (5) are shared — 3 failures on A plus 2 on B locks the account; a 6th session on B evicts A's oldest. Confirms the split stated at `docs/SECURITY.md:1538-1541` | | AUTH-61 | Real-DOM `/ui` auth surface | Usability | browser | browser-matrix | SQLite | T | P1 | In a real browser: the login form renders and submits under the shipped CSP; the `__Host-` session cookie is actually stored with `Secure`+`SameSite=Strict`; the SSO link is hidden when the provider is degraded and no dead link is reachable; the OIDC 200 + meta-refresh landing navigates; the RFC 4559 401 is answered. Recorded per browser | | AUTH-62 | Advisory mutation + diff coverage scoped to `messagefoundry/auth/` and `api/security.py` | Compat | CI-leg | container-CI | n/a | C | P2 | The drafted jobs from `docs/quality-gates/HANDOFF-mutation-coverage.md` run advisory (`--exit-zero`), publish a surviving-mutant list for the auth scope, and never block a merge; a planted `and`→`or` mutation inside `require()` appears in the surviving-mutant report or is killed | | AUTH-63 | Documentation-drift closure for this area | Compat | pytest + manual | dev-PC | n/a | T | P2 | `docs/FEATURE-MAP.md` no longer says `require_mfa` is Administrator-scoped (`:129`), no longer defers federated SSO to 0.2 (`:132`), and no longer says the PySide6 desktop console "stays (additive)" (`:130`); ADR 0045 custom roles appear in the map; the two stale reconcile-default comments are fixed (see AUTH-37); the `FCP:AUTHN-11` (`docs/testing/FEATURE-COVERAGE-PLAN.md:1066`) and `FCP:RBAC-17` (`:1113`) notes are corrected | diff --git a/docs/testing/master-test-plan/12-vs-code-ide-extension.md b/docs/testing/master-test-plan/12-vs-code-ide-extension.md index daa0734d..197d831a 100644 --- a/docs/testing/master-test-plan/12-vs-code-ide-extension.md +++ b/docs/testing/master-test-plan/12-vs-code-ide-extension.md @@ -137,7 +137,7 @@ TypeScript and Python, and the **delivery vehicle**. | `engineLog.logState`/`logAction` (`engineLog.ts:79-92`) have no test; nothing forbids a caller passing a token/username/server `detail` as `detail` | A bearer token or attacker-controlled FastAPI `detail` lands in a channel users paste into public bug reports | Credential leak into a public issue | No — only `logProbe`'s route allowlist is indirectly anchored | P1 | | `home.ts:88-93` executes an **arbitrary** command id posted by the webview — no `CMD` allowlist, unlike `engineSetup.ts` | Any future path that gets attacker-influenced text into that webview escalates to command execution; the pattern gets copied into the next webview | Command execution from webview content | No | P1 | | `liveStatus.ts` is a **second, unpinned** poller (`:69-102`) | A regression promoting the background poll to an authenticating call refreshes the engine idle clock on a timer | The 30-minute idle timeout becomes unreachable (CWE-613) — the defect ADR 0110 AC-3 froze for `statusBar` only | No | P1 | -| `statusBar.ts` shell untested (ADR 0112 concedes it): `controlContext` trust+loopback gate (`:446-462`), trust refusal (`:516`), no-store fork-guard modal (`:524-535`), double-launch guard (`:490-501`), terminal ownership for Stop | Creating a rogue empty engine database with a fresh bootstrap admin; or terminating an engine the IDE does not own | Destructive and operator-invisible | No | P1 | +| `statusBar.ts` shell untested (ADR 0112 concedes it): `controlContext` trust+loopback gate (`:446-462`), trust refusal (`:516`), no-store fork-guard modal (`:524-535`), double-launch guard (`:490-501`), terminal ownership for Stop | Creating a rogue empty engine database; or terminating an engine the IDE does not own | Destructive and operator-invisible | No | P1 | | Activation is `onLanguage:python` + `workspaceContains:**/*.py`, and `validate.ts:29` raises an **error toast** when the CLI is unavailable | Every unrelated Python repo gets three subprocess launches and a red "MessageFoundry: validate failed" toast on open and on every save | The most visible install-quality defect; the most likely one-star review | No | P1 | | `debugpy` is an undeclared dependency (`testBench.ts:437` launches `type: "debugpy"`; `ide/package.json` declares no `extensionDependencies`) | Without `ms-python.python`, the advertised Test Bench "Debug" button fails with a raw VS Code error | A broken advertised feature on a fresh install | No | P1 | | The `test:unit` `--ignore` list (`package.json:822`) is hand-maintained, and the split is by **transitive** `vscode` import (e.g. `promote-target.test.ts` is excluded only because `promoteTarget.ts` imports a value from `cli.ts`) | A new test file added without an entry reds the ubuntu leg; a file that stops importing `vscode` stays Windows-only forever. On a fork (ubuntu-only matrix) the 86 excluded tests — **including every ADR 0035 test** — run nowhere | Security tests silently stop running | No | P1 | @@ -460,7 +460,8 @@ synthetic bodies, but the residue itself is the defect to report. half is the *unreachable-engine* branch, where the local token must still be forgotten (`auth.ts:73-86`'s `finally`). Getting this wrong makes "signed out" a lie on a shared workstation. -**Preconditions.** A real loopback engine with auth enabled and a bootstrap admin: +**Preconditions.** A real loopback engine with auth enabled and an administrator created by +`messagefoundry admin-create`: `python -m messagefoundry serve --config samples/config --db ./messagefoundry.db --env dev`. Credentials come from the environment (`MEFOR_*`), never from a file in the repo. @@ -661,7 +662,7 @@ testable one. **Services and accounts** -- **A loopback MessageFoundry engine**, auth-enabled with a bootstrap admin, for IDE-10, IDE-52, +- **A loopback MessageFoundry engine**, auth-enabled with an `admin-create` administrator, for IDE-10, IDE-52, IDE-61 and the status-pill/deep-probe paths: `python -m messagefoundry serve --config samples/config --db ./messagefoundry.db --env dev`. Scratch store only — never a real one. diff --git a/docs/testing/master-test-plan/15-alerting-and-observability.md b/docs/testing/master-test-plan/15-alerting-and-observability.md index 76f21c5c..8b49349d 100644 --- a/docs/testing/master-test-plan/15-alerting-and-observability.md +++ b/docs/testing/master-test-plan/15-alerting-and-observability.md @@ -74,7 +74,7 @@ can suffer, (2) the telling is **routable, suppressible and clearable** by rule | Evidence | What it proves | |---|---| -| `tests/test_alert_rules.py` (422 lines, ~28 cases) | `AlertRuleSet` matcher: event-type / connection glob / `min_depth` / `min_oldest_seconds` / AND-conjunction / first-match-wins / case-sensitive glob; severity tag; transport subset; `[]` suppression; cooldown override; factory fail-loud on an unconfigured transport; model validation. Lines 357/370/381/395 pin `lane_stuck`, `rcsi_off_degraded` and `bootstrap_admin_expiring` as rule-targetable end to end | +| `tests/test_alert_rules.py` (422 lines, ~28 cases) | `AlertRuleSet` matcher: event-type / connection glob / `min_depth` / `min_oldest_seconds` / AND-conjunction / first-match-wins / case-sensitive glob; severity tag; transport subset; `[]` suppression; cooldown override; factory fail-loud on an unconfigured transport; model validation. It pins `lane_stuck` and `rcsi_off_degraded` as rule-targetable end to end | | `tests/test_alert_sinks.py` (342 lines, 22 cases) | Fan-out to every transport; one failing transport does not starve the others; re-alert throttle; suspend / resume / window-expiry; per-rule mute; the no-message-body payload assertion; webhook JSON POST; SMTP send; SMTP allowlist refusal; `notifier_from_settings` variants; webhook URL length bound | | `tests/test_alert_state.py` (548 lines) | ADR 0044 lifecycle: first-fire open, refire dedupe on the throttle key, ack / resolve / reopen, auto-resolve on an inverse, `count_open_by_connection`, `reason` encrypted at rest, purge resolved-only, `escalation_tier` persisted monotonic, suspend/resume durable, side-observer never pins a disposition, a state-write failure never raises, three-backend method + column parity | | `tests/test_alert_escalation.py` (211 lines) | ADR 0133 AC-1..AC-4: occurrence escalation, highest-satisfied-tier wins, schedule-aware `decide`, `content_label` routing, `content_match` PHI-free payload, `content_match` re-emit idempotence — **all at the sink, none through a Handler** (see gap G2) | diff --git a/ide/src/engineControlModel.ts b/ide/src/engineControlModel.ts index 8c8a2d03..f3374160 100644 --- a/ide/src/engineControlModel.ts +++ b/ide/src/engineControlModel.ts @@ -72,7 +72,7 @@ export function classifyPreflight(result: { /** * Whether a REAL engine store already lives where `serve` would run. Pure over what the shell reads from * the run directory. When false, Start must not silently create a store — it confirms it will make a NEW - * database + bootstrap admin (the ADR 0110 §5 fork hazard, now an explicit, labelled choice). + * empty database with no accounts (the ADR 0110 §5 fork hazard, now an explicit, labelled choice). * * The heuristic is deliberately permissive-of-presence: the service TOML OR any `*.db` file is enough to * say "an engine already lives here", because either one means a `serve` run here adopts an existing engine diff --git a/ide/src/engineSetupContent.ts b/ide/src/engineSetupContent.ts index 1e0ea39e..82733f0e 100644 --- a/ide/src/engineSetupContent.ts +++ b/ide/src/engineSetupContent.ts @@ -98,8 +98,8 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ // The amendment's conditional truth, near-verbatim: the command is palette-visible and this page // is context-blind, so the copy states BOTH outcomes (runDirHasEngine guards only the store-less // launch — a has-store launch shows no modal). - "If no engine store exists here, you'll be asked to confirm creating a NEW database and a " + - "bootstrap admin; if one exists, this starts that engine.", + "If no engine store exists here, you'll be asked to confirm creating a NEW empty database " + + "with no accounts; if one exists, this starts that engine.", ], button: { id: "start-dev-engine", diff --git a/ide/src/engineStatusModel.ts b/ide/src/engineStatusModel.ts index ddefcf35..c718d398 100644 --- a/ide/src/engineStatusModel.ts +++ b/ide/src/engineStatusModel.ts @@ -635,7 +635,7 @@ export const CMD = { * - `canControl` — the target is loopback AND the workspace is trusted AND there is a workspace to run in * (a remote engine reads its OWN filesystem; an untrusted workspace must not exec a repo-supplied venv). * - `hasStore` — a real engine store already lives where `serve` would run; when false, Start does not - * silently create one — the shell confirms it will make a NEW database + bootstrap admin first. + * silently create one — the shell confirms it will make a NEW empty database first. * - `weStartedIt`— the IDE owns a LIVE engine process it launched; only then may Stop/Restart act. We never * kill an engine we did not start (with parallel worktrees a port-kill could down another session's). */ diff --git a/ide/src/statusBar.ts b/ide/src/statusBar.ts index f1cd4024..c8f79d43 100644 --- a/ide/src/statusBar.ts +++ b/ide/src/statusBar.ts @@ -441,7 +441,7 @@ export class EngineStatusBar implements vscode.Disposable { // ── Engine lifecycle (ADR 0112 — supersedes ADR 0110 §5) ───────────────────────────────────────── // The IDE may now RUN `serve`, not just hand over the command. Two safety properties, both enforced // here, keep this from reintroducing ADR 0110 §5's fork hazard (a stray `serve` that creates a rogue - // empty database + bootstrap admin): it runs ONLY for a loopback target in a trusted workspace, and it + // empty database with no accounts): it runs ONLY for a loopback target in a trusted workspace, and it // never SILENTLY creates a store — a run dir with no engine gets an explicit "create a new one" confirm. // Stop/Restart act ONLY on a process this IDE owns — never a port-kill of an engine started elsewhere. @@ -530,11 +530,13 @@ export class EngineStatusBar implements vscode.Disposable { notify("the engine is already running from here — use Restart to reload it."); return; } - // Fork guard (ADR 0110 §5): with no engine store here, starting would create a NEW database and a fresh - // bootstrap admin. That is sometimes exactly what the user wants — but it must be a deliberate choice. + // Fork guard (ADR 0110 §5): with no engine store here, starting would create a NEW, EMPTY database + // — no accounts (BACKLOG #1020 retired the implicit first-run admin), so the forked engine is one + // nobody can sign into until `messagefoundry admin-create` runs against it. That is sometimes + // exactly what the user wants — but it must be a deliberate choice. if (!this.controlContext().hasStore) { const go = await vscode.window.showWarningMessage( - `No engine store found in ${ws}. Starting here creates a NEW database and a bootstrap admin. Continue?`, + `No engine store found in ${ws}. Starting here creates a NEW empty database with no accounts. Continue?`, { modal: true }, "Create new engine", ); @@ -830,7 +832,7 @@ export function registerEngineStatusBar(context: vscode.ExtensionContext): Engin vscode.commands.registerCommand("messagefoundry.engineCopyStartCommand", async () => { // Deliberately COPY, not run. A terminal spawned here defaults its cwd to the workspace folder — // in a git worktree that has no service TOML and no store, so `serve` would quietly create a - // BRAND-NEW empty database and a fresh bootstrap admin, forking the user's engine. Handing them + // BRAND-NEW empty database with no accounts, forking the user's engine. Handing them // the command lets them run it where they mean to. No --db/--env overrides: the service TOML is // the authority on which store and which environment this engine is. const cmd = `python -m messagefoundry serve --config ${configDir()}`; diff --git a/ide/src/test/suite/engine-setup.test.ts b/ide/src/test/suite/engine-setup.test.ts index 43b1d0a2..6ccf5d12 100644 --- a/ide/src/test/suite/engine-setup.test.ts +++ b/ide/src/test/suite/engine-setup.test.ts @@ -80,10 +80,12 @@ suite("engine setup page — the test-only dev engine is separated and context-h const dev = SETUP_SECTIONS.find((s) => s.tone === "dev"); assert.ok(dev, "the dev-engine section is missing"); const body = dev.body.join(" "); - // No-store half: the modal create-DB confirm (runDirHasEngine guards only this case). + // No-store half: the modal create-DB confirm (runDirHasEngine guards only this case). BACKLOG + // #1020 retired the implicit first-run admin, so the promise is an EMPTY database — a forked + // engine nobody can sign into until `messagefoundry admin-create` runs against it. assert.ok( - /NEW database and a bootstrap admin/i.test(body), - "must state that a store-less launch confirms creating a NEW database and a bootstrap admin", + /NEW empty database with no accounts/i.test(body), + "must state that a store-less launch confirms creating a NEW empty database with no accounts", ); // Has-store half: a launch where a store exists shows no modal — it just starts that engine. assert.ok( diff --git a/ide/src/test/suite/engine-status.test.ts b/ide/src/test/suite/engine-status.test.ts index f0887b58..15d1bc08 100644 --- a/ide/src/test/suite/engine-status.test.ts +++ b/ide/src/test/suite/engine-status.test.ts @@ -79,7 +79,7 @@ suite("engineStatusModel — the tokenless /health probe cannot lie", () => { test("version present ⇒ unverified — NOT ok, and NOT green", () => { // The other half of the same bug. `optional_identity` applies no RBAC and no must-change gate, so a - // version merely proves that *a* session exists — a bootstrap admin locked out of every route still + // version merely proves that *a* session exists — a must-change account locked out of every route still // gets one back. Only a protected route can earn `ok` (see classifyDeep). const link = classifyHealth({ kind: "ok", body: { status: "ok", version: "0.3.0" } }, T, NOW, false); assert.strictEqual(link.state, "unverified"); diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index cac6da31..b7a6d81d 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -454,6 +454,37 @@ def main(argv: list[str] | None = None) -> int: ) init.add_argument("--json", action="store_true", help="emit JSON") + admin_create = sub.add_parser( + "admin-create", + help="create a local Administrator directly on the engine's store — the explicit, " + "operator-invoked route to the FIRST account on a fresh install (there is no implicit " + "first-run account: ASVS 6.3.2, BACKLOG #1020). Run it on the box, with the engine stopped " + "or running, then sign in to the console", + ) + admin_create.add_argument("--username", required=True, help="the account name to create") + admin_create.add_argument("--display-name", default=None, help="optional display name") + admin_create.add_argument( + "--email", + default=None, + help="optional address for the out-of-band security notices (lockout, password/roles change). " + "Without one those notices have no mailbox to reach and only the audited " + "GET /me/security-events feed records them", + ) + admin_create.add_argument( + "--db", default=None, help="store path (overrides [store].path for this run)" + ) + admin_create.add_argument( + "--service-config", + default="messagefoundry.toml", + help="service settings TOML the [store] + [auth] sections come from", + ) + admin_create.add_argument( + "--password-stdin", + action="store_true", + help="read the password from stdin (first line) instead of prompting — for an unattended " + "install. The password is NEVER an argv flag: argv is readable by every process on the box", + ) + support_bundle = sub.add_parser( "support-bundle", help="write a SECRET-FREE / PHI-free support zip (engine version + config summary + a " @@ -3936,6 +3967,114 @@ async def run() -> tuple[int, str]: return 0 +def _read_new_password(from_stdin: bool) -> str | None: + """The password for ``admin-create``: one stdin line, or a confirmed no-echo prompt. + + Never an argv flag. On every supported platform ``/proc//cmdline``, ``ps`` or the Windows + process list makes another user's argv readable, so a ``--password`` option would publish the + engine's most privileged credential to every account on the box for the life of the process. + Returns ``None`` (message already printed) when the operator supplied nothing usable.""" + import getpass + + if from_stdin: + password = sys.stdin.readline().rstrip("\r\n") + else: + password = getpass.getpass("New administrator password: ") + if password != getpass.getpass("Confirm password: "): + print("error: the two passwords did not match", file=sys.stderr) + return None + if not password: + print("error: no password supplied", file=sys.stderr) + return None + return password + + +def _admin_create(args: argparse.Namespace) -> int: + """Create a local Administrator on the engine's store (BACKLOG #1020). + + This is the ONLY route to the first account. A fresh store has no users at all: the implicit + first-run ``admin`` was retired because ASVS 6.3.2 wants no default account to exist, and the two + could not both hold. What replaces it is explicit rather than implicit — an operator standing at + the box names the account and chooses its password, so nothing privileged exists until somebody + decides it should, and no credential is written to disk for a later reader to find. + + Runs against the SAME resolved settings ``serve`` uses, so the password is held to the deployment's + own ``[auth]`` policy rather than a CLI-local default. The role is Administrator: the command exists + to break the chicken-and-egg (``POST /users`` needs ``users:manage``, which needs an account), and a + lesser role would not. Creating further accounts is the API's job, not this command's.""" + import asyncio + + from pydantic import ValidationError + + from messagefoundry.auth.permissions import Role + from messagefoundry.auth.service import AuthService + from messagefoundry.config.secretprovider import resolve_secret_provider + from messagefoundry.config.settings import StoreBackend, load_settings + from messagefoundry.store.base import open_store + + cli: dict[str, dict[str, object]] = {} + if args.db is not None: + cli.setdefault("store", {})["path"] = args.db + try: + settings = load_settings(config_path=args.service_config, cli=cli) + except (FileNotFoundError, ValueError, ValidationError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + password = _read_new_password(args.password_stdin) + if password is None: + return 2 + + async def run() -> tuple[int, str]: + # Same provider serve resolves, so an [auth] secret reference (e.g. the AD bind password) + # behaves identically here — the CLI must not be a second, laxer configuration path. + secret_provider = resolve_secret_provider(settings.secrets) + store = await open_store(settings.store) + try: + auth = AuthService(store, settings.auth, secret_provider=secret_provider) + await auth.initialize() # seeds the built-in roles; creates no account + if await store.get_user_by_username(args.username) is not None: + return 2, f"error: a user named {args.username!r} already exists" + violations = auth.password_violations(password, username=args.username) + if violations: + return 2, "error: password must " + "; ".join(violations) + await auth.create_local_user( + username=args.username, + password=password, + display_name=args.display_name, + email=args.email, + roles=[Role.ADMINISTRATOR.value], + actor="cli", + # The operator IS the account holder here, so there is no second party a forced + # rotation would protect — unlike an admin-issued credential via POST /users. + must_change_password=False, + ) + finally: + await store.close() + return 0, "" + + rc, message = asyncio.run(run()) + if rc != 0: + print(message, file=sys.stderr) + return rc + # Name the store that was actually written. A --db / [store].path / MEFOR_STORE_* typo otherwise + # surfaces only as a login failure against the engine's REAL database, long after the cause. + store = settings.store + where = ( + repr(store.path) + if store.backend is StoreBackend.SQLITE + else f"{store.database!r} on {store.server!r}" + ) + print(f"created Administrator {args.username!r} in the {store.backend.value} store at {where}") + if not args.email: + print( + "note: no --email — the out-of-band security notices for this account have no mailbox " + "to reach. Set one from the console (Users -> edit) to enable them.", + file=sys.stderr, + ) + return 0 + + def _rekey_audit(args: argparse.Namespace) -> int: """Enable HMAC keying of an EXISTING keyless audit chain (#190-D migration). @@ -4910,6 +5049,7 @@ def _emit_error(message: str, *, as_json: bool) -> int: "supervise": _supervise, "import": _import, "init": _init, + "admin-create": _admin_create, "validate": _validate, "graph": _graph, "dryrun": _dryrun, diff --git a/messagefoundry/api/app.py b/messagefoundry/api/app.py index 216acf14..e41e701a 100644 --- a/messagefoundry/api/app.py +++ b/messagefoundry/api/app.py @@ -24,7 +24,6 @@ import asyncio import base64 import binascii -import datetime import json import logging import mimetypes @@ -182,7 +181,7 @@ # behavior is preserved via three seams the console installs: app.state.ui_csp, # app.state.ui_ws_authorize, app.state.ui_connections_render (read by the always-on middleware/routes). from messagefoundry.auth import Identity, Permission -from messagefoundry.auth.service import AuthService, BootstrapAdmin +from messagefoundry.auth.service import AuthService from messagefoundry.auth.trust_anchors import ( AnchorSpec, TrustAnchorError, @@ -262,7 +261,7 @@ from messagefoundry.parsing.sniff import attachment_mime_agrees, nontext_upload_reason from messagefoundry.pipeline import ConfigReloadDenied, Engine from messagefoundry.pipeline.alert_sinks import EmailTransport, notifier_from_settings -from messagefoundry.pipeline.alerts import AlertSink, LoggingAlertSink +from messagefoundry.pipeline.alerts import LoggingAlertSink from messagefoundry.pipeline.cluster import build_coordinator from messagefoundry.pipeline.connscale_shim import maybe_install_executor_shim from messagefoundry.pipeline.dr import DrActivationError @@ -289,7 +288,6 @@ make_spec, ) from messagefoundry.store.metadata import user_metadata -from messagefoundry.store.store import _secure_file from messagefoundry.transports.ai_broker import AiBrokerError, ai_broker_from_settings from messagefoundry.transports.base import ( DeliveryError, @@ -5129,54 +5127,6 @@ def _oidc_authorization_host(endpoint: str) -> str: return app -def _emit_bootstrap_admin(bootstrap: BootstrapAdmin, store_settings: StoreSettings) -> None: - """Persist the one-time bootstrap password to a restricted file — never the rotating log. - - Until rotated it is a standing Administrator credential, so it must not land in NSSM's broadly - readable stdout capture. Write it to an owner-only file the operator consumes and deletes; log - only the location. Paired with server-side must_change_password enforcement, it dies at first login. - """ - base = Path(store_settings.path or ".").resolve() - secret_file = base.parent / "bootstrap-admin.txt" - body = f"username: {bootstrap.username}\npassword: {bootstrap.password}\n" - # ASVS 6.4.5: state the renewal deadline WITH the credential — an unclaimed bootstrap is - # auto-disabled at this instant, so the "sign in and change it before then" instruction ships - # alongside the secret rather than being an out-of-band assumption. None when expiry is off. - deadline = ( - datetime.datetime.fromtimestamp(bootstrap.expires_at, tz=datetime.UTC).isoformat() - if bootstrap.expires_at is not None - else None - ) - if deadline is not None: - body += ( - f"expires: {deadline} — sign in and change this password before then, " - "or the unclaimed credential is disabled.\n" - ) - # Create the file owner-only from the instant it exists, closing the POSIX create-then-chmod TOCTOU - # (SEC-020): O_EXCL + 0o600 means the secret is never group/world-readable even momentarily, and - # O_EXCL also refuses to follow a pre-planted symlink/file at that path. A second service start - # before the operator deletes the prior file would hit FileExistsError — remove the stale file we - # own, then re-create exclusively. - flags = os.O_CREAT | os.O_WRONLY | os.O_EXCL | os.O_TRUNC - try: - fd = os.open(str(secret_file), flags, 0o600) - except FileExistsError: - secret_file.unlink() # the prior owner-only file we wrote; replace it under the same mode - fd = os.open(str(secret_file), flags, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(body) - # On Windows os.open's mode is minimal, so still apply the icacls owner-only DACL (the store's - # platform-correct primitive: chmod on POSIX is a no-op here since O_EXCL already set 0o600). - _secure_file(secret_file) - _log.warning( - "Created bootstrap admin %r; one-time password written to %s — sign in, change it, then " - "delete that file%s.", - bootstrap.username, - secret_file, - f" (expires {deadline} unless claimed)" if deadline is not None else "", - ) - - _SESSION_REAP_INTERVAL = 3600.0 # purge expired/idle sessions hourly to bound the sessions table @@ -5217,34 +5167,6 @@ async def _directory_reconciler(auth: AuthService, interval: float) -> None: _log.exception("directory reconcile: pass failed; will retry next interval") -_BOOTSTRAP_EXPIRY_REMINDER_INTERVAL = 3600.0 # re-check the bootstrap warn window hourly - - -async def _bootstrap_expiry_reminder(auth: AuthService, sink: AlertSink) -> None: - """Remind an operator, ONCE, that an UNCLAIMED first-run bootstrap admin is nearing its auto-disable - deadline (ASVS 6.4.5 arm 2). API-lifespan-owned (like :func:`_session_reaper`), NOT engine-owned — it - reaches the :class:`AuthService` directly. ``auth.bootstrap_expiry_warning()`` evaluates the warn - window and latches once-per-process; a non-None result is the fresh reminder to emit as the PHI-free - ``bootstrap_admin_expiring`` alert (the ISO deadline + whole hours remaining — never the password). - - A transient store error must not kill the loop for the process lifetime (that would silently drop the - reminder) — log and retry next interval, the session-reaper precedent.""" - while True: - try: - warning = await auth.bootstrap_expiry_warning() - if warning is not None: - expires_at, hours_remaining = warning - iso = datetime.datetime.fromtimestamp(expires_at, tz=datetime.UTC).isoformat() - sink.bootstrap_admin_expiring( - "bootstrap-admin", expires_at=iso, hours_remaining=hours_remaining - ) - except asyncio.CancelledError: - raise - except Exception: - _log.exception("bootstrap expiry reminder: pass failed; will retry next interval") - await asyncio.sleep(_BOOTSTRAP_EXPIRY_REMINDER_INTERVAL) - - def create_managed_app( *, db_path: str | Path | None = None, @@ -5332,8 +5254,9 @@ def create_managed_app( Pass ``store_settings`` for full backend selection (the service path), or ``db_path`` (+optional ``synchronous``) as a SQLite shortcut. ``config_dir`` loads the code-first Connection/Router/ - Handler graph. ``auth_settings`` (when enabled) attaches an :class:`AuthService`, seeds the - built-in roles, and creates a bootstrap admin on first run. The store is opened via the + Handler graph. ``auth_settings`` (when enabled) attaches an :class:`AuthService` and seeds the + built-in roles; it creates NO account (BACKLOG #1020 — the first Administrator comes from + ``messagefoundry admin-create``, never from a first run). The store is opened via the backend-agnostic :func:`~messagefoundry.store.open_store`. ``api_listener`` is the engine's own ``(host, port)`` (from ``[api]``), reserved so no inbound listener can be wired onto the API's port — the CLI server passes it; in-process/test callers omit it (no separate API socket is bound). @@ -5633,7 +5556,6 @@ async def _audit_upload_prune(meta: UploadedFileMeta) -> None: upload_retention_runner.start() reaper: asyncio.Task[None] | None = None reconciler: asyncio.Task[None] | None = None - bootstrap_reminder: asyncio.Task[None] | None = None security_notifier = None # Back the COMPLETE loosening list on GET /security/posture: [auth] carries posture switches # (ad_session_recheck_seconds) that security_loosenings() must see. Stashed here, OUTSIDE the @@ -5676,10 +5598,8 @@ async def _audit_upload_prune(meta: UploadedFileMeta) -> None: # connector-construction gate, so the clamp is inert unless the posture arrives here). hop_posture=_hop_posture, ) - bootstrap = await auth.initialize() + await auth.initialize() app.state.auth = auth - if bootstrap is not None: - _emit_bootstrap_admin(bootstrap, resolved) if not auth.webauthn_available() and await store.any_webauthn_credentials(): # L5b (ADR 0068 decision 5): enrolled passkeys exist but the [webauthn] extra is # not installed (engine moved/reinstalled, same DB) — affected users stay @@ -5724,14 +5644,6 @@ async def _audit_upload_prune(meta: UploadedFileMeta) -> None: auth_settings.oidc_redirect_path, ) reaper = asyncio.create_task(_session_reaper(store)) - if auth_settings.bootstrap_expiry_hours > 0: - # ASVS 6.4.5 arm 2: nudge an operator BEFORE an unclaimed first-run bootstrap admin is - # auto-disabled. API-lifespan-owned (like the session reaper), NOT engine-owned — it - # reaches the AuthService directly. The warn method latches once-per-window; the sink logs - # (LoggingAlertSink fallback) or notifies. No task when time-expiry is off (byte-identical). - bootstrap_reminder = asyncio.create_task( - _bootstrap_expiry_reminder(auth, notifier or LoggingAlertSink()) - ) if auth.directory_reconcile_enabled: # ADR 0079 mechanism 2: propagate an AD disable/delete to live engine sessions. # Default OFF (ad_session_recheck_seconds = 0) — no task, no behaviour change. @@ -5760,11 +5672,6 @@ async def _audit_upload_prune(meta: UploadedFileMeta) -> None: # previously-died reaper stored, so it can't propagate here and skip engine.stop() # (review M-33). await asyncio.gather(reaper, return_exceptions=True) - if bootstrap_reminder is not None: - bootstrap_reminder.cancel() - # gather(return_exceptions): absorb our cancellation + any stored exception so it can't - # propagate here and skip engine.stop() (the reaper precedent). - await asyncio.gather(bootstrap_reminder, return_exceptions=True) await engine.stop() # B11: shut down the harness-only instrumented executor (None in production / other tests). # The engine is stopped (no more to_thread work), so a non-blocking shutdown is clean. diff --git a/messagefoundry/api/auth_routes.py b/messagefoundry/api/auth_routes.py index c1a0a68a..f8ff4007 100644 --- a/messagefoundry/api/auth_routes.py +++ b/messagefoundry/api/auth_routes.py @@ -434,8 +434,12 @@ async def disable_my_mfa( ) -> SimpleMessage: """Self-service: turn off the caller's TOTP MFA. Step-up gated — you prove your current factor (a TOTP or recovery code via ``/auth/mfa-verify``) and a fresh password BOUND to this disable - action (ADR 0077): a hijacked session inside the login window can't silently strip MFA.""" - await service.disable_mfa(identity, client=_client(request)) + action (ADR 0077): a hijacked session inside the login window can't silently strip MFA. **400** + when TOTP is your last enrolled factor and MFA is still required (ADR 0068 AC-10).""" + try: + await service.disable_mfa(identity, client=_client(request)) + except ValueError as exc: # last-required-factor refusal — mirrors the confirm 400 above + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc return SimpleMessage(detail="MFA disabled") # --- self-service session inventory (WP-10, ASVS 7.5.2/7.4.5) ------------- diff --git a/messagefoundry/auth/service.py b/messagefoundry/auth/service.py index 70f2c50e..b8717e4e 100644 --- a/messagefoundry/auth/service.py +++ b/messagefoundry/auth/service.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 MessageFoundry Organization and contributors -"""AuthService — orchestrates authentication, sessions, role resolution, and first-run bootstrap. +"""AuthService — orchestrates authentication, sessions, and role resolution. Pure engine-side code (no FastAPI): the API layer composes it. It ties together the store (users, roles, sessions, audit), password hashing/policy, opaque session tokens, and the LDAP/Kerberos @@ -67,9 +67,6 @@ _log = logging.getLogger(__name__) -#: The account created on first run when the store has no users (HIPAA unique-user bootstrap). -BOOTSTRAP_USERNAME = "admin" - def _warn_if_corpus_unreadable(path: str | None) -> None: """Eagerly load (and cache) an operator breach corpus at startup so a misconfigured path surfaces @@ -169,18 +166,6 @@ class MfaStatus: webauthn_enrolled: bool = False -@dataclass(frozen=True) -class BootstrapAdmin: - """Credentials for the one-time bootstrap admin (printed once, then must be changed).""" - - username: str - password: str - # ASVS 6.4.5: the instant an unclaimed bootstrap credential is auto-disabled - # (created_at + [auth].bootstrap_expiry_hours), or None when expiry is off (hours=0). Surfaced at - # issuance so the renewal instruction ("claim it before ") ships WITH the credential. - expires_at: float | None = None - - @dataclass(frozen=True) class CustomRoleInfo: """An admin-defined custom role and its resolved permission subset (ADR 0045).""" @@ -358,9 +343,6 @@ def __init__( self._reconcile_last_probed: dict[str, float] = {} #: Latched mass-revoke circuit-breaker trip, cleared by the next clean pass. self._reconcile_alert: str | None = None - #: ASVS 6.4.5 arm 2: once-per-process latch so the bootstrap-expiry reminder fires exactly once - #: while the unclaimed bootstrap sits inside its warn window (see :meth:`bootstrap_expiry_warning`). - self._bootstrap_expiry_warned = False # Advisory, NON-STICKY federated-IdP health (ADR 0142 AC-8) — see the oidc_available docstring. self._oidc_unavailable_reason: str | None = None self._oidc_client_secret: str | None = None @@ -510,13 +492,14 @@ async def audit_oidc_reject(self, reason: str) -> None: # --- lifecycle ----------------------------------------------------------- - async def initialize(self) -> BootstrapAdmin | None: - """Seed the built-in roles and, on an empty store, create the bootstrap admin. Also retires an - unclaimed bootstrap that became superseded/expired while the service was down (WP-3).""" + async def initialize(self) -> None: + """Seed the built-in roles. Creates **no account**. + + There is deliberately no implicit first-run administrator (ASVS 6.3.2 — no default accounts, + BACKLOG #1020, which closes as superseded by that verb rather than as built). A fresh store + therefore has zero users until an operator runs ``messagefoundry admin-create`` on the box, + which is the only route that mints the first Administrator and names it themselves.""" await self._seed_roles() - created = await self._ensure_bootstrap_admin() - await self._retire_superseded_bootstrap() - return created async def _seed_roles(self) -> None: for role in Role: @@ -525,36 +508,9 @@ async def _seed_roles(self) -> None: role_id=role.value, display_name=label, description=description, builtin=True ) - async def _ensure_bootstrap_admin(self) -> BootstrapAdmin | None: - if await self._store.count_users() > 0: - return None - password = self._generate_policy_password() - user_id = uuid4().hex - await self._store.create_user( - user_id=user_id, - username=BOOTSTRAP_USERNAME, - auth_provider=AuthProvider.LOCAL.value, - display_name="Bootstrap Administrator", - password_hash=await self._argon2(hash_password, password), - must_change_password=True, - ) - await self._store.set_user_roles( - user_id, [Role.ADMINISTRATOR.value], assigned_by="bootstrap" - ) - await self._audit("auth.bootstrap_admin_created", actor="bootstrap") - # ASVS 6.4.5: derive the expiry from the STORED created_at (the same base - # _retire_superseded_bootstrap uses), so the surfaced deadline is exactly the retirement instant. - expiry_hours = self._settings.bootstrap_expiry_hours - expires_at: float | None = None - if expiry_hours > 0: - created = await self._store.get_user(user_id) - if created is not None: - expires_at = created.created_at + expiry_hours * 3600 - return BootstrapAdmin(username=BOOTSTRAP_USERNAME, password=password, expires_at=expires_at) - def _generate_policy_password(self) -> str: - """A random password that satisfies the active policy — so the printed bootstrap credential - is held to the same bar operators are. ``token_urlsafe(n)`` yields ~1.33·n chars (so length is + """A random password that satisfies the active policy — so a machine-issued credential is held + to the same bar operators are. ``token_urlsafe(n)`` yields ~1.33·n chars (so length is guaranteed ≥ ``min_length``); the loop covers the astronomically-unlikely breach/context hit or an opt-in character-class requirement a given token happens to miss.""" length = max(16, self._policy.min_length) @@ -564,68 +520,6 @@ def _generate_policy_password(self) -> str: return candidate return secrets.token_urlsafe(length) + "aA1!" # defensive: satisfies any class requirement - async def _other_enabled_admin_exists(self, exclude_id: str) -> bool: - """True iff some enabled administrator other than ``exclude_id`` exists.""" - for user in await self._store.list_users(): - if user.disabled or user.id == exclude_id: - continue - if Role.ADMINISTRATOR.value in await self._store.get_user_role_ids(user.id): - return True - return False - - async def _retire_superseded_bootstrap(self, now: float | None = None) -> None: - """Disable the first-run bootstrap admin once it's no longer needed (WP-3): when a **second** - administrator exists, or — while still **unclaimed** (never password-changed) — once its expiry - window lapses. Only ever touches an unclaimed bootstrap (``must_change_password`` still set): if - the operator changed its password it is a normal admin account and is left alone, so this can't - lock out a legitimate single-admin deployment.""" - now = time.time() if now is None else now - boot = await self._store.get_user_by_username(BOOTSTRAP_USERNAME) - if boot is None or boot.disabled or not boot.must_change_password: - return # gone, already disabled, or claimed (a real account now) - expiry_hours = self._settings.bootstrap_expiry_hours - expired = expiry_hours > 0 and now >= boot.created_at + expiry_hours * 3600 - superseded = await self._other_enabled_admin_exists(boot.id) - if not (expired or superseded): - return - await self._store.set_user_disabled(boot.id, disabled=True) - await self._store.revoke_user_sessions(boot.id) - await self._audit( - "auth.bootstrap_admin_retired", - actor="system", - detail=_json({"reason": "superseded" if superseded else "expired"}), - ) - - async def bootstrap_expiry_warning(self, now: float | None = None) -> tuple[float, int] | None: - """ASVS 6.4.5 arm 2: if the first-run bootstrap admin is STILL UNCLAIMED and ``now`` sits inside - its warn window ``[expires_at - bootstrap_warn_hours, expires_at)``, return the retirement instant - + whole hours remaining ONCE — an in-memory latch means a periodic caller emits exactly one - reminder per process. Returns ``None`` when time-expiry is off, the bootstrap is - gone/disabled/claimed, ``now`` is before the window (or already at/past it — retirement itself has - taken over by then), or a reminder already fired this process. Advisory only: the actual - auto-disable is :meth:`_retire_superseded_bootstrap`; this nudges an operator BEFORE it happens. - - ``expires_at`` is derived from the STORED ``created_at`` — the same base - :meth:`_retire_superseded_bootstrap` uses — so the surfaced deadline is exactly the retirement - instant, not a fresh clock. The caller (the API-lifespan reminder) turns it into an ISO string + - the PHI-free ``bootstrap_admin_expiring`` AlertSink event; the password is never surfaced.""" - if self._bootstrap_expiry_warned: - return None - expiry_hours = self._settings.bootstrap_expiry_hours - if expiry_hours <= 0: - return None # no time-expiry configured → nothing to warn about - boot = await self._store.get_user_by_username(BOOTSTRAP_USERNAME) - if boot is None or boot.disabled or not boot.must_change_password: - return None # gone, already disabled, or claimed (a real account now) - now = time.time() if now is None else now - expires_at = boot.created_at + expiry_hours * 3600 - warn_start = expires_at - max(0, self._settings.bootstrap_warn_hours) * 3600 - if not (warn_start <= now < expires_at): - return None # not yet in the window, or already at/past the retirement instant - self._bootstrap_expiry_warned = True # latch: exactly one reminder per process - hours_remaining = max(0, int((expires_at - now) // 3600)) - return (expires_at, hours_remaining) - # --- login --------------------------------------------------------------- async def login( @@ -643,12 +537,6 @@ async def login( async def _login_local( self, username: str, password: str, *, client: str | None ) -> LoginOutcome: - # Enforce bootstrap expiry/supersession before the credential check: an unclaimed bootstrap - # that lapsed (or was superseded) is disabled here, so the disabled-account path below refuses - # it like any other invalid login (WP-3). Scoped to the bootstrap username to keep normal - # logins free of the extra lookups. - if username == BOOTSTRAP_USERNAME: - await self._retire_superseded_bootstrap() user = await self._store.get_user_by_username(username) if user is None or user.auth_provider != AuthProvider.LOCAL.value or user.disabled: # Equalize timing with the real-password path so a missing/disabled/AD account is not @@ -688,13 +576,13 @@ async def _login_local( # ASVS 6.4.1: an admin-issued initial/reset credential that was never claimed EXPIRES — the # password verified, but a `must_change_password` temp that is older than # `initial_password_expiry_hours` is refused like any other invalid login (a generic error, so - # it is indistinguishable from a wrong password) and audited. The bootstrap admin has its own - # expiry path (handled above) and is carved out; a user who set their own password has - # `must_change_password=False` and is never gated here. + # it is indistinguishable from a wrong password) and audited. A user who set their own password + # has `must_change_password=False` and is never gated here. The retired first-run bootstrap + # account used to be carved out of this rule because it had its own expiry path; with no + # implicit account left (BACKLOG #1020), every unclaimed temp is now held to the same deadline. expiry_hours = self._settings.initial_password_expiry_hours if ( - username != BOOTSTRAP_USERNAME - and user.must_change_password + user.must_change_password and expiry_hours > 0 and user.password_changed_at is not None and now - user.password_changed_at > expiry_hours * 3600.0 @@ -2139,8 +2027,28 @@ async def _verify_second_factor(self, user: UserRecord, code: str) -> bool: async def disable_mfa(self, identity: Identity, *, client: str | None = None) -> None: """Self-service: turn off the caller's TOTP MFA (the API gates this behind step-up). Audited + - the user is notified out-of-band (ASVS 6.3.7).""" + the user is notified out-of-band (ASVS 6.3.7). + + Raises :class:`ValueError` when TOTP is the caller's LAST enrolled second factor and MFA is + still required for them — the same refusal, keyed the same way, as + :meth:`delete_webauthn_credential` (ADR 0068 AC-10). The invariant is a property of the + resulting STATE, not of one removal route: guarding only the passkey path left zero enrolled + factors reachable by ordering, since a user holding TOTP plus one passkey could delete the + passkey (permitted while TOTP remained) and then disable TOTP unguarded. + + Deliberately NOT a TOTP-only check — it consults ``has_webauthn_credentials`` first, so a user + who keeps a passkey stays free to drop TOTP. Deliberately gated on ``totp_enabled`` — a + disable that removes nothing stays idempotent rather than refusing a no-op.""" user = await self._store.get_user(identity.user_id) + if user is not None and user.totp_enabled: + keeps_a_passkey = await self._store.has_webauthn_credentials(identity.user_id) + if not keeps_a_passkey and self._mfa_required_for( + user, identity.roles, second_factor_enrolled=False + ): + raise ValueError( + "this is your last second factor and MFA is required for your account — " + "enroll another factor first" + ) await self._store.disable_totp(identity.user_id) await self._audit( "auth.mfa_disabled", @@ -2530,7 +2438,15 @@ async def create_local_user( email: str | None, roles: Sequence[str], actor: str, + must_change_password: bool = True, ) -> str: + """Create a local account with ``roles``. + + ``must_change_password`` defaults True because the common caller is an ADMIN setting SOMEONE + ELSE's credential: that is a one-time temp and first login must rotate it (ASVS 6.4.6 / + WP-L3-12). The one caller that passes False is ``messagefoundry admin-create``, where the + operator standing at the box is choosing THEIR OWN password — there is no second party the + rotation would protect, and forcing it would only add a step to first sign-in.""" user_id = uuid4().hex await self._store.create_user( user_id=user_id, @@ -2539,16 +2455,12 @@ async def create_local_user( display_name=display_name, email=email, password_hash=await self._argon2(hash_password, password), - # Admin-set the credential is a one-time temp: force rotation on first login so the - # operator never sets a lasting password the user keeps (ASVS 6.4.6 / WP-L3-12). - must_change_password=True, + must_change_password=must_change_password, ) await self._store.set_user_roles(user_id, roles, assigned_by=actor) await self._audit( "user.created", actor=actor, detail=_json({"username": username, "roles": list(roles)}) ) - # If this created a second administrator, retire the now-redundant bootstrap admin (WP-3). - await self._retire_superseded_bootstrap() return user_id async def update_user( @@ -2764,7 +2676,9 @@ async def is_last_enabled_admin(self, user_id: str) -> bool: """True iff ``user_id`` is an enabled administrator and the only one remaining. Guards the role-removal path so the deployment can never be left with no usable admin - account (the bootstrap admin only regenerates against a fully empty users table). + account. Nothing regenerates one: since BACKLOG #1020 there is no implicit first-run + administrator, and ``messagefoundry admin-create`` is an on-the-box command, not a fallback + the running engine can reach. """ admins: set[str] = set() for user in await self._store.list_users(): diff --git a/messagefoundry/config/settings.py b/messagefoundry/config/settings.py index 672b14ee..91ffefe8 100644 --- a/messagefoundry/config/settings.py +++ b/messagefoundry/config/settings.py @@ -1709,8 +1709,9 @@ class AuthSettings(_Section): # secure default over back-compat. It cannot lock a fresh admin out: a required-but-unenrolled # Administrator can still reach the factor-enrollment routes (they are gated by a fresh PASSWORD # step-up bound to the enroll/confirm action, never by the MFA gate — see - # api/security.py:require_reauth_only_action), so the bootstrap admin enrolls TOTP then satisfies - # it. Set ``require_mfa = false`` (the documented opt-out) to revert to the single-factor default. + # api/security.py:require_reauth_only_action), so the first operator-created Administrator enrolls + # TOTP then satisfies it. Set ``require_mfa = false`` (the documented opt-out) to revert to the + # single-factor default. # An off-loopback bind that serves local accounts MUST keep this on; ``serve`` makes that posture # explicit (sec-mfa-on) — on an exposed (non-loopback) PHI bind with this **explicitly opted out** # it **refuses to start** on a production instance and **warns** on a non-production one, mirroring @@ -1772,20 +1773,12 @@ class AuthSettings(_Section): password_breach_corpus_file: str | None = None lockout_threshold: int = 5 # consecutive failed logins before the account locks lockout_minutes: int = 15 - # First-run bootstrap admin: auto-disabled once a second administrator exists, and (if still - # unclaimed — never password-changed) disabled this many hours after creation. 0 = no time expiry. - bootstrap_expiry_hours: int = 72 - # ASVS 6.4.5 arm 2: how many hours BEFORE that auto-disable to start reminding an operator (via the - # `bootstrap_admin_expiring` AlertSink event) that the unclaimed first-run credential is about to be - # retired. The API-lifespan reminder fires once per process while now sits inside - # [expires_at - bootstrap_warn_hours, expires_at). Only meaningful when bootstrap_expiry_hours > 0. - bootstrap_warn_hours: int = 24 # ASVS 6.4.1: an admin-issued initial/reset credential (a `must_change_password` temp password) that # is never claimed EXPIRES this many hours after it was set. Without it, an unused reset password # grants an authenticated session indefinitely — and the one action it permits is to SET the # password, i.e. account takeover. Keyed on `password_changed_at`; a user who set their own password - # has `must_change_password=False` and is unaffected. The bootstrap admin has its own - # `bootstrap_expiry_hours` path and is exempt. 0 = no expiry (not recommended on a PHI instance). + # has `must_change_password=False` and is unaffected. 0 = no expiry (not recommended on a PHI + # instance). initial_password_expiry_hours: int = 72 # Active Directory / LDAP. The bind password is a secret: MEFOR_AUTH_AD_BIND_PASSWORD. @@ -2556,9 +2549,6 @@ class AlertSeverity(str, Enum): # noqa: UP042 "leadership_acquired", # #145 (ADR 0014 amendment): a node went non-leader→leader (HA failover / election) "dr_activated", # #145 (ADR 0014 amendment, ADR 0048): a third-tier DR standby was promoted "content_match", # #81 (ADR 0133): a code-first Handler ("Action Point") matched message content (PHI-free) - # ASVS 6.4.5 arm 2: an UNCLAIMED first-run bootstrap admin is nearing its auto-disable deadline - # (payload is the ISO deadline + whole hours remaining — never the password; PHI-free) - "bootstrap_admin_expiring", # NOTE: the INVERSE events (leadership_lost / dr_released) are auto-resolve-only (alert_sinks # _AUTO_RESOLVE), NOT rule-targetable alert types — a step-down / fail-back needs no page. } @@ -2669,7 +2659,7 @@ class AlertRule(BaseModel): model_config = ConfigDict(extra="forbid") # --- match (all conditions must hold) --- - event_type: str = "any" # "any" | connection_stopped | queue_buildup | storage_threshold | cert_expiry | secret_rotation | connection_error | message_stall | saturation | integrity_drift | update_available | backup_failed | lane_stuck | rcsi_off_degraded | bootstrap_admin_expiring + event_type: str = "any" # "any" | connection_stopped | queue_buildup | storage_threshold | cert_expiry | secret_rotation | connection_error | message_stall | saturation | integrity_drift | update_available | backup_failed | lane_stuck | rcsi_off_degraded connection: str = "*" # fnmatch glob over the connection name; "*" = all min_depth: int | None = Field(None, ge=1) # queue_buildup: match only at/over this lane depth min_oldest_seconds: float | None = Field( diff --git a/messagefoundry/pipeline/alert_sinks.py b/messagefoundry/pipeline/alert_sinks.py index 79c310c7..0a5fdfee 100644 --- a/messagefoundry/pipeline/alert_sinks.py +++ b/messagefoundry/pipeline/alert_sinks.py @@ -793,21 +793,6 @@ def secret_rotation_due( } ) - def bootstrap_admin_expiring(self, name: str, *, expires_at: str, hours_remaining: int) -> None: - # ASVS 6.4.5 arm 2: the UNCLAIMED first-run bootstrap admin is nearing its auto-disable deadline. - # The fixed label ("bootstrap-admin") stands in for "connection" so the realert throttle + subject - # keying + rule matching work uniformly; the payload carries only the ISO deadline + whole hours - # remaining (never the password or any secret — no PHI). The AuthService latch already collapses - # it to one emit per process; the (type, connection) throttle is a second belt. - self._emit( - { - "type": "bootstrap_admin_expiring", - "connection": name, - "expires_at": expires_at, - "hours_remaining": hours_remaining, - } - ) - def gcm_invocations(self, name: str, *, key_id: str, invocations: int, ceiling: int) -> None: # ASVS 11.3.4: the active DEK is approaching its AES-GCM invocation ceiling. The key label # stands in for "connection" so the realert throttle + subject keying + rule matching work diff --git a/messagefoundry/pipeline/alerts.py b/messagefoundry/pipeline/alerts.py index 1ae8484a..7cdb27cc 100644 --- a/messagefoundry/pipeline/alerts.py +++ b/messagefoundry/pipeline/alerts.py @@ -120,18 +120,6 @@ def secret_rotation_due( :meth:`cert_expiry`) so an operator can route a rotation reminder apart from a cert-expiry alert.""" ... - def bootstrap_admin_expiring(self, name: str, *, expires_at: str, hours_remaining: int) -> None: - """The first-run **bootstrap admin** is still UNCLAIMED (never password-changed) and now sits - inside its retirement warn window — an operator must sign in and change the password (or stand - up a second administrator) before the unclaimed credential is auto-disabled (ASVS 6.4.5). ``name`` - labels the credential (``"bootstrap-admin"``); ``expires_at`` is the ISO instant it is disabled; - ``hours_remaining`` is the whole hours left (``0`` in the final hour). Carries **only** the - deadline + hours — **never** the password or any secret, and no message content (no PHI). Emitted - once per process (an in-memory latch on :class:`~messagefoundry.auth.service.AuthService`) by the - API-lifespan reminder task. Dedicated (not reusing :meth:`secret_rotation_due`) so an operator can - route a first-run-credential reminder apart from a long-lived-secret rotation reminder.""" - ... - def gcm_invocations(self, name: str, *, key_id: str, invocations: int, ceiling: int) -> None: """The active store data-encryption key has crossed the AES-GCM soft invocation threshold (2**31 of the 2**32 birthday ceiling) on its PERSISTED, fleet-wide cumulative count (ASVS @@ -338,15 +326,6 @@ def secret_rotation_due( last_rotated, ) - def bootstrap_admin_expiring(self, name: str, *, expires_at: str, hours_remaining: int) -> None: - log.warning( - "ALERT bootstrap_admin_expiring: %r is UNCLAIMED and is auto-disabled in %d hour(s) " - "(expires %s) — sign in and change the password, or add a second admin, before then", - name, - hours_remaining, - expires_at, - ) - def gcm_invocations(self, name: str, *, key_id: str, invocations: int, ceiling: int) -> None: log.warning( "ALERT gcm_invocations: %r (key_id=%s) has encrypted %d value(s), %.1f%% of the %d " diff --git a/messagefoundry/scaffold.py b/messagefoundry/scaffold.py index d2ead021..85a8378f 100644 --- a/messagefoundry/scaffold.py +++ b/messagefoundry/scaffold.py @@ -231,8 +231,6 @@ def archive(msg): # type: ignore[no-untyped-def] *.db-shm *.db-wal *.log -# the one-time bootstrap admin credential the engine writes next to the store -bootstrap-admin.txt .env .env.* /out/ diff --git a/messagefoundry_webconsole/routes/account.py b/messagefoundry_webconsole/routes/account.py index 307f0913..4608ef3d 100644 --- a/messagefoundry_webconsole/routes/account.py +++ b/messagefoundry_webconsole/routes/account.py @@ -278,7 +278,15 @@ async def ui_mfa_disable( identity: Identity = Depends(require_ui_step_up_action(STEP_UP_ACTION_MFA_DISABLE)), ) -> Response: assert_same_origin(request) - await admin.disable_my_mfa(request=request, service=service, identity=identity) + try: + await admin.disable_my_mfa(request=request, service=service, identity=identity) + except HTTPException as exc: + # The seam's ONLY in-body raise is the last-required-factor refusal (ADR 0068 AC-10) — + # the step-up/CSRF gates above have already run. Render it on the account page exactly as + # ui_webauthn_delete renders the passkey twin, instead of leaking the API's JSON body. + return await _account_response( + service, identity, request, error=str(exc.detail), status_code=exc.status_code + ) return RedirectResponse("/ui/account?m=mfa_off", status_code=303) # --- L6b: self-service session management (#75 parity — desktop sessions.py twin) --- diff --git a/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py b/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py index 8cc8e593..dbdb594b 100644 --- a/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py +++ b/packaging/messagefoundry-webconsole/tests/test_ui_mfa_gate.py @@ -295,14 +295,23 @@ async def test_must_change_outranks_the_second_factor_on_the_gate_page( ) -> None: """RED when: GET /ui/mfa checks mfa before must_change. - A bootstrap admin is BOTH. Leading with MFA parks it on a page it cannot answer until it has + An admin-ISSUED account is BOTH: create_local_user sets must_change_password, and require_mfa + covers it un-enrolled. Leading with MFA parks it on a page it cannot answer until it has rotated — the cookie-plane twin of the JSON ordering rule. """ service = AuthService(engine.store, AuthSettings(login_rate_limit_enabled=False)) - boot = await service.initialize() # the FIRST initialize is what mints the bootstrap admin - assert boot is not None + await service.initialize() + temp = "an-issued-temp-passphrase" + await service.create_local_user( + username="issued", + password=temp, + display_name=None, + email=None, + roles=[Role.ADMINISTRATOR.value], + actor="test", + ) async with _client(engine, service) as c: - r = await c.post("/ui/login", data={"username": boot.username, "password": boot.password}) + r = await c.post("/ui/login", data={"username": "issued", "password": temp}) assert r.status_code == 303 and r.headers["location"] == "/ui/account/password" r = await c.get("/ui/mfa") assert r.status_code == 303 and r.headers["location"] == "/ui/account/password" diff --git a/packaging/messagefoundry-webconsole/tests/test_webui.py b/packaging/messagefoundry-webconsole/tests/test_webui.py index e243678b..cb6c7614 100644 --- a/packaging/messagefoundry-webconsole/tests/test_webui.py +++ b/packaging/messagefoundry-webconsole/tests/test_webui.py @@ -3260,6 +3260,30 @@ async def test_disable_mfa_enforces_full_stepup_when_stale(engine: Engine) -> No assert user is not None and user.totp_enabled # still on — the gate held +async def test_disable_mfa_last_factor_refusal_renders_on_the_account_page( + engine: Engine, +) -> None: + # BACKLOG #1022: the console's disable action reaches the SAME seam the JSON DELETE /me/mfa does, + # so the last-factor refusal arrives as the route's HTTPException. It must render on the account + # page exactly as the passkey twin does (ui_webauthn_delete), not leak the API's JSON error body. + # RED when the except-HTTPException arm is dropped (the JSON detail surfaces) or when the service + # guard stops firing (303 to /ui/account?m=mfa_off — MFA stripped to zero enrolled factors). + service = AuthService(engine.store, AuthSettings()) # require_mfa on (secure default) + await service.initialize() + await _add(service, "op", Role.OPERATOR) + async with _client(engine, service) as c: + await _cookie_login(c, "op") + await _enroll_mfa(c, service, "op") + uid = await _uid(service, "op") + await _mint_action(c, "/ui/account/mfa/disable") + r = await c.post("/ui/account/mfa/disable", headers={"Sec-Fetch-Site": "same-origin"}) + assert r.status_code == 400 + assert "enroll another factor first" in r.text + assert r.headers["content-type"].startswith("text/html") + user = await service.store.get_user(uid) + assert user is not None and user.totp_enabled # still on — the guard held + + async def test_mfa_posts_reject_cross_site(engine: Engine) -> None: service = await _service(engine) await _add(service, "op", Role.OPERATOR) diff --git a/tests/_first_admin.py b/tests/_first_admin.py new file mode 100644 index 00000000..24620313 --- /dev/null +++ b/tests/_first_admin.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Stand up the first Administrator the way an operator does, for tests that used to lean on the +implicit first-run bootstrap account. + +That account was retired under BACKLOG #1020 (ASVS 6.3.2 — no default accounts), so +``AuthService.initialize()`` now seeds roles and creates nothing. The replacement route is the +``messagefoundry admin-create`` CLI, whose one privileged step is the ``create_local_user`` call +reproduced here. Tests call this helper rather than the CLI so they exercise the auth service +directly; ``tests/test_admin_create_cli.py`` is what proves the CLI itself drives a fresh store to a +usable administrator, and is therefore the test that must fail if this helper drifts from it. + +``must_change_password=False`` matches the CLI: the operator standing at the box chooses their own +password, so there is no second party a forced rotation would protect. +""" + +from __future__ import annotations + +from messagefoundry.auth.permissions import Role +from messagefoundry.auth.service import AuthService + +#: Satisfies the shipped policy (>=15 chars, no app/vendor terms) so it works under stock settings. +FIRST_ADMIN_PW = "a-strong-test-passphrase" +FIRST_ADMIN = "admin" + + +async def create_first_admin( + service: AuthService, + *, + username: str = FIRST_ADMIN, + password: str = FIRST_ADMIN_PW, + email: str | None = None, +) -> str: + """Seed the built-in roles (idempotent) and create ``username`` as an Administrator.""" + await service.initialize() + return await service.create_local_user( + username=username, + password=password, + display_name="Administrator", + email=email, + roles=[Role.ADMINISTRATOR.value], + actor="cli", + must_change_password=False, + ) diff --git a/tests/test_admin_create_cli.py b/tests/test_admin_create_cli.py new file mode 100644 index 00000000..d1576c19 --- /dev/null +++ b/tests/test_admin_create_cli.py @@ -0,0 +1,294 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""``messagefoundry admin-create`` — the operator route to the FIRST administrator (BACKLOG #1020). + +The implicit first-run bootstrap account was retired because ASVS 6.3.2 wants no default account to +exist, and an engine nobody can sign into is not the stricter end state — it is a broken one. So the +removal and this command are one change, and these are the tests that hold the pair together: + +* the OLD behaviour is gone — a fresh store reaches ``serve`` with zero users and no privileged + account appears by itself; and +* the NEW route works end to end — the command drives that same fresh store to an account that can + authenticate against the real API and reach an authorization-gated route. + +Both directions matter. Either one alone would pass while the product was unusable or unsafe. + +The command is driven in-process through ``main()`` (the CLI-dispatch precedent in +``tests/test_cli_backup_dispatch.py``) rather than as a subprocess: there is then no ambient PATH, +interpreter or venv the result could silently depend on. ``_admin_create`` calls ``asyncio.run``, so +every test here is deliberately SYNC — an async test would already own the session event loop. +""" + +from __future__ import annotations + +import asyncio +import io +import os +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from messagefoundry.__main__ import main +from messagefoundry.api import create_app +from messagefoundry.auth import Role +from messagefoundry.auth.service import AuthService +from messagefoundry.config.settings import AuthSettings +from messagefoundry.pipeline import Engine +from messagefoundry.store.store import MessageStore + +PW = "a-strong-operator-passphrase" + +#: The env keys the CLI's own settings load would honour. Left ambient, `MEFOR_STORE_PATH` sends the +#: command at a DIFFERENT database than the one the test asserts on, and `MEFOR_AUTH_*` moves the +#: password policy out from under it — measured 2026-08-10: with those two set, 7 of these 9 tests +#: fail. They fail loudly rather than passing falsely, but a green that depends on the developer's +#: shell is not a green either way, so the env is pinned and `test_the_env_pin_holds_under_a_hostile_ +#: ambient_value` is the positive control proving the pin is what makes it so. +_ENV_PREFIX = "MEFOR_" +#: Set by tests/conftest.py for per-process test isolation (slot, port base, Qt org) — these are not +#: settings the CLI reads, and clearing them would break the isolation they exist for. +_KEEP = ("MEFOR_TEST_",) + + +@pytest.fixture(autouse=True) +def _pinned_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + for key in list(os.environ): + if key.startswith(_ENV_PREFIX) and not key.startswith(_KEEP): + monkeypatch.delenv(key, raising=False) + yield + + +def _service_toml(tmp_path: Path, db: Path) -> str: + toml = tmp_path / "messagefoundry.toml" + toml.write_text(f'[store]\npath = "{db.as_posix()}"\n', encoding="utf-8") + return str(toml) + + +def _run_admin_create( + monkeypatch: pytest.MonkeyPatch, toml: str, *args: str, password: str = PW +) -> int: + """Invoke the command with ``password`` on stdin. The password is never an argv element — see + ``_read_new_password``: argv is readable by other accounts on the box.""" + monkeypatch.setattr("sys.stdin", io.StringIO(password + "\n")) + return main(["admin-create", "--service-config", toml, "--password-stdin", *args]) + + +async def _users(db: Path) -> list[tuple[str, bool, list[str]]]: + store = await MessageStore.open(str(db)) + try: + out = [] + for user in await store.list_users(): + out.append( + (user.username, user.must_change_password, await store.get_user_role_ids(user.id)) + ) + return out + finally: + await store.close() + + +# --- the OLD behaviour is gone ------------------------------------------------------------------ + + +def test_a_fresh_store_gets_no_account_from_starting_the_engine(tmp_path: Path) -> None: + """RED when an implicit first-run account comes back. + + This is the half that is easy to lose: a reintroduced bootstrap would leave every other test in + the suite green, because a working admin account is what they all want.""" + + async def run() -> tuple[int, Any]: + store = await MessageStore.open(str(tmp_path / "fresh.db")) + try: + service = AuthService(store, AuthSettings()) + await service.initialize() # what the API lifespan calls on every start + await service.initialize() # ...and again on the next start + return await store.count_users(), await store.get_user_by_username("admin") + finally: + await store.close() + + count, admin = asyncio.run(run()) + assert count == 0 + assert admin is None + + +# --- the NEW route works ------------------------------------------------------------------------ + + +def test_admin_create_makes_an_administrator_on_a_fresh_store( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + db = tmp_path / "mf.db" + rc = _run_admin_create(monkeypatch, _service_toml(tmp_path, db), "--username", "alice") + assert rc == 0 + out = capsys.readouterr() + # The resolved store path is named back, so a --db/[store].path typo shows up here rather than as + # a login failure against the engine's real database much later. + assert "alice" in out.out and db.name in out.out + assert PW not in out.out and PW not in out.err # the credential is never echoed + + assert asyncio.run(_users(db)) == [("alice", False, ["administrator"])] + + +def test_admin_create_then_the_api_authenticates_that_account( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The behavioural acceptance test: a FRESH store, the new route, then a real authenticated + session over the engine's own API reaching an authorization-gated route.""" + db = tmp_path / "mf.db" + assert _run_admin_create(monkeypatch, _service_toml(tmp_path, db), "--username", "alice") == 0 + + async def run() -> tuple[int, int, str]: + engine = await Engine.create(db, poll_interval=0.02) + try: + # require_mfa=False keeps this test about PROVISIONING; the second factor an operator + # then enrolls is covered by tests/test_mfa.py. + service = AuthService(engine.store, AuthSettings(require_mfa=False)) + await service.initialize() + transport = httpx.ASGITransport(app=create_app(engine, auth=service)) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + login = await c.post( + "/auth/login", + json={"username": "alice", "password": PW, "provider": "local"}, + ) + token = login.json().get("token", "") + h = {"Authorization": f"Bearer {token}"} + me = await c.get("/auth/me", headers=h) + gated = await c.get("/users", headers=h) + return login.status_code, gated.status_code, me.json().get("username", "") + finally: + await engine.stop() + + login_status, gated_status, username = asyncio.run(run()) + assert login_status == 200 and username == "alice" + # /users is USERS_MANAGE-gated, so a 200 proves the account carries real administrator authority + # — not merely that some session was minted. No password rotation stood in the way. + assert gated_status == 200 + + +# --- refusals ----------------------------------------------------------------------------------- + + +def test_admin_create_refuses_a_duplicate_username( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + toml = _service_toml(tmp_path, tmp_path / "mf.db") + assert _run_admin_create(monkeypatch, toml, "--username", "alice") == 0 + capsys.readouterr() + assert _run_admin_create(monkeypatch, toml, "--username", "alice") == 2 + assert "already exists" in capsys.readouterr().err + + +def test_admin_create_holds_the_password_to_the_deployments_own_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # The policy comes from the resolved [auth] settings, not a CLI-local default: the command must + # not be a second, laxer way into the same account store. + db = tmp_path / "mf.db" + toml = tmp_path / "messagefoundry.toml" + toml.write_text( + f'[store]\npath = "{db.as_posix()}"\n\n[auth]\npassword_min_length = 40\n', encoding="utf-8" + ) + rc = _run_admin_create(monkeypatch, str(toml), "--username", "alice") + assert rc == 2 + assert "password must" in capsys.readouterr().err + assert not db.exists() or asyncio.run(_users(db)) == [] + + +def test_admin_create_refuses_an_empty_password( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + toml = _service_toml(tmp_path, tmp_path / "mf.db") + assert _run_admin_create(monkeypatch, toml, "--username", "alice", password="") == 2 + assert "no password supplied" in capsys.readouterr().err + + +def test_admin_create_warns_when_no_email_is_set( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # BACKLOG #1020's original finding was that the first administrator had no deliverable address, + # so the out-of-band security notices for the most privileged account silently no-opped. The + # account is gone, but the hole it exposed is a property of ANY privileged account with no + # address — so the operator route says so at the moment the account is made, and takes one. + toml = _service_toml(tmp_path, tmp_path / "mf.db") + assert _run_admin_create(monkeypatch, toml, "--username", "alice") == 0 + assert "no --email" in capsys.readouterr().err + + toml2 = _service_toml(tmp_path, tmp_path / "mf2.db") + assert ( + _run_admin_create(monkeypatch, toml2, "--username", "bob", "--email", "bob@example.org") + == 0 + ) + assert "no --email" not in capsys.readouterr().err + + async def email_of(db: Path, username: str) -> str | None: + store = await MessageStore.open(str(db)) + try: + user = await store.get_user_by_username(username) + assert user is not None + return user.email + finally: + await store.close() + + assert asyncio.run(email_of(tmp_path / "mf2.db", "bob")) == "bob@example.org" + + +def test_admin_create_audits_the_account_it_made( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db = tmp_path / "mf.db" + assert _run_admin_create(monkeypatch, _service_toml(tmp_path, db), "--username", "alice") == 0 + + async def actions() -> list[str]: + store = await MessageStore.open(str(db)) + try: + return [row["action"] for row in await store.list_audit()] + finally: + await store.close() + + # The most privileged account on the box must not be creatable without a durable record of it. + assert "user.created" in asyncio.run(actions()) + + +def test_the_env_pin_is_load_bearing_and_the_hazard_is_real( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Two halves, because either alone would be a claim rather than a measurement. + + 1. The pin is OBSERVABLE: inside a pinned test no ``MEFOR_`` setting key survives, whatever the + developer's shell exported. RED when ``_pinned_env`` stops clearing the prefix. + 2. The hazard it pins against is REAL: with ``MEFOR_STORE_PATH`` deliberately set, the command + follows it and writes somewhere the test never named. That is the behaviour every other test + in this file would otherwise be exposed to. + """ + assert not [k for k in os.environ if k.startswith(_ENV_PREFIX) and not k.startswith(_KEEP)], ( + "the autouse env pin did not clear the MEFOR_ settings prefix" + ) + + named = tmp_path / "named.db" + hostile = tmp_path / "hostile.db" + monkeypatch.setenv("MEFOR_STORE_PATH", str(hostile)) + assert ( + _run_admin_create(monkeypatch, _service_toml(tmp_path, named), "--username", "alice") == 0 + ) + assert hostile.exists() and not named.exists() # it went where the ENV said, not the config + + +def test_the_created_account_holds_the_administrator_role_object( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db = tmp_path / "mf.db" + assert _run_admin_create(monkeypatch, _service_toml(tmp_path, db), "--username", "alice") == 0 + + async def roles() -> frozenset[Role]: + store = await MessageStore.open(str(db)) + try: + service = AuthService(store, AuthSettings(require_mfa=False)) + out = await service.login("alice", PW) + assert out.ok and out.identity is not None + return out.identity.roles + finally: + await store.close() + + assert Role.ADMINISTRATOR in asyncio.run(roles()) diff --git a/tests/test_admin_new_ip.py b/tests/test_admin_new_ip.py index e4874a04..ef531c66 100644 --- a/tests/test_admin_new_ip.py +++ b/tests/test_admin_new_ip.py @@ -17,6 +17,7 @@ import httpx import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from _totp_clock import pin_totp_clock from messagefoundry.api import create_app @@ -204,9 +205,8 @@ async def test_verify_mfa_reanchors_session_to_the_new_ip( store = await MessageStore.open(":memory:") try: service = AuthService(store, AuthSettings(admin_new_ip_step_up=True)) - boot = await service.initialize() - assert boot is not None - out = await service.login("admin", boot.password, client="10.1.1.1") + await create_first_admin(service) + out = await service.login(FIRST_ADMIN, FIRST_ADMIN_PW, client="10.1.1.1") assert out.ok and out.identity is not None and out.token is not None identity, token = out.identity, out.token enroll = await service.begin_mfa_enrollment(identity) diff --git a/tests/test_alert_rules.py b/tests/test_alert_rules.py index 6cebc8a4..114b4243 100644 --- a/tests/test_alert_rules.py +++ b/tests/test_alert_rules.py @@ -378,20 +378,6 @@ def test_rule_targets_rcsi_off_degraded_and_routes() -> None: assert d.transports == ("webhook",) # decide() returns the rule's subset as a tuple -def test_rule_targets_bootstrap_admin_expiring_and_routes() -> None: - # ASVS 6.4.5 arm 2: an unclaimed first-run bootstrap admin about to be auto-disabled is page-worthy - # on an exposed instance — an operator must be able to escalate the reminder and route it to a - # dedicated transport, not just take the default (all transports, warning). - rule = AlertRule( - event_type="bootstrap_admin_expiring", severity=AlertSeverity.CRITICAL, transports=["email"] - ) - d = AlertRuleSet([rule]).decide( - {"type": "bootstrap_admin_expiring", "connection": "bootstrap-admin"} - ) - assert d.severity == "critical" - assert d.transports == ("email",) - - async def test_lane_stuck_and_rcsi_off_degraded_emit_and_route_end_to_end() -> None: # End-to-end: a real NotifierAlertSink fans out both new event types through _emit, honoring # per-rule severity escalation and transport routing -- the operator control the config gap denied. diff --git a/tests/test_alert_sinks.py b/tests/test_alert_sinks.py index d37fd158..e41f0889 100644 --- a/tests/test_alert_sinks.py +++ b/tests/test_alert_sinks.py @@ -18,7 +18,6 @@ WebhookTransport, notifier_from_settings, ) -from messagefoundry.pipeline.alerts import LoggingAlertSink class _RecordingTransport: @@ -72,41 +71,6 @@ async def test_realert_throttle_suppresses_repeats() -> None: assert keys == [("OB_X", 1), ("OB_Y", 1)] -async def test_bootstrap_admin_expiring_emits_phi_free() -> None: - # ASVS 6.4.5 arm 2: the reminder rides the standard fan-out; its payload is the ISO deadline + whole - # hours remaining only — never the password or any secret. - t = _RecordingTransport("t") - sink = NotifierAlertSink([t]) - sink.bootstrap_admin_expiring( - "bootstrap-admin", expires_at="2026-07-27T12:00:00+00:00", hours_remaining=24 - ) - await _drain(sink) - assert len(t.events) == 1 - ev = t.events[0] - assert ev["type"] == "bootstrap_admin_expiring" - assert ev["connection"] == "bootstrap-admin" - assert ev["expires_at"] == "2026-07-27T12:00:00+00:00" - assert ev["hours_remaining"] == 24 - # no credential material ever rides the payload - assert not any(k in ev for k in ("password", "secret", "token")) - - -def test_bootstrap_admin_expiring_logging_sink_states_deadline_not_password( - caplog: pytest.LogCaptureFixture, -) -> None: - # The fallback LoggingAlertSink surfaces the deadline + hours at WARNING (so an operator sees it with - # no notifier wired) and never a secret — there is no secret in the signature to leak. - import logging - - with caplog.at_level(logging.WARNING): - LoggingAlertSink().bootstrap_admin_expiring( - "bootstrap-admin", expires_at="2026-07-27T12:00:00+00:00", hours_remaining=24 - ) - assert "bootstrap_admin_expiring" in caplog.text - assert "2026-07-27T12:00:00+00:00" in caplog.text - assert "24 hour" in caplog.text - - async def test_suspend_gate_mutes_notification() -> None: # #143: a suspended (type, connection) is muted at the notification enqueue while the window is # active; a different, un-suspended key still delivers. NOTIFICATION-only. diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 91a92775..11ab7417 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -282,6 +282,29 @@ async def test_require_mfa_admin_is_not_bootstrap_locked_out(engine: Engine) -> assert status["enabled"] is True and status["required"] is True +async def test_delete_me_mfa_maps_the_last_factor_refusal_to_400(engine: Engine) -> None: + # BACKLOG #1022: the guard added to disable_mfa raises ValueError; without this mapping the route + # would 500. RED when the try/except around service.disable_mfa is dropped (500, not 400) or when + # the guard itself stops firing (200 — MFA silently stripped to zero enrolled factors). + service = await _service(engine, AuthSettings(login_rate_limit_enabled=False)) # require_mfa on + await _add(service, "adm", Role.ADMINISTRATOR) + async with _client(engine, service) as c: + tok = (await _login(c, "adm")).json()["token"] + await _reauth(c, tok, purpose="mfa_enroll") + secret = (await c.post("/me/mfa/enroll", headers=_auth(tok))).json()["secret"] + await _reauth(c, tok, purpose="mfa_confirm") + assert ( + await c.post("/me/mfa/confirm", json={"code": fresh_totp(secret)}, headers=_auth(tok)) + ).status_code == 200 + # Confirming marked the session MFA-verified, so the disable step-up is password-only. + assert (await _reauth(c, tok, purpose="mfa_disable")).status_code == 200 + + r = await c.request("DELETE", "/me/mfa", headers=_auth(tok)) + assert r.status_code == 400 + assert "enroll another factor first" in r.json()["detail"] + assert (await c.get("/me/mfa", headers=_auth(tok))).json()["enabled"] is True + + async def test_security_events_feed_payload_is_phi_free(engine: Engine) -> None: # The feed carries only non-PHI audit metadata (ts/action/detail) — never message bodies or # credential material. Mirrors the PHI-free assertion already made on the email-notification body. diff --git a/tests/test_asvs_phase0.py b/tests/test_asvs_phase0.py index a9ff3bde..428a61c8 100644 --- a/tests/test_asvs_phase0.py +++ b/tests/test_asvs_phase0.py @@ -15,6 +15,7 @@ import httpx import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from pydantic import ValidationError from messagefoundry.api import create_app @@ -135,11 +136,10 @@ async def test_logout_emits_audit_event() -> None: store = await MessageStore.open(":memory:") try: service = AuthService(store, AuthSettings()) - boot = await service.initialize() - assert boot is not None - out = await service.login("admin", boot.password) + await create_first_admin(service) + out = await service.login(FIRST_ADMIN, FIRST_ADMIN_PW) assert out.ok and out.token is not None - await service.logout(out.token, actor="admin") + await service.logout(out.token, actor=FIRST_ADMIN) actions = [row["action"] for row in await store.list_audit()] assert "auth.logout" in actions finally: diff --git a/tests/test_auth_hardening.py b/tests/test_auth_hardening.py index 5ed26e06..f02d372d 100644 --- a/tests/test_auth_hardening.py +++ b/tests/test_auth_hardening.py @@ -6,7 +6,6 @@ H1 PHI summaries are redacted for callers lacking messages:view_summary H2 AD requires LDAPS unless an explicit insecure override is set M2 must_change_password is enforced server-side (not merely advisory) - M3 the bootstrap one-time password goes to a restricted file, never the log M4 an AD login cannot adopt/overwrite a like-named local account M5 the last enabled administrator cannot be stripped of the admin role M6 /me/password requires the current password (defeats session-only takeover) @@ -23,15 +22,16 @@ import httpx import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from _totp_clock import fresh_totp from pydantic import ValidationError from messagefoundry.api import create_app -from messagefoundry.api.app import _emit_bootstrap_admin, _session_reaper +from messagefoundry.api.app import _session_reaper from messagefoundry.auth import Role, hash_password from messagefoundry.auth.ldap import AdPrincipal, LdapAuthenticator, LdapError -from messagefoundry.auth.service import AuthService, BootstrapAdmin -from messagefoundry.config.settings import AuthSettings, StoreSettings +from messagefoundry.auth.service import AuthService +from messagefoundry.config.settings import AuthSettings from messagefoundry.pipeline import Engine from messagefoundry.store import MessageStatus @@ -158,16 +158,27 @@ def test_ad_requires_ldaps_unless_overridden() -> None: async def test_must_change_password_blocks_until_rotated(engine: Engine) -> None: - # M2 + ASVS 6.3.3. A bootstrap admin is must_change AND (since 6.3.3) mfa_pending at the same - # instant, so this pins BOTH the refusal ORDER and the fact that the pair is escapable — the + # M2 + ASVS 6.3.3. An admin-ISSUED account is must_change AND (since 6.3.3) mfa_pending at the + # same instant, so this pins BOTH the refusal ORDER and the fact that the pair is escapable — the # bricked-fresh-account regression. Order is load-bearing: GET /me/mfa is MFA-exempt but NOT # must-change-exempt, so leading with MFA would send this account to /auth/mfa-verify, which it # cannot satisfy before rotating. The account must be told to rotate FIRST. + # (It used to be the implicit first-run bootstrap admin; BACKLOG #1020 retired that account, so + # the same pair of conditions is now produced the way it is actually reachable in the product — + # an existing administrator issuing a temp credential through create_local_user.) service = AuthService(engine.store, AuthSettings(login_rate_limit_enabled=False)) - boot = await service.initialize() - assert boot is not None + await service.initialize() + issued = "an-issued-temp-passphrase" + await service.create_local_user( + username="admin", + password=issued, + display_name=None, + email=None, + roles=[Role.ADMINISTRATOR.value], + actor="test", + ) async with _client(engine, service) as c: - login = await _login(c, boot.username, boot.password) + login = await _login(c, "admin", issued) assert login.status_code == 200 and login.json()["must_change_password"] is True h = _auth(login.json()["token"]) # a rotation-required session may not reach protected routes... @@ -181,7 +192,7 @@ async def test_must_change_password_blocks_until_rotated(engine: Engine) -> None rotated = await c.post( "/me/password", headers=h, - json={"current_password": boot.password, "new_password": "a-rotated-passphrase-99"}, + json={"current_password": issued, "new_password": "a-rotated-passphrase-99"}, ) assert rotated.status_code == 200 @@ -208,60 +219,12 @@ async def test_must_change_password_blocks_until_rotated(engine: Engine) -> None assert (await c.get("/users", headers=_auth(tok))).status_code == 200 -# --- M3: bootstrap one-time password goes to a file, not the log ------------- - - -def test_bootstrap_password_written_to_file_not_log( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - store_settings = StoreSettings(path=str(tmp_path / "mf.db")) - boot = BootstrapAdmin(username="admin", password="S3cret-One-Time-Value") - with caplog.at_level(logging.WARNING): - _emit_bootstrap_admin(boot, store_settings) - secret_file = tmp_path / "bootstrap-admin.txt" - assert secret_file.exists() - assert "S3cret-One-Time-Value" in secret_file.read_text() - # the credential must never appear in the (NSSM-captured) log - assert "S3cret-One-Time-Value" not in caplog.text - assert "bootstrap-admin.txt" in caplog.text - - -def test_bootstrap_file_and_log_state_the_expiry_deadline( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - # ASVS 6.4.5 arm 1: the renewal deadline ships WITH the credential — the file body and the log line - # both carry the ISO instant, so "claim it before " is not an out-of-band assumption. - import datetime - - exp = 1_800_000_000.0 - iso = datetime.datetime.fromtimestamp(exp, tz=datetime.UTC).isoformat() - store_settings = StoreSettings(path=str(tmp_path / "mf.db")) - boot = BootstrapAdmin(username="admin", password="one-time-value", expires_at=exp) - with caplog.at_level(logging.WARNING): - _emit_bootstrap_admin(boot, store_settings) - body = (tmp_path / "bootstrap-admin.txt").read_text() - assert iso in body and "expires" in body - assert iso in caplog.text # the deadline is not a secret — safe to log - assert "one-time-value" not in caplog.text # ...the password still is not - - -def test_bootstrap_states_no_deadline_when_expiry_is_off( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - # expires_at=None (bootstrap_expiry_hours=0) → no deadline line; byte-compatible with pre-6.4.5. - store_settings = StoreSettings(path=str(tmp_path / "mf.db")) - boot = BootstrapAdmin(username="admin", password="one-time-value", expires_at=None) - with caplog.at_level(logging.WARNING): - _emit_bootstrap_admin(boot, store_settings) - assert "expires" not in (tmp_path / "bootstrap-admin.txt").read_text() - - # --- M4: AD login cannot adopt a like-named local account -------------------- async def test_ad_login_conflicting_with_local_account_is_rejected(engine: Engine) -> None: principal = AdPrincipal( - username="admin", # collides with the LOCAL bootstrap admin + username="admin", # collides with a LOCAL account of the same name display_name=None, email=None, dn="CN=admin,DC=x", @@ -283,7 +246,7 @@ def resolve_principal(self, username: str) -> AdPrincipal | None: ad_bind_password="x", ) service = AuthService(engine.store, settings, ldap=_FakeLdap()) # type: ignore[arg-type] - await service.initialize() # creates the LOCAL 'admin' + await create_first_admin(service) # the LOCAL 'admin' the AD principal collides with async with _client(engine, service) as c: r = await _login(c, "admin", "pw", provider="ad") assert r.status_code == 401 # the AD bind cannot take over the local account @@ -296,17 +259,9 @@ async def test_cannot_remove_last_administrator(engine: Engine) -> None: # Last-admin guard test (step-up admin CRUD), not an MFA test: pin require_mfa=False so the # BACKLOG #187 secure default (require_mfa now ON) doesn't 403 the roles/CRUD ops first. service = AuthService(engine.store, AuthSettings(require_mfa=False)) - boot = await service.initialize() - assert boot is not None + await create_first_admin(service) async with _client(engine, service) as c: - h = _auth((await _login(c, "admin", boot.password)).json()["token"]) - # clear the must-change flag so the admin can operate - await c.post( - "/me/password", - headers=h, - json={"current_password": boot.password, "new_password": "a-rotated-passphrase-99"}, - ) - h = _auth((await _login(c, "admin", "a-rotated-passphrase-99")).json()["token"]) + h = _auth((await _login(c, FIRST_ADMIN, FIRST_ADMIN_PW)).json()["token"]) my_id = (await c.get("/auth/me", headers=h)).json()["user_id"] # stripping admin from the only administrator is refused assert ( @@ -569,11 +524,19 @@ async def test_must_change_password_blocks_websocket(engine: Engine) -> None: from messagefoundry.auth import Permission service = AuthService(engine.store, AuthSettings(require_mfa=False)) - boot = await service.initialize() - assert boot is not None - boot_token = (await service.login("admin", boot.password)).token - # the not-yet-rotated bootstrap admin (holds monitoring:read) is denied the WS - denied = await authorize_ws(_FakeWS(service, boot_token), Permission.MONITORING_READ) # type: ignore[arg-type] + await service.initialize() + issued = "an-issued-temp-passphrase" + await service.create_local_user( + username="tempadm", + password=issued, + display_name=None, + email=None, + roles=[Role.ADMINISTRATOR.value], + actor="test", + ) + temp_token = (await service.login("tempadm", issued)).token + # a not-yet-rotated admin-issued account (holds monitoring:read) is denied the WS + denied = await authorize_ws(_FakeWS(service, temp_token), Permission.MONITORING_READ) # type: ignore[arg-type] assert denied is None # a normal user with the permission is allowed through await _add(service, "vw", Role.VIEWER) @@ -588,7 +551,7 @@ async def test_ws_permission_denied_is_audited(engine: Engine) -> None: from messagefoundry.auth import Permission service = AuthService(engine.store, AuthSettings(require_mfa=False)) - assert await service.initialize() is not None + await service.initialize() await _add(service, "vw", Role.VIEWER) vw_token = (await service.login("vw", PW)).token # VIEWER holds monitoring:read but not config:deploy → requesting it on the WS is denied + audited. @@ -606,7 +569,7 @@ async def test_ws_permission_granted_is_audited_for_sensitive_only(engine: Engin from messagefoundry.auth import Permission service = AuthService(engine.store, AuthSettings(require_mfa=False)) - assert await service.initialize() is not None + await service.initialize() await _add(service, "adm", Role.ADMINISTRATOR) await _add(service, "vw", Role.VIEWER) adm_token = (await service.login("adm", PW)).token @@ -667,7 +630,7 @@ async def _assert_http_grant_deny_precision(store: object) -> None: # sits ABOVE the permission loop — leaving it on would refuse every request with auth.mfa_denied # before any grant/deny row could be written, testing the wrong guard. service = AuthService(store, AuthSettings(require_mfa=False)) # type: ignore[arg-type] - assert await service.initialize() is not None + await service.initialize() await _add(service, "adm", Role.ADMINISTRATOR) # holds approvals:approve + messages:purge await _add(service, "op", Role.OPERATOR) # holds messages:purge, NOT approvals:approve await _add(service, "vw", Role.VIEWER) # holds neither @@ -739,7 +702,7 @@ async def test_audit_all_authz_audits_every_grant_but_never_phi_view(engine: Eng from messagefoundry.auth import Permission service = AuthService(engine.store, AuthSettings(require_mfa=False)) - assert await service.initialize() is not None + await service.initialize() await _add( service, "adm", Role.ADMINISTRATOR ) # holds read + view_summary + purge + monitoring:read diff --git a/tests/test_auth_service.py b/tests/test_auth_service.py index d42d21ae..bc87a1da 100644 --- a/tests/test_auth_service.py +++ b/tests/test_auth_service.py @@ -1,12 +1,13 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 MessageFoundry Organization and contributors -"""AuthService unit tests: bootstrap, local login + lockout, sessions, AD group->role mapping.""" +"""AuthService unit tests: first-admin provisioning, local login + lockout, sessions, AD group->role mapping.""" from __future__ import annotations import time import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from messagefoundry.auth import Role, hash_password, hash_token from messagefoundry.auth.identity import AuthProvider @@ -43,236 +44,39 @@ async def _store() -> MessageStore: return await MessageStore.open(":memory:") -async def test_bootstrap_admin_created_once_and_can_log_in() -> None: - store = await _store() - try: - service = AuthService(store, AuthSettings()) - boot = await service.initialize() - assert boot is not None and boot.username == "admin" and len(boot.password) >= 15 - out = await service.login("admin", boot.password) - assert out.ok and out.must_change_password is True - assert out.identity is not None and Role.ADMINISTRATOR in out.identity.roles - # a second service over the same (now non-empty) store does not re-bootstrap - assert await AuthService(store, AuthSettings()).initialize() is None - finally: - await store.close() - - -async def test_bootstrap_password_satisfies_active_policy() -> None: - # The printed bootstrap credential is generated *through* the active policy (WP-3), even a strict one. - store = await _store() - try: - service = AuthService( - store, AuthSettings(password_min_length=20, password_require_symbol=True) - ) - boot = await service.initialize() - assert boot is not None - assert service.policy.violations(boot.password) == [] and len(boot.password) >= 20 - finally: - await store.close() - +async def test_initialize_seeds_roles_and_creates_no_account() -> None: + """BACKLOG #1020 / ASVS 6.3.2: a fresh store has NO users. The implicit first-run Administrator + was retired rather than given an email address, because the verb wants that account not to exist; + the replacement is the operator-invoked `messagefoundry admin-create` (tests/test_admin_create_cli.py). -async def test_bootstrap_auto_disabled_when_second_admin_created() -> None: + RED when initialize() mints an account again — the exact regression this item closed.""" store = await _store() try: service = AuthService(store, AuthSettings()) - boot = await service.initialize() - assert boot is not None - await service.create_local_user( - username="alice", - password="a-long-unguessable-passphrase", - display_name=None, - email=None, - roles=[Role.ADMINISTRATOR.value], - actor="admin", - ) - # the unclaimed bootstrap admin is retired the moment a real second admin exists - assert not (await service.login("admin", boot.password)).ok - retired = await store.get_user_by_username("admin") - assert retired is not None and retired.disabled - finally: - await store.close() - - -async def test_bootstrap_expires_when_left_unclaimed() -> None: - store = await _store() - try: - service = AuthService(store, AuthSettings(bootstrap_expiry_hours=72)) - boot = await service.initialize() - assert boot is not None - assert (await service.login("admin", boot.password)).ok # within the window: usable - # age the account past the expiry window - admin = await store.get_user_by_username("admin") - assert admin is not None - await store._db.execute( - "UPDATE users SET created_at=? WHERE id=?", (time.time() - 73 * 3600, admin.id) - ) - await store._db.commit() - assert not (await service.login("admin", boot.password)).ok # expired → refused - expired = await store.get_user_by_username("admin") - assert expired is not None and expired.disabled - finally: - await store.close() - - -async def test_claimed_bootstrap_is_not_retired() -> None: - # Once the operator changes the bootstrap password (must_change → False) it's a normal admin - # account; neither supersession nor expiry may disable it (no single-admin lockout). - store = await _store() - try: - service = AuthService(store, AuthSettings(bootstrap_expiry_hours=72)) - await service.initialize() - admin = await store.get_user_by_username("admin") - assert admin is not None - await store.set_password( - admin.id, - password_hash=hash_password("a-claimed-real-passphrase"), - must_change_password=False, - ) - # age it past expiry AND add a second admin — still must not be disabled - await store._db.execute( - "UPDATE users SET created_at=? WHERE id=?", (time.time() - 99 * 3600, admin.id) - ) - await store._db.commit() - await service.create_local_user( - username="alice", - password="another-long-passphrase", - display_name=None, - email=None, - roles=[Role.ADMINISTRATOR.value], - actor="admin", - ) - still = await store.get_user_by_username("admin") - assert still is not None and not still.disabled - finally: - await store.close() - - -# --- ASVS 6.4.5 arm 1: the bootstrap credential carries its own expiry deadline ------------------ - - -async def test_bootstrap_admin_carries_its_expiry_deadline() -> None: - store = await _store() - try: - service = AuthService(store, AuthSettings(bootstrap_expiry_hours=72)) - boot = await service.initialize() - assert boot is not None and boot.expires_at is not None - admin = await store.get_user_by_username("admin") - # exactly created_at + window (same base _retire_superseded_bootstrap uses) — not a fresh clock - assert abs(boot.expires_at - (admin.created_at + 72 * 3600)) < 1.0 - finally: - await store.close() - - -async def test_bootstrap_admin_deadline_is_none_when_expiry_off() -> None: - store = await _store() - try: - service = AuthService(store, AuthSettings(bootstrap_expiry_hours=0)) - boot = await service.initialize() - assert boot is not None and boot.expires_at is None - finally: - await store.close() - - -# --- ASVS 6.4.5 arm 2: an unclaimed bootstrap admin is reminded BEFORE it is auto-disabled -------- - - -async def test_bootstrap_expiry_warning_fires_once_in_window() -> None: - # An unclaimed bootstrap 24h from auto-disable draws exactly ONE reminder: the in-memory latch - # collapses a periodic caller to a single emit per process (the runner re-checks hourly). - store = await _store() - try: - service = AuthService( - store, AuthSettings(bootstrap_expiry_hours=72, bootstrap_warn_hours=24) - ) - boot = await service.initialize() - assert boot is not None - admin = await store.get_user_by_username("admin") - assert admin is not None - expires_at = admin.created_at + 72 * 3600 - at_t_minus_24h = expires_at - 24 * 3600 # exactly the window's leading edge - first = await service.bootstrap_expiry_warning(now=at_t_minus_24h) - assert first is not None - surfaced_expires, hours_remaining = first - assert ( - abs(surfaced_expires - expires_at) < 1.0 - ) # the exact retirement instant, not a fresh clock - assert hours_remaining == 24 - # a second pass anywhere in the window is latched → no second reminder - assert await service.bootstrap_expiry_warning(now=at_t_minus_24h + 3600) is None - finally: - await store.close() - - -async def test_bootstrap_expiry_warning_silent_before_the_window() -> None: - # Before [expires_at - warn_hours, expires_at): nothing — and a pre-window pass does NOT consume the - # latch, so the real window still fires afterwards. - store = await _store() - try: - service = AuthService( - store, AuthSettings(bootstrap_expiry_hours=72, bootstrap_warn_hours=24) - ) await service.initialize() - admin = await store.get_user_by_username("admin") - assert admin is not None - expires_at = admin.created_at + 72 * 3600 - assert await service.bootstrap_expiry_warning(now=expires_at - 25 * 3600) is None # before - assert ( - await service.bootstrap_expiry_warning(now=expires_at - 12 * 3600) is not None - ) # inside + assert await store.count_users() == 0 + assert await store.get_user_by_username("admin") is None + # Roles ARE seeded, so the operator route has something to assign. + assert {r["id"] for r in await store.list_roles()} >= {"administrator", "viewer"} + # No account means no credential to guess: every sign-in attempt fails, including the name + # the retired account used to carry. + assert not (await service.login("admin", "a-strong-test-passphrase")).ok finally: await store.close() -async def test_bootstrap_expiry_warning_silent_when_claimed() -> None: - # "claimed fires nothing": once the operator changes the password (must_change → False) it is a - # normal admin account and draws no retirement reminder, even inside what would be the window. +async def test_first_admin_from_the_operator_route_can_log_in() -> None: + # The replacement path, exercised at the service layer the CLI drives (the CLI's own end-to-end + # test is tests/test_admin_create_cli.py). Its password is the operator's, so no forced rotation. store = await _store() try: - service = AuthService( - store, AuthSettings(bootstrap_expiry_hours=72, bootstrap_warn_hours=24) - ) - await service.initialize() - admin = await store.get_user_by_username("admin") - assert admin is not None - await store.set_password( - admin.id, - password_hash=hash_password("a-claimed-real-passphrase"), - must_change_password=False, - ) - expires_at = admin.created_at + 72 * 3600 - assert await service.bootstrap_expiry_warning(now=expires_at - 1 * 3600) is None - finally: - await store.close() - - -async def test_bootstrap_expiry_warning_silent_when_expiry_off() -> None: - # No time-expiry configured → the credential is never auto-disabled, so there is nothing to warn of, - # however far past the (non-existent) deadline the clock is pushed. - store = await _store() - try: - service = AuthService( - store, AuthSettings(bootstrap_expiry_hours=0, bootstrap_warn_hours=24) - ) - await service.initialize() - assert await service.bootstrap_expiry_warning(now=time.time() + 999 * 3600) is None - finally: - await store.close() - - -async def test_bootstrap_expiry_warning_silent_after_the_deadline() -> None: - # At/after expires_at, retirement itself takes over (_retire_superseded_bootstrap); the pre-warning - # does not fire past the deadline. - store = await _store() - try: - service = AuthService( - store, AuthSettings(bootstrap_expiry_hours=72, bootstrap_warn_hours=24) - ) - await service.initialize() - admin = await store.get_user_by_username("admin") - assert admin is not None - expires_at = admin.created_at + 72 * 3600 - assert await service.bootstrap_expiry_warning(now=expires_at + 1) is None + service = AuthService(store, AuthSettings()) + await create_first_admin(service, username="alice") + out = await service.login("alice", FIRST_ADMIN_PW) + assert out.ok and out.identity is not None + assert Role.ADMINISTRATOR in out.identity.roles + user = await store.get_user_by_username("alice") + assert user is not None and user.must_change_password is False finally: await store.close() @@ -314,7 +118,7 @@ async def test_reset_temp_password_expires_when_unclaimed() -> None: out = await service.login("alice", temp) assert not out.ok # expired → refused, even with the CORRECT temp password assert out.error == "invalid credentials" # generic — not distinguishable from a wrong pw - # the account is NOT disabled (unlike bootstrap) — an admin can re-issue a fresh temp + # the account is NOT disabled — an admin can re-issue a fresh temp assert (await store.get_user_by_username("alice")).disabled is False finally: await store.close() @@ -361,24 +165,23 @@ async def test_initial_password_expiry_zero_disables_the_gate() -> None: await store.close() -async def test_bootstrap_admin_is_not_gated_by_initial_password_expiry() -> None: - # The bootstrap admin is must_change + carries password_changed_at, but is CARVED OUT of the - # 6.4.1 gate (it has its own bootstrap_expiry_hours path). With bootstrap expiry off, an aged, - # unclaimed bootstrap still logs in — the initial-password gate must not catch it. +async def test_operator_created_admin_is_not_gated_by_initial_password_expiry() -> None: + # ASVS 6.4.1 gates an admin-ISSUED temp (must_change_password). The operator route sets the + # operator's OWN password with must_change_password clear, so an aged first-admin credential is + # not caught by that gate. This replaces the carve-out the retired bootstrap account used to need + # (BACKLOG #1020): the exemption is now a property of the account, not a username special case. store = await _store() try: - service = AuthService( - store, AuthSettings(initial_password_expiry_hours=1, bootstrap_expiry_hours=0) - ) - boot = await service.initialize() - assert boot is not None - admin = await store.get_user_by_username("admin") + service = AuthService(store, AuthSettings(initial_password_expiry_hours=1)) + await create_first_admin(service) + admin = await store.get_user_by_username(FIRST_ADMIN) + assert admin is not None await store._db.execute( "UPDATE users SET password_changed_at=? WHERE id=?", (time.time() - 500 * 3600, admin.id), ) await store._db.commit() - assert (await service.login("admin", boot.password)).ok # not gated by the 6.4.1 expiry + assert (await service.login(FIRST_ADMIN, FIRST_ADMIN_PW)).ok finally: await store.close() diff --git a/tests/test_bootstrap_admin_perms.py b/tests/test_bootstrap_admin_perms.py deleted file mode 100644 index 240798a8..00000000 --- a/tests/test_bootstrap_admin_perms.py +++ /dev/null @@ -1,77 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later -# Copyright (C) 2026 MessageFoundry Organization and contributors -"""SEC-020 — the one-time bootstrap-admin password file is owner-only from the instant it exists. - -``_emit_bootstrap_admin`` must create ``bootstrap-admin.txt`` via an exclusive 0o600 ``os.open`` so a -co-located local user can never read the standing admin credential in a create-then-chmod window -(POSIX TOCTOU), and ``O_EXCL`` must refuse to follow a pre-planted symlink/file at that path.""" - -from __future__ import annotations - -import os -import sys -from pathlib import Path - -import pytest - -from messagefoundry.api.app import _emit_bootstrap_admin -from messagefoundry.auth.service import BootstrapAdmin -from messagefoundry.store import sqlite_settings - - -def _settings(tmp_path: Path) -> object: - # path → tmp_path/db.sqlite; the secret lands beside it as bootstrap-admin.txt - return sqlite_settings(tmp_path / "db.sqlite") - - -def _secret_path(tmp_path: Path) -> Path: - return (tmp_path / "db.sqlite").resolve().parent / "bootstrap-admin.txt" - - -def test_creates_file_with_expected_content(tmp_path: Path) -> None: - boot = BootstrapAdmin(username="admin", password="s3cr3t-one-time") - _emit_bootstrap_admin(boot, _settings(tmp_path)) # type: ignore[arg-type] - secret = _secret_path(tmp_path) - assert secret.is_file() - text = secret.read_text(encoding="utf-8") - assert "username: admin" in text - assert "password: s3cr3t-one-time" in text - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX chmod semantics (Windows uses an ACL)") -def test_file_is_owner_only_0600(tmp_path: Path) -> None: - boot = BootstrapAdmin(username="admin", password="s3cr3t") - _emit_bootstrap_admin(boot, _settings(tmp_path)) # type: ignore[arg-type] - mode = os.stat(_secret_path(tmp_path)).st_mode & 0o777 - assert mode == 0o600 - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only TOCTOU regression") -def test_does_not_leave_world_readable_or_follow_planted_file(tmp_path: Path) -> None: - # Pre-plant a world-readable file at the target path: the O_EXCL create must NOT inherit its - # permissions nor leave a 0o644 file — the secret only ever lands in a fresh 0o600 file we own. - secret = _secret_path(tmp_path) - secret.write_text("attacker-seeded\n", encoding="utf-8") - os.chmod(secret, 0o644) - boot = BootstrapAdmin(username="admin", password="s3cr3t") - _emit_bootstrap_admin(boot, _settings(tmp_path)) # type: ignore[arg-type] - mode = os.stat(secret).st_mode & 0o777 - assert mode == 0o600 - assert "attacker-seeded" not in secret.read_text(encoding="utf-8") - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") -def test_refuses_to_follow_planted_symlink(tmp_path: Path) -> None: - # A pre-planted symlink to a victim file: O_EXCL must refuse to follow it, so the victim is never - # overwritten with the secret and the secret never lands in an attacker-controlled location. - victim = tmp_path / "victim.txt" - victim.write_text("untouched\n", encoding="utf-8") - secret = _secret_path(tmp_path) - secret.symlink_to(victim) - boot = BootstrapAdmin(username="admin", password="s3cr3t") - _emit_bootstrap_admin(boot, _settings(tmp_path)) # type: ignore[arg-type] - # the symlink target was NOT overwritten with the credential - assert victim.read_text(encoding="utf-8") == "untouched\n" - # and the real secret file is a regular 0o600 file we created (not a symlink) - assert not secret.is_symlink() - assert os.stat(secret).st_mode & 0o777 == 0o600 diff --git a/tests/test_dr_server_seed_gate_postgres.py b/tests/test_dr_server_seed_gate_postgres.py index 6a261c45..db694384 100644 --- a/tests/test_dr_server_seed_gate_postgres.py +++ b/tests/test_dr_server_seed_gate_postgres.py @@ -2,7 +2,7 @@ # Copyright (C) 2026 MessageFoundry Organization and contributors """Live Postgres proof of the #102 server-DB DR seed gate (ADR 0048), reproducing the REAL deployment path. On a genuinely fresh/unrestored 'mefor' store whose audit_log is NON-EMPTY-but-has-no-dr_backup-row -(the bootstrap+login signature) + attestation → REFUSED (the data-loss case the config-only archive and +(the first-admin-creation+login signature) + attestation → REFUSED (the data-loss case the config-only archive and the refuted count>0 probe both miss). A store carrying a dr_backup row (restored-primary signature) + attestation → PASS. No attestation → REFUSED. run_restore_verify is stubbed to PASS so the test isolates the LIVE restore-provenance probe (has_prior_backup_history against a real backend). @@ -69,13 +69,15 @@ async def deact() -> None: async def _reset_to_fresh_bootstrapped(store: Any) -> None: - # Reproduce a FRESH/UNRESTORED but engine-started DB: audit_log NON-EMPTY (bootstrap + login) yet with - # NO dr_backup row. This is the exact real-path state the refuted count>0 probe would have PASSED. + # Reproduce a FRESH/UNRESTORED but engine-started DB: audit_log NON-EMPTY (first-admin creation + + # login) yet with NO dr_backup row. This is the exact real-path state the refuted count>0 probe + # would have PASSED. The two actions are the ones a first run actually writes: since BACKLOG #1020 + # that is `user.created` from `messagefoundry admin-create`, not the retired + # `auth.bootstrap_admin_created` — a fixture naming an action the engine can no longer emit is a + # fixture describing a state the product cannot reach. async with store._pool.acquire() as conn: await conn.execute("DELETE FROM audit_log") - await store.record_audit( - "auth.bootstrap_admin_created", actor="bootstrap", detail="{}", now=1.0 - ) + await store.record_audit("user.created", actor="cli", detail="{}", now=1.0) await store.record_audit("auth.login_success", actor="alice", detail="{}", now=2.0) diff --git a/tests/test_dr_server_seed_gate_sqlserver.py b/tests/test_dr_server_seed_gate_sqlserver.py index b0ccd60a..fcb74ac6 100644 --- a/tests/test_dr_server_seed_gate_sqlserver.py +++ b/tests/test_dr_server_seed_gate_sqlserver.py @@ -2,7 +2,7 @@ # Copyright (C) 2026 MessageFoundry Organization and contributors """Live SQL Server proof of the #102 server-DB DR seed gate (ADR 0048), reproducing the REAL deployment path. On a genuinely fresh/unrestored 'mefor' store whose audit_log is NON-EMPTY-but-has-no-dr_backup-row -(the bootstrap+login signature) + attestation → REFUSED (the data-loss case the config-only archive and +(the first-admin-creation+login signature) + attestation → REFUSED (the data-loss case the config-only archive and the refuted count>0 probe both miss). A store carrying a dr_backup row (restored-primary signature) + attestation → PASS. No attestation → REFUSED. run_restore_verify is stubbed to PASS so the test isolates the LIVE restore-provenance probe (has_prior_backup_history against a real backend). @@ -69,15 +69,17 @@ async def deact() -> None: async def _reset_to_fresh_bootstrapped(store: Any) -> None: - # Reproduce a FRESH/UNRESTORED but engine-started DB: audit_log NON-EMPTY (bootstrap + login) yet with - # NO dr_backup row. This is the exact real-path state the refuted count>0 probe would have PASSED. + # Reproduce a FRESH/UNRESTORED but engine-started DB: audit_log NON-EMPTY (first-admin creation + + # login) yet with NO dr_backup row. This is the exact real-path state the refuted count>0 probe + # would have PASSED. The two actions are the ones a first run actually writes: since BACKLOG #1020 + # that is `user.created` from `messagefoundry admin-create`, not the retired + # `auth.bootstrap_admin_created` — a fixture naming an action the engine can no longer emit is a + # fixture describing a state the product cannot reach. async with store._pool.acquire() as conn: cur = await conn.cursor() await cur.execute("DELETE FROM audit_log") await conn.commit() - await store.record_audit( - "auth.bootstrap_admin_created", actor="bootstrap", detail="{}", now=1.0 - ) + await store.record_audit("user.created", actor="cli", detail="{}", now=1.0) await store.record_audit("auth.login_success", actor="alice", detail="{}", now=2.0) diff --git a/tests/test_last_admin_guard.py b/tests/test_last_admin_guard.py index b4eaf975..b3dcd201 100644 --- a/tests/test_last_admin_guard.py +++ b/tests/test_last_admin_guard.py @@ -22,6 +22,7 @@ import httpx import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from messagefoundry.api import create_app from messagefoundry.auth.service import AuthService @@ -54,16 +55,13 @@ async def _login(c: httpx.AsyncClient, username: str, password: str) -> httpx.Re async def _admin_session(c: httpx.AsyncClient, service: AuthService) -> tuple[dict[str, str], str]: - """Bootstrap the first admin, clear its must-change flag; return (auth-headers, admin-user-id).""" - boot = await service.initialize() - assert boot is not None - h = _auth((await _login(c, "admin", boot.password)).json()["token"]) - await c.post( - "/me/password", - headers=h, - json={"current_password": boot.password, "new_password": "a-rotated-passphrase-99"}, - ) - h = _auth((await _login(c, "admin", "a-rotated-passphrase-99")).json()["token"]) + """Create the first admin and sign it in; return (auth-headers, admin-user-id). + + BACKLOG #1020 retired the implicit first-run account, so there is no machine-issued password to + rotate here — the operator route (`admin-create`, mirrored by `create_first_admin`) sets the + password the operator chose, with must_change_password already clear.""" + await create_first_admin(service) + h = _auth((await _login(c, FIRST_ADMIN, FIRST_ADMIN_PW)).json()["token"]) my_id = (await c.get("/auth/me", headers=h)).json()["user_id"] return h, my_id @@ -75,7 +73,7 @@ async def _reauth_update(c: httpx.AsyncClient, h: dict[str, str]) -> None: r = await c.post( "/me/reauth", headers=h, - json={"password": "a-rotated-passphrase-99", "purpose": "admin_user_update"}, + json={"password": FIRST_ADMIN_PW, "purpose": "admin_user_update"}, ) assert r.status_code == 200, r.text diff --git a/tests/test_mfa.py b/tests/test_mfa.py index 2b0eceae..f93d3ca7 100644 --- a/tests/test_mfa.py +++ b/tests/test_mfa.py @@ -13,6 +13,7 @@ import asyncio import pytest +from _first_admin import FIRST_ADMIN, FIRST_ADMIN_PW, create_first_admin from _totp_clock import fresh_totp, pin_totp_clock from messagefoundry.auth import totp @@ -21,7 +22,7 @@ from messagefoundry.auth.notifications import MFA_DISABLED, MFA_ENABLED, SecurityEvent from messagefoundry.auth.service import AuthService from messagefoundry.config.settings import AuthSettings -from messagefoundry.store.store import MessageStore +from messagefoundry.store.store import MessageStore, WebAuthnCredential class _FakeNotifier: @@ -39,12 +40,14 @@ async def _store() -> MessageStore: async def _bootstrap_login(service: AuthService) -> tuple[Identity, str, str]: - """Bootstrap the admin and log it in; return (identity, token, password) for the MFA flows.""" - boot = await service.initialize() - assert boot is not None - out = await service.login("admin", boot.password) + """Create the first Administrator and log it in; return (identity, token, password). + + BACKLOG #1020 retired the implicit first-run account, so this stands one up the way the + ``admin-create`` CLI does before signing in.""" + await create_first_admin(service) + out = await service.login(FIRST_ADMIN, FIRST_ADMIN_PW) assert out.ok and out.identity is not None and out.token is not None - return out.identity, out.token, boot.password + return out.identity, out.token, FIRST_ADMIN_PW async def test_enroll_confirm_status_and_recovery_codes() -> None: @@ -139,9 +142,8 @@ async def test_require_mfa_forces_admin_even_unenrolled() -> None: store = await _store() try: service = AuthService(store, AuthSettings(require_mfa=True)) - boot = await service.initialize() - assert boot is not None - out = await service.login("admin", boot.password) + await create_first_admin(service) + out = await service.login(FIRST_ADMIN, FIRST_ADMIN_PW) # Admin must MFA even though not enrolled — they can log in but can't satisfy step-up until # they enroll a TOTP authenticator. assert out.ok and out.mfa_required is True and out.token is not None @@ -226,8 +228,17 @@ async def test_disable_and_admin_reset_clear_mfa(monkeypatch: pytest.MonkeyPatch store = await _store() try: notifier = _FakeNotifier() + # ``require_mfa=False`` is load-bearing, not incidental: this test's subject is that disable and + # admin-reset CLEAR the enrollment, and under the default (``require_mfa=True``, + # ``every_local_account``) the last-factor guard added for BACKLOG #1022 correctly refuses to + # strip the account's only factor. The refusal itself is covered below, under the default; here + # the documented opt-out puts the clear-semantics back in view. Enrolling a passkey instead + # would have been the other way to get here — rejected because it would have made this test + # depend on the optional [webauthn] extra. service = AuthService( - store, AuthSettings(mfa_recovery_code_count=2), security_notifier=notifier + store, + AuthSettings(mfa_recovery_code_count=2, require_mfa=False), + security_notifier=notifier, ) identity, token, _ = await _bootstrap_login(service) enroll = await service.begin_mfa_enrollment(identity) @@ -260,6 +271,132 @@ async def test_disable_and_admin_reset_clear_mfa(monkeypatch: pytest.MonkeyPatch await store.close() +# --- last-factor guard parity: TOTP-disable vs passkey-delete (BACKLOG #1022) --- +# +# Deliberately EXTRA-FREE (ADR 0068 section 4). The guard is a property of the account's factor state +# and has nothing to do with the optional ``[webauthn]`` extra, so the passkey side is staged through +# the ``AuthStore`` surface (the ``tests/_webauthn_store_contract.py`` precedent) rather than a real +# ceremony. A ``pytest.importorskip("webauthn")`` on this path would silently skip the guard on every +# leg that installs without the extra — a skip wearing a pass. + + +async def _stage_passkey(store: MessageStore, user_id: str, *, id_hash: str) -> None: + await store.add_webauthn_credential( + WebAuthnCredential( + credential_id_hash=id_hash, + credential_id=f"cred-{id_hash}", + user_id=user_id, + rp_id="t", + public_key="cose-public-key-b64url", + sign_count=0, + transports=None, + device_type="multi_device", + backed_up=True, + label=id_hash, + aaguid="aaguid-0000", + created_at=1000.0, + last_used_at=None, + ) + ) + + +async def _enable_totp(service: AuthService, identity: Identity, token: str, at: float) -> None: + enroll = await service.begin_mfa_enrollment(identity) + await service.confirm_mfa_enrollment(identity, totp.totp(enroll.secret, now=at), token=token) + assert (await service.mfa_status(identity)).enabled is True + + +async def test_disable_mfa_refused_when_totp_is_the_last_factor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # BACKLOG #1022 / ADR 0068 AC-10: TOTP-disable now refuses exactly where passkey-delete does. + # RED when disable_mfa stops consulting has_webauthn_credentials + _mfa_required_for. + store = await _store() + try: + service = AuthService(store, AuthSettings()) # require_mfa on by default + identity, token, _ = await _bootstrap_login(service) + pin_totp_clock(monkeypatch, 1_000_000.0) + await _enable_totp(service, identity, token, 1_000_000.0) + + with pytest.raises(ValueError, match="enroll another factor first"): + await service.disable_mfa(identity) + assert (await service.mfa_status(identity)).enabled is True # still on — the guard held + finally: + await store.close() + + +async def test_disable_mfa_allowed_while_a_passkey_remains( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The guard must NOT be a TOTP-only check: a user who keeps a passkey is still enrolled and stays + # free to drop TOTP. RED when the guard degenerates into "an enrolled user keeps TOTP". + store = await _store() + try: + service = AuthService(store, AuthSettings()) + identity, token, _ = await _bootstrap_login(service) + pin_totp_clock(monkeypatch, 1_000_000.0) + await _enable_totp(service, identity, token, 1_000_000.0) + await _stage_passkey(store, identity.user_id, id_hash="pk1") + + await service.disable_mfa(identity) + assert (await service.mfa_status(identity)).enabled is False + assert await store.has_webauthn_credentials(identity.user_id) is True + finally: + await store.close() + + +async def test_disable_mfa_allowed_when_mfa_is_not_required( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The guard keys on _mfa_required_for, exactly as the passkey path does, so the documented + # ``[auth].require_mfa = false`` opt-out still lets a voluntarily-enrolled user turn TOTP off. + store = await _store() + try: + service = AuthService(store, AuthSettings(require_mfa=False)) + identity, token, _ = await _bootstrap_login(service) + pin_totp_clock(monkeypatch, 1_000_000.0) + await _enable_totp(service, identity, token, 1_000_000.0) + + await service.disable_mfa(identity) + assert (await service.mfa_status(identity)).enabled is False + finally: + await store.close() + + +async def test_zero_factor_state_is_unreachable_by_either_removal_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The item's actual claim, and the reason the guard is stated over the STATE rather than over one + route: with TOTP plus one passkey enrolled, EITHER removal is permitted first and the SECOND one is + refused. Before the guard, the passkey-then-TOTP order reached zero enrolled factors while the + TOTP-then-passkey order did not — the same end state, allowed or refused purely by ordering.""" + store = await _store() + try: + service = AuthService(store, AuthSettings()) # require_mfa on + identity, token, _ = await _bootstrap_login(service) + pin_totp_clock(monkeypatch, 1_000_000.0) + + # Order A — passkey first, then TOTP. This is the ordering that used to reach zero factors. + await _enable_totp(service, identity, token, 1_000_000.0) + await _stage_passkey(store, identity.user_id, id_hash="pk-a") + assert await service.delete_webauthn_credential(identity, "pk-a") is True # TOTP remains + with pytest.raises(ValueError, match="enroll another factor first"): + await service.disable_mfa(identity) + assert (await service.mfa_status(identity)).enabled is True + + # Order B — TOTP first, then the passkey. Refused at the second step, as it always was. + await _stage_passkey(store, identity.user_id, id_hash="pk-b") + await service.disable_mfa(identity) # a passkey remains, so this is permitted + with pytest.raises(ValueError, match="enroll another factor first"): + await service.delete_webauthn_credential(identity, "pk-b") + + # Whichever order was taken, exactly one factor survives. + status = await service.mfa_status(identity) + assert (status.enabled, status.webauthn_enrolled) == (False, True) + finally: + await store.close() + + async def test_ad_login_is_mfa_satisfied_by_delegation() -> None: store = await _store() try: diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index 5270161a..3c53bc0b 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -46,10 +46,6 @@ def test_scaffold_writes_the_skeleton(tmp_path: Path) -> None: # the service settings template carries the new posture model, not the retired enum toml = (repo / "messagefoundry.toml").read_text() assert 'environment = "dev"' in toml and "data_class" in toml and "production" 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() - assert "bootstrap-admin.txt" in gitignore # the template + README teach WS-1's env-anchor so a config repo run under a service (CWD != repo # root) still resolves environments/.toml (ADR 0017): base_dir in the toml, --project-root in docs assert "base_dir" in toml diff --git a/tests/test_security_doc_drift.py b/tests/test_security_doc_drift.py index 650f8794..b482e025 100644 --- a/tests/test_security_doc_drift.py +++ b/tests/test_security_doc_drift.py @@ -304,8 +304,6 @@ "oidc_required_acr_values", "oidc_allowed_username_domains", "oidc_username_strip_domain", - # a time attribute x admin-population state that DENIES (disable + revoke + audit) - "bootstrap_expiry_hours", # DATA PLANE — the binding correction: these are pre-auth, IP-keyed ALLOW/DENY decisions too "source_ip_allowlist", "calling_ae_allowlist", @@ -373,7 +371,6 @@ ("auth", "ad_session_recheck_max_users", 200, "200 users"), ("auth", "ad_session_revoke_max", 5, "**5**"), ("auth", "ad_session_revoke_max_fraction", 0.34, "**0.34**"), - ("auth", "bootstrap_expiry_hours", 72, "72 h"), ("auth", "oidc_require_mfa_claim", True, "on"), # These three were documented but unpinned — precisely the defaults the trailing lanes plan to # move (#297's 8.3.2 route proposes an ADR-0080-style derived ad_session_recheck_seconds), so a @@ -397,7 +394,6 @@ "step_up", "session_", "lockout_", - "bootstrap_", "_allowlist", "_networks", "_origins", @@ -435,11 +431,6 @@ # WP #285 (ASVS 6.7.1): the optional SHA-256 integrity pin over the OIDC CA anchor above — # an integrity control on trust material, not a consumer/environment access-decision input. "oidc_tls_ca_cert_pin", - # ASVS 6.4.5 arm 2: how long BEFORE the bootstrap deadline to start reminding an operator that - # the unclaimed first-run credential is about to be retired. Purely the timing of an advisory - # ALERT — no login, session or authorization outcome turns on it (contrast its sibling - # `bootstrap_expiry_hours`, which DISABLES the account and is therefore an inventoried input). - "bootstrap_warn_hours", # ASVS 3.7.3: destinations exempted from the "you are leaving this site" interstitial. It # decides whether the operator is SHOWN A NOTIFICATION before an outbound navigation — not # whether any request is authorized. No login, session, permission or authorization outcome @@ -468,7 +459,10 @@ #: Body-row counts of the two decision tables. Row-scoping alone cannot catch the deletion of a row #: whose tokens are shared with a sibling row (Sec-Fetch, bind/exposure, the DICOM construction #: gate), so the counts are pinned too: removing ANY row reds CI. -_CONTEXT_TABLE_A_ROWS = 35 +#: 35 -> 34 on 2026-08-10 (BACKLOG #1020): the "Bootstrap-admin age x admin population" row went with +#: the account it inventoried. This gate did its job — the row was deleted deliberately, and the count +#: is moved in the same change rather than after a red. +_CONTEXT_TABLE_A_ROWS = 34 _CONTEXT_TABLE_B_ROWS = 9 #: The closed action vocabulary the section declares. Every Action cell in BOTH tables must OPEN with diff --git a/tests/test_security_doc_rate_limits.py b/tests/test_security_doc_rate_limits.py index 1d5758a9..0f2feb2f 100644 --- a/tests/test_security_doc_rate_limits.py +++ b/tests/test_security_doc_rate_limits.py @@ -414,8 +414,8 @@ def test_business_logic_limit_table_states_both_dimensions_for_every_row() -> No #: Enforcement-scope vocabulary the Scope cell must declare, per row. The blanket claim this #: replaces ("All are in-process, per API process — N engine shards multiply every budget by N") was -#: false for three of the table's own rows: two API processes share ONE lockout counter, ONE session -#: cap and ONE bootstrap timer, because all three are written through the store. +#: false for rows of the table's own: two API processes share ONE lockout counter and ONE session +#: cap, because both are written through the store. _SCOPE_TOKENS = ("**in-process**", "**store-backed**", "**stateless**", "**n/a**") #: Limits whose state lives in the STORE, mapped to the ``self._store`` method that proves it. If a @@ -423,7 +423,6 @@ def test_business_logic_limit_table_states_both_dimensions_for_every_row() -> No _STORE_BACKED_LIMITS: dict[str, str] = { "lockout_threshold": "record_login_failure", "max_sessions_per_user": "enforce_session_cap", - "bootstrap_expiry_hours": "set_user_disabled", } #: Reject-when-full caches that are plain in-process objects, mapped to the class ``_bounded_caches`` @@ -472,9 +471,9 @@ def test_business_logic_limit_scope_is_stated_per_row() -> None: RULE (2.1.3): "N engine shards multiply every budget by N" is a claim an assessor tests. It is true of the sliding-window limiters and the two pending-flow caches and FALSE of the account - lockout, the session cap and the bootstrap timer, which are written through the one unified - store. Blanket-scoping the table inverts exactly the distinction that separates a per-process - limiter from a durable one. + lockout and the session cap, which are written through the one unified store. Blanket-scoping + the table inverts exactly the distinction that separates a per-process limiter from a durable + one. """ table = next(t for t in _tables(_section(_H_LIMITS)) if t[0][0] == "Limit") scope = table[0].index("Scope") @@ -1323,7 +1322,6 @@ def test_lockout_is_fed_by_two_legs_but_enforced_on_the_assertion_leg_too() -> N ("admin_write_rate_limit_per_actor", 12), ("admin_write_rate_limit_window_seconds", 1.0), ("max_sessions_per_user", 5), - ("bootstrap_expiry_hours", 72), ], ) def test_limit_defaults_quoted_in_the_table_match_the_code(field: str, pinned: object) -> None: diff --git a/tests/test_settings.py b/tests/test_settings.py index ba8edd84..f8647981 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -212,7 +212,6 @@ def test_auth_password_policy_defaults_are_asvs_aligned() -> None: assert a.password_check_breached and a.password_check_context assert a.password_check_username # v2: own-username rejection on by default (6.2.11) assert a.password_breach_corpus_file is None # opt-in larger offline corpus (6.2.12) - assert a.bootstrap_expiry_hours == 72 def test_auth_breach_corpus_and_username_check_from_env() -> None: diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index 9b94e1d7..636412f0 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -24,6 +24,11 @@ from messagefoundry.auth.service import AuthService # noqa: E402 from messagefoundry.config.settings import AuthSettings # noqa: E402 from messagefoundry.store.store import MessageStore # noqa: E402 +from tests._first_admin import ( # noqa: E402 + FIRST_ADMIN, + FIRST_ADMIN_PW, + create_first_admin, +) from tests._soft_webauthn import SoftAuthenticator # noqa: E402 RP = "t" @@ -51,11 +56,11 @@ async def _service( async def _bootstrap_login(service: AuthService) -> tuple[Identity, str, str]: - boot = await service.initialize() - assert boot is not None - out = await service.login("admin", boot.password) + # BACKLOG #1020: no implicit first-run account — stand one up as `admin-create` does. + await create_first_admin(service) + out = await service.login(FIRST_ADMIN, FIRST_ADMIN_PW) assert out.ok and out.identity is not None and out.token is not None - return out.identity, out.token, boot.password + return out.identity, out.token, FIRST_ADMIN_PW async def _enroll(