Repository navigation
Batch 257 tail: refused search base pages, supersession follows a retired-hash lineage, lock-notice follow-ups (BACKLOG #3010 #3170 #3120 #3028) - #2387
wshallwshall wants to merge 74 commits into
Conversation
…, not read as no entry (BACKLOG #3010) _search now refuses every result code but success through a named helper, _refuse_unanswered, which raises LdapUnansweredError (an LdapError). The reconciler reads it as UNAVAILABLE, a sign-in as auth.login_error. This covers the user search, the nested group search and the bind-account read. Row item 4 is deferred to vault BACKLOG #3100 item 3. Proposed PR title: An LDAP search that does not end with success is an outage, not "no such account" (BACKLOG #3010) Proposed vault banner: BUILT on branch b257-3010. _search in auth/ldap.py refuses any result code but success, with or without entries, through _refuse_unanswered; the reconciler reads it as unavailable and sign-in as a login error. Closing items 1-3 built and tested; item 4 deferred to #3100 item 3.
The api/app.py lifespan called AuthService.probe_credential_generation synchronously, so the password generator's policy screens ran on the loop thread at start. A pathological password_extra_context_words list makes the generator retry many times. The lifespan now awaits asyncio.to_thread(auth.probe_credential_generation), the same shape as the Kerberos preflight a few lines below, and keeps its except guard. The method stays synchronous, so the existing synchronous test in tests/test_generated_credential_failure.py is untouched, and the verify CLI caller, which has no loop, is untouched too. tests/test_credential_probe_off_loop.py drives the real lifespan and records where the generator's screen ran; it fails on the old code. A second test pins that the guard still logs a raise and the start continues.
…trator (BACKLOG #3121) The directory reconciler's ACCOUNT_DISABLED and ROLES_CHANGED notices said an administrator made the change and closed with "If this was you". _apply_reconcile_revocation now tags its notice detail with source "directory", and so does the sign-in role re-sync in _complete_ad_login, so the two directory ROLES_CHANGED notices read alike. In pipeline/security_notify.py the source tag alone now decides from_directory, for any kind, so a new directory notice cannot fall back to the administrator wording by missing from a list. ROLES_CHANGED and ACCOUNT_DISABLED take directory descriptions that name no administrator, gain the directory line, and close with "If you did not expect this change". The administrator path keeps its wording, as the row says; that wording is not judged correct (BACKLOG #1141, vault #3072 item 10). Tests pin the descriptions whole, not only words the subject would satisfy: the directory variants, the unchanged administrator variants, the sign-in address line on the re-sync notice, and the existing directory_sessions_ended description. The reconciler and login-path tests assert the source tag. docs/SECURITY.md says how directory notices are worded.
This commit is the BACKLOG #3113 layer; the two before it are #3164 and #3121. Item 1: provision_first_administrator's refusal when enable_totp matches no row no longer tells the operator to remove the entry it showed. Under the re-stage race (vault #3025) another run can turn on this run's secret, so the text says to keep the entry until a sign-in shows which works. A race test drives two runs on one row in a fixed order and measures that the loser's secret is the live one. Items 2 and 7: in confirm_mfa_enrollment a store refusal of revoke_session is logged via write_audit_soft and the auth.mfa_enroll_refused row is still written; session_ended records the revoke's outcome instead of a constant. Items 3 and 5: the Elevation docstring, the "a retry works" comment and two long SECURITY.md sentences now say what the code does. Item 4: the docs/ASVS-L2-PHASE0-CHANGES.md TOTP key row no longer says a live code proves the copy that is enabled; it names #3025. Item 6: test_webui asserts enable_totp's result. Items 8 and 9 are declined in code comments: 8 would undo #1902's rotate-first order; 9 needs enable_totp to return a reason on all three store backends. Proposed PR title: Auth follow-ups: start-up probe off the loop, directory notice wording, TOTP enrolment refusal fixes (BACKLOG #3164 #3121 #3113) Proposed banner, #3164: BUILT on engine branch b257-d1. The lifespan awaits asyncio.to_thread(auth.probe_credential_generation) with its guard kept; tests/test_credential_probe_off_loop.py proves the generator runs off the loop thread. Proposed banner, #3121: BUILT on engine branch b257-d1. Reconciler and sign-in re-sync ROLES_CHANGED and ACCOUNT_DISABLED notices carry source directory and render directory wording with the "If you did not expect this change" closing; administrator wording kept; tests pin the descriptions. Proposed banner, #3113: BUILT on engine branch b257-d1. Items 1 to 7 done with tests (race test for item 1, raise test for items 2 and 7); items 8 and 9 declined in writing in confirm_mfa_enrollment. The PHASE0 line is corrected; no vault docs/security file quotes it, so no cell re-read is owed unless the scorecard holder finds one.
…le error, named costs (BACKLOG #3010) Review round one. _refuse_unanswered now refuses a referral first itself, so no caller can reorder the two. LdapUnansweredError survives copy and pickle. The docstring says the #3100 item 3 deferral holds only once that item covers the reconciler's except LdapError arm. The changelog names the costs: a standing code stops revocation unalerted, and an unpaged nested group search over a directory's cap would refuse sign-in. New tests: a busy account beside a departed one in a mixed pass, copy and pickle. Proposed PR title: An LDAP search that does not end with success is an outage, not "no such account" (BACKLOG #3010) Proposed vault banner: BUILT on branch b257-3010. _search in auth/ldap.py refuses any result code but success, with or without entries, through _refuse_unanswered; the reconciler reads it as unavailable and sign-in as a login error. Closing items 1-3 built and tested; item 4 deferred to #3100 item 3, which must widen to the except LdapError arm. Shipped open: bind result codes (#3169), unpaged group search, stale reconcile.py docstring.
#3121: the directory line "not from the MessageFoundry console" is gone from the role re-sync and disable notices. A re-sync's roles come from the directory's groups through the group-to-role map an administrator edits in the console, so that line could be false. The role notice now says MessageFoundry changed the roles to match the directory's groups. from_directory is gated on the kind again, so the reconciler's directory_sessions_ended, which now carries the source tag, does not claim the directory changed anything. A directory ACCOUNT_DISABLED gets its own subject, since the administrator subject says the MessageFoundry account was disabled. Tests render the as-sent shapes and pin the subject. Comments that called a directory change an administrator's act are reworded. #3113: the race test cancels the second run on failure and uses a low-entropy synthetic secret; the PHASE0 row links SECURITY.md rather than restating it; the revoke comment states it covers a refusal of the revoke alone. #3164: the lifespan comment names what the loop is already running. Proposed PR title: Auth follow-ups: start-up probe off the loop, directory notice wording, TOTP enrolment refusal fixes (BACKLOG #3164 #3121 #3113) Proposed banner, #3164: BUILT on engine branch b257-d1. The lifespan awaits asyncio.to_thread(auth.probe_credential_generation) with its guard kept; tests/test_credential_probe_off_loop.py proves the generator runs off the loop thread. Proposed banner, #3121: BUILT on engine branch b257-d1. Reconciler and sign-in re-sync ROLES_CHANGED and ACCOUNT_DISABLED notices carry source directory, name no administrator, and close with "If you did not expect this change"; the directory disable has its own subject; administrator wording kept; tests pin descriptions and subject. Proposed banner, #3113: BUILT on engine branch b257-d1. Items 1 to 7 done with tests (race test for item 1, raise test for items 2 and 7); items 8 and 9 declined in writing in confirm_mfa_enrollment. The PHASE0 line is corrected; no vault docs/security file quotes it, so no cell re-read is owed unless the scorecard holder finds one.
…e fault (BACKLOG #3113) Round-two findings 1, 3, 5 and 6 from the D1 review, carried to this branch. - Finding 1: confirm_mfa_enrollment writes auth.mfa_enroll_refused in a finally around the revoke, so a defect the soft write re-raises (NotImplementedError, sqlite3.ProgrammingError) no longer skips the row, and aiosqlite's closed-connection ValueError is logged as a store refusal, so the confirm still answers session_lost. The row's read and write move into _audit_enroll_refused. - Finding 3: the directory role notice says the sessions already open on the account were ended, as the disable notice does; both senders (reconciler pass, sign-in re-sync) revoke first. - Finding 5: the DIRECTORY_SESSIONS_ENDED comment in auth/notifications.py no longer says ACCOUNT_DISABLED names an administrator. - Finding 6: docs/SECURITY.md says the directory notice names no administrator as what made the change (its closing still names one), and no longer says flatly that the refused confirm ends the session. Findings 2, 4 and 7 stay open by brief.
…refused, not a KeyError (BACKLOG #3010) Follow-up limb of #3010, findings 3, 6, 8 and the ldap side of 10 from the first Builder's review. - Finding 3: the nested group search goes through new _search_paged, the simple paged results control at 500 per page. Each page passes _search, so a page that does not end with success refuses the whole search and the pages already read are dropped. A search still paging after 20 pages is refused too (each page may take up to ad_receive_timeout). - Finding 6, verified in the installed ldap3 2.9.1: the default fast decoder does RESULT_CODES[code] (utils/asn1.py ldap_result_to_dict_fast, operation/bind.py), so an unknown code raises KeyError(code) from inside conn.search or a bind. _unknown_result_code recognises only that shape; _search and _socket_faults_as_ldap_error map it to LdapUnansweredError. A real-ldap3 loopback test measures the premise. - Finding 8: the test module docstring is in the conditional. - Finding 10 (ldap side): the group-search-busy reconciler case is its own test, reading roles through a mapped group; the stored roles survive.
…(BACKLOG #3260)
Limb 2 of vault BACKLOG #3260, built on the owner ruling of 2026-10-16
(fleet wiki note 20261016T211331989Z-vfzmx7).
AuthService.revoke_own_session writes auth.session_revoke_missed through
write_audit_soft on a miss, with no defect re-raised, so the 404 and the
console's revoke_missed notice stand whatever the audit write does. The
detail is the id's first 12 characters and nothing else, so an unknown
id and another account's id write the same row as well as getting the
same answer. The method takes a required keyword client, which both
planes pass (DELETE /me/sessions/{id} and /ui/account/sessions/{id}/revoke);
the auth.session_revoked row now carries it too.
The seam digest records names, not parameters, so it did not move; the
_ui_seam history and the console changelog say so. SECURITY.md and a
changelog fragment describe the row.
Tests: tests/test_self_target_refusal_audit.py (JSON plane, unknown and
foreign in one run, plus the success control) and
packaging/messagefoundry-webconsole/tests/test_ui_session_revoke_miss_audit.py
(console plane, unknown, foreign and an over-long plain-text id).
Each item fixed or declined in one written line: 1. Declined. A gate-refused restart paging config_changed on every attempt is #2597's crash-loop contract, pinned by test_the_alert_fires_even_when_the_start_then_fails (comment in the app.py lifespan). 2. Declined the same way: the audit-chain walk runs inside engine.start(), and moving it ahead of the notice gate reorders that start (comment above auth.initialize()). 3. Built in part. build_idp_opener_from_settings in auth/oidc_http.py assembles the arguments for both call sites, AuthService.__init__ and verify/federation.py::_idp_opener; each keeps its own error mapping. The checks.py settings-preamble refactor is declined: it would collide with vault #3063 on the same helper. 4. Built. A step-up refusal made after the flow was redeemed carries start_client and client_moved through _step_up_address_fields, the helper the success row now uses too. They come from the staged flow, never the token; extra stays first. A refusal before the state check (or with no flow) carries neither. 5. Built. SECURITY.md documents the two fields; PHI.md's audit_log section row no longer says writers "only ever" store shapes, counts and ids, and the detail row names the client-address fields. 6. (a) Declined: the client raises IdpStepUpRequired before the registered handler because that handler can only re-prove a password, which POST /me/reauth refuses for this session. (b) Built as wording: the three IdP refusals say to repeat the action in the web console, which opens /ui/reauth for it; a bare /ui/reauth still bounces an unregistered next to /ui, unchanged. (c) Built: _step_up_refusal catches a store fault reading the mechanism (driver errors, RuntimeError, OSError, aiosqlite's ValueError; AUDIT_WRITE_DEFECTS re-raised) and keeps the 403 with the password wording instead of a 500. The one extra session read on a refusal is declined: it is the read that picks the wording. (d) Built: STEP_UP_VIA_HEADER / STEP_UP_VIA_IDP live in api/auth_models.py, which the FastAPI-free apiclient already imports.
…s share the unknown-code mapping (BACKLOG #3010) Review round one on the follow-up limb, applied: - A later page with no readable paged results control is refused, not read as the last page; only an empty cookie ends the paging. The first page may still lack it (the control is sent non-critical). - The unknown-code mapping is one context manager, _unknown_code_refused, used by _search, by _socket_faults_as_ldap_error (binds) and around the Who am I extended operation, which ldap3 decodes the same way. A failed Who am I is reported in whoami_error and the group read still runs. - The paging refusals carry their own code_name (too many pages, paging control lost); _MAX_PAGES is described as a count of pages. - The stale "ldap3 raises on none of these codes" docstring is corrected. Proposed PR title: An LDAP search that does not end with success is an outage, not "no such account" (BACKLOG #3010) Proposed vault banner, addition to the one in commit c6113d9: Follow-up limb (branch b257-3010b): the nested group search is paged at 500 per page, so an account in more groups than a directory's unpaged cap would no longer fail sign-in; a page that does not end with success, a later page that drops the paging control, or a search still paging after 20 pages refuses the whole search as unanswered, never a shorter group list. A result code ldap3 has no name for, which its default fast decoder raises KeyError on inside a search, a bind or Who am I, now reads as the directory not answering instead of an unhandled error. Measured through real ldap3 against a loopback server, not a domain controller. Shipped open: no overall deadline across pages, no RFC 2696 abandon of a refused paged search, the socket an unknown-code auto_bind leaves open, no static pin that every ldap3 call reads through the mapping.
… only (BACKLOG #3145) mypy strict on tests refused reading the constant off apiclient.client, which does not re-export it. The test now pins the constant's value in auth_models and that each of the three modules names it and no longer spells the literal.
…OG #3260 #3145 #2950) Item #2950. EngineClient.login, reauth and verify_mfa pass timeout=httpx.Timeout(SIGN_IN_TIMEOUT_SECONDS), a named 30-second constant matching the IDE's SIGN_IN_TIMEOUT_MS, the way integrity_check passes its own. reauth queues behind the per-account re-proof lock and verify_mfa behind the credential lock, and both rotate the token, so they get the same timeout. The engine's queue (keyed_lock.hold_keyed_lock) has no bound, so no value covers every wait: the login docstring says a lost reply is rarer, still possible, and why. Every other request keeps the 5-second default. apiclient stays FastAPI-free. New test tests/test_apiclient_sign_in_timeout.py reads request.extensions["timeout"] on an httpx.MockTransport: the three requests carry 30 s on every phase and /auth/me keeps 5 s; a second test reads the IDE's constant from ide/src/signInSession.ts. Proposed PR title: b257 D1+D2 auth follow-ups: TOTP refusal row, directory notices, revoke-miss audit, OIDC step-up follow-ups, sign-in timeout Proposed vault banners: #3113 (D1 carry-over, round-two findings 1, 3, 5, 6): BUILT on engine branch b257-d2. The auth.mfa_enroll_refused row is written in a finally around the revoke, so no exception the revoke raises skips it, and aiosqlite's closed-connection ValueError is logged as a store refusal. The directory role notice says the open sessions were ended; the stale notifications.py comment and two SECURITY.md wording points are fixed. Round-two findings 2, 4 and 7 stay open. #3260: limb 2 BUILT on engine branch b257-d2, closing the row with limb 1 (engine PR 2190). revoke_own_session writes one auth.session_revoke_missed row per miss on both planes, through write_audit_soft, with the id cut to 12 characters and nothing else, so an unknown id and a foreign id get the same answer and the same row (owner ruling 2026-10-16, wiki note 20261016T211331989Z-vfzmx7). Register 1af question 4 is answered by this build, and the 1af entry needs a re-draft. #3145: BUILT on engine branch b257-d2. Items 4, 5 and 6(b)(c)(d) built, item 3 built for the opener's argument assembly; items 1, 2, 6(a), the checks.py preamble half of 3, and 6's extra session read declined in one written line each (see the #3145 commit body). #2950: BUILT on engine branch b257-d2. login, reauth and verify_mfa wait 30 s (SIGN_IN_TIMEOUT_SECONDS, the IDE's value); every other request keeps 5 s. The per-account queue has no bound, so a lost reply is rarer, not impossible.
…d refusal (BACKLOG #3145 #3113 #2950) - store/base.py gains a cached store_call_errors() (driver errors, RuntimeError, OSError, aiosqlite's closed-connection ValueError). api/security.py's step-up refusal catches it in place of its own copy, the refused enrolment's revoke catches it plus CipherError, and AuthService builds it at startup so the first fault does not import the server drivers on the event loop. - api/security.py drops its private _STEP_UP_VIA_HEADER alias for the shared auth_models constant; the test imports that, and its source scan is replaced by a pin of the wire spelling. - complete_oidc_step_up binds actor, client, return_to and flow once in a partial, so a new refusal arm cannot leave the address fields out. - apiclient builds its sign-in httpx.Timeout once at module level. Skipped: a shared test helper for swapping EngineClient's transport (four copies, three outside this diff); moving the sign-in timeout into _adopt_rotated (confirm_mfa rotates but holds no per-account lock on the engine, so it keeps the default).
…ged searches are abandoned (BACKLOG #3010) _ldap3_call replaces _unknown_code_refused. Every ldap3 call that reads a response runs inside it: _search (each page, and the abandon), the service account's auto_bind, the user and decoy binds, and Who am I. A KeyError, TypeError, IndexError, ValueError or AttributeError that ldap3 raised while reading a response (innermost frame in ldap3, an outer frame in a strategy's get_response) becomes LdapUndecodableError, an outage, labelled with the operation and the fault type and no result code. Anything else propagates. Fixes the two defects measured on b257-3010b: a bind response carrying a referral (KeyError(3)) escaped bare, and a malformed paged results control (TypeError, IndexError) escaped _resolve_groups. A stray tag [22] is no longer called "result code 22". The service bind's socket is closed when its response cannot be decoded. A refused paged search is abandoned (RFC 2696). Coverage removed: the double-based tests that mapped an engine-raised KeyError(4242) are replaced, since that mapping was the defect; the real-ldap3 unknown-code tests moved to tests/test_ldap_decode_boundary.py.
Confirmed and fixed: - The seam digest DOES record AuthService method signatures, so the required client keyword on revoke_own_session moved it. Regenerated ENGINE_UI_SEAM and the golden (0971e2440e4237c7), set the console's SUPPORTED_ENGINE_SEAMS, and corrected the _ui_seam note and the console changelog, which said the digest did not move. - confirm_mfa also rotates under the account's re-proof lock, and a lost reply there loses the recovery codes: it gets the sign-in timeout too. - The sign-in timeout raises the read phase only (httpx.Timeout(self._timeout, read=30)), so a GUI-thread caller still learns of an unreachable engine within the client's own bound. - The step-up refusal's store-fault catch logs with its traceback, so a defect raising ValueError or RuntimeError can be told from a fault. - The refused enrolment's row write is soft as well, so a store fault there cannot replace the answer or a propagating defect; the cancellation-after-commit limit is stated in the comment. - A sign-in flow refused as flow_purpose_mismatch no longer records a start_client from another ceremony. - The console's plain-text session id is copied into the miss row only when it is lower-case hex; anything else is recorded as malformed. - SECURITY.md says the miss row is soft-written; the IDE's step-up text says to repeat the action, as the engine's now does. - The refuse(...) calls lost their magic trailing commas. Open, out of this brief's scope: _audit_write_errors() still lacks aiosqlite's closed-connection ValueError for the reconciler's soft writes; that is the reconcile alert path, left to the D3 Builder. Proposed PR title: b257 D1+D2 auth follow-ups: TOTP refusal row, directory notices, revoke-miss audit, OIDC step-up follow-ups, sign-in timeout Proposed vault banners: #3113 (D1 carry-over, round-two findings 1, 3, 5, 6): BUILT on engine branch b257-d2. The auth.mfa_enroll_refused row is written in a finally around the revoke, soft-written itself, so no exception the revoke raises skips it, and aiosqlite's closed-connection ValueError is logged as a store refusal. The directory role notice says the open sessions were ended; the stale notifications.py comment and two SECURITY.md wording points are fixed. Round-two findings 2, 4 and 7 stay open. #3260: limb 2 BUILT on engine branch b257-d2, closing the row with limb 1 (engine PR 2190). revoke_own_session writes one auth.session_revoke_missed row per miss on both planes, through write_audit_soft, with the id cut to 12 characters (a console id that is not hex is recorded as malformed) and nothing else, so an unknown id and a foreign id get the same answer and the same row (owner ruling 2026-10-16, wiki note 20261016T211331989Z-vfzmx7). The engine UI seam moved to 0971e2440e4237c7. Register 1af question 4 is answered by this build, and the 1af entry needs a re-draft. #3145: BUILT on engine branch b257-d2. Items 4, 5 and 6(b)(c)(d) built, item 3 built for the opener's argument assembly; items 1, 2, 6(a), the checks.py preamble half of 3, and 6's extra session read declined in one written line each (see the #3145 commit body). #2950: BUILT on engine branch b257-d2. login, reauth, verify_mfa and confirm_mfa wait 30 s for their reply (SIGN_IN_TIMEOUT_SECONDS, the IDE's value), read phase only; every other request keeps 5 s. The per-account queue has no bound, so a lost reply is rarer, not impossible.
…bind never masks (BACKLOG #3010) The boundary also recognises ldap3's extended-operation decode frames, so a Who am I value that is not text (UnicodeError in populate_result) is an outage and the group read still runs. A search that meets an undecodable response closes its socket, since the stream may be out of step and the service connection is never unbound. _released keeps an unbind error from replacing a bind's refusal, so the decoy and real binds still fail alike. A failed RFC 2696 abandon is logged at debug level by type. The changelog names what is not covered (ldap3's ranged memberOf follow-ups) and the reconciler log-level cost, in shorter sentences.
…builder (BACKLOG #3260 #3145 #2950) Review round two found tests/test_revocation_guard_census.py red on this branch: build_idp_opener_from_settings became a site the census did not list, and its two callers stopped counting as sites, so the census could no longer see AuthService.__init__'s tie to _refuse_idp_revocation. The wrapper is now in _BUILDERS, so both callers are sites again and the census passes (5 of 5). Round-two findings left open, for the PR body: a client timeout above 30 s shortens the read on the queued calls (use max); store_call_errors lacks pyodbc.Error for an unmapped SQLSTATE such as a deadlock; change_password also queues behind the re-proof lock and keeps 5 s; the store-fault fallback drops X-Step-Up-Via, so a client prompts a federated user for a password; the finally row write can replace a propagating CancelledError with a defect; the miss row's soft write catches Exception; three overlapping store-fault sets; the queue itself is unbounded (engine-side bound would close the lost reply); the 3260 changelog omits the malformed case; store_call_errors now imports the server drivers at every AuthService construction; _SESSION_ID_SHAPE admits short hex and duplicates DIGEST_ID_PATTERN; some added comment lines exceed 100 columns. Proposed PR title: b257 D1+D2 auth follow-ups: TOTP refusal row, directory notices, revoke-miss audit, OIDC step-up follow-ups, sign-in timeout Proposed vault banners: #3113 (D1 carry-over, round-two findings 1, 3, 5, 6): BUILT on engine branch b257-d2. The auth.mfa_enroll_refused row is written in a finally around the revoke, soft-written itself, so no exception the revoke raises skips it, and aiosqlite's closed-connection ValueError is logged as a store refusal. The directory role notice says the open sessions were ended; the stale notifications.py comment and two SECURITY.md wording points are fixed. Round-two findings 2, 4 and 7 stay open. #3260: limb 2 BUILT on engine branch b257-d2, closing the row with limb 1 (engine PR 2190). revoke_own_session writes one auth.session_revoke_missed row per miss on both planes, through write_audit_soft, with the id cut to 12 characters (a console id that is not hex is recorded as malformed) and nothing else, so an unknown id and a foreign id get the same answer and the same row (owner ruling 2026-10-16, wiki note 20261016T211331989Z-vfzmx7). The engine UI seam moved to 0971e2440e4237c7. Register 1af question 4 is answered by this build, and the 1af entry needs a re-draft. #3145: BUILT on engine branch b257-d2. Items 4, 5 and 6(b)(c)(d) built, item 3 built for the opener's argument assembly; items 1, 2, 6(a), the checks.py preamble half of 3, and 6's extra session read declined in one written line each (see the #3145 commit body). #2950: BUILT on engine branch b257-d2. login, reauth, verify_mfa and confirm_mfa wait 30 s for their reply (SIGN_IN_TIMEOUT_SECONDS, the IDE's value), read phase only; every other request keeps 5 s. The per-account queue has no bound, so a lost reply is rarer, not impossible.
…r (BACKLOG #3010) No code change. Review round two ran on 2cae56b and is the last round; its findings ship open, listed below and in the Builder's report. Proposed PR title: An LDAP search that does not end with success is an outage, not "no such account" (BACKLOG #3010) Proposed vault banner (consolidated, replaces the texts in c6113d9 and 6512a27): BUILT on branches b257-3010, b257-3010b and b257-3010c (stacked). - _search in auth/ldap.py refuses any result code but success, with or without entries, through _refuse_unanswered; the reconciler reads it as unavailable and sign-in as a login error. Closing items 1-3 built and tested; item 4 deferred to #3100 item 3, which must widen to the except LdapError arm before the deferral holds. - The nested group search is paged at 500 per page. A page that does not end with success, a later page that drops the paging control, or a search still paging after 20 pages refuses the whole search, never a shorter group list. A refused paged search is abandoned (RFC 2696) once the directory has sent a cookie. - One boundary, _ldap3_call, wraps every ldap3 call that reads a response: each search and page, the service auto_bind, the user and decoy binds, and Who am I. A KeyError, TypeError, IndexError, ValueError or AttributeError that ldap3 raised while reading the response, decided by traceback origin and not by value, becomes LdapUndecodableError, an outage, with no result code claimed. Engine-raised exceptions propagate. This covers a bind response carrying a referral (KeyError(3)), a code outside ldap3's table, a malformed paged control, and a Who am I value that is not text. The unknown-code value mapping is gone. A static test pins every search, bind and Who am I call inside the boundary. - Measured through real ldap3 2.9.1 against a loopback server, not a DC. Shipped open: the user bind still reads busy and other non-success codes as a wrong password (#3169); ldap3's ranged memberOf follow-up searches are not refused (a row); the close of an undecodable search's socket is undone by ldap3's Connection.__exit__, which reopens it (measured); a referral in a bind response reads as undecodable, not REFERRED; standing configuration codes and decode faults reach the reconciler at debug level only; no overall deadline across pages; the abandon is sent after a busy page and can reuse a spent cookie; no cap on the paged cookie's length.
… it (BACKLOG #3010) _search no longer passes close=True to _ldap3_call. Callers hold the service connection in a with block, and ldap3 2.9.1's Connection.__exit__ reopens a connection that was open on entry and is closed on exit, so the close cost one more TCP and TLS connect per malformed search and closed nothing. The service-account auto_bind close stays. The paged-control test now pins that a search refusal leaves the caller's connection open; the docstrings and the changelog claim the close for the service-account bind only. Proposed PR title: An LDAP search that does not end with success is an outage, not "no such account" (BACKLOG #3010) Proposed vault banner (consolidated, replaces the texts in c6113d9, 6512a27 and ebd88b2): BUILT on branches b257-3010, b257-3010b, b257-3010c and b257-3010d (stacked). - _search in auth/ldap.py refuses any result code but success, with or without entries, through _refuse_unanswered; the reconciler reads it as unavailable and sign-in as a login error. Closing items 1-3 built and tested; item 4 deferred to #3100 item 3, which must widen to the except LdapError arm before the deferral holds. - The nested group search is paged at 500 per page. A page that does not end with success, a later page that drops the paging control, or a search still paging after 20 pages refuses the whole search, never a shorter group list. A refused paged search is abandoned (RFC 2696) once the directory has sent a cookie. - One boundary, _ldap3_call, wraps every ldap3 call that reads a response: each search and page, the service auto_bind, the user and decoy binds, and Who am I. A KeyError, TypeError, IndexError, ValueError or AttributeError that ldap3 raised while reading the response, decided by traceback origin and not by value, becomes LdapUndecodableError, an outage, with no result code claimed. Engine-raised exceptions propagate. This covers a bind response carrying a referral (KeyError(3)), a code outside ldap3's table, a malformed paged control, and a Who am I value that is not text. The unknown-code value mapping is gone. A static test pins every search, bind and Who am I call inside the boundary. After an undecodable response the socket is closed for the service-account bind only; a search's socket is left to its caller, because ldap3's Connection.__exit__ would reopen it. - Measured through real ldap3 2.9.1 against a loopback server, not a DC. Shipped open: the user bind still reads busy and other non-success codes as a wrong password (#3169); ldap3's ranged memberOf follow-up searches are not refused (a row); after an undecodable search response the connection stays open on a stream that may be out of step, and authenticate and probe_principal never unbind the service connection (a row); a referral in a bind response reads as undecodable, not REFERRED; standing configuration codes and decode faults reach the reconciler at debug level only; no overall deadline across pages; the abandon is sent after a busy page and can reuse a spent cookie; no cap on the paged cookie's length.
… and what not closing costs (BACKLOG #3010) The previous commit said a close on the search path closed nothing. It did shut the failed socket; ldap3's Connection.__exit__ then opened a fresh, unbound one, which is the extra connect. _ldap3_call now says so, names authenticate and probe_principal as the callers whose with block reopens it, and states the cost of not closing: their connection stays open and bound on a stream that may be out of step until it is collected, while read_bind_account unbinds its own. _search_paged says the directory keeps the paging state after a decode fault. The test comment no longer cites a with block the test helper does not use. Proposed PR title: An LDAP search that does not end with success is an outage, not "no such account" (BACKLOG #3010) Proposed vault banner (consolidated, replaces the texts in c6113d9, 6512a27, ebd88b2 and cf6b2b7): BUILT on branches b257-3010, b257-3010b, b257-3010c and b257-3010d (stacked). - _search in auth/ldap.py refuses any result code but success, with or without entries, through _refuse_unanswered; the reconciler reads it as unavailable and sign-in as a login error. Closing items 1-3 built and tested; item 4 deferred to #3100 item 3, which must widen to the except LdapError arm before the deferral holds. - The nested group search is paged at 500 per page. A page that does not end with success, a later page that drops the paging control, or a search still paging after 20 pages refuses the whole search, never a shorter group list. A refused paged search is abandoned (RFC 2696) once the directory has sent a cookie. - One boundary, _ldap3_call, wraps every ldap3 call that reads a response: each search and page, the service auto_bind, the user and decoy binds, and Who am I. A KeyError, TypeError, IndexError, ValueError or AttributeError that ldap3 raised while reading the response, decided by traceback origin and not by value, becomes LdapUndecodableError, an outage, with no result code claimed. Engine-raised exceptions propagate. This covers a bind response carrying a referral (KeyError(3)), a code outside ldap3's table, a malformed paged control, and a Who am I value that is not text. The unknown-code value mapping is gone. A static test pins every search, bind and Who am I call inside the boundary. After an undecodable response the socket is closed for the service-account bind only. A search's is not: authenticate and probe_principal hold the connection in ldap3's with block, whose __exit__ reopens a closed connection, so a close there cost one more connect. - Measured through real ldap3 2.9.1 against a loopback server, not a DC. Shipped open: the user bind still reads busy and other non-success codes as a wrong password (#3169); ldap3's ranged memberOf follow-up searches are not refused (a row); after an undecodable search response in authenticate or probe_principal the service connection stays open and bound, on a stream that may be out of step and holding any paging state, until it is collected, because neither method ever unbinds it (a row; the fix is the release read_bind_account uses, not a close flag); read_bind_account's unbind is then sent on that stream; a referral in a bind response reads as undecodable, not REFERRED; standing configuration codes and decode faults reach the reconciler at debug level only; no overall deadline across pages; the abandon is sent after a busy page and can reuse a spent cookie; no cap on the paged cookie's length.
…inistrator_granted (BACKLOG #3141) - AuthService takes an admin_grant_alert callable, the #3229 shape: the API lifespan builds it with pipeline.alerts.administrator_granted_alert and the <directory> marker, so auth/ imports no sink. - _apply_reconcile_revocation raises it right after the role write, on the sealed user.role_ids read at the top of the method, never the plan's roles. Only Administrator absent before and present after pages. Only a pass that did not abort applies a revocation. via = directory_reconcile. It pops the no_enabled_administrator cooldown, as intended. - Docs: alert_sinks comment, changelog.d/3229 and 2610 (2610 now states the server-error-after-role-write limit and the never-seen grant), new changelog.d/3141, SECURITY.md "A directory grant" bullets and the signal table, PHI.md alert_instance row, AlertSink docstring, the settings alert-type comment. - Item 3 of the row, from PR 2076 "Known, not fixed": the two low test findings are fixed (the console refused-after-sync tests assert the role was written; the caplog check filters on the logger name). Declined: two concurrent first sign-ins can each page once (a duplicate page is the safe direction); a future directory sign-in route must remember to call the alert (no route registry to enforce it against, and a census test is its own item).
…tage still strikes (BACKLOG #3170) - Item 1: the referral's latched message, its auth.ad_reconcile_referred row (key renamed probed -> asked) and its ad_reconcile_aborted alert count plan.asked. The breaker-trip alert keeps plan.probed, its judged denominator. AlertSink docstring says which count is which. - Item 2: DECLINED, one line at the breaker call in plan_pass: exempting UNKEYED revocations changes what the mass-revoke breaker counts. - Item 3: plan_pass's two early returns (outage, referrals only) carry the UNKEYED rows' strikes, and only those: no outcomes, so the hold's record and judged_nothing are unchanged and the latch carries over. - Item 4: changelog.d/2434 names the auth.ad_reconcile_skipped probed -> asked rename. New changelog.d/3170. - Item 5: docs/PHI.md ad_session_revoked reasons gain directory_object_id_missing (as "at least"); the ad_reconcile_aborted count is described per source. - Item 6: the comment now in _directory_presence no longer says the reconciler probes an id-less row by name. - Item 7: the sign-in, step-up, re-proof, presence and bind refusals read _directory_key/_holds_directory_key; reason strings unchanged. _directory_key returns the id so mypy still narrows where it is sent. - Tests: an UNKEYED row is struck on a pass that judged nothing (three shapes, with an ordinary-pass control); a referral is counted over the probes asked, end to end and through _alert_reconcile_plan.
…n ValueError (b257 D2 carry-over) D2 carry-over. _audit_write_errors is now CipherError plus store_call_errors(), the one store-fault set D2 added, so a reconciler audit write against a closed aiosqlite connection (a bare ValueError, raised before sqlite3 is reached) is logged and passed over like any other refused write, instead of ending the pass. Callers that must not read a bug as a refusal still re-raise AUDIT_WRITE_DEFECTS first. The skip-report refusal test gains a closed-connection case, red on the previous set.
…e they are used (BACKLOG #3170) plan_pass built the UNKEYED strike map on every pass and read it only in the two early returns. It is now a small helper, _unkeyed_strikes, called from those returns; the rule and its reason are stated there. Skipped from the pass: a shared factory for the two user-keyed alert callables (two copies only); converting the bind and presence refusals to _directory_key (they need no id); a RolesGained-based grant test in the reconciler (a two-line rule); reusing another file's test sink.
- changelog.d/3170 and SECURITY.md: an outage or referral-only pass strikes an id-less row but revokes nothing, so its sessions end at the next pass that is not aborted, not at the threshold regardless. - SECURITY.md: a sign-in running at the same time as the pass can raise a second administrator_granted; a duplicate is the safe direction. - LoggingAlertSink's ad_reconcile_aborted line no longer calls the count the pass's size, since a referral's count is the probes asked. - Tests: the reconciler-grant test now proves the next sign-in gains nothing (the old second-pass check ran on no candidate); the reconciler cooldown-pop test gains the cross-account control.
…es (BACKLOG #3141 #3170) Docs and docstrings only, from the second code-review round: - SECURITY.md's Fail-OPEN bullet and the referring-base paragraph, and reconcile.py's module and judged_nothing docstrings, no longer say an outage or referral-only pass strikes or judges nothing: it strikes an id-less row (BACKLOG #3170). - SECURITY.md and changelog.d/3170: an id-less row is revoked only by a pass that is not aborted, once its strikes reach the threshold and it is in that pass's probe budget. - SECURITY.md "A directory grant": the cases that raise no administrator_granted are "at least" these, adding a breaker-aborted pass and recheck_seconds = 0. Proposed PR title: Directory reconcile follow-ups: a reconciler grant pages administrator_granted, UNKEYED referral counts and outage strikes (BACKLOG #3141 #3170) Proposed vault banner, #3141: BUILT on engine branch b257-d3 (batch 257, D3). A reconcile pass whose role write newly gives an account Administrator raises administrator_granted (via directory_reconcile, granted_by <directory>) right after the write, through an injected callable, judged on the sealed apply-time roles; an aborted pass writes nothing and pages nothing. It pops the no_enabled_administrator cooldown as intended. changelog.d/2610 now states the server-error-after-role-write limit. Item 3: the two low test findings fixed; concurrent first sign-ins paging twice and the future-route reminder declined in writing. Vault #3602 item 4 closes with this; item 8 (the reconciler-first timing race) closes too, since the reconciler-first case now pages. Proposed vault banner, #3170: BUILT on engine branch b257-d3 (batch 257, D3). Items 1, 3, 4, 5, 6, 7 fixed: the referral's message, audit row (field renamed to asked) and alert count plan.asked, the breaker alert keeps probed; outage and referral-only passes strike UNKEYED rows (strikes only); changelog 2434 names the rename; PHI.md lists directory_object_id_missing; the _directory_presence comment is corrected; the inline id checks read _directory_key/_holds_directory_key with reason strings unchanged. Item 2 DECLINED by Manager ruling: exempting UNKEYED revocations would change what the mass-revoke breaker counts. Items 1 and 3 have tests.
Brings b257-d3 (ed6efa2) up to date with main at 6f66234: PR 2317 (auth service follow-ups), PR 2340 (batch 258 wave 1) and PR 2342 (a directory probe that raises is a fault, not an outage, and it pages). No rebase; the published d1/d2/d3 commits are untouched. Conflicts, and what each resolution keeps: - messagefoundry/auth/reconcile.py: main added ProbeOutcome.FAULT and folded a pass of faults only into the referral's early return; the chain (vault #3170 item 3) made that early return and the outage's carry the UNKEYED rows' strikes. Kept both: a pass of faults only now strikes an id-less row too, as an outage or a pass of referrals only does, because that row was judged without the directory either way. judged_nothing keeps main's probe_fault term and the chain's strike sentence. The asked field's comment carries both sides' readers. - messagefoundry/pipeline/alerts.py: ad_reconcile_aborted docstring names main's third source and slug and keeps the chain's per-source meaning of probed: the breaker and a probe fault pass plan.probed, a referral passes plan.asked (app.py, unchanged by this merge). - messagefoundry/auth/notifications.py: main's list of the reasons that send DIRECTORY_SESSIONS_ENDED (#3171) with the chain's corrected last sentence (ACCOUNT_DISABLED says the account is disabled, by an administrator or in the directory; vault #3121). - tests/test_security_notify.py: main's per-reason test and its new wording (#3171) replace the chain's pin of the old description; the chain's as-sent shape (source directory, #3121) and its assertion that the generic directory line is absent are kept as a second parametrize axis, and the subject is read through _subject(). - docs/SECURITY.md: the chain's directory-notice paragraph (#3121) followed by main's sessions-ended paragraph (#3171), which replaces the chain's older one-sentence version of it. - docs/PHI.md, the alert_instance row: main's probe-fault source, slug and inverse wording, with the chain's count wording (#3170), directory_object_id_missing reason and reconcile-pass grant (#3141). Edits outside the markers, to make the two sides agree: - docs/SECURITY.md: the id-less-row paragraph and main's probe-fault paragraph now say a pass of faults only also strikes an id-less row and judges no account it asked about. - pipeline/alerts.py LoggingAlertSink and reconcile._unkeyed_strikes: comments name the probe fault beside the breaker and the referral. - changelog.d/3170.security.md: names probe-fault-only passes beside outages and referral-only passes. - tests/test_ad_session_reconcile.py: the #3170 unkeyed-strike test gains faults-only and faults-and-failures cases, pinning the choice above. Auto-merged files read for semantic clashes: auth/service.py (the provision-admin reorder of #2915 against the #3113 refusal text; the fault recorder beside the #3170 referral counts; #3117 MFA gates), api/app.py, pipeline/alert_sinks.py, pipeline/security_notify.py, store/base.py, and the shared test files. UI seam: main did not move it, so the chain's 0971e2440e4237c7 stands. Items touched: BACKLOG #3164 #3121 #3113 #3260 #3145 #2950 #3141 #3170 Proposed PR title: b257 D1-D3 auth follow-ups: TOTP refusal row, directory notices, revoke-miss audit, OIDC step-up follow-ups, sign-in timeout, reconcile admin grant and unkeyed counts
Applies round-one findings that concern text and tests the merge itself wrote; nothing here changes behaviour. - pipeline/alerts.py, docs/PHI.md: the ad_reconcile_aborted count for a probe fault is no longer called "judged". It is the same total as the breaker's, the candidates the pass took up, asked or not, which counts the faulted accounts left unjudged. - docs/SECURITY.md, changelog.d/3170.security.md: an id-less row is struck on any pass that aborts on referrals, probe faults or failures, not only on one whose every lookup was referred or faulted. - tests/test_ad_session_reconcile.py: the unkeyed-strike cases pin the abort slug, so a faults-only pass cannot pass as an outage. Left open, for the Manager: the probe fault's alert and its auth.ad_reconcile_faulted row count plan.probed while its latched message counts plan.asked (main's code, PR 2342); #3170's rule for the referral would apply to it unchanged. Proposed PR title: b257 D1-D3 auth follow-ups: TOTP refusal row, directory notices, revoke-miss audit, OIDC step-up follow-ups, sign-in timeout, reconcile admin grant and unkeyed counts
Brings the third main merge on b257-dchain into the tail. Conflict: messagefoundry/api/outlive.py, the two comments over DRAIN_TIMEOUT_SECONDS and CANCEL_GRACE_SECONDS. This branch (vault BACKLOG #3120) pointed them at a sum of every shutdown drain stated at _BACKGROUND_DRAIN_SECONDS in auth/service.py. Main (#3191) now runs every lifespan teardown step under one budget, _SHUTDOWN_BUDGET_SECONDS in api/app.py, and passes these constants as upper limits. Took main's text, which keeps #3120's intent: the window arithmetic is stated once. Semantic clash, resolved in the same commit: the auto-merged comment over _BACKGROUND_DRAIN_SECONDS (auth/service.py, #3120) listed the drains' fixed bounds as summing to 17 s and said nothing made the teardown fit the window, which #3191 made false. It now points at _SHUTDOWN_BUDGET_SECONDS and names close_background as the caller. The auto-merged DRAIN_TIMEOUT_SECONDS comment in api/approvals.py pointed at the same stale sum; it now points at the budget too. Also read: auth/service.py (step-up arms all carry the #3220 marker), api/app.py, __main__.py, store/base.py, store/postgres.py, store/sqlserver.py, docs/PHI.md, docs/SECURITY.md, docs/CONFIGURATION.md. No other clash found.
Main moved during the 064700f merge; this takes e9e52ae and 3f1f884 too. Conflicts: - messagefoundry/api/app.py, the api.security import list: main added AUTH_NOT_CONFIGURED (BACKLOG #3105), this branch DIRECTORY_GRANTED_BY (vault BACKLOG #3141). Kept both, sorted. - The web console seam: messagefoundry/api/_ui_seam.py, messagefoundry_webconsole/__init__.py and tests/golden/webconsole_seam.snapshot. Main's ReloadResult restart_required (#3015) and this branch's revoke_own_session client keyword (vault BACKLOG #3260) both moved the digest. Kept both comment paragraphs and re-derived the digest with scripts/webconsole_seam_snapshot.py --write over the merged surface (851fb3e4d3d5b825), with the console side set to match. Auto-merged files both sides touched, read for semantic clashes: docs/SECURITY.md, packaging/messagefoundry-webconsole/CHANGELOG.md, packaging/messagefoundry-webconsole/tests/test_webui.py. None found.
Review of the main merge into b257-dchain: the resolution spelled extra=floor_marker on each of the 13 federated step-up refusals past the floor, so a new arm written as refuse(REASON) would silently drop callback_floor_exempt_amr. The marker is now bound into the refuse partial once, right after it is computed, as the address fields are bound above (vault BACKLOG #3145). No arm past that point passes its own extra, and the audit rows are unchanged. BACKLOG #3220.
Review of the merge of b257-dchain into b257-dtail found three statements made false or left false by main's single shutdown budget: - api/approvals.py called DRAIN_TIMEOUT_SECONDS an upper limit on what the lifespans pass. They pass a share of the step's bound instead, which can exceed it; it is only the method's default. - That comment and the one over _BACKGROUND_DRAIN_SECONDS said _SHUTDOWN_BUDGET_SECONDS states the steps and their order and bounds every step. It states the budget arithmetic; _app_drain_steps and the managed teardown hold the steps, and some steps are unbounded. - docs/SECURITY.md said pending lock notices are cut off at the shutdown bound, each with one line. Where the budget cuts the drain first, they are not cancelled and run on. Comments and docs only. BACKLOG #3191, vault BACKLOG #3120.
Review round two of the batch 257 main merges: - The #2216 residual in docs/SECURITY.md said the engine waits about two seconds and a pending notice is cut off. It now says the drain waits at most two seconds and cancels, that the shutdown budget (BACKLOG #3191) can cut it first, and what a notice left running then costs: the closed security notifier still reports it queued, so the row says mailed: true for a mail never sent. The no-cap bullet points at that paragraph instead of restating it. - The _BACKGROUND_DRAIN_SECONDS comment named the wrong threshold (the drain plus its grace); a bound below the drain alone cuts it before it cancels. It now points at the SECURITY.md residual for the cost, and says the steps hold floors, from which _run_teardown computes each bound. - DRAIN_TIMEOUT_SECONDS in api/approvals.py is described as the default for a caller that passes no timeout, which the lifespans do not use. Comments and docs only. Vault BACKLOG #3120.
…port it queued (BACKLOG #3120) At shutdown the teardown can cut the lock-notice drain before it cancels a pending notice. That notice ran on, reached the security notifier after aclose(), and was put on a queue nothing drains, so notify() answered True and auth.lock_notice recorded mailed: true for a mail never sent, holding the account's next notice of the kind back for 24 hours. SecurityEventNotifier now sets a closing flag at the start of aclose(), before the stop sentinel goes in, and notify() answers False from then on. The lock notice's row then reads mailed: false. No undelivered audit row is started for such a notice: one started then could outlive aclose() and meet the closed store. The teardown step in api/app.py is unchanged; only its comment is corrected. docs/SECURITY.md: the #2216 residual says what holds now, and the stale "drains its queue after the store has closed" bullet is replaced (stale since #3191).
…ine, restartable (BACKLOG #3120) - A notice refused while the notifier closes is recorded on its undelivered row with reason "closing", as a full queue's drop is, so the full-queue-at- close window keeps the BACKLOG #2383 record it used to get. - The refusal warning names the kind through notice_kind_log_label and the username through scrub_log_argument, and says "is closing". - start() after a close clears the flag, so a restarted notifier sends again. - Lists of why a lock notice reads mailed: false now include the closing notifier, or point at the SecurityNotifier contract (notifications.py, service.py _notify_security, docs/SECURITY.md). - SECURITY.md: a cut close can also lose or unrecord a send under way, and a mailed: true row then holds the next lock notice back for 24 hours. - Tests close the relay in finally; the end-to-end test runs a started relay with its drain loop, sends the control, and reads the closing record.
…CKLOG #3120) No code change. This commit carries the handover text for the draft PR 2387. PR title: unchanged from the b257-dtail head; this limb adds to #3120. Ledger banner addendum for vault BACKLOG #3120: A security notifier that has begun to close, or has closed, now refuses a notice (notify answers False) instead of queueing it where nothing drains it. A lock notice that a cut shutdown drain leaves running therefore records auth.lock_notice mailed: false, not mailed: true, and the relay records the drop on its undelivered row with reason "closing". The teardown in api/app.py is unchanged. docs/SECURITY.md states what holds after the fix and drops the stale "drains its queue after the store has closed" bullet (stale since #3191). Review round two left findings open; they are in the Builder's report.
…says what happens (BACKLOG #3120) Review round two on the closing refusal found two defects the previous limb introduced. 1. After a cut close, start() cleared _closing, but the stop sentinel stayed on the queue, so the restarted drain loop stopped on it and notify() answered True for events nothing sends. start() now raises RuntimeError once aclose() has begun, and the notifier keeps refusing. No engine path restarts a closed notifier: each lifespan builds its own. The test that a fresh start sends again is replaced by one that a closed notifier refuses to start, and a new test cuts a close mid-send, calls start(), and requires that a notice reported queued is sent. It failed on f7ba6d6. 2. The throttle read's fail-open line said the notice "was sent unthrottled". It now says the notice is offered to the notifier unthrottled and the notifier's answer decides whether it is recorded as mailed. docs/SECURITY.md no longer says a failed row write after a refused notice "costs at most a duplicate mail": no mail goes, and no row holds back the next notice. PR title: unchanged from the b257-dtail head; this limb adds to #3120. Ledger banner addendum: a closed security notifier now refuses to start again, so a cut close cannot leave a drain loop that reports notices queued and never sends them.
…KLOG #3120) The code-review pass at xhigh found the new wording claimed more than holds. A cut close CAN leave its sentinel (not always: a cut during a blocked put leaves none), so the start() comment says "can". The throttle fail-open line no longer claims a hand-off or a recorded outcome, either of which a detach or the same store fault can prevent; it says the throttle did not hold the notice back and the notifier decides. docs/SECURITY.md states the outcome only, without an ordering of the read and the write. The class docstring states the single-use contract. The refusal test's control now asserts the task it starts.
|
Shutdown follow-up, |
|
Its two self-introduced round-two defects, |
Brings main b4fab85 into b257-dchain. Main side carries BACKLOG #2858, vault #2702, vault #3099, #3029, #3181, #3217, #3178, #3180, vault #2223. Conflicts: - messagefoundry/auth/service.py, revoke_own_session docstring: kept both paragraphs, the vault #3260 missed-revoke audit and main's #3029 note that a revoke matching no unrevoked row returns False with no row. Code merged cleanly: the miss branch audits, the supersede_session miss returns False. - Web console seam digest (_ui_seam.py, webconsole __init__, golden snapshot): both sides moved it; re-derived on the merged tree with scripts/webconsole_seam_snapshot.py --write (41c0a8c59f83685a) and set SUPPORTED_ENGINE_SEAMS to match. Semantic clash fixed: main's tests/test_session_supersession.py called revoke_own_session without client, which this branch made required (vault #3260); both calls now pass client=None.
Brings the fourth main merge on b257-dchain (515d1c3) into b257-dtail. Main side carries BACKLOG #2858, vault #2702, vault #3099, #3029, #3181, #3217, #3178, #3180, vault #2223; this branch carries vault #3028 (ADR 0210). Conflicts: - tests/test_session_supersession.py, _touched_first: kept main's #3029 injection counter (touches.append) and this branch's lineage_since pass-through to the real supersede_session. - docs/adr/README.md: kept both index rows, 0209 (main) before 0210 (this branch). Read for semantic clashes: main's logout and revoke_own_session now call supersede_session without lineage_since, so they do not follow the retired-hash lineage; ADR 0210 limits the walk to the login supersession, so the two agree. Seam digest unchanged by this merge (check passes).
Review of the fourth main merge. Main's #3029 added a second 404 path to
revoke_own_session: the revoke matched no unrevoked row, because a rotation
re-keyed the session or a concurrent revoke ended it first. That path
returned False with no audit row, while this branch's vault #3260 limb 2
(owner ruling 2026-10-16) has every miss of DELETE /me/sessions/{id} write
one auth.session_revoke_missed row, as docs/SECURITY.md states. The race
miss now writes that same row, and still no auth.session_revoked row.
- service.py: the supersede_session miss joins the unknown and foreign id
branch; docstring says so.
- tests/test_session_supersession.py: the race test also pins the miss row.
- changelog.d/3029.security.md: no longer says the 404 writes no row.
- _ui_seam.py: a paragraph names the #3029 logout return type in the digest.
BACKLOG #3029, vault BACKLOG #3260.
Main moved during the fourth merge's checks; brings in BACKLOG #3302, #2613 item 3 and #2812. Conflict: docs/PHI.md, the diagnostics-stream table row. Kept main's tls_handshake_failed cell (#3302, MLLP and HTTP listeners, any loop) and this branch's operator-alerts cell (vault #3141, #3170 reconcile text).
Brings b257-dchain 1248224 into b257-dtail: main 0ed70dc (BACKLOG #3302, #2613 item 3, #2812) and the review fix that has the #3029 race miss write the vault #3260 miss row. Conflict: docs/PHI.md, the diagnostics-stream table row. Kept main's tls_handshake_failed cell (#3302) and this branch's operator-alerts cell, which carries vault #3010's configuration refusal on top of #3141/#3170. Semantic clash fixed (review finding): main's _slow stand-in in test_a_session_live_when_the_revoke_began_is_audited_though_it_lapsed_during_it did not take lineage_since, which this branch's login supersession passes (vault #3028), so it raised TypeError. It now takes and forwards it.
Brings in main through cc768e4 (past 13082ca; it includes PR 2352). Conflicts, each resolved by keeping both sides: - auth/service.py imports: main's is_idp_transport_http_exception and store_open_errors beside the branch's build_idp_opener_from_settings and store_call_errors (store_driver_errors and build_idp_opener are no longer named in this module). - auth/service.py complete_oidc_step_up: main reads the floor's exempt values once and passes them to _oidc_callback_floor_exemption, and clears the IdP flag after a verified token; the branch's bound `refuse` partial (address fields on every post-redeem refusal) is kept on both refusal arms. - auth/oidc_http.py __all__: main's IdpReply* errors and is_idp_transport_http_exception plus the branch's build_idp_opener_from_settings and its TYPE_CHECKING import. - api/_ui_seam.py: both history paragraphs kept, plus one for this merge. ENGINE_UI_SEAM re-derived with the seam tool's --write on the merged tree (909ed2c80270abb3, covering main's route-gate section and release_blocked DTO and the branch's revoke_own_session signature); SUPPORTED_ENGINE_SEAMS set to match; golden rewritten by the tool. - packaging/messagefoundry-webconsole/tests/test_webui.py: deleted on main, which split it into nine files. The branch's one change to it (assert that enable_totp returns True in test_reauth_both_factors_enrolled_renders_code_and_passkey) is carried into test_webui_search_sso.py, the test's new home; the old file stays deleted. Backlog: vault #3260, #3029, #3145, #3221, #2709, #2509, #3192, #3193, #3236.
Brings in b257-dchain at 43abc1a, which merged main through cc768e4. Conflicts, each resolved by keeping both sides: - api/approvals.py, the DRAIN_TIMEOUT_SECONDS comment: this branch's text (a default for a caller passing no timeout; the lifespans pass a share of the shutdown budget stated at _SHUTDOWN_BUDGET_SECONDS) with main's scope (the gate's writes still running, at least the outcome and request writes) and its per-write cost (plus at most a read-back). Main's sentence about sharing the window with the upload runner's 5 s stop is not kept: on this branch that stop is a budgeted teardown step, which the _SHUTDOWN_BUDGET_SECONDS pointer covers. - store/sqlserver.py _write_session_txn and rotate_session: this branch's `landed` callback (the lineage link in the re-key's transaction) and main's `after_commit` borrow both kept; rotate_session passes both. Backlog: vault #3028, #3191, #3193, #3023.
…he merge Review round one of the dchain merge into b257-dtail found the resolution left one semantic conflict: main's SQL Server busy-pool test for the post-commit borrow asserted that rotate_session ends on its UPDATE, and this branch's rotation now ends on the lineage INSERT in the same transaction. The test now asserts what its Postgres twin asserts: an UPDATE of sessions, then the lineage INSERT last for rotate_session. Without this, test_sqlserver_post_commit_session_writes_wait_out_a_busy_pool[rotate_session-True] fails on the merged tree and on neither parent. The DRAIN_TIMEOUT_SECONDS comment the merge wrote is reordered so the "no timeout" clause cannot read as narrowing which writes are drained, and no longer restates the graceful-stop window that _SHUTDOWN_BUDGET_SECONDS states (SDS-3.5). Backlog: vault #3028, #3023, #3193, #3191.
… b257-dtail Main landed engine PR 2347 as squash 5c33135 (BACKLOG #3010, its send-back head b4bb5b1) and 838a562 (BACKLOG #3018, #3085). This tail already carried #3010 at b666975, so each conflicted file was resolved as the tail's version plus main's send-back delta (24aadec..b4bb5b1) plus 9cec001 and 838a562. - auth/ldap.py: _is_bind_referral, _referred_bind and _REFERRAL_TAG/_CONTEXT_CLASS are byte-identical on both sides (main ported D3b's, with the class check), so nothing was chosen. Main's _referral_error, LdapUndecodableError docstring and undecodable text are taken; the tail's configuration codes and connection release are kept. One duplicated _ldap3_call docstring paragraph removed. - auth/service.py: both: main's LdapUndecodableError arm reading FAULT through _fault_probe (vault #3696 item 2), ahead of the tail's LdapError arm that reads a configuration code as MISCONFIGURED. Main's #3018 _audit_reconciler_row signature and docstring, naming the configuration refusal's row too. The LdapError arm comment no longer claims an undecodable response reaches it. - auth/reconcile.py, api/app.py, docs/SECURITY.md, changelog.d/3010: both sides' text; the referral alert names ad_bind_dn, as the tail's code does. - tests/test_ldap_decode_boundary.py: union; main's private-tag-3 case, renamed tests and reconciler FAULT tests beside the tail's session-release test. tests/test_ldap_search_result_codes.py: the tail's (main's equals the pre- send-back version). None dropped. - tests/test_directory_last_admin_alert.py: the tail's spy on _apply_reconcile_revocation takes #3085's watermark and returns its outcome.
…undecodable bind an outage The union kept a tail docstring saying an undecodable response is an outage that pages nobody; since main's FAULT arm (vault #3696 item 2) it pages as a probe fault. Its referral tests now reuse main's _bad_bind_dc and _probe_through_the_reconciler rather than a second copy of the same setup. For BACKLOG #3010.
|
Merge of main with #3010 landed, head 3faa362 (merge a4f88a4 of tail 9e65ba5 and main d2a3454, plus review fix 3faa362). merge-tree into main d2a3454: rc 0. Eight conflicted files (auth/ldap.py, auth/service.py, auth/reconcile.py, api/app.py, docs/SECURITY.md, changelog.d/3010.security.md, tests/test_ldap_decode_boundary.py, tests/test_ldap_search_result_codes.py), resolved as the tail plus main's send-back delta (24aadec..b4bb5b1), 9cec001 and 838a562. Open findings, each named (12 distinct after merging repeats across the two rounds):
|
…line landed) into b257-dtail Resolved through the auth chain's own pre-squash head 573fbd3, whose tree equals main's: the merge took that head's later merges and review fixes (BACKLOG #3164 #3121 #3113 #3260 #3145 #2950 #3141 #3170) wherever this branch carried an earlier version, and kept the tail's own changes (#3010/#3170 reconciler follow-ups, #3120, #3028). Paging latch overlap (vault BACKLOG #3696, PR 2431): kept both mechanisms, because they cover different conditions. The paging latch is per account, for a group search the engine refused for its own bound (deadline, entry cap, page backstop), read UNAVAILABLE. The configuration refusal (#3010) is per pass, for a result code the directory returned (32, 34, 50), read MISCONFIGURED. A probe takes one arm or the other, never both. Docs and docstrings now name both, and the paging refusal's count (the breaker's, plan.probed) apart from the asked count of the referral, the configuration refusal and the fault. One release mechanism kept for the service connection: main's _service_session (#3017) replaces the tail's _released(svc, quiet=True); quiet and its unit test are gone, the end-to-end test stays. A duplicate no-op unbind in a test double (test_ad_directory_identity.py) is removed so main's counting one stands. The scripted DC in test_ldap_search_result_codes.py counts unbinds in main's _serve.
…ging and clear texts missed it Round one of the code review on the merge commit. Text only, no behaviour change: - CONFIGURATION.md and CONNECTIONS.md: a pass with a configuration refusal resolves no reconcile alert and does not re-arm the paging latch, as the code already does (BACKLOG #3010, vault BACKLOG #3696). - SECURITY.md: the configuration refusal marks no alert clear, and a service-account bind refused with one of those codes takes the same path. - service.py: the unjudged-others count names the paging refusal beside an unreachable directory; _track_reconcile_episodes lists misconfigured among the probes that end nothing. - changelog.d/3010.security.md: drops the service-connection unbind bullet. Main's _service_session (BACKLOG #3017) delivers it now, not this branch. - test_ldap_decode_boundary.py: the end-to-end unbind test says it exercises _service_session. Proposed PR title (unchanged): the draft PR 2387 title. Ledger banner text: unchanged from the D3b, D4, D4b and D5 handover commits.
|
Merge of main after PRs 2347 and 2375 landed, head fd7884f (merge 5ffe45d of 3faa362 and main 6d6986f, plus text-only review fixes fd7884f; fast-forward, no force). Method: 2375's pre-squash head 573fbd3 has exactly main's tree and shares history with this branch, so Open findings, each named (12):
|
|
Reading on the red |
Batch 257 (auth), the last PR: the #3010 and #3170 reconciler follow-ups, vault BACKLOG #3120 (lock-notice follow-ups and the audit actor index), and vault BACKLOG #3028 (login supersession follows a time-bounded retired-hash lineage, ADR 0210).
Status, 2026-10-19 (batch 257 Manager): READY. Engine PRs 2347 (#3010) and 2375 (the D1-D3 chain) have both landed, and main 6d6986f is merged in at head fd7884f. The diff against main is now only this tail's own work: 59 files, +3028/-525 (#3010/#3170 reconciler follow-ups, #3120, #3028). Two changes from the earlier description: main's #3017
_service_sessionnow does the service-connection release that D3b built (the duplicate was removed); and main's PR 2431 paging-refusal latch is kept beside D3b's misconfigured alert, because they cover different conditions (an engine-side paging bound per account, versus a directory result code 32/34/50 per pass). One merge-emergent behaviour, accepted by the Manager: a service-account bind refused with 32, 34 or 50 now reads MISCONFIGURED and pagesdirectory_misconfigured. It adds a page and weakens nothing; finding 1 below names the alert-text gap. Full suite on this tree: 50082 passed, 1232 skipped, 151 xfailed, 0 failed (run twice).Merge danger
Reversible: yes, but a revert leaves an orphan table and indexes on a server store that has already run
provision-schema. The newsession_lineagetable and theix_audit_actor/ix_audit_actor_idindexes move the server schema hash. A revert of the code leaves them in place: the table goes unused and the indexes stay as harmless extras. SQLite rebuilds from code. No data migration of existing rows.Blast radius:
auth/ldap.py: a new MISCONFIGURED probe outcome and its alert; a referral in a bind reply now reads as a referral; the service connection is released explicitly.auth/service.py: login supersession, session rotation, the session reaper, and the lock-notice throttle.auth/notifications.pyandpipeline/security_notify.py:notifynow returns a bool.session_lineagetable.api/app.py: lifespan wiring, and the misconfigured alert.docs/SECURITY.md,docs/PHI.md,docs/CONFIGURATION.md, ADR 0210, and changelog fragments.Evidence (per item)
directory_misconfigured, latches an alert, auditsauth.ad_reconcile_misconfigured, and pagesad_reconcile_aborted. A bind reply carrying a referral reads REFERRED, decided by where in ldap3 the error was raised; a test pins the ldap3 internals it relies on.authenticateandprobe_principalunbind their service connection; a local-server test counts sessions opened against unbinds across 7 cases.auth.ad_reconcile_faultedrow countasked, as the referral does; the breaker keepsprobed. New tests: the fault and refusal twins of the counted-over-asked test, and a service test that a faults-only or refusals-only pass strikes an id-less row but never revokes it.Before: a lock notice dropped because the queue was full still recorded
mailed: true, so the 24-hour throttle suppressed the next one. After:SecurityNotifier.notifyreturns whether the notice was queued, and a dropped notice recordsmailed: false.Actor index: the throttle scan seeks
ix_audit_actor (actor, action, id), andix_audit_actor_id (actor, id)keeps actor-only reads fast. Measured on SQLite 3.50.4 with 600k rows, 300k under one actor:One accepted regression: a narrow time window over a heavy actor takes about 50 ms, against about 0.01 ms on main. No measured index shape wins every case. Filed as a row.
Postgres keys on
left(actor, 200)with a whole-value recheck, so a long typed username cannot fail an audit write. An 8000-character actor test covers it.tests/test_session_supersession_lineage.py::test_an_elevation_that_finished_before_the_sign_in_does_not_keep_the_session_livefails on the old path ("the session lives on under its new token"), along with 3 more tests. After: 14 of 14 pass.session_lineagerow, in the same transaction as the re-key. Supersession revokes the live end of the chain: at most 32 hops, inside a window ofoidc_flow_ttl_seconds+ 120 s, in one transaction. The reaper purges expired links.-n 8: 47987 passed, 1202 skipped, 154 xfailed, 1 failed. The one failure,tests/test_child_process_environment.py::test_the_worker_does_not_search_the_working_directory_and_loads_the_parents_build, comes from settingPYTHONPATHto the worktree root. It fails the same way on main 231e1dd with that setting, and passes without it.ledger_check.py --ci: rc 0.git merge-treeinto main 231e1dd: rc 0.Links
b257-d3bd8d955a5e8b0fecce9ce03a3c52f457fae20e673batch/b257-3010)b257-d48bbae9b21ed47ddec3d522492571365fda72c477b257-d4ba67d10587932615f54fc2b5b5f1f5b1dffbf2233b257-d5323c42fe2bf48374cedb1acbe406a1e03e409361b257-dtail120eedff0eac5626d3f871ff19d80c908f8c02e8Unread legs
test_session_lineage_contractand the Postgres prefix index have never run.ci.ymlnamestests/test_audit_actor_index.py. Its Postgres SQL and its near-prefix test therefore run nowhere in CI, even after this PR. Row.windows-service-smoke,gates (ubuntu-latest)andgates (windows-latest).Known defects shipped open (for rows)
D3b
_is_bind_referral's comment is wrong about a private-class tag 3, and no test plants one._directory_step_up_refusal, ADR 0083, and_unjudged_others(which skips referrals).D4 and D4b
limit=50page is shared across lock kinds.send_failedis restated in about six places.D5
retired_attiming.Docs follow-up
Proposed vault banner text
From the commit messages. The Lander composes the final banners.