fix(webconsole): attribute the bulk connection control's denial rows to the browser (BACKLOG #1742) - #1202
Conversation
…to the browser (BACKLOG #1742) ui_bulk_control called dual_role_control without `client`, so a per-channel RBAC denial raised from a browser-initiated bulk control would land an auth.channel_denied row with a NULL client. The JSON per-name routes pass client=client_ip(request); the console per-name routes forward the browser's Request into those same handlers. ADR 0150 gives NULL a meaning: no client was in scope. A browser-initiated control has one, so NULL there would not be missing data, it would be a false statement about the row's provenance -- on the row an investigator most wants a host for. client_ip was already imported in this module for the purge-confirm route, so the fix adds no import. Measured before and after, on one tree, with the worktree source forced ahead of site-packages: without the kwarg the two attribution tests fail with client=None (and [None, None, None] across a three-target batch); with it all four pass. The per-name console control was verified to record 127.0.0.1 over the same denial, which isolates the bulk route as the difference rather than leaving "the column is never written" as an alternative explanation.
LANDER INSPECTION -- labelled self-review, not a peer reviewPosted under the korus The defect is an audit-attribution gap, and I verified it by enumeration
Every call site on
So one path out of four loses the operator's host on exactly the rows that record a refusal. The Why the fix is the right shapeSix lines, one keyword argument, no new plumbing -- One note for the reader, not a blockerThe failure mode here is the quiet kind. An audit row written with VerdictVerdict: merge. |
What this changes
ui_bulk_controlinmessagefoundry_webconsole/routes/connection_writes.pycalleddual_role_controlwithoutclient, so a per-channel RBAC denial raised from a browser-initiatedbulk control would write an
auth.channel_deniedrow with a NULL client.One line changes:
client_ipwas already imported in this module for the purge-confirm route, so the fix adds noimport.
_dual_role_control(messagefoundry/api/app.py) declaresclient: str | None = Noneandthreads it into
_control_guardand_audit_channel_denied.ADR 0150 gives NULL a meaning: no client was in scope. A browser-initiated control has one, so NULL
there would not be missing data, it would be a false statement about the row's provenance, on the
row an investigator most wants a host for.
Severity is Low and conditional. MessageFoundry has zero deployments, so nothing has recorded a
wrong row; a deploying site would.
BACKLOG #1742 status text
Do not edit
docs/BACKLOG.mdhere (policy stub; the ledger is maintainer-internal). Suggestedstatus for the item:
Measured before and after
Both arms on ONE tree, only the
client=kwarg differing, with the worktree source forced ahead ofsite-packages (see the import-path note below).
client=client=test_the_per_name_console_control_records_the_browser_address(positive control)test_the_bulk_console_control_records_the_browser_addressclient=Nonetest_every_denied_target_in_one_batch_is_attributed[None, None, None]test_a_permitted_bulk_target_is_not_denied(discriminator)The pre-fix row, read from the audit table:
The positive control matters: a bare "client is not NULL" test could fail for reasons unrelated to
the bulk route (a column nothing writes, a denial path taking no client, a transport reporting no
peer). Driving the SAME denial through the sibling per-name route on the same engine and transport,
and seeing
127.0.0.1, isolates the bulk route as the difference. The discriminator guards themirror failure: an in-scope name must reach the 404 and write no denial row, or the other three
tests would be vacuous against a guard that denied everything.
HOW THE TESTS WERE RUN, and why that qualifier is here
In this worktree,
pytestimports the web console from.venv/Lib/site-packages/, NOT from theworktree source. The engine is installed editable (
_editable_impl_messagefoundry.pth); the consoleis a plain non-editable copy plus
messagefoundry_webconsole-0.2.15.dist-info. So an unqualifiedlocal console run tests a snapshot and would go green having executed none of this change.
Every result above was produced with the source forced ahead of site-packages:
CI is unaffected:
.github/workflows/ci.ymlinstalls-e packaging/messagefoundry-webconsole, sothe
webconsolejob runs against the source tree.A note for anyone else checking this, because the obvious instrument gives the WRONG answer. Run
from the worktree root,
python -c "import messagefoundry_webconsole as m; print(m.__file__)"printsthe WORKTREE path and reports all clear, because
-cputs the working directory first onsys.path. Pytest does not. The question is what PYTEST imports, so measure it from inside thesuite:
Measured here: the
-cform printed the worktree path; the in-suite form printed the site-packagespath. Same worktree, same interpreter, opposite answers.
Checks run
All from the worktree venv, with
PYTHONPATHset for the pytest legs.ruff check .- passedruff format --check .- passed, 1306 filesmypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/'(the CI form) - passed, 293 filespytest packaging/messagefoundry-webconsole/tests- 505 passed, 3 skippedNOT run, and why: the engine suite (
tests/), which this change cannot reach - it touches oneconsole route and adds one console test file. Legs that only ever run on a hosted runner, including
windows-service-smoke, the SQL Server and Postgres store legs, and the load leg, must be read onthe PR after this process exits.
Findings raised in passing, none acted on
None of these are fixed here. Each is reported so the measurement is not lost.
docs/adr/0150-client-address-on-audit-entries.md(decision item 4, deliberate-NULL bullets)says
_audit_channel_deniedgets no request "when handed to the console seam as a barecallback". The shipped code contradicts that:
connection_writes.pypassesclient_ip(request)intocore.audit_channel_deniedin the purge-confirm route. This is theSDS-3.7 shape, a stated rationale resting on a premise that stopped holding. An ADR edit, not a
code edit.
docs/adr/0155-*.mdandscripts/security/dast_target.pyboth state that an in-process ASGItransport leaves
request.clientasNone. That is false forhttpx.ASGITransport, whichdefaults the peer to
("127.0.0.1", 123). The new tests depend on the true behavior, so areviewer reading ADR 0155 would wrongly conclude they cannot pass.
messagefoundry/api/app.pyinlinesrequest.client.host if request.client else Nonein twoplaces rather than calling
client_ip. That is the second, divergent notion of the clientaddress the
client_ipdocstring exists to prevent. Predates this change.messagefoundry_webconsole/routes/core.pyrenders a missing client as the string"<unknown>"where
client_ipreturnsNone. Predates this change; NOT touched, that file belongs toanother Builder right now.
dr_activate/dr_releasewrite NULL clients on BOTH the JSON and console planes, so this issymmetric rather than a console-parity defect, and it is already on ADR 0150's own "not done"
list. Reported, not touched.
source change is untestable in a
new.ps1worktree withoutPYTHONPATH, and the failure issilent and green.
scripts/worktree/new.ps1passes-e packaging/messagefoundry-webconsole,yet this venv ended up with a non-editable console, so either the flag does not take or
something later replaced it.
Question the brief left open
The four tests all land on the fall-through
_control_guarddenial, because the target names noregistered connection. The shared-outbound denial inside
_dual_role_controlis a second site thatthe same
clientnow feeds, and it is not covered here. Adding that would need a liveRegistryRunnerwith a registered outbound, which is a larger fixture than this item's scope. Saywhether you want it and I will take it as a follow-up.
Not done, by rule
Not enqueued, auto-merge not armed, not merged. The Lander owns all three. The item claim is still
held (
claim.ps1 -Release 1742once this lands).