Skip to content

Organization require-MFA can be turned on with two-factor off, and after the grace period everyone is locked out with no way to enroll #30201

Closed

Description

@objectstack-fleet

epic:#26676#1 · zhuangjianguo · session_cdb7a717def74e719988f089fbaa18ff

From a real-user walkthrough of Setup, filed on the maintainer's word: 「以上都同意,立卡」 (2026-11-05). Measured on main b7075d9908, with the console in dev mode, unless a line says otherwise. Test app scaffolded with create-objectstack@18.3.0.

What a customer sees

An administrator is told to harden accounts, ticks 强制多因素认证 on the organization, and sees it saved. Nothing changes that day. When the grace period ends, every member, the owner included, is sent to 设置双重验证, enters their password, and reads Two-factor enable failed (404). There is no way forward, and no one inside the company can undo it, because Setup is behind the same gate.

Measured

  • GET /api/v1/auth/config: features.twoFactor: false, the default.
  • Edit organization, tick the box, save: toast 「组织已更新」, sys_organization.require_mfa = true, no warning.
  • Inside the grace period: reads answer 200, and mfa_required_at is stamped.
  • With mfa_required_at moved 8 days back in the test database to simulate the 7-day grace: reads answer 403 MFA_REQUIRED for a member and for the owner, and POST /api/v1/auth/two-factor/enable answers 404.
  • Control: Setup → Settings → Authentication → 要求多因素认证, the global switch, says it also turns two-factor on. After it, features.twoFactor is true and a member enrolled and verified a TOTP code. So only the organization field reaches the dead end.
  • Whether members are reminded during the grace period was not measured specifically; none was seen.

Expected

  • Turning the organization field on also makes enrollment available, the same as the global switch. If that cannot be done, the save is refused and says why.
  • The save states who is affected and when the block starts.
  • The enrollment screen's refusal is localized and says what to do.

Activity

  1. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: first-touch audit · p1 · domain:server · pm:queue · 2026-11-05T02:55Z

    ⛔ Not a claim.

    • Read fresh. An organization's require-MFA can be turned on while two-factor is off (features.twoFactor: false). After the grace period, every member, the owner included, is sent to a two-factor setup that cannot be completed, and nobody can recover from inside the product. A whole-tenant lockout from one Setup toggle, so p1.
    • Fix site: plugin-auth, refusing the save (or the enforcement) while two-factor is off. The Setup wording is the domain:ui half.

    Generated by Claude Code

  2. added
    domain:serverDomain lane — seat card indexed by label:pm:seat
    pm:queueReady for the PM dispatch loop
    priority:p1High: required for production / M2
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 5, 2026
  3. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

    Claim: PM loop round 3
    Session: session_01Utp6Cbvcmv8YWFhFJwzjKL (seat) · dev thread session_01Utp6CYRZxPEjGeTbGfW4YC
    Account: os-bill
    Branch: claude/project-thread-mjj479 (harness-pinned thread branch; platform exception to claude/issue-<n>-<slug>)
    Worktree: objectstack-issue-30201
    Domain: domain:server
    Seat: domain:server#2
    File surface: packages/plugins/plugin-auth/src/ (the session-policy MFA gate, the org write path or the twoFactor enablement, tests), possibly packages/platform-objects/src/identity/sys-organization.object.ts (help text only), one changeset .changeset/30201-org-mfa-needs-enrollment.md. ⛔ packages/spec/**, ui/**, apps/**, skills/**, docs/adr/**, .claude/**. (stop on breach; explain in the report)
    Container & model: M, mode:thread, model: default tier (opus) — dispatch-gates --tier: no path-derived mandate, effort: xhigh (p1)
    Clause-②: no — a refused save / gate fail-safe on an existing door; the dev re-declares yes for a new published code, export or settings key, or no (narrowing) if a value an admin could save before is now refused (then CONTRACT_REVIEW_TIER review)
    Metadata-protocol: none
    Responsibility: the per-user MFA gate (auth-manager-password-session-policy.ts) enforces sys_organization.require_mfa while the twoFactor plugin is off | the global mfa_required switch already pairs enforcement with enrollment (auth-plugin.ts, #20412) | every member and owner of an org that ticks 强制多因素认证 on a deployment with two-factor off, after the grace period
    Thread-read: 6648424134
    Premise: holds — at 5fa9375e the global switch applies { mfaRequired: true, plugins: { twoFactor: true } } as one refusable patch, while the org field is read by the gate (refreshOrgMfaCacheIfStale, resolveUserAuthGates) with no enrollment pairing; the 404 on /two-factor/enable is the card's measurement, re-measured by the dev
    Serial constraints cleared: #30202 (this seat) edits auth-plugin.ts at the email_password_enabled apply; disjoint hunks, each pulls main before PR and before landing. No other in-flight domain:server claim names plugin-auth (#29997 closed; sibling seats' cards #30005/#30006, #30093/#30095, #30215/#30217, #30000, #30052, #30123, #30117, #30228, #29848, #30204 read by title/surface)

    Order: project file server-seat2/dispatch-30201.md, delivered as the card thread's opening message.

  4. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

    os-dev-report

    {
      "issue": 30201,
      "status": "done",
      "branch": "claude/project-thread-mjj479",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/30293",
      "session": "session_01Utp6CYRZxPEjGeTbGfW4YC",
      "head": "8c4430f5fdf9d92204dd4f13a523f5e95d15b55a",
      "premise_still_valid": true,
      "summary": "Route (b): a new beforeInsert/beforeUpdate hook on sys_organization (priority 15, after the ADR-0092 D2 guard) refuses any write whose payload turns require_mfa on while two-factor is off, 400 VALIDATION_FAILED with one fields[] entry require_mfa/invalid_value saying why and the two remedies; judged for every context and on the payload (by-id, multi-row, insert), with two-factor read per write from the live AuthManager. Route (a) was measured and not taken: the twoFactor switch is process config (settings pass + OS_AUTH_TWO_FACTOR, re-applied at boot), so enabling it from an org row would not survive a restart or reach another node, and disabling it when the last org clears the column would drop enrolled factors. Gate fail-safe: resolveUserAuthGates holds a user for MFA only while two-factor is on, so no MFA_REQUIRED and no mfa_required_at stamp otherwise (one warning per process names the remedy); rows already stored require_mfa = true lock nobody out. resolveTwoFactorEnabled is now the one decision point for the plugin list, features.twoFactor, the gate and the refusal.",
      "tests": "New organization-mfa-enrollment-guard.test.ts 25/25 (rule on a capture engine; AuthPlugin kernel:ready registration; real SQLite ObjectQL with SysOrganization + D2 guard: admin by-id refused and row unchanged, admitted after two-factor on, system write / multi-row update / insert refused, other writes pass; end to end on real better-auth: two-factor off -> POST /two-factor/enable 404 and session authGate not MFA_REQUIRED, resolveUserAuthGates [], warning once, no restamp; two-factor on -> MFA_REQUIRED and enable 200 with totpURI). auth-manager.test.ts: 4 new cases (no hold/no stamp/warn once; OS_AUTH_TWO_FACTOR=false outranks plugins.twoFactor and features.twoFactor false; control; per-org no block). On 8c4430f5 under os-verify-lock: pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 -> Test Files 221 passed (221), Tests 3905 passed | 10 skipped, VERDICT command-exit 0; plugin-auth typecheck VERDICT command-exit 0; verify held-session-impersonation-target + vendor-admin-doors + platform-admin -> 3 files, 49 passed, VERDICT 0. Ablations on 9b256807 via scripts/ablation-replace.mjs WRAP, each restored blob == HEAD: A guard throw deleted -> 5 red; B gate fail-safe neutralized -> 4 red incl. e2e \"expected 'MFA_REQUIRED' not to be 'MFA_REQUIRED'\"; C registration deleted -> registration pin red; D env precedence dropped -> env-pin red. Full output: project file server-seat2/30201/ablations-ran.txt.",
      "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 8c4430f5: 64 derived, exit codes recorded before any pipe. 62 exit 0 on the first pass; 2 exit 3 (prerequisite), each re-run once met: pnpm check:dual-build-cjs-loads exit 0 after building the 7 packages it named without dist/; pnpm check:type-check-debt exit 0 under the lock (first pass queued behind the dogfood hold). --ran over the final record: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. pnpm lint exit 0. CI on the PR: pending at posting time.",
      "dogfood_battery": "8c4430f5 · VERDICT command-exit 1 (held the lock 2328s) · Test Files 418 passed, 1 failed, 3 skipped (422) · Tests 3491 passed, 1 failed, 36 skipped (3528) · failure: permission-set-clone-door.dogfood.test.ts 'a platform set … copies into its shipping package's pair' (clone of member_default -> 422 INVALID_METADATA, grants on sys_comment not defined). Not this change: it arrived with the main merge from #30222; with default-permission-sets.ts put back to before that commit and plugin-security rebuilt, the file is 11/11 green on this head (restore proven blob == HEAD). main has since reverted #30222 (#30262, main red #30255).",
      "line_budget": "704 changed lines (+692 / -12) over 10 files, under the 3000-line human-merge threshold (dispatch-gates change-set reading)",
      "files_changed": [
        ".changeset/30201-org-mfa-needs-enrollment.md",
        "packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts",
        "packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts",
        "packages/plugins/plugin-auth/src/auth-manager.test.ts",
        "packages/plugins/plugin-auth/src/auth-manager.ts",
        "packages/plugins/plugin-auth/src/auth-plugin.ts",
        "packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts",
        "packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts",
        "packages/verify/src/held-session-impersonation-target.pin.test.ts",
        "packages/verify/src/held-session-vendor-admin-doors.pin.test.ts"
      ],
      "deviations": [
        "Clause-② declared 'no (narrowing)', not the Claim's 'no': a require_mfa = true an administrator could save before is refused while two-factor is off. Owes the CONTRACT_REVIEW_TIER review; the changeset carries the BREAKING banner, the '!' summary and the ADR-0087 not-required (no-migration-prescription) marker.",
        "File surface: two packages/verify held-session pin tests outside the declared surface, one config key each (plugins: { twoFactor: true } beside mfaRequired), because the gate fail-safe no longer holds a user nobody can enroll and those pins set mfaRequired without twoFactor.",
        "Skills-docs follow-up owed (content/docs/permissions/authentication.mdx, the mfa_required row); not edited, outside the file surface.",
        "The PM-suggested route (a) was not taken; measured reason in summary.",
        "dispatch-gates --commands ran without paths (change set derived from git), per os-dev.",
        "The dogfood and verify closure builds ran unlocked (nohup, no locked run was queued in this single-agent container); the dist top-up build ran under the lock.",
        "check-plugin-teardown-shape --self-test fixture commit fetched with git fetch --depth=1 of that one commit.",
        "One push was refused locally by check:commit-card-trailers (a model name in the co-author trailer); the two unpublished commits were re-created with identical trees and the model-free pair; nothing published was rewritten.",
        "main moved 20 commits after the pre-PR merge (incl. the #30222 revert), none in plugin-auth or packages/verify; not re-merged before opening, the seat re-pulls before landing.",
        "Write budget 6, not 3: git push x4 (WIP pushes per the order: 004729a0, 1aa1a1d0, 4a878a7e, 8c4430f5), relay pr_create, this comment."
      ],
      "write_budget": "6 — git push x4, relay pr_create #30293, this comment via post-stamped",
      "mcp_calls": "3 — mcp__github__issue_read x3 (get, get_comments x2); no GitHub MCP write",
      "api_writes": "2 — fleet-write pr_create #30293 (run 41972763469, 14017 bytes sent, 14017 stored, identical; assignees none, draft); this comment via post-stamped",
      "open_questions": [],
      "security_findings": "none",
      "out_of_scope_findings": [
        "class: a · reach: Setup organization edit form (Require Multi-Factor Auth) and the enrollment screen · the domain:ui half: Setup copy saying who is affected and when the block starts, and a localized enrollment refusal saying what to do; until then the form shows the server's English refusal text · carrier: seat (domain:ui, lane off duty) · dedupe: require_mfa setup copy, two-factor enrollment refusal, organization MFA toggle",
        "carrier: seat · content/docs/permissions/authentication.mdx mfa_required row says require_mfa is settable per organization with no condition (Skills-docs follow-up)",
        "carrier: #30255 · dogfood permission-set-clone-door red from #30222, already reverted by #30262 · noted, not filed"
      ]
    }

    Report taken 2026-11-05T05:16Z.


    Generated by Claude Code

  5. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

    Claim correction (supersedes the Clause-② line and widens the file surface of claim 6648643507; every other line stands)
    Clause-②: no (narrowing) — measured by the dev: route (a) cannot hold (the twoFactor switch is process configuration; an org row turning it on survives neither a restart nor another node), so route (b): an organization write of require_mfa = true while two-factor is off is refused (400 VALIDATION_FAILED), a value an admin could save before. Plus the gate fail-safe: with two-factor off the MFA gate neither blocks nor stamps mfa_required_at, so rows already stored true lock no one out. Changeset carries the BREAKING banner and its ADR-0087 marker. Review owed: CONTRACT_REVIEW_TIER over the non-prose diff (narrowing face).
    File surface (added): packages/verify/src/held-session-impersonation-target.pin.test.ts, packages/verify/src/held-session-vendor-admin-doors.pin.test.ts (one config key plugins.twoFactor: true each, so the held-session pins still exercise an enforcing gate after the fail-safe). Accepted by the seat, subject to the contract review.

  6. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

    Landed · 2026-11-05T07:22Z

    Reading Value
    PR #30293 merged 2026-11-05T07:21Z, merge commit 9afcd6a6, reviewed head 05e6e0bd (seat ACCEPT 6651898119)
    Contract review CONTRACT_REVIEW_TIER, narrowing face: PASS 6650579682 (8c4430f5), delta PASS 6651713006 (05e6e0bd)
    #30201 closed by Fixes #30201
    Release @objectstack/plugin-auth patch with !, BREAKING banner and ADR-0087 marker; next release

    State change: pm:dispatched removed. Follow-ups: #30302 (global switch under an env pin, the "not enforced" signal, docs; claimed by this seat), #30295 (ui half).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions