Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1613,7 +1613,7 @@ jobs:
# are declared there (ADR 0013), and SQL Server / Postgres both declare supports_response_capture
# + supports_pt_reingress True — so a regression in that surface is a SERVER-DB regression and must
# pull these legs, not just the SQLite suite.
if echo "$changed" | grep -qE '^(messagefoundry/store/|messagefoundry/__main__|messagefoundry/api/app|messagefoundry/parsing/(__init__|binary|message|peek|x12/)|messagefoundry/pipeline/(__init__|alerts|cluster|config_convergence|dr|leader_tasks|phase_timing|sharding|stage_dispatcher|wiring_runner)|messagefoundry/config/(models|response|settings|wiring)|messagefoundry/transports/(__init__|base|database|dicomweb|fhir|http_auth|mllp|rest|soap|tcp|x12)|tests/test_(sqlserver|postgres|cluster|database_connector|database_source|pooled|stage_dispatcher|batch_claim|claim_fifo|inline_fast_path|seq_only_fifo|fifo_index|per_lane_wake|response_capture|reingress|x12_rte|shard_recovery|shard_cert|adr0071|adr0075|adr0114|adr0157|dr_server_seed_gate|dr7_server_config_only_backup|backup_runner_server_db|connscale|load_failover|load_runner)|\.github/workflows/ci\.yml)'; then
if echo "$changed" | grep -qE '^(messagefoundry/store/|messagefoundry/__main__|messagefoundry/api/(__init__|app)|messagefoundry/parsing/(__init__|binary|message|peek|x12/)|messagefoundry/pipeline/(__init__|alerts|cluster|config_convergence|dr|leader_tasks|phase_timing|sharding|stage_dispatcher|wiring_runner)|messagefoundry/config/(models|response|settings|wiring)|messagefoundry/transports/(__init__|base|database|dicomweb|fhir|http_auth|mllp|rest|soap|tcp|x12)|tests/test_(sqlserver|postgres|cluster|database_connector|database_source|pooled|stage_dispatcher|batch_claim|claim_fifo|inline_fast_path|seq_only_fifo|fifo_index|per_lane_wake|response_capture|reingress|x12_rte|shard_recovery|shard_cert|adr0071|adr0075|adr0114|adr0157|dr_server_seed_gate|dr7_server_config_only_backup|backup_runner_server_db|connscale|load_failover|load_runner|store_privilege)|\.github/workflows/ci\.yml)'; then
echo "serverdb=true" >> "$GITHUB_OUTPUT"
else
echo "serverdb=false" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -2109,6 +2109,34 @@ jobs:
tests/test_backup_runner_server_db_sqlserver.py
tests/test_dr7_server_config_only_backup_sqlserver.py

- name: Run the store-principal privilege preflight on real SQL Server
env:
MEFOR_TEST_SQLSERVER: "1"
MEFOR_STORE_BACKEND: sqlserver
MEFOR_STORE_SERVER: localhost
MEFOR_STORE_PORT: "1433"
MEFOR_STORE_DATABASE: MessageFoundry
MEFOR_STORE_AUTH: sql
MEFOR_STORE_USERNAME: sa
MEFOR_STORE_PASSWORD: "Str0ng_P@ssw0rd!"
MEFOR_STORE_TRUST_SERVER_CERTIFICATE: "true"
MEFOR_ALLOW_INSECURE_TLS: "1"
PYTHONFAULTHANDLER: "1"
# BACKLOG #1008 (ASVS 13.2.2). The preflight's POLICY is backend-free and runs on every leg;
# its SQL is not, and `IS_SRVROLEMEMBER` / `IS_ROLEMEMBER` / `HAS_PERMS_BY_NAME` execute
# against a real server or nowhere. This file gates those legs PER TEST rather than at module
# level (its SQLite cases must still run everywhere), so tests/test_serverdb_ci_coverage.py
# deliberately does not see it and would not have flagged its absence here — the file's own
# test_the_live_legs_of_this_file_are_run_by_a_server_db_ci_step does, and it was failed on
# purpose against this workflow before this step existed.
# This leg connects as `sa`, which is `sysadmin` by construction, so it is also the standing
# POSITIVE control: a probe that stopped seeing an over-grant reds here.
# Same pyodbc 5.3.0 + py3.14 native-crash retry as the steps above (upstream pyodbc#1459).
run: >-
bash scripts/ci/retry-native-crash.sh
pytest -v
tests/test_store_privilege_preflight.py

# Postgres store backend (Track B): run the gated store suite against a real PostgreSQL service
# container (Linux, so 1x minutes). Runs NIGHTLY + on-demand (workflow_dispatch — use
# `gh workflow run ci.yml --ref <branch>` to exercise it on a feature branch) + on PRs that touch
Expand Down Expand Up @@ -2298,6 +2326,27 @@ jobs:
tests/test_backup_runner_server_db_postgres.py
tests/test_dr7_server_config_only_backup_postgres.py

- name: Run the store-principal privilege preflight on real Postgres
env:
MEFOR_TEST_POSTGRES: "1"
MEFOR_STORE_BACKEND: postgres
MEFOR_STORE_SERVER: localhost
MEFOR_STORE_PORT: "5432"
MEFOR_STORE_DATABASE: messagefoundry
MEFOR_STORE_USERNAME: postgres
MEFOR_STORE_PASSWORD: mefor
MEFOR_STORE_ENCRYPT: "false"
MEFOR_ALLOW_INSECURE_TLS: "1"
# BACKLOG #1008 (ASVS 13.2.2), the Postgres twin of the SQL Server step — see its comment for
# why this file needs naming here even though tests/test_serverdb_ci_coverage.py does not flag
# it. `pg_has_role` / `rolsuper` / `has_database_privilege` execute against a real server or
# nowhere. This leg connects as `postgres`, a SUPERUSER owning the database, so it is the
# standing POSITIVE control; the both-directions case additionally creates a purpose-made
# least-privilege role and asserts the probe stays SILENT on it.
run: >-
pytest -v
tests/test_store_privilege_preflight.py

# Headless load test (Track B / throughput): serve the synthetic high-fan-out load config (auth
# off, small fan-out) and drive the smoke profile through the real `python -m harness --load` CLI,
# asserting zero message loss + all SLOs (exit 0) and uploading the JSON/CSV report. Skipped on PRs
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,45 @@ All notable changes to MessageFoundry are documented here. The format follows
## [Unreleased]

### Added
- **A startup preflight that reads the store principal's *effective* privileges, so the least-privilege
grant the runbooks prescribe stops being a claim the engine cannot check.**
[`DEPLOY-SERVER-DB.md`](docs/DEPLOY-SERVER-DB.md) told operators exactly which grant the engine's
database login needs, and the engine had no way to see what it had actually been given: no
fixed-server-role probe and no database-role probe existed anywhere, and
`[store].require_managed_identity` constrains the credential's *kind* rather than its privilege — a
`sysadmin` gMSA satisfies it clean. On a first deployment an over-granted store principal would
therefore have gone unobserved. `serve` now reads fixed **server**-role and **database**-role
membership plus `CONTROL SERVER` / database `CONTROL` on SQL Server, and role attributes
(`SUPERUSER`, `CREATEROLE`, `CREATEDB`, `REPLICATION`, `BYPASSRLS`), assumable predefined roles and
database ownership on PostgreSQL — before any listener binds. The PostgreSQL attributes are read
across **every role the principal may assume**, not only its own row: attributes are never
inherited, but a member may `SET ROLE` to the holder and exercise them, so a wrapper role carrying
`CREATEROLE` is named (`CREATEROLE via role site_ops`) instead of reading clean.
**It observes and warns; it does not refuse by default** — refusing on an over-grant could block a
legitimate deployment mid-setup, and the engine does not own the grant. Every start logs what it saw,
writes a `store_privilege_preflight` audit row, and names each excess grant in
`security_loosenings()` and `GET /security/posture`. Set `[store].require_least_privilege = true` to
turn the warning into a refusal (refuse/warn splits on `[security].enforcement`, exactly like
`require_managed_identity`).
**It does not fail open, and that is the part to know before reading its output.** A probe that
cannot run — permission denied, a driver error, a store handle with no probe — reports
`unobservable`, which is a *different* result from "observed, and it is fine" in the log line, in the
audit row and in the posture response, and which a declared `require_least_privilege` also refuses.
SQLite reports `not_applicable` and says why: a local file has no server principal, and the control
there is the filesystem ACL. The PostgreSQL least-privilege grant is now documented
([`DEPLOY-SERVER-DB.md`](docs/DEPLOY-SERVER-DB.md) §1.2), which it previously was not.
([BACKLOG #1008](docs/BACKLOG.md))

### Changed
- **Web console engine UI seam `93ba1f10b9dccfc8` -> `b93f38d097f97a45`.** `SecurityPosture` gained the
additive `store_privilege` object above, and `StorePrivilegeView` joins the discovered surface.
Additive with a default, so an older console ignores it; the seam still moves because the golden seam
contract introspects that model's field set.
- **`DEPLOY-SERVER-DB.md` §1.2 posture B now states its prerequisite.** "A DBA pre-creates the objects"
is not sufficient on its own: the engine skips its DDL batch only when the `schema_meta` marker
records the current batch, and on PostgreSQL `CREATE TABLE IF NOT EXISTS` against an existing table
is still refused for a role holding only `USAGE` (the schema ACL is checked before the existence
skip, measured on 16.14). Bootstrap once with a DDL-capable principal, then hand over.
- **`messagefoundry audit-anchor`, and `audit-verify --expected-anchor` / `--expected-anchor-file` to
check one back.** The audit hash chain links each row to its predecessor, so deleting the *newest*
rows leaves a shorter chain that still walks cleanly — `audit-verify` on its own reports OK after a
Expand Down
42 changes: 42 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14614,6 +14614,48 @@ preflight is re-implemented against current `main` as its own item. **Neither is
**AND THE SCREEN THAT MISSED IT CANNOT SEE THIS BY CONSTRUCTION.** Verdict, closing-act, claim state,
build commits and retirement markers all read the LEDGER. ***"Does the subject exist on main" is the
only check that reads the CODE, and it is the one that decides startability.***

**AMENDMENT 2026-09-03, builder seat. THE CLEARING CONDITION IS MET -- THIS ITEM IS STARTABLE. IT IS
NOT CLOSED.** The amendment above named the exact condition: *"that branch is rebased onto `main`
with a PR opened"*. That has now happened, on the owner's ruling of 2026-09-03. **PR #764** carries
the preflight onto current `origin/main`, so the subject this item reports a defect in now exists
where a builder would branch from.

***THE DEFECT IS UNCHANGED AND UNFIXED, WHICH IS WHY THIS ROW STAYS OPEN.*** PR #764 is a carry, not
a fix, and deliberately so: the two-arm test this item specifies is its own reviewable change, and
folding a behaviour fix into a 530-commit catch-up would make both unreviewable. **What moved is
startability, and only that.**

**WHERE THE DEFECT SITS ON THAT BRANCH,** so the next builder does not re-find it. In
`messagefoundry/store/sqlserver.py`, inside `probe_principal_privileges` at `:2917`: `:2980` returns
`status=StorePrivilegeStatus.OBSERVED` unconditionally once the row is non-NULL, and `:2954`/`:2957`
fold a NULL role result to False via `== 1`, which reads as *"not a member"* and therefore as clean.
`:2988` and `:2989` fold `HAS_PERMS_BY_NAME` the same way -- **a limb this item's original scope did
not name**, so a fix that repairs only the role columns would leave a direct `GRANT CONTROL SERVER`
mis-read exactly as before. Sibling arms for comparison: `postgres.py:1245`, `store.py:4672`.
`[store].require_least_privilege` remains `False` at `config/settings.py:569`, so the WARN-is-the-
only-reachable-arm reading above still holds unchanged.

**THE 328-BEHIND FIGURE ABOVE IS CORRECTED TO 530, AND THE DRIFT IS THE POINT.** Measured 2026-09-03
at `origin/main`: `w3-store-privilege-preflight`, tip `94cb72e6`, dated 2026-08-11, is 4 ahead and
**530 behind**. It was 328 behind when the amendment above was written and kept moving for the
eleven days this row sat in the pool, which is the cost that amendment was warning about. The
absence measurement was re-run and still held at carry time: `require_least_privilege` and
`least_privilege` both return **zero** hits in any `.py` on `origin/main`. **Positive control,
same instrument and ref: `require_managed_identity` returns hits across four `.py` files** -- a
stronger control than the 2026-08-23 reading used, which established only that the instrument could
find the word `store`.

**THE ORIGINAL BRANCH WAS NOT REBASED IN PLACE AND WAS NOT FORCE-PUSHED.** PR #764 cherry-picks onto
a NEW branch cut from current `main`. Three of the four commits were carried; the fourth is an empty
coordination record correcting a false claim in the second, and that correction was applied to the
carried commit message rather than carried as a commit of its own.
`w3-store-privilege-preflight` is untouched and remains the only copy of the pre-carry history.

**PROVENANCE.** Written by a builder, which `CLAUDE.md` section 5 assigns: the Builder owns *"the PR
carrying the `BACKLOG.md` update"*. That supersedes the 2026-08-13 ruling cited at **#1235** that
builders may not author ledger content -- the pre-2026-09-01 method it belonged to was replaced.

## 1235. a citation to an unallocated backlog number is a trap that arms itself the day the number is issued

> ✅ **SHIPPED -- verified on main 2026-08-25.** The wiring limb landed at `b47c9fd9` (PR #560, sibling gate `backlog_citation_check.py` run in CI on documentation-only PRs). The header-falsification that kept this row must-not-close (2026-08-24 marker, below) is now corrected: `scripts/docs/dangling_citation_check.py`'s docstring on main states plainly, in its own words, "THIS SCRIPT IS RUN BY NO WORKFLOW AND NO PRE-COMMIT HOOK," distinguishes itself from the CI-wired sibling by name, and carries the wiring measurement with a positive control (this script: zero hits across `.github/` and `.pre-commit-config.yaml`; `backlog_status_check`/`ledger_check`/`scan_forbidden`: 3/2/3). That content is on main by a non-ancestor commit (`06427921`) whose CONTENT matches exactly -- confirmed by direct read, not cited as an ancestor. The marker's stated condition ("the closing PR falsified the detector's own header and left it uncorrected") has cleared; retiring it below rather than leaving a cleared condition standing as a live blocker. 🚧 **Re-scored 2026-08-20 -> P3.** Value **4/10** · Difficulty **2/10** · _fill-in_. The rule and both coverage residuals landed, leaving one gap: the detector runs only inside pytest, which is skipped on documentation-only pull requests, and a citation is introduced by editing prose. Wiring it as a workflow step with a paired must-trip and must-not-trip arm is a small additive change on an existing gate seam, and the coverage bound stands regardless since the detector cannot see the private companion repository where the filed instances live. _(was 6/10 · 2/10.)_
Expand Down
1 change: 1 addition & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ backend-limited.
| `username` | str | — | server DBs (required when `auth = sql`) |
| `password` | secret | — | **env only** (`MEFOR_STORE_PASSWORD`) |
| `require_managed_identity` | bool | `false` | delegated-identity precondition (#203, ASVS 13.2.1/13.3.2): when `true`, `serve` **refuses to start (exit 2)** unless the store authenticates via a managed identity — SQL Server `auth = integrated`/`entra`. SQLite is exempt; Postgres cannot satisfy it. Off by default. **The refuse/warn split is `[security].enforcement`, not the production tier** — the gate reads `enforcing` ([`__main__.py`](../messagefoundry/__main__.py), the `managed_identity_precondition` block), and `enforce` is the shipped default on `dev` and `staging` as much as on `prod`, so a staging box that turns this on and leaves `auth = "sql"` is **refused**, not warned. It downgrades to a warning only under `enforcement = warn`. **It covers the STORE hop and nothing else** — the check is a `StoreSettings` method, so the graph's own `Database` / `DatabasePoll` / `DatabaseLookup` / `DatabaseRef` hops are outside its reach by construction and each defaults to a static SQL login; `messagefoundry check`'s advisory `static-db-credentials` line names that set (BACKLOG #1182, [`CONNECTIONS.md`](CONNECTIONS.md) §*Static database credentials*) |
| `require_least_privilege` | bool | `false` | least-**privilege** precondition on the store principal (#1008, ASVS 13.2.2) — the privilege sibling of `require_managed_identity` above, which constrains the credential's *kind* and never what it may do (a `sysadmin` gMSA satisfies that one clean; never grant one — [`DEPLOY-SERVER-DB.md` §1.1](DEPLOY-SERVER-DB.md)). **The probe itself is NOT gated by this setting:** it runs at every start regardless, logs what it observed, writes a `store_privilege_preflight` audit row, and reports any excess grant in `security_loosenings()` / `GET /security/posture`. This flag adds the **refusal**: when `true`, `serve` refuses to start if the principal holds more than the grant [`DEPLOY-SERVER-DB.md` §1.1/§1.2](DEPLOY-SERVER-DB.md) prescribes — **and also if the probe could not run at all**, since a declared refusal that passed an unobservable principal would be the fail-open shape it exists to prevent. Off by default so it can never block a legitimate deployment mid-setup. Refuse/warn splits on `[security].enforcement` exactly like `require_managed_identity`. SQLite is exempt (a local file has no server principal). |
| `encrypt`, `trust_server_certificate` | bool | `true`/`false` | TLS to the DB |
| `ssl_root_cert` | path | — | server DBs — pin the DB server's certificate by **file** so a private/self-signed DB CA verifies **without** a machine-wide trust import, on the **secure** posture only (`encrypt = true`, `trust_server_certificate = false`) — it never disables verification. **Postgres:** an asyncpg `SSLContext` CA-bundle (chain + hostname still checked). **SQL Server:** the ODBC Driver **18.1+** `ServerCertificate` keyword (a leaf/exact-cert match; needs driver ≥ 18.1). Rejected for SQLite (no TLS); a missing file fails loud at load. A path, not a secret — may live in the file. See [`DEPLOY-SERVER-DB.md` §5](DEPLOY-SERVER-DB.md). |
| `multi_subnet_failover` | bool | `false` | **SQL Server only** — emit the ODBC `MultiSubnetFailover=Yes` keyword so a client connecting to an Always On Availability Group **listener** reaches the current primary promptly across subnets, instead of serially waiting out each replica subnet's DNS/TCP timeout on failover. A no-op for Postgres/SQLite (they never see the ODBC string). Off by default — only a multi-subnet AOAG needs it. |
Expand Down
Loading
Loading