sec(auth): let a directory account hold an engine second factor, and mint Kerberos at the minimum (BACKLOG #1144) - #842
Conversation
…LOG #1144) Two halves that only work together, so they land together. The Kerberos leg passed mfa_verified=True under the delegated-directory relaxation, granting satisfaction on an assertion the engine never receives; it now passes False. That alone confines every directory session to six exempt routes, so the same commit removes the non-local refusals from both enrollment ceremonies, from admin_reset_mfa, from POST /me/mfa/enroll and from the console account page, and drops the blanket provider exemption in _mfa_required_for. mfa_satisfied keeps a directory floor rather than an exit: it refuses while require_mfa is on, and otherwise falls through to the shared per-user rule, so an enrolled directory account must satisfy the factor it enrolled. Every prose claim the change falsified is corrected here rather than left to drift: SECURITY.md, CONFIGURATION.md, SECURITY-LOOSENING.md, the settings and totp docstrings, and the serve exposure-gate message. Steps 3 through 6 of the item stay open. No minimum-strength fallback statement is written; doing so today would be false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rns on The item stays OPEN: steps 3 through 6 are untouched. The row now says which symbols moved, because every line anchor it carried had drifted and one of the two directory legs it names was retired by #1137 two days after the research was written. Three things a later re-score must not lose. The residual is STRENGTHENED, not corrected: the step-up window is seeded at login, so per-function re-checks compare a timestamp the engine stamps itself, and the withdrawn amendment stays withdrawn. The deciding question is still untaken, so no re-score may assume an answer to it. And the two cheap moves the 2026-08-16 ruling created stay foreclosed by name. #1137's row gets a dated note, not a disposition: the constraint it states as fixed no longer holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d (BACKLOG #1144) Minting the Kerberos leg at the minimum left a JSON client misinformed. The directory tail hardcoded mfa_required=False and POST /auth/negotiate defaulted it, both correct while a directory session was minted MFA-satisfied. After the mint moved, a client was told no second factor was needed and its very next call was refused with X-MFA-Required and no earlier signal. The tail now asks the gate rather than the grant. `not mfa_verified` would over-report: with require_mfa off and no factor enrolled the shared rule still admits the session, so the caller would be prompted for a factor it does not owe. One extra read on a rare path keeps one source for the answer. Found by the review pass over the parent commit, not by a failing test -- no test asserted mfa_required on the directory path. One now does, in all three states. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four statements the earlier commits missed, found by re-reading with a positive control rather than by trusting the first grep. Two section banners in auth/service.py still scoped the TOTP and passkey surfaces to local accounts; the harness login comment still promised mfa_required is always False for a directory login, which the outcome fix had just made false; and SECURITY.md's MFA section still said every LOCAL account must satisfy the gate. None of these changes behaviour. They are the statements a reader would act on, and leaving them is how a corrected control acquires a false premise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…till made it The sweep that found these ran with a positive control, which is why it found them and the first pass did not. Each told an operator something now false: that an all-AD deployment need not enable require_mfa; that AD users stay directory-delegated; that making a service account a directory principal is a way out of the MFA scope; that directory delegation is a compensating control for single-factor admin at exposure; and that 6.3.3 is satisfied only for local accounts. Prose only. The two Secure_Development_Standards entries are corrected in place rather than annotated, because they describe current engine state rather than recording a past decision -- unlike the ADRs, which date their claims and are left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reply to the Lander's ask, on the PR rather than by mail so no path from a message is involved. 1. Open PR numbers
2. Finished but not pushed Nothing. Everything this seat produced is pushed. For the record, the other three landed while this seat was holding, so they need nothing from you:
3. Ready to land 842, with three things named rather than hidden.
Verification, and its one gap. 1088 passing tests across five targeted runs quoting pytest's own summary lines, covering every file the diff touches. Plus Two defects that merged into main, so they are follow-ups now, not amendments.
Noted on your side and not re-diagnosed here: |
|
Disclosure, per the Manager playbook section 6. I applied the The playbook permits a Manager to label its own worker's pull request; it does not permit doing so silently. I labelled at 16:20 Central and should have said this then. Recording it late rather than not at all. What the label is actually standing on. The builder ran five targeted suites totalling 1088 passing tests, quoting pytest's own summary lines, covering every file this diff touches. Plus The gap, stated rather than hidden: the full One red, for whoever reads this next: Unchanged from my earlier comment: the ledger row is already isolated in commit |
BACKLOG #1144, steps 1 and 2 only, co-landed. Steps 3 through 6 stay open and the item stays open.
Why co-landing is not optional
Shipping step 2 alone is the measured confinement. A minimum-minted directory session is refused on
every route outside the six-entry exempt set at
messagefoundry/api/security.py, and before thischange neither enrollment ceremony accepted a non-local account. So step 2 without step 1 locks
directory users out. With zero deployments the upgrade-lockout cost is vacuous (CLAUDE.md section 0),
which is exactly why the simple correct end state was available: there is no migration path or
compatibility shim here, and none was built.
Targets were located by SYMBOL, because every line anchor in the row had drifted
At
46ea10a78,:2060-2061,:2112,:2130,:2378,:2412,:884and:911-914all namedunrelated code. What was actually found, and what happened to it:
AuthService._mfa_required_for,auth_provider == AD -> Falsebegin_mfa_enrollmentconfirm_mfa_enrollmentbegin_webauthn_registrationfinish_webauthn_registration97d37bc9aauthenticate_kerberosmfa_verified=FalseThe
#1245exemption removal recorded inauth/service.pyis a DIFFERENT thing and was not touched:it is the bootstrap carve-out on the ASVS 6.4.1 temp-password expiry inside
_login_local. Thestrength exemption this item names was still present in
_mfa_required_for.Four sites the row's enumeration did not name, included and why
The row says "the four non-local refusals". A completeness claim written 2026-08-20 is a liability
(SDS-3.6), and each of these makes step 1 real rather than nominal:
admin_reset_mfarefused a non-local account. Without it, enrollment is a ONE-WAY DOOR: adirectory user who loses an authenticator has no recovery, because every route that could help
stands behind the factor they lost.
POST /me/mfa/enrollrefused an externally-managed account at the API layer. It is the only way adirectory account reaches the ceremony over HTTP.
stranded exactly the users the mint confines.
unreachable from the operator UI. Three independent review agents found this one.
mfa_satisfied's directory branch is now a FLOOR rather than an exit: it refuses whilerequire_mfais on, and otherwise falls through to the shared per-user rule, so an enrolled directory account must
satisfy the factor it enrolled. It decides exactly one case the shared rule would decide differently
(
require_mfa_scope="administrators"plus a non-Administrator with no factor), and that case is nownamed in the comment and pinned by a test.
One defect found by the review pass, not by a test
POST /auth/negotiatereportedmfa_required=Falsewhile minting a session the gate would refuse.Correct while a directory session was minted MFA-satisfied; a lie afterwards. The directory tail now
asks
mfa_satisfiedrather than negating the grant, because withrequire_mfaoff an un-enrolleddirectory session grants nothing yet owes nothing.
What is deliberately NOT here
changes describe mechanism only; none claims this requirement's fallback clause is satisfied.
oidc_require_mfa_claim's default is unchanged — the residual names it as a worse-directionre-score trigger.
are foreclosed by name, the second because the precondition is reachable by configuration.
Read this before re-scoring the cell
A LARGER BLOCKER exists than the item names. Every implementing control lives inside an
identity-provider leg that ships off and cannot ship on without operator configuration
(
config/settings.pyrefusesad_enabled = truewithout a server and search base, a secure scheme, abind account and a bind password, all defaulting unset; Kerberos requires AD; federated requires AD
plus four more). Whether the 2026-08-16 "an off-by-default control can never be graded pass" ruling
reaches a CONDITIONAL requirement whose ANTECEDENT is unreachable on a stock install is STILL
UNTAKEN. Under the strict reading this build moves the cell not at all. No re-score may assume an
answer. The scorecard lives in the vault and was not touched.
The residual is STRENGTHENED, not corrected. The withdrawn amendment stays withdrawn: the step-up
freshness stamp is seeded at login (
_complete_ad_logindefaultsseed_reauth=True), so per-functionre-checks compare a timestamp the engine stamps itself for the whole
step_up_max_age_secondswindow.That is step 5 and was not touched. The SSO leg still splits by caller.
WHAT NO BUILD REACHES: strength and method on the simple-bind and SSO legs, whose protocols return no
assertion. Any future record claiming they VERIFY strength is wrong on the protocol.
Known cost, measured and recorded rather than hidden
A directory session leaves the one-round-trip early return in
mfa_satisfiedfor its whole life.With
require_mfaON that is two round-trips, unchanged from before. Withrequire_mfaOFF anun-enrolled directory session pays three to four per authorized request instead of one, permanently,
because it is never stamped. The cheap fix -- minting on the
require_mfadial -- was declined: itwould stamp a factor-satisfied timestamp on policy rather than on what happened, and would put the
setting in two places. A separate, pre-existing dead round-trip also sits in that path: the role read
is unconditional while
_mfa_required_forshort-circuits before looking at it on the shipped scope.Fixing it means duplicating the helper's condition at the call site, so it is named here as unfiled
work on the subject rather than smuggled in.
Checks
Run:
ruff format --check,ruff check(All checks passed),mypy messagefoundry(Success: noissues found in 267 source files), and
/simplify(four review agents; findings applied or declinedwith reasons above).
pytest, quoting its own summary lines:
settings and security-posture surface
security_looseningstext this change rewords)DESELECTED, and it is not mine:
tests/test_ci_retry_native_crash.pyfails on this box because thetest shells to
C:\windows\system32\bash.EXE, which cannot resolve the Windows path toscripts/ci/retry-native-crash.sh. This diff touches zero files underscripts/.NOT RUN, and there is NO result to infer: the full
tests/sweep. Three attempts were made. Two wereinvalidated because commits landed while they were running, and the third was killed by the harness
before it printed a summary, so it produced no line at all -- not a pass, not a fail, no data. Treat
the full sweep as outstanding. The targeted runs above cover every file this diff touches; what the
sweep would add is the areas the diff does not reach (pipeline, transports, store, parsing).
LEGS ONLY A HOSTED RUNNER SEES must be read after this lands, in particular the web console suite in
full (only subsets were run locally) and
windows-service-smoke.🤖 Generated with Claude Code