Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c19b90f
TOTP enable is bound to the secret the confirm verified (BACKLOG #3025)
wshallwshall Oct 16, 2026
44c2dce
Review round 2: the TOTP stage keeps the step mark, so provision's co…
wshallwshall Oct 16, 2026
957a746
A stale reconciler rename plan no longer overwrites a newer sign-in r…
wshallwshall Oct 16, 2026
5464353
Docs state the TOTP enable compare-and-set built under BACKLOG #3025
wshallwshall Oct 16, 2026
9036293
Review round 1: the stale-plan skip is the reconciler's alone (BACKLO…
wshallwshall Oct 17, 2026
47c876c
Review round 2: from_plan is required, residuals named once (BACKLOG …
wshallwshall Oct 17, 2026
cc31972
A stale held read no longer files a false rename conflict (BACKLOG #3…
wshallwshall Oct 17, 2026
20d01a3
Review round 1: a holder failing its seal still holds the name (BACKL…
wshallwshall Oct 17, 2026
1c90908
SECURITY.md: a refused TOTP confirm is retried by signing in again (B…
wshallwshall Oct 17, 2026
01f4a28
Review repair: refusal actor rationale restored, pre_check case disti…
wshallwshall Oct 17, 2026
bb26090
Review round 2: the re-read keeps one holder rule (BACKLOG #3070)
wshallwshall Oct 17, 2026
abfd159
Merge remote-tracking branch 'origin/b259-3069' into b260-3070
wshallwshall Oct 17, 2026
07c109a
SECURITY.md: the refused-confirm recovery is stated once, with its st…
wshallwshall Oct 17, 2026
3aba14d
Review round on 01f4a28b: exception named exactly, retry lag worded o…
wshallwshall Oct 17, 2026
f88fae5
Merge remote-tracking branch 'origin/b259-3069' into b260-3070
wshallwshall Oct 17, 2026
53a6241
_refresh_cached_username re-reads the holder of the new name before r…
wshallwshall Oct 18, 2026
b5e0455
Review round 1 on the main merge: tests take the soft-audit path, pro…
wshallwshall Oct 18, 2026
e73eb51
_refresh_cached_username re-reads the holder of the new name before r…
wshallwshall Oct 18, 2026
585852d
Holder re-read keeps the seal state and survives a store fault (BACKL…
wshallwshall Oct 18, 2026
0beae23
Review round 1 on the send-back: the fault skip is the reconciler's a…
wshallwshall Oct 18, 2026
60339cf
Review round 2 on the send-back: skip line names the holder, prose ma…
wshallwshall Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions changelog.d/3070.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
- **A directory reconcile pass no longer files a false rename conflict from a stale holder read.**
In the shipped code the reconciler read which account held an AD account's new name, then read
the account itself, and refused the rename on the first read. A holder that let the name go in
between still produced an `auth.ad_username_refresh_conflict` audit row and a lockout warning
that nobody faced, and the rename waited for a later pass. Now the holder is read again after the
account, and that check refuses the rename only when another account holds the name at the
second read. The conflict row's `held_by_user_id` then names that account, not the one the first
read saw. A holder whose record fails its integrity check at the second read is still refused
with the lockout warning, naming the holder the first read verified. A store error on the second
read skips that one rename for the pass instead of ending the pass; a store error on the
pass's other rename reads still ends it. A holder that already fails its integrity check at the
first read still reads as no holder there, so the write's guard stops the rename and the
conflict row names no holder, with a warning that a later pass retries it. Vault BACKLOG #3070.
67 changes: 55 additions & 12 deletions messagefoundry/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2982,8 +2982,10 @@
self._reconcile_unkeyed_refused: dict[str, None] = {}
if self.directory_reconcile_enabled:
# Built now, at startup, so a reconciler's first refused audit write does not import
# the server drivers on the event loop (see `_audit_write_errors`).
# the server drivers on the event loop (see `_audit_write_errors`). The same for the
# rename refresh's holder re-read, which catches `_session_write_errors` (#3070).
_audit_write_errors()
_session_write_errors()
# Advisory, NON-STICKY federated-IdP health (ADR 0142 AC-8) — see the oidc_available docstring.
self._oidc_unavailable_reason: str | None = None
self._oidc_client_auth: oidc.ClientAuthentication | None = None
Expand Down Expand Up @@ -7651,7 +7653,7 @@
elif event == "settled" and current in _HOLD_RELEASABLE:
self._reconcile_hold_standing = "settled"

async def _refresh_cached_username(

Check warning on line 7656 in messagefoundry/auth/service.py

View workflow job for this annotation

GitHub Actions / complexity triage (advisory)

Complexity increased

`_refresh_cached_username` complexity 13 -> 16 (mccabe threshold 10)
self,
*,
user_id: str,
Expand All @@ -7670,21 +7672,22 @@

``audit_soft`` is passed by the reconciler alone (BACKLOG #3018), so its audit writes go
through :meth:`_audit_reconciler_row`. The login path leaves it off, so there a refused
write still fails the sign-in, as it always has.
write still fails the sign-in, as it always has. It also lets a store fault on the ``held``
re-read skip the rename rather than raise (BACKLOG #3070; the reason is at that read).

**ONE implementation for two callers, deliberately.** The login path
(:meth:`_upsert_ad_user`) and the ADR 0079 reconciler pass both reach a renamed account and
both must resolve the collision case the same way. Two copies of this decision would be two
policies, and the one that ran less often would be the one nobody noticed drifting.

``held`` is the row currently holding ``new_username``, read by the caller. Passed rather than
re-read for the reason :meth:`_upsert_ad_user` gives about ``by_name``: it puts the dependency
in the signature instead of only in prose, and here it also saves a query the login caller
**provably** already has the answer to. ``_complete_ad_login`` reads that exact row before it
calls down, and refuses outright when it exists with a different id -- so the login path
reaches this method only with ``held=None``, and the collision branch below is reachable from
the **reconciler alone**, which probes an account it has already identified and has no such
guard.
``held`` is the row holding ``new_username`` when the caller read it. One naming another
row is re-checked, not trusted: the rule is at the ``held`` pre-check below (BACKLOG
#3070). ``held=None`` skips that pre-check, so a holder appearing after the caller's read is
caught only by the write's guard. The login caller **provably** already has the answer:
``_complete_ad_login`` reads that exact row before it calls down, and refuses outright when
it exists with a different id -- so the login path reaches this method only with
``held=None``, and the collision branch below is reachable from the **reconciler alone**,
which probes an account it has already identified and has no such guard.

**THE CHECK-THEN-ACT RACE IS REAL AND IS ABSORBED BELOW, NOT PREVENTED BY THE STORE.** This
docstring used to say the store's in-statement guard made "a row claiming the name between
Expand Down Expand Up @@ -7882,8 +7885,48 @@
)
return
if held is not None and held.id != user_id:
await _refuse(held.id, "pre_check", before.username)
return
# BACKLOG #3070. THE ONE STATEMENT OF THIS RULE. The caller read ``held`` before
# ``before``, so its holder may have let the name go since, and filing on that read
# would audit a conflict and warn of a lockout nobody faces. So the holder of
# ``new_username`` is read again here, after ``before``, and only a row that holds the
# name now is refused; the audit names that row. If nobody holds it, or this row does
# (a sign-in renamed it just now, and the compare-and-set below then writes nothing),
# it is no conflict. A verified holder load, not one statement, but only when the
# caller's read named another row.
#
# A holder that fails its seal at this re-read still holds the name, so it is refused
# as the caller's read would have been, with the lockout warning, not left to the
# write-path refusal's "a later pass retries it", which is false while that holder
# stays seal-failed. Its id is not verified, so the audit names ``held``, the last
# verified holder. NOT CLOSED HERE: the reconciler's own ``held`` read, and at least
# the two write-path holder reads below, still read a seal-failed holder as None; and
# ``refused`` also reads true for a holder deleted or re-keyed between the re-read's
# own row and role reads, a narrow window that still files this refusal.
#
# On the reconciler's path (``audit_soft``) a store fault on this re-read skips this
# one rename for this pass rather than raising: a raise would end the pass before the
# renames after this one. Nothing was written; a later pass that probes the account
# retries it. The other store reads in the rename loop still raise. A sign-in re-raises,
# so it fails loud as it does elsewhere in this method. The error tuple is cached and
# built at construction when the reconciler runs, so no driver is imported here.
try:
current = await self._lookup_by_username(new_username)
except _session_write_errors() as exc:
if not audit_soft:
raise
_log.warning(
"AD account %s: re-reading the holder of its new directory name (account %s "
"held it at the first read) raised %s, so its rename was not applied on this "
"pass; a later pass that probes it retries it (BACKLOG #3070)",
user_id,
held.id,
type(exc).__name__,
)
return
if current.refused or (current.user is not None and current.user.id != user_id):
holder_id = held.id if current.user is None else current.user.id
await _refuse(holder_id, "pre_check", before.username)
return
try:
# BACKLOG #2290. A compare-and-set on the name just read, so of two refreshes of this
# row running at once only one writes.
Expand Down
Loading
Loading