feat(tls): install [vault] on CI, then assert the Vault hops' suites (BACKLOG #1317) - #809
feat(tls): install [vault] on CI, then assert the Vault hops' suites (BACKLOG #1317)#809wshallwshall wants to merge 5 commits into
Conversation
…(BACKLOG #1317) ADR 0180 declined to build the hvac arm because no CI leg installed the [vault] extra, so the control could never be executed. Owner-ruled 2026-09-03: add the extra to a CI job first, then build the assertion. The order is the deliverable. The `test` leg installs [vault] now. It is the only leg that runs tests/, so it is the only install line that can exercise the control. Six pure-Python wheels, all already pinned in requirements.lock and constraints.lock, so no re-lock was needed. tests/_extras_probe.py gains a vault row, so an interpreter without it reports the run INCOMPLETE rather than a quiet green over skipped security tests. assert_hvac_tls_suites asserts a rebuilt context, pinned to urllib3's own public constructor. Measured against the locked pins by driving a real client at a real socket: no layer of the hvac stack holds an SSLContext, requests 2.34 no longer carries the preloaded one 2.32 had, and urllib3 builds the context lazily per connection. It refuses any hvac.Client argument it cannot replicate, session= above all. Both _build_client factories call it, which covers all three clients since crypto_transit reuses one; a test pins that sharing by identity. _build_client also moves out of its callers' try blocks, whose broad except would have relabelled this refusal as a connectivity failure. Seven tests, five mutations each applied alone, every one red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Lander: heads-up on ordering with #760, which I have queued ahead of this. Both pull requests are BACKLOG #1317 and they are sequential rather than competing: #760 measures and records the residual, and this one closes it. #760 adds So once #760 lands, this branch will both conflict in that test file and make its new assertion false. This is expected work, not a defect in either pull request: the scope-out was true when #760 measured it, and this pull request is what retires it. What this branch needs on its next push:
I am not making that change from here, because it is a judgment about how the retired scope-out should read, not a mechanical conflict resolution. Say the word if you would rather I did. |
|
Lander: this is now DIRTY, and it needs your judgement rather than my guess. 760 has landed. As flagged earlier, it adds Both pull requests are BACKLOG #1317 and they are sequential, not competing: 760 measured and recorded the residual, and this one closes it. What this needs, and why I am not doing it. The resolution is to FLIP The Everything else on this branch is fine. When you push, remember: push, WAIT for the review-gate |
…1317) PR 760 merged, so its test_the_hvac_scope_out_premise_still_holds is now on main. That test asserts urllib3 is absent and no leg installs [vault]. This branch makes both false by design, so it went red. That is the tripwire working, and 760's own text says what to do: build the arm and amend the ADR, never delete the test. It is now test_the_hvac_arm_stays_built_and_stays_executable, a guard in the opposite direction. Drop [vault] from CI and all seven vault tests SKIP, which a suite reports as green; delete the call from a _build_client and, without the extra, nothing else in the file notices. Both halves are pinned. The call-site half parses rather than string-matches, and that was measured. Both modules import assert_hvac_tls_suites at module scope, so deleting the call from the factory body leaves the name in the file: a substring scan stayed GREEN over a removed security control. The AST check asks whether the call is inside _build_client, and reds on both sites. The assertion itself is untouched by this merge. Measured, not assumed: the only change to tls_policy.py, secretprovider_vault.py, keyprovider_vault.py or crypto_transit.py since 5f5a074 is main's own smtp_login_approved edit. So the five mutations recorded on this branch still stand as measured. ADR 0180 Amendment A records the conversion. The ledger reconciliation is the next commit, on its own, so it can be merged as rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…1317) Isolated on purpose: this commit touches docs/BACKLOG.md and nothing else, so the row merge can be read and merged as rows. Row #1317 collected two amendments dated 2026-09-03 that were written blind to each other. PR 760's re-measured all three library arms and left hvac as a named residual gated on a CI change. This branch's installed the extra and built the arm, which is that residual. Ordered 760's first, since it was written and landed first, and marked what it supersedes in place rather than deleting it -- the convention this row already uses a few paragraphs up. Four claims in 760's amendment expired the same day and now say so: the "hvac -- NOT BUILT" bullet, the trigger table's hvac row, the "TRIGGERS, not guards" reading, and the residual in section 5. The two ODBC tests are still triggers, and the text now says not to read the three as a set. The banner reading looked like a contradiction and is not one. 760 measures the strict positive allowlist as BUILT; this branch says it governs the operator knob and does not reach an inherited default context. Build-state and reach are different questions about one measurement. What is left is a scope question, no builder may rule on it, and both amendments left the banner PARTIAL. It stays PARTIAL. Also disambiguates an earlier pointer that read "the 2026-09-03 amendment below" while two now sit below it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clean auto-merge, no resolution decisions. origin/main advanced under this worktree while the first merge was being verified -- the fleet shares one repository, so another worktree's fetch moved the ref. Nothing here touches #1317. The only ledger overlap is #1435's own new row, which git merged textually and which sits nowhere near #1317. The row reconciliation stays isolated in c8f9569, which is what a scripted row-merge needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…OG #1317) The ADR body carries Amendment A and marks its superseded paragraph. The index row did not, so docs/adr/README.md still read "hvac is unmeasurable here ... NO CI leg installs the [vault] extra" as a present-tense claim about a security control's testability. That premise expired when the test leg started installing the extra. Marked expired in place rather than rewritten, and the status cell now names the amendment. Same treatment the ADR body already gives the paragraph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the last open library arm of BACKLOG #1317, under the owner ruling of 2026-09-03: add the
[vault]extra to a CI job, then build the hvac TLS suite assertion.The CI leg is the deliverable, not the preamble
ADR 0180 declined to build this assertion, and its stated reason was not that the hop was fine. It was that "no CI leg installs the
[vault]extra", so the control could never be executed once. A Builder earlier that day refused for the same reason and was right to.[vault]now installs onci.yml'stestleg. That is the leg where it costs least and the only leg it can go on:tests/runs there and nowhere else, so no other install line could exercise the assertion. Adding it totoolingorwebconsolewould install a dependency where the test never runs.No re-lock was needed, and that was verified rather than assumed.
hvac==2.4.0,requests==2.34.2andurllib3==2.7.0are already pinned in bothrequirements.lockandconstraints.lock, becauseuv exportalready covered every extra.pyproject.tomlalready declaredvault = ["hvac>=2.3.0,<3"].tests/_extras_probe.pygains avaultrow, so an interpreter without the extra prints INCOMPLETE RUN instead of a quiet green over skipped security tests. That is what stops the leg being silently removed later.It IS assertable, and here is the measurement that settles it
Measured on the exact locked pins by driving a real client at a real socket. Nothing below is read off source.
SSLContext?hvac.Clientssl/contextattributesrequests.SessionPoolManagerit buildsconnection_pool_kw == {'maxsize': 10, 'block': False}requests.adapters._preloaded_ssl_contexturllib3 builds the context lazily, per connection, inside
_ssl_wrap_socket_and_match_hostname. Driving the engine's own client shows it callingcreate_urllib3_contextonce, producing 17 suites: 14 TLS 1.2 + 3 TLS 1.3, zero NULL, zero anonymous, zero non-forward-secret. The bare replica matches that list exactly.One measurement corrects a guess that nearly shipped.
create_urllib3_context()and stdlibcreate_default_context()yield the identical 17 suites on the current OpenSSL. So "a stdlib look-alike would be measurably wrong" is false and is not written anywhere. The reason to use urllib3's own function is that only it tracks urllib3 if urllib3 narrows its own defaults.What the assertion does
assert_hvac_tls_suitesmirrorsassert_ldap3_tls_suites. It refuses anyhvac.Clientargument it cannot replicate ({url, token, allow_redirects}are admitted).session=is the one that matters: it is the documented way to hand this hop a different TLS context, and a replica that accepted it would keep reporting a clean suite list for a context the hop had stopped using.verify=is refused too, and not because it changes the suite list, which it measurably does not, but because it is the knob that turns peer verification off.Two improvements over the LDAPS precedent:
_build_clientfactories assert, covering all three clients, sincecrypto_transitreuses one. A test pins that sharing by identity (crypto_transit._build_client is keyprovider_vault._build_client) instead of restating ADR 0180's prose.One behaviour change, stated plainly
_build_clientmoved out of its callers'tryblocks in all three callers. Each had anexcept Exceptionthat relabels: "could not read secret ... from Vault KV", "could not envelope-decrypt the store DEK", "could not reach a Vault Transit key". Left inside, this configuration refusal would have surfaced as what reads like a connectivity failure, the same mistake ADR 0180 records for the LDAPS site.Fail-closed is unchanged: both paths propagate and the subsystem refuses to come up. What changes is that an hvac client construction failure now surfaces raw rather than wrapped. Assumption stated because a Builder cannot ask: at zero deployments the simple correct end state beats a compatibility shim (CLAUDE.md sec. 0).
Severity, conditional per CLAUDE.md sec. 0: nothing is exposed, there are zero deployments. A deploying site that selected a Vault secret or key provider would have crossed an unasserted TLS context on every KV read, DEK unwrap and Transit operation.
The mutation run
Five mutations on the assertion itself, each applied alone with the tree restored between, every one RED, and the restored tree green both before and after:
harden_cipher_suitesfrom the replicaThe last row is the one that matters for a replica: it proves the equivalence test detects drift rather than merely passing. The positive control is separate and load-bearing:
test_the_shipped_vault_hop_offers_no_weak_suiterequires the real 17-suite list to be non-empty and clean on all three shipped predicates, because a refusal pinned alone cannot tell a working control from one that refuses everything.That evidence is carried forward, and the right to carry it was measured rather than assumed.
git diff 5f5a074c9 -- tls_policy.py secretprovider_vault.py keyprovider_vault.py crypto_transit.pyreports exactly one hunk after the merge: main's ownsmtp_login_approvedchange. The assertion and both call sites are byte-identical to what was mutated, so the five results still stand. The merge added its own mutations, below.The merge, and what it changed
PR 760's tripwire fired, which is the tripwire working
760 added
test_the_hvac_scope_out_premise_still_holds, asserting urllib3 is absent and that no workflow installs[vault]. This branch makes both false by design. 760's own text says what to do: "A red does not mean the engine got worse. It means the reason an arm was left unasserted has stopped being true and the arm is now buildable -- build it and amend ADR 0180, never delete the test."It is converted, not deleted.
test_the_hvac_arm_stays_built_and_stays_executableis a guard in the opposite direction, covering the failure a fired trigger cannot see: the arm gets built and then quietly stops running.[vault]from CI and all seven vault tests SKIP, which a suite reports as green. The extra is now pinned rather than trusted._build_clientand, on an interpreter without the extra, nothing else in the file notices, because every test that would catch it is skipped. A source-level half runs there.The call-site half parses, and the first version of it was WRONG
A substring scan stayed GREEN over a deleted security control, and I found that by mutating rather than by reading. Both vault modules carry
from ...tls_policy import assert_hvac_tls_suitesat module scope, so deleting the call from the factory body leaves the name in the file. The scan I wrote first passed._factory_calls_the_assertionparses the module withastand asks whether the call is inside_build_client, which is the question the test means to ask, and which also reds if the call is moved somewhere that never runs.Three fresh mutations, each applied alone, tree restored between, green before and after:
vaultfromci.yml'stestinstall lineno CI workflow installs the [vault] extra any more...keyprovider_vault._build_client, import left in place_build_client no longer calls assert_hvac_tls_suites in ['messagefoundry/store/keyprovider_vault.py'](the substring version was GREEN here)secretprovider_vault._build_clientA fourth, renaming
assert_hvac_tls_suitesintls_policy.py, is RED at collection: both vault modules import the name, so a rename is caught earlier and louder than thecallable(getattr(...))control. That control still covers the narrower case where the name survives but stops being callable. Recorded as what it is rather than claimed as a clean control hit.The five conflicts, resolved by reading
Two files conflicted textually; three auto-merged and were checked rather than trusted.
tests/test_tls_cipher_assertion_sites.pytransports.database, this branch addedstore.crypto_transit, keyprovider_vault. Both are used.docs/BACKLOG.mdmessagefoundry/config/tls_policy.pysmtp_login_approved, nowhere nearassert_hvac_tls_suites.pyproject.toml[vault]block is untouched.scripts/security/crypto_inventory_check.pyThe ledger: two amendments dated the same day, written blind to each other
Ordered 760's first, since it was written and landed first, then this branch's. Superseded claims are marked in place rather than deleted, which is the convention this row already uses a few paragraphs up.
Four of 760's claims expired the same day and now say so: the
hvac -- NOT BUILTbullet, the trigger table's hvac row, the "TRIGGERS, not guards" reading, and the section 5 residual. The two ODBC tests are still triggers, and the text now says not to read the three as a set.The banner reading looked like a contradiction and is not one. 760 measures the strict positive allowlist as BUILT. This branch says it governs the operator KNOB (
validate_tls_ciphers) and does not reach an inherited default context. Those are build-state and reach, two questions about one measurement, so neither corrects the other. What remains is a scope question, no builder may rule on it, and both amendments left the banner PARTIAL. It stays PARTIAL and I did not flip it.git checkout MERGE_HEAD -- docs/BACKLOG.mdbefore the merge commit puts the whole row reconciliation in one isolated commit,c8f956904, touching that file and nothing else, so it can be merged as rows.Commits
63699ca53merge + the tripwire conversion + the ADRc8f956904the ledger reconciliation, alone2d1b4678fa second clean merge oforigin/main(docs(testing): mark the vaulted tree so absence stops reading as evidence (BACKLOG #1435) #831).origin/mainmoved under this worktree while the first merge was being verified, because the fleet shares one repository and another worktree's fetch moves the ref. No resolution decisions; the only ledger overlap is #1435's own new row, nowhere near #1317.0662e9e62the ADR index row, which this change falsifies.docs/adr/README.mdstill read "hvac is unmeasurable here ... NO CI leg installs the[vault]extra" as a present-tense claim about a security control's testability, while the ADR body had already been amended. Marked expired in place, same treatment the body gives it.The ledger commit is not literally the last, and here is why that is still what a scripted row-merge needs.
c8f956904is the only commit that resolves a ledger row, and it touchesdocs/BACKLOG.mdand nothing else. The two commits after it touch that file only through git's own clean merge of an unrelated row (#1435), and not at all, respectively.Checks
.venvabsent, so the primary interpreter was run from this worktree root and the source it imported was verified:That interpreter has no hvac, so the seven vault tests skip there. A second venv was built in the scratchpad from it with
--constraint constraints.lock -e ".[dev,vault]", and it resolves the same worktree source, so the seven ran rather than skipped.test_tls_cipher_assertion_sites.py+test_tls_policy.py, WITH[vault]vaultin INCOMPLETE RUNcrypto_inventory_check.pyruff check ./ruff format --check .mypy messagefoundrystrict, primarymypy messagefoundrystrict, vault venvimport-not-foundinauth/webauthn.py,transports/dicom.py,parsing/dicom/_deps.py,parsing/fhir/_deps.py-- four files untouched here, all from that venv lacking those extras. Zero in any file this PR touches.actionlintvia pre-commit--no-verifyNot run, and someone must read them: the hosted-only legs.
windows-service-smoke(NSSM),docker-smokeand the server-DB legs never run in a Builder's process. Thetestmatrix leg is the one to read closely, since it installs a new extra on ubuntu, windows-2022 and windows-2025. The fullpytest tests/suite was not run, per the brief: over ten minutes, plus the knowntest_connscale_smoke.pyfixed-port collision under worktree contention (BACKLOG #1014).Two reds on this PR are NOT this PR, measured against the live API
Branch protection changed under this branch while it was being verified. Read myself, not relayed:
gh api repos/MEFORORG/MessageFoundry/branches/main/protection/required_status_checksreturns 13 contexts, and zero of them match "reviewer".a reviewer has read thiswill show RED and blocks nothing. The required context is gone;.github/workflows/review-gate.ymlstill runs the job and still fails without the label. Both are true at once.the required-contexts file matches the serverwill go RED on EVERY open PR, and the fix is not mine to make..github/required-contexts.txt:205still listsa reviewer has read this, andtests/test_required_contexts.py:119still pinsassert len(contexts) == 14against a live 13. CLAUDE.md says that file and that count move in the same PR as the protection change, and this is not that PR. Editing them here would collide with whoever owns it. Flagged, not fixed.Neither red is attributable to this diff.
gh pr checksalso renders a CANCELLED job as "fail", so tally the check-runs API and take the latest run per name.Two things a reviewer should look at deliberately
1. I force-released the #1317 claim, and that deserves review.
claim.ps1 -Listreported the holder asHOLDER GONE -- worktree no longer exists; release with -Force-- the tool's own instruction, and a stronger signal than theDIRECTORY ONLYcase that gave the previous Builder pause. The dead claim's own note names this rebase as its outstanding job. Recorded in.historyand reversible. I did not use--no-verifyand did not reword a subject to dodge a gate.2. The crypto inventory gate documents
tls_policyon both vault modules rather than being silenced, and it re-passes after the merge.Closure proposed, not taken
Every library arm this row named is now closed or recorded with evidence:
ldap3asserts (2026-08-30),hvacasserts (this PR), and ODBC Driver 18 is un-assertable with the reason in ADR 0180 -- TLS terminates inside the native driver, so the suite list belongs to the driver and the OS TLS stack rather than the interpreter's OpenSSL. That is a finding, not a deferral.The banner stays PARTIAL. A seat with the authority should rule on whether the allowlist-scope question survives as this row or a new one. I did not close the item.
I applied the
reviewedlabel to my own work after my last push, as the process requires. That records that the step happened; it is not an independent review, and this PR has not had one.🤖 Generated with Claude Code