fix(auth): refuse directory login on a disabled mirror row, and reset the failure counter only at full authentication (BACKLOG #1637, #1638) - #1179
Conversation
…ear the failure counter only at full authentication (BACKLOG #1637, #1638) #1637: _complete_ad_login checked provider and directory id and never disabled, so an engine-disabled AD mirror row completed a Kerberos or OIDC login with an auth.login_success row and a live session. Two guards, because a check on `existing` alone does not close it. On a directory-side rename the caller's name-keyed read misses and the row the login lands on comes from get_user_by_directory_object_id inside _upsert_ad_user. The guard that matters is sited there, immediately after that lookup and before any write; the resolver signals with a private _DirectoryLoginRefused that its one caller catches and audits. The caller-side arm is for the reader, not for the control, and its comment says so rather than claiming to be load-bearing. #1638: record_login_success zeroes failed_attempts and NULLs locked_until in one UPDATE, and both _login_local and _complete_ad_login called it before the second factor was proven. A first-factor holder could shed a run of wrong codes by logging in again, and a directory re-login cleared a second-factor lock outright. The call now runs only when the session owes no further factor; verify_mfa already cleared on its own success and finish_webauthn_assertion now does too. The passkey clear is not a reversal of ADR 0068. That divergence is about not feeding _register_failure on assertion FAILURE and stands unchanged. Without the success clear a passkey-only account would shed password failures only by waiting out the lockout window. disabled and locked are deliberately absent from the web console's _REASON_TO_CODE, so both collapse to the generic oidc_failed. A distinct code would tell an unauthenticated caller that the account exists. The omission is pinned by a test and explained at the map. docs/SECURITY.md: one sentence corrected. "The engine's per-account lockout protects Local accounts only" was already false (verify_mfa never filtered on auth_provider) and these guards make it false in a new way. Shipping a control whose own security document says it does not apply there is the false-premise shape SDS-3.7 forbids. The wider packet-18 rewrite is untouched.
|
The deferred check leg finished green after the body was written, as that section said it would.
That closes every local check named in the body. The hosted-runner legs ( |
…d pins The directory-login rewrite of the lockout paragraph left its second mention as plain 'do not feed it', dropping the literal '**feed** it' that test_lockout_is_fed_by_two_legs_but_enforced_on_the_assertion_leg_too requires in docs/SECURITY.md. All three test legs failed on that single assertion. Bolding the second mention restores the pinned literal and matches the paragraph's own bold-feed versus italic-enforced device. The corrected claim about directory accounts and the FEEDING versus ENFORCING distinction are both untouched.
Lander reviewRead at head The one thing that could have made this worse than the defectThis PR stops clearing It does not. I enumerated every Both MFA-completion legs clear it, unconditionally on their success paths. So every account that can fully authenticate has a path that clears the counter, and the accounts that do not are exactly the ones that have not completed authentication. The comment at 1298 is accurate as written. The The SECURITY.md enforcement claim, checked rather than readThe doc now says the lock is enforced wherever a pathway reaches an engine account row, directory accounts included, because Positive control in the same pass: The no-enumeration property, which is the part most likely to be "fixed" later
The refusal slugs are closed-set literals, never directory- or IdP-supplied text, which keeps attacker-influenced strings off the audit row. The honest guard, and why it earns its placeThe The ordering after the two conflict branches is also reasoned: a recycled Stated no larger than it isI verified the control flow and the two documentary claims above. I did not re-run the suite locally and I did not independently re-derive the ADR 0068 divergence this builds on; I checked that this change does not contradict it, not that it is itself correct. Verdict: merge. |
What this builds
Two rows as one pull request, because both add a refusal branch to the entry region of
_complete_ad_loginand as separate PRs they would conflict in one large file.BACKLOG #1637 -- an engine-disabled mirror row completed a directory login
_complete_ad_loginchecked provider and directory id and neverdisabled. An engine-disabled ADmirror row therefore completed a Kerberos or OIDC login with an
auth.login_successaudit row and alive session. Only
identity_for_tokenrefused it later.Two guards, and the second is the one that closes it. A check on
existingalone does not:on a directory-side rename the caller's name-keyed read misses,
existingisNone, and the row thelogin actually lands on comes from
get_user_by_directory_object_idinside_upsert_ad_user.resolver signals with a private
_DirectoryLoginRefusedthat its one caller catches and audits.A check on the returned
UserRecordwould be too late: the rename refresh, the profile write andthe role resync would already have run.
control. The resolver's gate runs on
by_nametoo, so deleting those four lines changes nooutcome. Documenting it as load-bearing would have been the false-premise shape SDS-3.7 names.
Ordered after the two existing conflict branches: a recycled
sAMAccountNamemeeting a staledisabled row is a
directory_identity_conflict, not adisabledlogin. The new holder's account isneither disabled nor locked, and saying otherwise would misdirect the operator reading the audit row.
BACKLOG #1638 -- the failure counter cleared at the password step
record_login_successzeroesfailed_attemptsand NULLslocked_untilin one UPDATE, and both_login_localand_complete_ad_logincalled it before the second factor was proven. So aholder of the first factor could shed a run of wrong codes by logging in again, and a directory
re-login cleared a second-factor lock outright.
The call now runs only when the session owes no further factor.
verify_mfaalready cleared on itsown success;
finish_webauthn_assertionnow does too._complete_ad_loginalso refuses a lockedmirror row, through the same gate as the disabled check.
No fourth store method.
record_login_successwriteslast_login_atin the same UPDATE, sodeferring the call moves that column's timing. Nothing reads it, ADR 0164 exists to reject deriving
anything from it, and a new store method would drag in four backends plus
test_store_backend.pyfor no gain.
prior_failuresis left where it is -- two tests pin the suspicious-login notice.Decisions this brief asked me to record
The passkey clear, and why it is not an ADR 0068 reversal
On a successful
finish_webauthn_assertionthe failure counter is now cleared.ADR 0068's recorded divergence is about not FEEDING
_register_failureon assertion FAILURE, andit stands unchanged (signatures are not guessable secrets; a flaky authenticator must not lock an
account). It says nothing about success. Since #1638 the password step no longer clears the counter,
so whichever leg completes the authentication has to -- and for a passkey-only account this leg is
the only one there is. Without it, that account's password failures would shed only by waiting out
the lockout window. Read this as closing a gap #1638 would otherwise open, not as a posture change.
_REASON_TO_CODE:disabledandlockedare deliberately unmappedBoth collapse to the generic
oidc_failedon the login page. A distinct code would confirm to anunauthenticated caller that the named account exists and say which of the two states it is in.
The operator loses nothing: the precise reason is on the
auth.login_failedaudit row.The omission is explained at the map and pinned by a test
(
test_the_browser_layer_gives_a_refused_account_no_distinguishing_code), because to the next readerof that map an absent entry looks exactly like an oversight.
Checked while there: neither
messagefoundry_webconsole/routes/sso.pynormessagefoundry/api/auth_routes.pyreadsoutcome.reasonat all, so the OIDC callback is the onlyconsumer and there is no second leak path.
Audit shape
Both refusals use the inline shape the existing
local_account_conflictrefusal uses, not_directory_reject_audit. That helper requires a mechanism slug and the Kerberos leg reaches thisdecision point without one (
mechis optional and defaults toNone). The reason is a closed-setliteral either way, so no directory- or IdP-supplied text is stored.
docs/SECURITY.md-- one sentence, per the Manager ruling"The engine's per-account lockout protects Local accounts only" was already false before this
change (
verify_mfaandfinish_webauthn_assertionnever filtered onauth_provider), and the#1637/#1638 guards make it false in a new way. Shipping a control whose own security document says it
does not apply there is the compensating-control-on-a-false-premise defect SDS-3.7 forbids.
Scope: that one sentence. The wider packet-18 rewrite is untouched. Two follow-on notes for
whoever owns packet 18, neither fixed here:
engine credential to lock out". Both describe feeding, and both remain accurate as written --
but a reader may now want an enforcement note beside them.
two legs feed it") now sits just after a broader claim and reads as a weaker restatement. Left
alone to hold the one-sentence scope.
BACKLOG #1569 is subsumed by #1638
Same counter, same call, same line. Not built separately and not claimed. The Lander can close
#1569 citing #1638.
Proposed ledger banner text
docs/BACKLOG.mdis a 23-line public stub in this repository (commit11a3934c1); the real ledgeris vault-only, so no ledger file is touched by this PR. Text for the Lander:
#1637 -- Shipped
<date>by PR<N>._complete_ad_loginnow refuses an engine-disabled mirrorrow before any write, on both the name-keyed and the id-keyed (rename) path, audited
login_failedwith reason
disabled. Kerberos and OIDC legs each covered by a test.#1638 -- Shipped
<date>by PR<N>.record_login_successruns only once the session owes nofurther factor, on the local and the directory path;
finish_webauthn_assertionclears on success;_complete_ad_loginrefuses a locked mirror row. Subsumes #1569, which may be closed citing thisrow.
Checks
Run in this worktree's own
.venv, all green:ruff format(reformatted 2 files, now clean) andruff check .-- All checks passedmypy messagefoundry-- strict, no issues in 274 source filespytest:test_asvs_login_deadline.py,test_auth_oidc_service.py,test_ad_directory_identity.py,test_auth_service.py,test_docs_security_pathways.py,test_auth_session_lifecycle.py,test_mfa_access_gate.py,test_webauthn.py-- 228 passedpytest tests/test_mfa.pypluspackaging/messagefoundry-webconsole/tests-- run separately(the MFA suite is argon2-bound and takes about 3 minutes); result recorded in a PR comment if it
landed after this body was written
/simplifypass: it caught one real thing. My first draft's comment claimed the caller-side guardwas load-bearing. It is not, and the comment now says so.
docs/FEATURE-MAP.md) proving the instrument works. The first attempt usedgrep -Pand died oncharacter value in \x{} too large-- an instrument failure, not a clean result (SDS-3.8).Not run: the full suite. The local interpreter lacks the
vaultextra, so pytest printsINCOMPLETE RUN -- coverage was NOT collectedand every vault-gated module removed itself atcollection time. Nothing here touches vault code, but that is a reason, not a measurement.
Legs that must be read after this process exits (a Builder never sees them): the hosted-runner
legs, including
windows-service-smoke(NSSM), the full-matrix pytest leg that installs thevaultextra, and the coverage gate.
What contradicts the brief
Three things, all minor, all in my favour to report rather than quietly absorb:
stops at the banner gets it backwards. They do not. Both bodies are short, and each agrees with
its own banner and heading throughout. I also checked for the 2026-09-15 body-slide: no slide at
1637 or 1638 -- each body matches its heading.
tests/test_mfa.pyneeded a test the brief did not anticipate. My first draft of themust-not-fire arm used a live TOTP for the passing factor and flaked:
confirm_mfa_enrollmentspends the current TOTP step (single-use, ASVS 6.5.1), and the wrong codes in between fall through
to the argon2id recovery path and cost real seconds, so waiting for a later step is a race. It now
uses a recovery code, which has no step to consume. The docstring records why.
add that. It is out of scope for both rows' "What closing looks like", and
identity_for_tokenplus the ADR 0079 reconciler already cover it. My tests assert the refusal mints no new
session rather than that the row has none -- the earlier, successful login's session is still
there by design.
Question this brief left open
None that blocked the build. One worth a second opinion: I placed the eligibility gate after the
directory_identity_conflictbranch so a recycled name meeting a stale disabled row keeps theconflict reason. The opposite order is defensible if you would rather a disabled row be refused as
early as possible regardless of who is presenting. I chose audit accuracy; the comment at the site
states the choice so it can be reversed knowingly.