Repository navigation
feat(plugin-auth, service-settings)!: the access policy is set per organization, with three MFA levels - #31013
objectstack-fleet[bot] wants to merge 21 commits into
Conversation
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
…on-time loads Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 75 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4dadfe80899de896dce2e4cbcddfd3e401ced874 && git checkout 4dadfe80899de896dce2e4cbcddfd3e401ced874
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aa3e21cca7ec885a8325eb24e9c31e11ed5e3453 b9c66ee9ddb3be486b9d7e40d34e8e64a4bcffe1 && git checkout -B drift-repro aa3e21cca7ec885a8325eb24e9c31e11ed5e3453 && git merge --no-ff b9c66ee9ddb3be486b9d7e40d34e8e64a4bcffe1
node scripts/docs-audit/affected-docs.mjs --json aa3e21cca7ec885a8325eb24e9c31e11ed5e3453
|
|
repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2 Contract-review-claim: session_01Q4EjjRhUZjdy8VXs6Kkur2 · head 4513cb9 · 2026-11-06T18:24Z Face: declared |
|
repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2 Contract reviewServed-tier: Read-only review of the net diff Check-runs on the head (read once). 32 success, 13 skipped, 5 failure, 0 pending. Red: ① Derived judgments
② Semver levelChangesets: ③ Boundary flags
④ Design judgment
Findings.
Implemented-by: VERDICT: FAIL |
…r-org-access-policy
… re-judgement, retired option refused Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
…ons and the switch judgement Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
…write Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
|
repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2 席位核对 · ACCEPT · 2026-11-07T00:45ZHead-sha:
|
Census conflicts resolved by taking main's generated census files and re-running tenant-audit-census --write (266 + this PR's switch revocation write = 267), with the page's prose counts moved to match. Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
|
repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2 Contract-review-claim: session_01Q4EjjRhUZjdy8VXs6Kkur2 · head a06983b · 2026-11-07T05:01Z Delta review of a merge-only head: origin/main merged into |
|
repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2 Contract reviewServed-tier: Delta review on the prior record 6695328434 (PASS on Check-runs on ① Derived judgments
② Semver levelUnchanged from 6695328434: three ③ Boundary flags
④ Design judgmentThe narrowing is the one 6695328434 judged, carried unchanged onto a main that moved around it without touching its rules. One thing read in passing, not a defect of this merge: the organization IP list is re-judged on a session read only when Findings.
Implemented-by: VERDICT: PASS |
|
repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2 席位核对 · ACCEPT(新 head)· 2026-11-07T05:12ZHead-sha:
|
|
repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2 维护者速读 · 超过人工合并线,需要你来放行 · 2026-11-07T07:30Z
|
Census conflicts resolved by taking main's generated census files and re-running tenant-audit-census --write (268 + this PR's switch revocation write = 269), with the page's prose counts moved to match. Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude <noreply@anthropic.com>
|
repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2 Contract reviewServed-tier: Delta review on the prior record 6702665651 (PASS on Check-runs on ① Derived judgments
② Semver levelUnchanged from 6695328434: three ③ Boundary flags
④ Design judgmentThe narrowing is the one 6695328434 judged, carried unchanged across a second main that moved around it without touching its rules or its predicates. No new finding. Findings. None. D3 (should-fix, 6702665651) stands as filed. Implemented-by: VERDICT: PASS |
|
repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2 维护者速读 · 冲突已解决,请再入队一次 · 2026-11-07T12:00Z
|
Fixes #30922
Clause-②: yes (narrowing) — a new
access_policysettings namespace (scopetenant) carries each organization's allowed IP ranges, idle, absolute and concurrent session limits and multi-factor requirement;auth.mfa_requiredchanges from a toggle to the levelsoff/administrators/everyone,sys_organization.require_mfais no longer writable, andAuthManagerOptions.mfaRequiredbecomesmfaRequirement.Metadata-protocol: none
Consumer: objectstack-ai/cloud#3668 (Enterprise P2-8),
pm:blockedon this card.维护者速读
auth.mfa_required从开关变成三档,组织表的require_mfa不再可写;启动时自动把旧值迁移成「全员」,迁移失败会以 error 记录并在下次启动重试。不设组织策略时,所有部署的行为与之前完全一样。回滚即回退此 PR(已迁移的组织设置行会留在sys_setting,回退后不再被读取)。往auth.mfa_required写true/false现在会被拒(invalid_option),cloud 移 pin 时要把它的一个测试改成everyone。packages/plugins/plugin-auth/src/organization-access-policy.ts(每个键的下限规则和合并规则,顶部有规则表);②packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts中refuseUnadmittedScoping(会话归属到组织时按组织 IP 判定);③packages/plugins/plugin-auth/src/organization-access-policy-binding.ts(两项迁移与保存校验的注册)。pnpm --filter @objectstack/plugin-auth exec vitest run src/organization-access-policy.test.ts src/organization-access-policy-mfa-level.test.ts,预期全部通过:两个组织各自的 IP 范围只放行自己的成员、各自的闲置超时只结束自己成员的会话、「管理员」档拦住组织管理员而放行普通成员、组织无法低于部署下限。What changes
The access policy is set per organization. Each organization sets its own values in Settings → Access policy — a new settings namespace,
access_policy, scopetenant(onesys_settingrow per organization, ADR-0131), read withsetup.accessand written withsetup.write, the permission the organization administrator already holds for the other tenant pages. The deployment's values in Settings → Authentication (theauthnamespace, scopeglobal) are the default and the floor.The rule, per key (the table also heads
organization-access-policy.ts). Each key's zero value — an empty list,0,off— means "nothing beyond the deployment's". A non-zero value weaker than the deployment's is refused at the save (the settings write predicate,SETTINGS_VALIDATION/invalid_valuenaming the deployment's value), and enforcement combines the two values so the floor holds whatever is stored:allowed_ip_rangessession_idle_timeout_minutes,session_absolute_max_hours,max_concurrent_sessions_per_user0; or a positive value no larger than the deployment's (any positive value when the deployment's is0)mfa_requiredoff; or a level at least as strong as the deployment'soff, thenadministrators, theneveryone)Which organization's policy applies. The organization the session is scoped to (
session.activeOrganizationId). The sign-in request comes before any organization is chosen, so it is judged by the deployment's policy alone — the floor, which binds every organization (ADR-0069 D5's gate ahead of better-auth, unchanged). The session the sign-in mints is scoped at creation (ADR-0093 D9: owner-preferred, else the oldest membership), and from there the scoped organization decides: the scoping itself is refused with403 IP_NOT_ALLOWEDfrom an address the organization's list does not admit (session.createbefore-hook, so a refused sign-in leaves no session row), a switch of the active organization is judged the same way (session.update), and every later request on the session is held to that organization's idle and absolute limits (customSession, ADR-0069 D4) and multi-factor level (resolveUserAuthGates). The concurrent cap sweeps at sign-in with the new session's organization. Only a request that came through the HTTP auth door is judged for its address, using the address that door established (the same address the deployment's list judged); a programmaticauth.api.*call is not, as the door never judged it.The three MFA levels.
administratorsholds ADR-0090 D12's three kinds through the predicates that already decide them — thePLATFORM_ADMINandTENANT_ADMINposture rungs ofresolveUserAuthzGrants(tenant-level; organization-level, conferred by an owner or admin membership), and a resolved permission set carrying anadminScope(delegated, asked of plugin-security'ssecurity.resolvePermissionSetsForContext) — and lets everyone else through. It is judged in the session's organization: an administrator of one organization is a plain member in another. A delegated check that fails holds the user rather than letting the requirement lapse. The deployment'sauth.mfa_requiredbecomes the same three-level select; any level other thanoffturns the two-factor plugin on with it, as the toggle did.Guards, per organization.
allowedIpRangesWritePredicate, the deployment list's own check, judged against the same address (judgedClientAddress), without the env-override recovery sentence that reopens only the deployment's list.organization-mfa-enrollment-guard.ts, now a settings write predicate onaccess_policy.mfa_required).Migration (
organization-access-policy-binding.ts, convergent and idempotent, run at boot before the settings pass): a storedauth.mfa_requiredtoggle is rewritten as a level (on →everyone, off →off); an organization whosesys_organization.require_mfais on getsaccess_policy.mfa_required = everyoneunless it already chose a level, and the column is cleared only after that row lands; a failed move is logged aterrorand retried at the next boot. TheOS_AUTH_MFA_REQUIREDenv override still readstrue/falsewith a one-time warning.require_mfastays provisioned for the move, readonly, and leaves the generic-edit whitelist.Tests
organization-access-policy.test.ts— the per-key floor rule, and two organizations in one deployment through the real better-auth pipeline (handleRequestwith a peer address): each organization's allowed IP ranges admit its own members from its own ranges only (403 IP_NOT_ALLOWEDotherwise), a refused scoping leaves no session row, a platform administrator is held by the deployment's list only, and each organization's idle timeout ends its own members' sessions only.organization-access-policy-mfa-level.test.ts—administratorsholds an organization administrator and lets a plain member through; holds tenant-level and delegated administrators; judged in the scoped organization; two organizations each held to their own level; an organization cannot go below the deployment's level.organization-mfa-enrollment-guard.test.ts(rewritten) — on a realSettingsService: the enrollment refusal, the floor refusals in the settings envelope (an organization cannot save below the deployment floor), the manager reading each organization's saved policy and dropping its cache on a change, and both migrations (including a failed move leaving the column for the next boot).auth-manager,auth-plugin,auth-settings-clear,auth-settings-refusal-isolation,mfa-required-enrollment-predicate,settings-copy-locale(the new floor copy carries the eleven platform languages),sys-organization-update-door(plugin-auth and platform-objects), the threeverifyheld-session pins, and two dogfood suites.Ablations — one per rule, each red, restored, control green
Through
scripts/ablation-replace.mjs(anchor hit 1×, on-disk counts x1→x0 / x0→x1, automatic restore), over the new test files (27 tests):organization-access-policy.tsorganization-access-policy.tsorganization-access-policy.tsorganization-access-policy.tsorganization-access-policy.tsorganization-access-policy.tsorganization-access-policy.tsauth-manager.tsauth-manager-password-session-policy.tsorganization-access-policy.tsorganization-mfa-enrollment-guard.tsrequire_mfamoveorganization-access-policy-binding.tsauth-manager-database-hooks.tsorganization-access-policy.tsauth-manager-database-hooks.tsorganization-access-policy.tsorganization-access-policy.tsorganization-access-policy.tsorganization-access-policy.tsorganization-access-policy.tsauth-manager.tsauth-manager.tsRestore:
git diff HEADoverpackages/plugins/plugin-auth/srcempty after each pass; control run 27/27 passed. The S rows are the contract-review rework (record 6692495550).Acceptance notes
auth.mfa_requiredwrites. A write oftrue/falsetoauth.mfa_requiredis now refused by the option table (invalid_option). Consumer objectstack-ai/cloudapps/cloud/test/settings-cross-org-doors.test.ts:533(write) and:552(read-back) must be rewritten toeveryonein the pin move. The changeset's upgrade note says so.sys_organization.*columns as where per-organization overrides live (require_mfalanded there;allowed_ip_rangesnever did). This PR follows the dispatch's measured route instead — the settings cascade's tenant rung (ADR-0007, ADR-0131): it is the store an organization administrator already writes (setup.write), and only a settings write predicate receives the saving request's transport, which the per-organization self-lock-out guard needs (asys_organizationengine hook has no client address). The tighten-only decision itself is ADR-0069's, unchanged. Movingrequire_mfaoff the organization row is a change to D3's/D7's text; a status-line amendment to ADR-0069 (Tier H) is owed and is left to the seat — not made here, to keep this PR off the governed surface.GET /admin/client-addressstays platform-admin only: an organization administrator learns the judged address from the save refusal'sconstraint.clientAddress, not before saving.last_activity_atis not refreshed first), and its cap. Idle is the session's own inactivity, so activity in another organization counts. A session past either limit is ended in place (the D4 revocation), and the switch is refused401 UNAUTHENTICATED.ip_not_allowed). Data requests are judged by neither list, as before. Staleness: 30 s per-organization policy cache, 60 s for the any-organization MFA flag.AuthManagerrefuses the retiredmfaRequiredkey, and anymfaRequirementthat is not a level, at construction and onapplyConfigPatch, naming the replacement.require_mfasave notice (Organization require MFA: the save refusal is English only, the grace period is unreadable by the console, and the field help does not say two-factor must be on #30411) has no settings-page equivalent: settings write predicates carry no notice channel. The UI'sorganizationMfaNoticescode underui/now never fires (the column is readonly).NOT MEASURED: check:type-check-debtlocally (exit 3, its closure build could not take the shared verify lock); CI runs it.Verification
Local runs and gate readings are cited in the report comments on #30922 (latest 6695160469, head
b0b494c04a).Generated by Claude Code