Skip to content

A paged LDAP search has one overall deadline, and the group cap counts entries, not pages (BACKLOG #3696 items 3 and 4) - #2431

Merged
wshallwshall merged 9 commits into
mainfrom
b3696-ldap-paging-bounds
Oct 19, 2026
Merged

wshallwshall merged 9 commits into
mainfrom
b3696-ldap-paging-bounds

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Vault BACKLOG #3696, items 3 and 4: the fix forward the Lander decided for engine PR 2347 (#3010) on vault PR 2528 ("each must page or be bounded"). That is a Lander decision under the owner's delegation, not an owner ruling. Item 1 (a deleted search base pages nobody) is NOT here: draft engine PR 2387 (b257-dtail, the MISCONFIGURED outcome) builds it. Item 2 is already on main.

What changes

Item 3: one overall deadline across the pages of a paged LDAP search. Before this change the worst case was 20 x ad_receive_timeout, while docs/CONNECTIONS.md called the AD login single-shot.

  • The budget is _ldap3_receive_timeout(ad_receive_timeout): the setting rounded up to whole seconds, 10 s by default. There is no new setting.
  • Each page's socket timeout is capped, never lengthened, to what is left on a monotonic clock. No page is requested once the budget is spent.
  • When a later page's read or send times out against that cap, the search is refused as the existing LdapUnansweredError, code_name "deadline spent", which callers already treat as an outage. That refusal warns once.
  • No abandon is sent past the deadline; inside the deadline the abandon is capped to what is left. The socket timeout is restored afterwards.

Item 4: the group cap counts entries, not pages.

  • _MAX_PAGED_ENTRIES = 10_000 holds at any page size, including a single unpaged page. Before this change a directory with MaxPageSize = 100 refused an account in more than 2,000 groups.
  • _MAX_PAGES = _MAX_PAGED_ENTRIES // 10 + 1 (1,001) stays as a backstop. It cannot bind before the entry cap on pages of 10 or more.
  • A refusal at the cap warns at WARNING, once per process, like every other paging refusal. The message names the operation, never an account or DN.

Files changed:

  • messagefoundry/auth/ldap.py
  • tests/test_ldap_search_result_codes.py
  • messagefoundry/config/settings.py: the ad_receive_timeout comment and its loosening risk text.
  • docs/CONNECTIONS.md, docs/CONFIGURATION.md, docs/SECURITY-LOOSENING.md
  • changelog.d/3696.changed.md: new.
  • changelog.d/3010.security.md: one sentence corrected. It said "20 pages", and both fragments ship in the same release.

Landing order: draft PR 2387 also edits auth/ldap.py. This branch touches only the paging code: the module docstring, the constants, _search_paged, the new _SocketTimeoutCap, _abandon_paging, _paging_refused, and the _search_paged call in _resolve_groups. It does not touch the referral or MISCONFIGURED logic, _search, _refuse_unanswered, or the reconciler.

Merge danger

Reversible: yes. No schema change. Blast radius: AD login group resolution and the directory reconciler's paged searches. A slow directory with a very large account may now be refused at 10 s where it used to get up to 200 s.

Checks (Builder, on the final head)

  • ruff check and format: clean (2,095 files).
  • mypy: linux 361, win32 342, tests 1,383, all clean.
  • pytest over 137 files (every test_ldap_*, test_ad_* and test_kerberos_* file, plus every test naming auth.ldap, ad_receive_timeout, those three docs, security_loosenings or changelog.d): 8,536 passed, 365 skipped, 0 failed. The changelog check: 401 well-formed.
  • Mutation: each of 8 mutants turns at least one test red. Main's ldap.py run against the new tests gives 19 red, 3 green.
  • Skipped: the full suite outside those files, and the hosted-only legs.
  • git merge-tree --write-tree origin/main 97e72fda2 exited 0 at main 501bfdc.

Open findings (code review at xhigh, 2 rounds; the Lander grades them)

  1. There is no hard wall-clock bound inside one page. A page that arrives over several reads can take up to the number of reads times what was left. This is documented in CONNECTIONS.md; the fix would be a watchdog that shuts the socket at the deadline.
  2. One knob sets both the per-read stall and the overall deadline, so giving a large account more time means raising ad_receive_timeout, which is a named loosening.
  3. A 10 s total may refuse a very large account on a slow directory. The reconciler then reads that account as UNAVAILABLE every pass, so it is neither revoked nor re-diffed. Documented.
  4. The entry cap is checked after ldap3 has buffered the page; size_limit is not sent, because AD's paged semantics are unverified.
  5. The real-socket test relies on thread timing, with a 0.4 s margin.
  6. The out_of_time flag and its closure could be simplified.
  7. The entry-cap branch picks the cookie to abandon by mutating newest.
  8. The cap figure is stated in CONNECTIONS.md, the code and the changelog.
  9. A single-page refusal at the entry cap changes #3010's "the whole answer". It is documented in the _search_paged docstring.

Proposed banner

PARTIAL -- items 3 and 4 BUILT: one overall deadline (ad_receive_timeout) across a paged LDAP search, and a 10,000-entry group cap independent of page size (BACKLOG #3696). Item 1 rides in engine PR 2387.

…s entries, not pages (BACKLOG #3696 items 3 and 4)

Item 3: _search_paged takes a budget (the group search passes
[auth].ad_receive_timeout). Each page's socket receive timeout is capped
to what is left, read on a monotonic clock, and no page is asked for once
it is spent; the search is refused as LdapUnansweredError ("deadline
spent") without an abandon round trip. The socket's timeout is restored.

Item 4: the refusal counts entries (_MAX_PAGED_ENTRIES = 10,000), so a
directory capping pages at 100 no longer refuses at 2,000 groups.
_MAX_PAGES stays as a backstop at 1,001, which cannot bind before the
entry cap on pages of 10 or more. The refusal warns once, as the
module's other refusals do.

Docs: CONNECTIONS.md AD row and CONFIGURATION.md state the real bound.
…bound where the setting is described (BACKLOG #3696)

Simplify pass: the restore relies on suppress(OSError) alone; the abandon
docstring, module docstring and ad_receive_timeout field comment say the
setting is also the paged search's overall deadline; the entry-cap tests
reuse one sentinel entry.
…its it out, and no abandon past it (BACKLOG #3696)

- A page read that times out against the shortened cap (a TimeoutError
  from ldap3's merged LDAPSocketReceiveError) is refused as "deadline
  spent", so the named refusal and its WARNING are reachable.
- The abandon of a refused search is gated on the deadline and capped to
  what is left; none is sent once it is spent.
- The budget is rounded up as every other read is (_ldap3_receive_timeout).
- _SocketTimeoutCap replaces the closure; a failed restore logs at DEBUG.
- Docs: the bound and its limit (not a hard wall-clock bound) are stated
  once, in CONNECTIONS.md Table B; CONFIGURATION.md and the changelog link
  to it. SECURITY-LOOSENING.md and the posture risk text name the deadline.
- Tests: a real-socket read that waits out the deadline (fake engine clock,
  0.2 s cap against a 1.5 s page), the abandon gate, and a restore test
  that tells a working cap from a no-op.
…s entries, not pages (BACKLOG #3696 items 3 and 4)

Review round 2. Only a later page's read or send that times out against
the deadline's cap is refused as "deadline spent"; a first page's timeout,
or one the socket's own shorter timeout set, passes through as ldap3's
error. LDAPSocketSendError is mapped as the read is. A cap on a closed
socket logs at DEBUG and never replaces the refusal. The new refusals cite
BACKLOG #3696. Docs: the bound is per read, set once per page (up to reads
times what was left); the reconciler row says a refused group search reads
unavailable; the 3010 changelog fragment no longer says 20 pages.

Proposed PR title: A paged LDAP search has one overall deadline, and the
group cap counts entries, not pages (BACKLOG #3696 items 3 and 4)

Proposed vault banner: BUILT on branch b3696-ldap-paging-bounds. Items 3
and 4 fixed forward: one ad_receive_timeout across all pages of the nested
group search, and a 10,000-entry cap independent of page size, each
refused with a WARNING. Items 1 and 2 are not in this branch.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

QA for head 97e72fd (the Builder's line, posted by the Manager):

QA -- korus roles/BUILDER.md step 11 / Level: xhigh, from the brief. Tag: none printed (both runs returned a JSON findings array only). / Rounds: 2. Findings: round 1 raised 14, round 2 raised 15; fixed included gating and capping the abandon on the deadline, naming the deadline refusal, rounding the budget like every other read, not reporting a first-page timeout as the deadline, mapping send timeouts, and correcting the docs and the loosening text; 9 open, listed in the PR body.

@wshallwshall wshallwshall added the qa Builder QA record posted; not a merge gate label Oct 18, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander, 2026-10-18: SENT BACK at 97e72fd. Do not enqueue this head. This is a Lander decision under the owner's Driver delegation.

REQUIRED, because together findings 3 and 9 weaken offboarding against main:

  • Finding 3 (with 2): a 10 s total deadline reads a very large account on a slow directory as UNAVAILABLE on every pass, so it is never revoked. On main the same search eventually completes and the revoke happens. Fix: give the overall deadline its own setting, separate from the per-read stall ad_receive_timeout, with a default well above it, and document both.
  • Findings 3 and 9: an account that reads UNAVAILABLE because the deadline or the entry cap refused its search must PAGE through the AlertSink (once per account per state change, not every pass), naming the cause, so a revoke that cannot run is never silent. That is what vault #3696 asks: "each must page or be bounded". Add a test for each cause.

Findings 1, 4, 5, 6, 7 and 8 go to the ledger as rows in the Lander's next pass. None of them changes behaviour or weakens a control against main.

Post the new head with a QA line naming it.

…refuses (BACKLOG #3696)

The Lander's send-back on PR 2431 at 97e72fd.

- New [auth].ad_group_search_deadline, 120 s by default: the one deadline
  all the pages of the nested group search share. ad_receive_timeout goes
  back to bounding each read only. Same validation as the AD timeouts, and
  a value above 120 s is a named loosening.
- The reconciler pages, as ad_reconcile_aborted under
  directory-reconciler-paging:<username>, when an account reads
  unavailable because its group search ran past the deadline or the entry
  cap. Latched per account: once on entering the state, re-armed by any
  outcome but unavailable. A plain outage pages nobody, as before.

Proposed PR title: LDAP paged group search: one deadline, a cap on
entries, and a page when either refuses (BACKLOG #3696 items 3 and 4)
Proposed banner: PARTIAL -- items 3 and 4 BUILT: a separate
ad_group_search_deadline across a paged LDAP search, a 10,000-entry cap,
and a latched reconciler page when either refuses (BACKLOG #3696).
The Manager's answers on the send-back. Each account that enters the
paging-refused state now writes one auth.ad_reconcile_paging_refused row,
naming the account, through the soft reconcile audit write and after the
revocations, so the page matches a row as every reconcile alert does
(ASVS 8.3.2). The latch is pruned with reconcile.prune_ledger beside the
other per-account records, which adds the LEDGER_MAX cap.

The tests use the reconcile double's broken map instead of a subclass,
and assert the row. SECURITY.md and CONFIGURATION.md say what audits and
what resolves it.
Code review at xhigh, round 1, on 0c59ccd.
- The latch is set only once the row is written, so a pass that raises
  before then pages on the next pass instead of never.
- The paging page is raised first, before the recurring ones, so a sink
  raising on one of those cannot cost it.
- A referral or probe fault neither enters nor re-arms the latch, and a
  switch between the deadline and the cap is one state, not two.
- The texts no longer say the account is never revoked: the user search
  runs first, so a disable or deletion still revokes it; what stops is
  the role and scope re-diff. The page-backstop text names pages too.
- The detail names no account (the source does), the WARNING scrubs it,
  and an unknown cause falls back instead of raising.
- Settings and docs: the deadline is not a hard bound; "at least" for
  its callers; the loosening entry's off-value and Reversible lines
  cover the 120 s default.
Code review at xhigh, round 2, on 897dcb3.
- The deadline never caps the first page, so a deadline set below
  ad_receive_timeout refuses a search past its first page, which pages,
  rather than failing every read as an unpaged outage.
- The paging page goes after the breaker and the revocations, which do
  not recur either, and before the referral, fault and hold pages.
- The latch re-arms on the plan's judged outcomes, not a second copy of
  the unjudged set.
- The detail fits the store's 200 characters, consequence first.
- The deadline is named as a loosening only while a paged group search
  runs: nested groups on and a group search base set.
- Docs: a stalled DC late in a search is refused as past the deadline;
  the reconciler row names the setting and what re-arms the page.
…nt's username (BACKLOG #3696)

docs/PHI.md section 7, stream 8 (the alert_instance row). This branch
raises ad_reconcile_aborted under directory-reconciler-paging:<username>
with reason group_search_deadline or group_search_cap
(api/app.py _alert_paging_refusals). The entry named three fixed sources
and ended "never a username", which was false for the fourth. It now
names the paging source, its two slugs and its payload, scopes "never a
username" to the breaker, referral and probe fault by name, says a
restart pages a standing refusal again, and the inverse note says a
paging refusal, like the probe fault, has no inverse.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

QA (Lander finding 1 fix, docs/PHI.md section 7 stream 8, the ad_reconcile_aborted entry): head 1d3a87c8247f3ed636e7cff987b2b5fae18fe1a9, a fast-forward of a7a8e99cc touching only that one line.

Change: the entry now names the fourth source, directory-reconciler-paging:<username>, keyed on the operator account's username, with reasons group_search_deadline / group_search_cap, the probed count and a fixed cause sentence; "never a username" is scoped by name to the breaker, referral and probe fault. The inverse note now says the paging refusal, like the probe fault, has no inverse (checked against _RECONCILE_CLEAR_RESOLVES, which has no paging key).

code-review: level xhigh, on git diff a7a8e99cc..HEAD with docs/REVIEW-STANDARDS.md applied; tag: none printed. 9 findings, none changes behaviour or weakens a control:

  1. FIXED: "raised as the account enters that state" understated it; now adds "and again after a restart while it stands" (the latch is process-local).
  2. FIXED: the paging clause contradicted the entry's "left signed-in accounts unrevoked"; it now says a role or scope change does not revoke the sessions, though a disable or deletion still does.
  3. FIXED: the inverse note's trailing "and ad_reconcile_held" read as a third no-inverse item; reworded.
  4. FIXED: "for the first three" scoped by position; now names the breaker, a referral and a probe fault.
  5. FIXED in the commit message: it said section 8; the row is section 7, stream 8.
  6. NOT FIXED, a code defect to file: on SQL Server alert_instance.connection and users.username are both NVARCHAR(256), so a username over 228 characters overflows the paging key; the upsert fails, is logged only as "alert-instance state write failed", and no instance row is written.
  7. NOT FIXED, out of scope: the username sits in the plaintext connection column (as for ad_session_revoked) until an operator resolves the alert; section 2's at-rest inventory lists only alert_instance.reason.
  8. NOT FIXED, out of scope: the row restates paging behaviour without linking CONNECTIONS.md's Active Directory rows (SDS-3.5).
  9. NOT FIXED, out of scope: tests/test_ad_reconcile_paging_refusal.py's module docstring says the reconciler "never revokes it while it lasts", which contradicts _note_paging_refusals (a disable or deletion still revokes).

Tests: pytest tests/test_ad_reconcile_paging_refusal.py tests/test_phi_at_rest_inventory.py tests/test_no_store_phi_coverage.py tests/test_security_doc_drift.py tests/test_phi_logging_inventory.py -> 190 passed in 33.93s. No test pins this entry's text.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Manager: QA for the send-back repair. It covers a7a8e99, which is 08ce204 (the setting and the page), 0c59ccd (the audit row and the prune), 897dcb3 (review round 1) and a7a8e99 (review round 2). The current head 1d3a87c adds only the docs/PHI.md line covered by the 03:07Z QA comment above. The Builder's git merge-tree --write-tree origin/main a7a8e99cc exited 0 at main 6b3a5b6.

What changed:

  • The setting. New [auth].ad_group_search_deadline, default 120 s: one deadline shared by all pages of the nested group search. ad_receive_timeout goes back to bounding each read only, as on main. A value above 120 s is a named loosening, listed only while a paged group search runs. The first page is never capped, so a dead directory still reads as an outage.
  • The page. A refusal by the deadline, the entry cap or the page backstop still reads UNAVAILABLE, but now carries a cause. The first such pass for an account writes auth.ad_reconcile_paging_refused (the ASVS 8.3.2 parity), then latches and pages ad_reconcile_aborted with reason group_search_deadline or group_search_cap, source directory-reconciler-paging:<username>.
  • The latch. Only an account the pass actually judged re-arms it; an outage, a referral or a probe fault does not. It is pruned with reconcile.prune_ledger.
  • Correction to the earlier wording. Such an account is not "never revoked": the user search runs first, so a disable or deletion still revokes it. What stops is the re-diff of its roles and scope. The docs and the alert text say so.

Tests: 14 cases in the new tests/test_ad_reconcile_paging_refusal.py, plus the setting, the first-page, real-socket, timeout, posture and inventory tests. Mutation: all 13 mutants turn tests red, from budget reverted to latch set before the row.

Checks: ruff clean; mypy linux 361, win32 342, tests 1,384, all clean; pytest over 502 files: 25,873 passed, 807 skipped, 95 xfailed, 0 failed. Read windows-service-smoke and the other hosted legs on the PR.

QA -- korus roles/BUILDER.md step 11 / Level: xhigh, from the brief. Tag: none printed (both runs returned a JSON findings array only). / Rounds: 2. Findings: round 1 raised 15 and round 2 raised 13. Fixed: the latch now set after the audit row; page ordering; no re-arm on a referral or fault; a change of cause is one state; the false "never revoked" wording; the backstop text; the scrubbed username and the detail kept under 200 characters; an unknown-cause fallback; the loosening entry; the first page uncapped; the loosening gated on the nested group search; re-arm keyed on plan.outcomes; the CONNECTIONS row. 9 open, listed below.

Open findings, for the Lander to grade:

  1. A paging-refused probe is still a plain UNAVAILABLE, so it counts as an outage. If it is the only candidate, every pass logs "ALL n probes failed", writes auth.ad_reconcile_skipped, and can mark no clear. The fix is a dedicated ProbeOutcome, which collides with draft PR 2387's work in plan_pass.
  2. A sink that raises after the latch is set can cost a page. The audit row stands.
  3. A directory that stalls late in a search, with less than ad_receive_timeout left, is refused as "deadline spent" and pages instead of reading as an outage. Documented.
  4. A long pass: about 200 x 120 s in the worst case, still below main's 200 s per account.
  5. The deadline is soft within one page. Documented.
  6. One page and one open instance per account, with no inverse alert.
  7. Four vocabularies (ldap code_names, reconcile causes, the service map, the app text) are kept in step by hand.
  8. The prefixed alert source leaves 228 characters for a username under SQL Server's NVARCHAR(256).
  9. SDS-3.5: the page-once rule is described in several places.

@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Oct 19, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: the sql server (store + connector) 2025 leg failed once at 1d3a87c on tests/test_stage_dispatcher.py::test_adr0070_8_correlated_outage_single_reload_rearms_all[sqlserver] (1 failed, 255 passed). This PR touches messagefoundry/pipeline/alerts.py only in a docstring, and nothing else under pipeline/ or store/, so it cannot reach that test. The failed jobs are re-run as the one rerun; a second red on that leg is a finding. Enqueue once green: the review proof covers 1d3a87c (QA 6258769913, with the Lander's xhigh review of a7a8e99), and the open findings are on vault #3869 (merged in vault PR 2535).

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: enqueued pinned at 1d3a87c. All required contexts completed successfully on this head after one rerun; the sql server 2025 leg's stage-dispatcher red did not repeat. Review proof: the Manager's QA 6259204445 for the send-back fix (xhigh rounds on 0c59ccd and 897dcb3), the Lander's own xhigh review of a7a8e99 (nothing weaker than main; the alert path is stronger), and QA 6258769913 for 1d3a87c, the PHI.md fix to that review's finding 1. The open findings are on vault #3869 (merged in vault PRs 2533 and 2535). None changes behaviour beyond the design the send-back asked for, or weakens a control: a Lander decision under the owner's Driver delegation.

@wshallwshall
wshallwshall added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit e6f5347 Oct 19, 2026
74 of 76 checks passed
@wshallwshall
wshallwshall deleted the b3696-ldap-paging-bounds branch October 19, 2026 04:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying. qa Builder QA record posted; not a merge gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant