Skip to content

feat(plugin-auth, service-settings)!: the access policy is set per organization, with three MFA levels - #31013

Open
objectstack-fleet[bot] wants to merge 21 commits into
mainfrom
claude/issue-30922-per-org-access-policy
Open

objectstack-fleet[bot] wants to merge 21 commits into
mainfrom
claude/issue-30922-per-org-access-policy

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #30922

Clause-②: yes (narrowing) — a new access_policy settings namespace (scope tenant) carries each organization's allowed IP ranges, idle, absolute and concurrent session limits and multi-factor requirement; auth.mfa_required changes from a toggle to the levels off / administrators / everyone, sys_organization.require_mfa is no longer writable, and AuthManagerOptions.mfaRequired becomes mfaRequirement.
Metadata-protocol: none

Consumer: objectstack-ai/cloud#3668 (Enterprise P2-8), pm:blocked on this card.

维护者速读

  • 改了什么:IP 白名单、会话闲置/最长时长/同时在线数、双重验证要求,改为每个组织可在「设置 → 访问策略」里各自设置;部署级「设置 → 认证」里的值是默认值也是下限,组织只能收紧不能放宽。双重验证变成三档:不强制 / 管理员必须开 / 全员必须开。
  • 为什么改:企业版一个数据库里有多个组织,此前这些策略整个部署只有一份;按你在 cloud#3668 的两个决定「支持按组织(推荐)」「可以,按三档做(推荐)」实现。
  • 风险与回滚:这是破坏性变更: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 范围只放行自己的成员、各自的闲置超时只结束自己成员的会话、「管理员」档拦住组织管理员而放行普通成员、组织无法低于部署下限。
  • 需要你做的:无,席位落地。ADR-0069 的存放位置按你的裁决「修订 ADR 写明存放位置(推荐)」,在受治理 PR docs(adr): ADR-0069 amended in place — per-organization overrides live in the settings tenant rung #31041 里修订,由你合并。

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, scope tenant (one sys_setting row per organization, ADR-0131), read with setup.access and written with setup.write, the permission the organization administrator already holds for the other tenant pages. The deployment's values in Settings → Authentication (the auth namespace, scope global) 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_value naming the deployment's value), and enforcement combines the two values so the floor holds whatever is stored:

key an organization may save enforced
allowed_ip_ranges an empty list; or ranges each inside a deployment range (any range when the deployment sets none), one of which admits the saving request's own address an address must pass the deployment's list AND the organization's
session_idle_timeout_minutes, session_absolute_max_hours, max_concurrent_sessions_per_user 0; or a positive value no larger than the deployment's (any positive value when the deployment's is 0) the smaller positive value of the two
mfa_required off; or a level at least as strong as the deployment's the stronger level (off, then administrators, then everyone)

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 with 403 IP_NOT_ALLOWED from an address the organization's list does not admit (session.create before-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 programmatic auth.api.* call is not, as the door never judged it.

The three MFA levels. administrators holds ADR-0090 D12's three kinds through the predicates that already decide them — the PLATFORM_ADMIN and TENANT_ADMIN posture rungs of resolveUserAuthzGrants (tenant-level; organization-level, conferred by an owner or admin membership), and a resolved permission set carrying an adminScope (delegated, asked of plugin-security's security.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's auth.mfa_required becomes the same three-level select; any level other than off turns the two-factor plugin on with it, as the toggle did.

Guards, per organization.

  • Self-lock-out: the organization's list is refused at the save when it leaves out the saving request's own address — 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.
  • Break-glass (the last-administrator guard's per-organization reading): an organization's network list never holds a tenant-level administrator — the deployment's list still does — so an organization that locks out all of its own administrators can be reopened from inside the product by the deployment's administrator.
  • Enrollment: an organization requirement is refused while two-factor enrollment is off (organization-mfa-enrollment-guard.ts, now a settings write predicate on access_policy.mfa_required).

Migration (organization-access-policy-binding.ts, convergent and idempotent, run at boot before the settings pass): a stored auth.mfa_required toggle is rewritten as a level (on → everyone, off → off); an organization whose sys_organization.require_mfa is on gets access_policy.mfa_required = everyone unless it already chose a level, and the column is cleared only after that row lands; a failed move is logged at error and retried at the next boot. The OS_AUTH_MFA_REQUIRED env override still reads true/false with a one-time warning. require_mfa stays 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 (handleRequest with a peer address): each organization's allowed IP ranges admit its own members from its own ranges only (403 IP_NOT_ALLOWED otherwise), 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 — administrators holds 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 real SettingsService: 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).
  • Existing suites moved to the levels: 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 three verify held-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):

# rule ablated file result
A1 organization list judges the scoping address organization-access-policy.ts 2 failed
A2 break-glass for a tenant-level administrator organization-access-policy.ts 1 failed
A3 limit floor refusal organization-access-policy.ts 3 failed
A4 IP range floor refusal organization-access-policy.ts 2 failed
A5 MFA level floor refusal organization-access-policy.ts 2 failed
A6 the shorter limit applies organization-access-policy.ts 2 failed
A7 the stronger level applies organization-access-policy.ts 7 failed
A8 the D12 administrator predicate auth-manager.ts 4 failed
A9 session limits read the session's organization auth-manager-password-session-policy.ts 1 failed
A10 self-lock-out check on the organization list organization-access-policy.ts 1 failed
A11 organization MFA enrollment refusal organization-mfa-enrollment-guard.ts 2 failed
A12 the require_mfa move organization-access-policy-binding.ts 2 failed
A13 the scoping hook auth-manager-database-hooks.ts 2 failed
S1a an organization cap counts only its own sessions organization-access-policy.ts 1 failed
S1b the switch sweeps the target's cap (session-update hook) auth-manager-database-hooks.ts 1 failed
S1c the switch sweep keeps the switching session organization-access-policy.ts 2 of 19 failed
S2a a switch is judged against the target organization-access-policy.ts 1 failed
S2b the switch idle rule (no heartbeat first) organization-access-policy.ts 1 failed
S3 a roaming session is re-judged by the organization list organization-access-policy.ts 1 failed
S4 a tenant-level administrator's save is admitted organization-access-policy.ts 1 failed
S8a the retired option is refused at construction auth-manager.ts 1 failed
S8b the retired option is refused on patch auth-manager.ts 1 failed

Restore: git diff HEAD over packages/plugins/plugin-auth/src empty after each pass; control run 27/27 passed. The S rows are the contract-review rework (record 6692495550).

Acceptance notes

  • auth.mfa_required writes. A write of true/false to auth.mfa_required is now refused by the option table (invalid_option). Consumer objectstack-ai/cloud apps/cloud/test/settings-cross-org-doors.test.ts:533 (write) and :552 (read-back) must be rewritten to everyone in the pin move. The changeset's upgrade note says so.
  • Store, measured against ADR-0069. Ruled by the maintainer, 「修订 ADR 写明存放位置(推荐)」; the status-line amendment is docs(adr): ADR-0069 amended in place — per-organization overrides live in the settings tenant rung #31041 (Tier H). ADR-0069 TL;DR 6 / D5 / D7 name sys_organization.* columns as where per-organization overrides live (require_mfa landed there; allowed_ip_ranges never 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 (a sys_organization engine hook has no client address). The tighten-only decision itself is ADR-0069's, unchanged. Moving require_mfa off 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-address stays platform-admin only: an organization administrator learns the judged address from the save refusal's constraint.clientAddress, not before saving.
  • Concurrent cap: two caps over two populations. The deployment cap counts every live session of the user in any organization, its old meaning, so the floor never loosens. An organization's cap counts only the user's sessions scoped to that organization and ends only those, oldest first, so signing in to a strict organization never evicts another organization's sessions. Both caps are swept at sign-in (keeping the new session) and at an active-organization switch (the switching session counts as scoped to the target and is kept).
  • Organization switch. It is judged against the target organization before it lands: its IP list, its idle and absolute limits (from the session row as it stands; the switch request runs no heartbeat, so last_activity_at is 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 refused 401 UNAUTHENTICATED.
  • Roaming sessions. Every session read through the HTTP auth door judges the organization's IP list again, with the address that door established; a session the list no longer admits is ended in place (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.
  • Retired option. AuthManager refuses the retired mfaRequired key, and any mfaRequirement that is not a level, at construction and on applyConfigPatch, naming the replacement.
  • The organization record form's require_mfa save 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's organizationMfaNotices code under ui/ now never fires (the column is readonly).
  • NOT MEASURED: check:type-check-debt locally (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

claude added 11 commits November 6, 2026 12:25
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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/service-settings, touching 176 documentable anchor(s).

75 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json aa3e21cca7ec885a8325eb24e9c31e11ed5e3453.

⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 43 pages)
  • 23 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 226 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json aa3e21cca7ec885a8325eb24e9c31e11ed5e3453 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4dadfe80899de896dce2e4cbcddfd3e401ced874 — the merge of head b9c66ee9ddb3be486b9d7e40d34e8e64a4bcffe1 into base aa3e21cca7ec885a8325eb24e9c31e11ed5e3453, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs aa3e21cca7ec885a8325eb24e9c31e11ed5e3453 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2

Contract-review-claim: session_01Q4EjjRhUZjdy8VXs6Kkur2 · head 4513cb9 · 2026-11-06T18:24Z

Face: declared Clause-②: yes (narrowing). A new tenant-scoped access_policy settings namespace; auth.mfa_required changes from a toggle to three levels; sys_organization.require_mfa becomes read-only; AuthManagerOptions.mfaRequired → mfaRequirement → CONTRACT_REVIEW_TIER, run by an isolated reviewer the seat starts now. ⛔ Not queued before a PASS on this head. The maintainer ruled on the ADR-0069 store question: a status-line amendment, filed separately.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4513cb9d4241b1a3049d7b01b9e59db21e15035b
Local-runs: none
Scope: packages/platform-objects/src/apps/setup-nav.contributions.ts, packages/platform-objects/src/apps/translations/en.objects.generated.ts, packages/platform-objects/src/apps/translations/en.ts, packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts, packages/platform-objects/src/apps/translations/es-ES.ts, packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts, packages/platform-objects/src/apps/translations/ja-JP.ts, packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts, packages/platform-objects/src/apps/translations/zh-CN.ts, packages/platform-objects/src/identity/sys-organization-update-door.test.ts, packages/platform-objects/src/identity/sys-organization.object.ts, packages/plugins/plugin-auth/src/allowed-ip-ranges.ts, packages/plugins/plugin-auth/src/auth-account-event-audit.test.ts, packages/plugins/plugin-auth/src/auth-manager-after-hook.ts, packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts, 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.test.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/auth-settings-clear.pin.test.ts, packages/plugins/plugin-auth/src/auth-settings-declared.ts, packages/plugins/plugin-auth/src/auth-settings-refusal-isolation.test.ts, packages/plugins/plugin-auth/src/managed-extension-fields.test.ts, packages/plugins/plugin-auth/src/managed-extension-fields.ts, packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts, packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.ts, packages/plugins/plugin-auth/src/organization-access-policy-binding.ts, packages/plugins/plugin-auth/src/organization-access-policy-mfa-level.test.ts, packages/plugins/plugin-auth/src/organization-access-policy.test.ts, packages/plugins/plugin-auth/src/organization-access-policy.ts, packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts, packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts, packages/plugins/plugin-auth/src/settings-copy-locale.test.ts, packages/plugins/plugin-auth/src/sys-organization-update-door.test.ts, packages/qa/dogfood/test/auth-session-audit-trail.dogfood.test.ts, packages/qa/dogfood/test/organization-update-door.dogfood.test.ts, packages/services/service-settings/src/manifests/access-policy.manifest.ts, packages/services/service-settings/src/manifests/auth.manifest.test.ts, packages/services/service-settings/src/manifests/auth.manifest.ts, packages/services/service-settings/src/manifests/index.ts, packages/services/service-settings/src/settings-visibility-declaration.pin.test.ts, packages/services/service-settings/src/translations/ar.ts, packages/services/service-settings/src/translations/de.ts, packages/services/service-settings/src/translations/en.ts, packages/services/service-settings/src/translations/es-ES.ts, packages/services/service-settings/src/translations/fr.ts, packages/services/service-settings/src/translations/ja-JP.ts, packages/services/service-settings/src/translations/ko.ts, packages/services/service-settings/src/translations/pt-BR.ts, packages/services/service-settings/src/translations/ru.ts, packages/services/service-settings/src/translations/zh-CN.ts, packages/services/service-settings/src/translations/zh-Hant.ts, packages/verify/src/held-session-impersonation-target.pin.test.ts, packages/verify/src/held-session-platform-admin.pin.test.ts, packages/verify/src/held-session-vendor-admin-doors.pin.test.ts, scripts/engine-double-contract.pinned.json + docs/adr/0069-enterprise-authentication-hardening.md, docs/adr/0090-permission-model-v2-concept-convergence.md (D12 and its 2026-10-17 note), packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/posture-ladder.ts, packages/core/src/security/admin-standing-surface.ts, packages/plugins/plugin-auth/src/auth-manager-actor-guards.ts, packages/plugins/plugin-auth/src/last-admin-guard.ts, packages/plugins/plugin-auth/src/client-address-canonical.ts, packages/services/service-settings/src/settings-service.ts, packages/objectql/src/engine.ts, packages/plugins/plugin-security/src/objects/default-permission-sets.ts, packages/spec/src/security/capabilities.ts, packages/cli/test/connect-agent-both-halves-wire.pin.test.ts, packages/services/service-settings/src/setup-nav-hub-parity.test.ts, packages/platform-objects/src/apps/translations/zh-Hant.ts (origin/main), ui/packages/app-shell/src/console/organizationMfaNotices.ts, objectstack-ai/cloud apps/cloud/test/settings-cross-org-doors.test.ts

Read-only review of the net diff origin/main...4513cb9d (merge-base 000a4c0d36, 31 commits behind), the card, cloud#3668's rulings, ADR-0069 and ADR-0090 D12 at the head, and the head's check-runs, read once at 2026-11-06T18:36Z. Nothing was built or run. The ADR-0069 store question is the maintainer's separate status-line amendment and is not judged here.

Check-runs on the head (read once). 32 success, 13 skipped, 5 failure, 0 pending. Red: Test Core (1/8), Test Core (8/8), Test Core (aggregate), Type Check · consumer gates, TypeScript Type Check (aggregate). Causes are M1 and M2 below.

① Derived judgments

  1. The floor at save and at enforcement — right, with one wrinkle (S4). Save: every access_policy key gets a write predicate (organization-access-policy-binding.ts, accessPolicyWritePredicate) that reads the floor per write from manager.getDeploymentAccessFloor() (the auth values as the settings pass applied them, env override included) and refuses a range outside every deployment range, a positive limit above the deployment's, a level below the deployment's; the settings service asks it from setMany only, which every settings door shares (set delegates). Enforcement: effectiveSessionLimits takes the smaller positive limit, effectiveMfaLevel the stronger level, and the organization list is judged in addition to the deployment list, which keeps its own auth-door gate. Write paths tried: settings API and bulk import both pass setMany (predicates run); a direct sys_setting row write and the boot migration bypass the predicates, but a looser stored value is not honoured — readOrganizationAccessPolicy reads an off-type value as the key's zero value and the combining rules hold the floor whatever is stored; a platform administrator writing on behalf passes the same predicates (the floor is not a per-caller rule). The deployment tightening later is in force at once with no stored row rewritten. One effect worth stating: an organization row that is now above a later-tightened floor still reads back on its settings page as the saved value while a different value is in force.
  2. administrators = ADR-0090 D12 exactly, no new concept — right. isMfaAdministrator asks resolveUserAuthzGrants with the session's organization as tenantId: PLATFORM_ADMIN (kind 1), TENANT_ADMIN (kind 2 — ORGANIZATION_ADMIN_GRANTS names both organization_admin and organization_admin_no_bypass, so the wall-less ADR-0105 D4 variant is held too), and kind 3 through plugin-security's own resolvePermissionSetsForContext reading an adminScope with a businessUnit. Judged in the scoped organization, so an administrator of one organization is a member in another; a failing delegated check holds rather than lapses. Right.
  3. Sign-in judged by the floor only; scoping refused with 403 IP_NOT_ALLOWED and no session row — right. The deployment gate ahead of better-auth is unchanged; refuseUnadmittedScoping runs in session.create.before after ADR-0093 D9 picked the organization and in session.update.before when activeOrganizationId changes, throws APIError('FORBIDDEN', { code: 'IP_NOT_ALLOWED' }) before the row is written, and only on a request that came through the HTTP door (the address readRequestClientAddress() holds is the one the deployment gate judged). ipMatchesRange reads IPv4-mapped IPv6 as IPv4 and refuses an undetermined address while a list is set, the deployment gate's reading. What it does not do is judge a roaming session again (S3).
  4. Break-glass by the deployment list only — right as a rule, disclosed. judgeSessionScopingAddress admits a user isPlatformAdminUserId answers true for (hasPlatformAdminStanding: the anchoring grant on a non-walled posture, the deployment-config list on every posture — so under objectos-ee's walled postures it is the config-named administrators, the platform's own standing rule). Disclosed in the manifest description ("Platform administrators are held to the deployment's ranges only"), the PR body and the changeset. It is the right break-glass: the organization's own administrators are held by the list they wrote, and the reopening hand is one the list never holds.
  5. Per-organization self-lock-out and the last-admin guard — holds; one wrinkle. allowedIpRangesWritePredicate(() => manager, { recovery: false }) judges the organization list against the saving request's address through the declared forwarded headers (same resolveClientAddress rule), without the env-override recovery sentence that reopens only the deployment list. last-admin-guard.ts (plugin-auth: break-glass 守卫 —— SCIM/ban 不得停用最后一个管理员(ADR-0024 D5.2,cloud#621 转入) #5892) is untouched by the diff and keeps its per-organization reading. Wrinkle S4: the save check also holds a tenant-level administrator saving on an organization's behalf, whom the list never holds at enforcement.
  6. Both migrations converge and are idempotent; a partial failure is safe — right (by reading). Toggle to level: skipped for env/default sources, a level reads legacy: false on the second boot. Column to row: the auth.mfa_required enrollment predicate is registered before the move and judges a stored legacy true as already-on, so the rewrite is not a turn-on; the access_policy predicates are registered after migrateLegacyOrganizationRequireMfa, so a stored require_mfa row moves even where two-factor is off; the set lands before the column clear, a failed set leaves the column (error, retried next boot), a failed clear finds alreadySet next boot and only clears; the limit: 5000 page converges over boots. The column clear under SYSTEM_CTX passes the engine's static readonly strip, which runs on !context.isSystem only, and the ADR-0092 D2 guard, which clamps user-context writes only — read in packages/objectql/src/engine.ts, not pinned: the move's test drives an engine double, not a real engine with the now-readonly column.
  7. The narrowing is stated honestly — mostly (S5, S8). PR body and changeset name all three: the auth.mfa_required value shape, the read-only column, the renamed option, with the FROM → TO mapping and the ADR-0087 marker. Not said: a write of true/false to auth.mfa_required is now refused at the settings door by the select's option table (S5), and a JS host still passing mfaRequired: true is silently unheld (S8).

② Semver level

Changesets: @objectstack/plugin-auth, @objectstack/service-settings, @objectstack/platform-objects each patch, with Clause-②: yes (narrowing), Metadata-protocol: none, a BREAKING banner, the migration and one ADR-0087 marker (not-required, runtime-interface-only). Against AGENTS.md Post-Task Checklist 3: the level follows the kind alone and a non-protocol change, widening or breaking, ships patch with its banner — so patch is right if none is right. none is right: the diff touches no packages/spec path; the access_policy manifest is a runtime registration of a built-in SettingsManifest instance and its values are sys_setting data, not a key, value or type a metadata author writes; readonly: true on sys_organization.require_mfa changes what a data writer may write to a platform-owned column (ADR-0092 D2's own tier), not what a metadata author can author; the renamed AuthManagerOptions field is a runtime option. CI's Check Changeset, check:adr-0087-registration and the docs guards are green on the head.

③ Boundary flags

  • (a) cloud settings-cross-org-doors.test.ts — disclosed in the wrong place; S5. On cloud main (b59b27484f, pinned to objectstack c50e0dfa) the test writes settings.set('auth', 'mfa_required', true, {}) at :533 and expects true back at :552. After the pin moves past this head, setMany's option-table validation refuses true (invalid_option). The PR body and the changeset do not say so; the changeset's upgrade note ("saved as on now reads everyone") is true of a stored value and silent about a refused write. The disclosure exists only in the card's report comment. Add the sentence to the changeset and name the consumer in the PR body.
  • (b) organizationMfaNotices.ts — inert; S6. ui/packages/app-shell/src/console/organizationMfaNotices.ts, AppContent.tsx:969 and the en.ts:2205 strings now fire for no write (the column is readonly and off the editable whitelist). Disclosed in the Acceptance notes, neither removed nor filed. Dead code over a retired column stays dead until someone files it: a ui/ card, or the removal in this PR under Post-Task Checklist 4.
  • (c) the judged address — disclosed; S7. An organization administrator learns constraint.clientAddress only from the refusal; GET /admin/client-address stays platform-admin only. Not a lock-out risk (the save that leaves the address out is refused), a usability gap. A follow-up card for a setup.write-gated address read.

④ Design judgment

  • Concurrent sessions — counted per user, capped per organization: S1. enforceConcurrentCap counts every live sys_session of the user, whatever organization each is scoped to, and takes the cap of the organization the new session was scoped to. Two consequences: a sign-in to a strict organization evicts the user's sessions in other organizations; and the sweep runs only in the sign-in after-hook, so N sessions opened against a lax organization and then switched into a strict one (session.update) exceed the strict organization's cap, which is never swept. Decide the semantics (count sessions scoped to that organization, and sweep on a switch) and state it in the key's description.
  • A user in two organizations with different limits — by design, with one hole: S2. Limits follow session.activeOrganizationId per request (customSession), so organization A's idle/absolute limits bind while the session works in A. The hole: the switch into A is judged for A's IP list, not A's limits, and the switch itself refreshes last_activity_at, so a session that idled past A's idle limit while active in B is re-admitted to A's data. The absolute limit is caught on the next request in A; the idle limit is not. Judge the target organization's limits at the switch.
  • Switching organization to evade a stricter idle limit — see S2; the evasion holds for idle, not for absolute.
  • IPv6 and proxies — right. One address for every judgement: the one the door established by the server.trustProxy rule (leftmost X-Forwarded-For, then X-Real-IP, only when trusted; else the peer), read from the request state the deployment gate wrote. parseIpRange/ipMatchesRange compare by family, an IPv4-mapped IPv6 address as its IPv4 address, and rangeWithin refuses a cross-family containment; an undetermined address is refused while a list is set. The save door judges the same address through FORWARDED_CLIENT_ADDRESS_HEADERS.
  • Cache staleness — acceptable, worth stating. Per-organization policy is cached ORG_ACCESS_POLICY_TTL_MS = 30 s and dropped by the access_policy change listener; the "any organization requires MFA" flag is reset on that change and otherwise refreshed every 60 s. The listener is in-process, so another node applies a changed policy up to 30 s (60 s for the flag) late — the same window the deployment's auth keys already have. Name it in the module header.
  • The roaming session — S3. The organization list is judged where a session gains an organization and never again; the deployment list judges every auth-door request. A tightened organization list leaves sessions opened from now-excluded addresses live until they expire. Disclosed in the Acceptance notes; asymmetric with the deployment list and with the idle limit, which customSession does re-judge. Either judge the organization list in customSession too, or say in the key's description that it binds at sign-in and switch only.

Findings.

  • M1 (must-fix) — Test Core (1/8) red on the head's own change: packages/cli/test/connect-agent-both-halves-wire.pin.test.ts:273,297 pin the platform administrator's Setup tree at 31 entries; the new nav_settings_access_policy entry makes it 32 and the PR does not move the pin or its count comment.
  • M2 (must-fix) — Test Core (8/8) and Type Check · consumer gates (check:app-nav-i18n: "nav_settings_access_policy … author's literal 'Access policy'") red on the merge: origin/main a11ef71e93 (i18n(platform-objects): ship the platform objects, Setup, Studio and Account in zh-Hant #30951) added packages/platform-objects/src/apps/translations/zh-Hant.ts and zh-Hant to setup-nav-hub-parity.test.ts's locales after the merge-base; this PR ships the zh-Hant hub title 存取原則 in service-settings but no zh-Hant sidebar label. Merge origin/main and add the label.
  • S1 (should-fix) — concurrent cap counted per user across organizations, chosen per organization, never swept on a switch (④).
  • S2 (should-fix) — an active-organization switch is judged for the target's IP list but not its idle/absolute limits (④).
  • S3 (should-fix) — the organization IP list binds at scoping only; a tightened list does not end roaming sessions (④).
  • S4 (should-fix) — the per-organization self-lock-out save check holds a tenant-level administrator saving on behalf, whom the list never holds at enforcement (①.5).
  • S5 (should-fix) — the refused true write to auth.mfa_required and the cloud consumer test are not named in the PR body or changeset (③a).
  • S6 (should-fix) — organizationMfaNotices.ts left inert, neither removed nor filed (③b).
  • S7 (should-fix) — no address read for an organization administrator before saving (③c).
  • S8 (should-fix) — a retired mfaRequired: true option is dropped silently at runtime; refuse the retired key at AuthManager construction as assertAllowedIpRanges refuses a bad list, so a requirement never lapses unannounced (①.7).

Implemented-by: claude/issue-30922-per-org-access-policy
Reviewed-by: session_01Q4EjjRhUZjdy8VXs6Kkur2

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2

席位核对 · ACCEPT · 2026-11-07T00:45Z

Head-sha: b0b494c04a92294308656bd183f2764c286e9665
Implemented-by: os-dev (session session_01Q4EjjRhUZjdy8VXs6Kkur2, branch claude/issue-30922-per-org-access-policy) · Reviewed-by: repo:cloud#3

  • 形态: IP 白名单、会话闲置时长 / 最长时长 / 同时在线数、双重验证要求,改为每个组织可以各自设置:新的组织级 access_policy 设置命名空间。部署级的值是默认值,也是下限,组织只能收紧。双重验证分三档:off / administrators / everyone。
  • 契约复核: 首次复核 FAIL 6692495550。返工后增量复核 PASS 6695328434(head b0b494c0):M1、M2,S1–S5、S8 都已关闭。
    • 两个上限各管一批会话:部署上限照旧统计这个用户的全部会话;组织上限只统计这个组织的会话。
    • 切换组织时,先按目标组织判定,再落地。
    • 会话漫游时按组织白名单重新判定。
    • 旧选项名会被拒收。
  • 两条 should-fix(转 auth follow-ups of #30922: an org admin cannot read the judged client address before saving an IP list, and the console MFA notices over sys_organization.require_mfa are inert #31030,不移动本 PR 的 head):
    • D1:ip_not_allowed 要补进 sys_session.revoke_reason 字段说明的取值表;
    • D2:「切换前不刷新活跃时间」要用真实的 /organization/set-active 路由固定,而不只是直接调用钩子。
  • 裁决: ADR-0069 的存放位置按「修订 ADR 写明存放位置(推荐)」在 docs(adr): ADR-0069 amended in place — per-organization overrides live in the settings tenant rung #31041 修订(受治理面,等维护者合并)。三档 MFA 依据 cloud#3668 的「可以,按三档做(推荐)」。
  • 门禁: plugin-auth、service-settings、platform-objects、cli 全量测试通过;dogfood 3561 个通过;22 组消融各自变红。check-runs 在 head b0b494c0 上全部 success。
  • 下游: cloud 的 pin 移过本次发版时,要把 apps/cloud/test/settings-cross-org-doors.test.ts:533/:552 改成 everyone(changeset 的升级说明里已写)。objectstack-ai/cloud#3668 的执行报告依赖本卡。
  • 落地条款: 非受治理面,复核 PASS 且 CI 绿 ⇒ 席位翻 ready、挂 auto-merge,经合并队列落地。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 7, 2026 00:47
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 7, 2026 00:47
@objectstack-fleet
objectstack-fleet Bot disabled auto-merge November 7, 2026 04:16
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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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 b0b494c04a, whose delta record PASS is 6695328434. The previous reviewer is unreachable after a container restart, so a fresh isolated reviewer the seat starts now judges the merge delta only. Auto-merge is disabled until it reports.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a06983b5c00a88df2c4484c769614aede24952b1
Local-runs: none
Scope: content/docs/permissions/tenant-audit-census.mdx, docs/audits/2026-08-tenant-audit-write-call-sites.counts.md + packages/plugins/plugin-auth/src/auth-manager.ts (main's hunks), packages/plugins/plugin-auth/src/auth-plugin.ts (main's hunks), packages/plugins/plugin-auth/src/auth-route-ledger.ts, packages/plugins/plugin-auth/src/auth-manager-mcp-access-token.ts, packages/plugins/plugin-auth/src/mcp-client-last-use.ts, packages/plugins/plugin-auth/src/authorized-clients-read.ts, packages/plugins/plugin-auth/src/authorized-clients-withdraw.ts, packages/plugins/plugin-auth/src/scim-connection-admin.ts, packages/plugins/plugin-auth/src/scim-connection-service.ts, packages/plugins/plugin-auth/src/platform-admin-gate.ts, packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts, packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts, packages/plugins/plugin-auth/src/organization-access-policy-binding.ts, packages/types/src/in-process-session-read.ts, packages/core/src/security/index.ts, scripts/tenant-audit-census.mjs, ui/apps/console/src/pages/settings/tests/SettingsView.clearBeneathPin-30665.test.tsx

Delta review on the prior record 6695328434 (PASS on b0b494c04a), rendered at 2026-11-07T05:11Z by a fresh reviewer (the prior reviewer is unreachable; records 6692495550 and 6695328434 read for context). The head is a merge of origin/main (second parent 24bc259363, 43 commits) into b0b494c04a, declared as carrying no PR code change (card report 6702553420). Read-only: git fetch, the two net diffs, main's own diff between the two merge-bases (7d9f1e973e..24bc259363), the merged tree where cited, and the head's check-runs, read once. Nothing built, run or re-run. The PR's rules themselves are not re-judged: they stand on 6695328434, whose PR-side hunks this head carries byte for byte (below). D1/D2 of that record stay with #31030.

Check-runs on a06983b5c0 (read once): 50 in all, 38 success, 12 skipped, 0 failure, 0 pending — nothing NOT MEASURED. Green include Test Core (all 8 shards), TypeScript Type Check and its consumer gates leg (the two reds of 6692495550), Check Changeset, Lint & Repo Gates, Dogfood Regression Gate, Governed Surface Queue Guard. The commit-status API reports no statuses (total_count 0), so check-runs are the whole reading.

① Derived judgments

  • The PR's net change is unchanged by the merge, apart from the two census files — right. git diff 7d9f1e973e b0b494c04a (the old merge-base) and git diff 24bc259363 a06983b5c0 (origin/main...a06983b5c0) both list the same 64 paths with the same name-status; comparing each file's hunks with index lines and @@ offsets stripped, 62 of 64 are identical and the two that differ are exactly content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md. The converse holds too: git diff b0b494c04a a06983b5c0 (what the merge brought) and git diff 7d9f1e973e 24bc259363 (what main moved) both list 621 paths, identical hunk for hunk except those same two. The PR's changeset, its five *.objects.generated.ts bundles (now carrying main's sys_oauth_consent.last_used_at copy beside the PR's retired-require_mfa copy) and scripts/engine-double-contract.pinned.json are unchanged on the PR side. No file's PR-side hunk changed.
  • Census arithmetic — right, and the prose is consistent. Main's ledger at the merge-base reads 266; the head reads 267, the one new row being this PR's auth-manager.ts · update · sys_session · enabled · elevated (judgeSessionSwitch, the write 6695328434 counted as 261 → 262 before main moved). Every derived row moves by exactly that one site and in the right column: decidable 189 → 190, tenancy-enabled 183 → 184, threading a context 203 → 204, decidably elevated 148 → 149, typed receiver 203 → 204, object name inline 120 → 121; run-time-named, tenancy-off, provably-none, unreadable and undecidable-elevation counts unchanged. The page's hand-written counts match the tables: 55 of the 267, 190 of 267 decidable, 149 of 267 (56%) decidably elevated (149/267 = 55.8%), 2 / 267. The Measured on line names b0b494c04, which is what scripts/tenant-audit-census.mjs prints (git rev-parse --short=9 HEAD, read while the merge was being resolved, so the first parent); the checker says those values are dated, never compared, and the tables, not that line, carry the count. Lint & Repo Gates (the census gate's job) is green on the head.
  • Main's auth changes do not interact with the PR's rules — read, not only merged. Main touched auth-manager.ts (+104), auth-plugin.ts (+134), the route ledger (+16), auth-manager-mcp-access-token.ts, mcp-token-revocation.ts, scim-connection-service.ts and added authorized-clients-read.ts, authorized-clients-withdraw.ts, mcp-client-last-use.ts, scim-connection-admin.ts. Main did not touch any module that carries this PR's rules: organization-access-policy.ts, organization-access-policy-binding.ts, auth-manager-database-hooks.ts, auth-manager-after-hook.ts, auth-manager-password-session-policy.ts, auth-manager-actor-guards.ts, last-admin-guard.ts, platform-admin-gate.ts, settings-service.ts, nor core's resolve-authz-context.ts, posture-ladder.ts, admin-standing-surface.ts (core's only change is the new vouched-reference exports). The three checks asked:
    1. A new session-creation or organization-switch path. None. Main's diff adds no sys_session insert, no internalAdapter/createSession call and no activeOrganizationId write outside tests; the sign-in hits are test rigs. The six new routes (GET|POST /authorized-clients[/withdraw], GET|POST /admin/authorized-clients[/withdraw], POST /admin/scim/{mint,revoke}-credential) read the existing session in-process (api.getSession(inProcessSessionReadInput(headers)), authorized-clients-read.ts:236, -withdraw.ts:159, gateAdmin at auth-plugin.ts:3731) and write sys_oauth_consent / sys_scim_connection_credential / sys_audit_log rows. That read runs customSession, so the PR's enforceSessionControls judges the scoped organization's idle and absolute limits on them; the PR's scoping hook, switch judgement and both caps are not on their path because they neither mint nor re-scope a session. The MCP bearer path (verifyMcpAccessToken) is a token principal, not a sys_session; main adds only an after-response last_used_at stamp under SYSTEM_CTX to it.
    2. A new reader of mfaRequired. None. Main's only require_mfa hit is SettingsView.clearBeneathPin-30665.test.tsx, a UI test whose fixture manifest names its own require_mfa toggle under OS_AUTH_REQUIRE_MFA — neither the real key (auth.mfa_required) nor the real pin (OS_AUTH_MFA_REQUIRED), so it reads nothing the PR changed. On the merged tree, mfaRequired survives only as the refusal in auth-manager.ts:1332, the level-derived local in auth-manager-password-session-policy.ts:235 and the ACCESS_POLICY_KEYS.mfaRequired property name — the PR's own code.
    3. A new admin predicate that should share isTenantLevelAdministrator. None added. Main's admin doors go through the pre-existing gateAdmin → judgePlatformAdmin → isPlatformAdminUser, which reads the isPlatformAdmin alias customSession derives as resolveUserAuthzGrants(...).posture === 'PLATFORM_ADMIN' (auth-manager-plugin-list.ts), byte-for-byte hasPlatformAdminStanding — the same predicate isTenantLevelAdministrator → isPlatformAdminUserId wraps (auth-manager.ts:6482, auth-manager-actor-guards.ts:208). The new modules' judgeHeldSession also honours the PR's administrators level: an unenrolled administrator the level holds is authGate-held and refused at these doors as at every admin door. One predicate, no drift to share.

② Semver level

Unchanged from 6695328434: three patch changesets with Clause-②: yes (narrowing), Metadata-protocol: none, the BREAKING banner, migration and ADR-0087 marker; the merge adds no spec path on the PR side and the changeset file is byte-identical. Check Changeset green on the head.

③ Boundary flags

④ Design judgment

The 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 ctx.request is present (auth-manager-plugin-list.ts, door built from readRequestClientAddress()), which is the better-auth-served door (handleRequest); an objectstack-mount route that reads the session in-process — the pre-existing in-process reads in auth-plugin.ts and now main's six routes alike — judges the organization's limits but not its list. The deployment's list still gates each of them at the HTTP door, so no floor is loosened; the PR body's "every session read through the HTTP auth door" is one sentence wider than the code's reach (D3).

Findings.

  • D3 (should-fix) — narrow the roaming sentence in the PR body, the access_policy.allowed_ip_ranges description and the changeset to what runs: the organization list is re-judged on better-auth-served session reads (handleRequest), not on an objectstack-mount route's in-process session read, which judges the limits only; or pass the mount's established address as door where a mount carries one. Pre-dates the merge and the prior PASS; not a reason to hold this head.

Implemented-by: claude/issue-30922-per-org-access-policy
Reviewed-by: session_01Q4EjjRhUZjdy8VXs6Kkur2

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2

席位核对 · ACCEPT(新 head)· 2026-11-07T05:12Z

Head-sha: a06983b5c00a88df2c4484c769614aede24952b1

  • 变化: 只是合并了 main(43 个提交)。冲突只出现在两个生成的审计统计文件里:取 main 的版本后重新生成,266 加上本 PR 新增的 1 处,共 267。PR 本身的改动没有变:64 个文件里 62 个逐行一致,另外 2 个就是这两个统计文件。
  • 契约复核: 原复核员已联系不上,由新复核员做增量复核,PASS 6702665651。main 在认证相关文件里的新改动没有新开登录或组织切换路径,没有新的地方读取已退役的选项,也没有新增管理员判定。
  • should-fix D3(转 auth follow-ups of #30922: an org admin cannot read the judged client address before saving an IP list, and the console MFA notices over sys_organization.require_mfa are inert #31030): 组织 IP 名单只在 better-auth 自己处理的请求上重新判定。objectstack 挂载的路由在进程内读会话时,只判定组织的时长上限,不判定 IP 名单;部署级名单照常生效,下限没有放松。要么把说法收窄,要么把挂载层的地址传进去。
  • check-runs(head a06983b5):50 项里 38 项 success、12 项跳过、0 项失败。
  • 落地条款: 复核 PASS 且 CI 绿 ⇒ 重新挂 auto-merge,经合并队列落地。

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 7, 2026
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 7, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as draft November 7, 2026 07:30
@objectstack-fleet objectstack-fleet Bot added the pm:awaiting-merge PR only: landing waits on the maintainer's own review and merge (SKILL.md 复核 ②); never on an issue label Nov 7, 2026
@objectstack-fleet
objectstack-fleet Bot requested a review from os-zhuang November 7, 2026 07:30
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2

维护者速读 · 超过人工合并线,需要你来放行 · 2026-11-07T07:30Z

  • 情况: 本 PR 改了 4058 行(+2812 / −1246),超过人工合并线 3000 行。合并队列的 Governed Surface Guard 两次把它移出队列(05:16Z、05:30Z):席位入队不算你本人的手,也没有授权账号的 APPROVED。席位已经把它改回 draft、关掉了 auto-merge。
  • 改了什么: IP 白名单、会话闲置时长 / 最长时长 / 同时在线数、双重验证要求,改为每个组织可以在「设置 → 访问策略」里各自设置。部署级的值是默认值,也是下限,组织只能收紧。双重验证分三档:不强制 / 管理员必须开 / 全员必须开。这是 cloud#3668(企业版执行报告)的前置。
  • 审查记录:
    • 首次契约复核 FAIL 6692495550,返工后增量复核 PASS 6695328434。
    • 合并 main 之后,新复核员又做了一次增量复核,PASS 6702665651。
    • CI 在 head a06983b5 上全绿。
  • 风险与回滚: 破坏性变更。auth.mfa_required 从开关变成三档,组织表的 require_mfa 不再可写;启动时会自动迁移,失败的话下次启动重试。不设组织策略时,所有部署的行为和以前一样。回滚 = 还原本 PR。
  • 需要你做的(三选一):
    1. 用 os-zhuang 提交一个 APPROVED review,席位随后翻 ready、挂 auto-merge 落地;
    2. 你本人点「Merge when ready」;
    3. 你本人直接合并。

@os-zhuang
os-zhuang marked this pull request as ready for review November 7, 2026 07:47
@os-zhuang
os-zhuang added this pull request to the merge queue Nov 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 7, 2026
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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b9c66ee9ddb3be486b9d7e40d34e8e64a4bcffe1
Local-runs: none
Scope: content/docs/permissions/tenant-audit-census.mdx, docs/audits/2026-08-tenant-audit-write-call-sites.counts.md + packages/platform-objects/src/plugin.ts (main's hunk), packages/platform-objects/src/system/sys-seed-landed-key.object.ts, packages/platform-objects/src/system/index.ts, packages/verify/src/parent-write-reach.ts (main's hunks), packages/plugins/plugin-security/src/security-plugin.ts (main's hunks), packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts, docs/adr/0069-enterprise-authentication-hardening.md (main's amendment), ui/packages/app-shell/src/console/connect/AuthorizedAccessList.tsx, scripts/tenant-audit-census.mjs

Delta review on the prior record 6702665651 (PASS on a06983b5c0), rendered at 2026-11-07T11:59Z. The head is a second merge of origin/main (second parent aa3e21cca7, 21 commits) into a06983b5c0, declared as carrying no PR code change (card report 6706620252). Read-only, the same method as 6702665651: git fetch, the two net diffs, main's own diff between the two merge-bases (24bc259363..aa3e21cca7), the merged tree where cited, and the head's check-runs, read once. Nothing built, run or re-run. The PR's rules stand on 6695328434 and 6702665651; D3 of the latter is unchanged and not re-judged.

Check-runs on b9c66ee9dd (read once): 50 in all, 38 success, 12 skipped, 0 failure, 0 pending — nothing NOT MEASURED. The same roster as on a06983b5c0: Test Core (8 shards and aggregate), TypeScript Type Check with its four legs, Check Changeset, Lint & Repo Gates, Dogfood Regression Gate, Governed Surface Queue Guard all success; the commit-status API reports no statuses (total_count 0).

① Derived judgments

  • (a) The PR's net change is unchanged by the merge, apart from the two census files — right. git diff 24bc259363 a06983b5c0 (the old merge-base) and git diff aa3e21cca7 b9c66ee9dd (origin/main...b9c66ee9dd) list the same 64 paths with the same name-status; with index lines and @@ offsets stripped, 62 files' hunks are identical and the two that differ are exactly content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md. The converse holds: git diff a06983b5c0 b9c66ee9dd (what the merge brought) and git diff 24bc259363 aa3e21cca7 (what main moved) both list 192 paths, identical hunk for hunk except those same two. The changeset, the five *.objects.generated.ts bundles and scripts/engine-double-contract.pinned.json are unchanged on the PR side. No file's PR-side hunk changed.
  • (b) Main's 21 commits do not interact with the PR's rules — read. Main did not touch any module carrying this PR's rules (organization-access-policy.ts, -binding.ts, auth-manager-database-hooks.ts, auth-manager-after-hook.ts, auth-manager-password-session-policy.ts, auth-manager-plugin-list.ts, auth-manager.ts, auth-plugin.ts, auth-manager-actor-guards.ts, platform-admin-gate.ts, last-admin-guard.ts, settings-service.ts, sys-organization.object.ts, sys-session.object.ts), nor core's resolve-authz-context.ts, posture-ladder.ts, admin-standing-surface.ts. On the three files the dev names: platform-objects/src/plugin.ts registers one new engine-owned object, sys_seed_landed_key (system/sys-seed-landed-key.object.ts, fix(metadata-protocol,runtime): a seed row a person deleted is no longer inserted again by the next seed replay (per-organization record of landed seed keys) #31083: a per-organization record of landed seed keys, apiEnabled: false, organization_id lookup with cascade) — a seed-loader ledger with no session, policy or admin reading; verify/src/parent-write-reach.ts grows the optional-master (controlled_by_parent with no master) reach cases of feat(security, verify): a controlled_by_parent row with no master is written by its owner or by Modify All on the detail #31082 — a verify probe over detail-record writes, not auth. The three checks asked:
    1. A new session-mint or organization-switch path. None. Main's non-test diff adds no sys_session insert, no createSession/internalAdapter call, no session.update and no activeOrganizationId write on the server; its only activeOrganizationId reads are in the Console (AuthorizedAccessList.tsx, feat(console): revoke an authorized AI assistant from Connect an Agent, and an admin widget for a person's assistants #31084, rendering which organization an authorized assistant belongs to). The PR's scoping hook, switch judgement, both caps and the roaming re-judgement keep their whole path.
    2. A new reader of mfaRequired. None in code. Main's only mfa_required/require_mfa hits are the ADR-0069 in-place amendment (docs(adr): ADR-0069 amended in place — per-organization overrides live in the settings tenant rung #31041, the maintainer's Tier H merge) documenting this PR's store and levels — prose that agrees with the code, no reader. On the merged tree mfaRequired survives only where 6702665651 read it: the PR's refusal, its level-derived local and the ACCESS_POLICY_KEYS property name.
    3. A new admin predicate. None. Main's non-test diff adds no isPlatformAdmin*, hasPlatformAdminStanding, judgePlatformAdmin, judgeHeldSession, posture or adminScope reader; plugin-security's security-plugin.ts and explain-engine.ts moved for the controlled_by_parent write gate (feat(security, verify): a controlled_by_parent row with no master is written by its owner or by Modify All on the detail #31082) without touching resolvePermissionSetsForContext or adminScope, so the PR's kind-3 (delegated) administrator reading through that service is as it was. Nothing to share with isTenantLevelAdministrator.
  • (c) Census arithmetic — right, and the prose is consistent. Main's ledger at the merge-base reads 268; the head reads 269, the one new row being this PR's auth-manager.ts · update · sys_session · enabled · elevated (judgeSessionSwitch), the same row 6702665651 counted as 266 → 267. Every derived row moves by exactly that one site and in the right column: decidable 190 → 191, tenancy-enabled 184 → 185, threading a context 204 → 205, decidably elevated 148 → 149, typed receiver 204 → 205, object name inline 121 → 122; run-time-named (78), tenancy-off, provably-none, unreadable (56 / 30) and undecidable-elevation (112) unchanged. The page's hand-written counts match the tables: 56 of the 269, 191 of 269 decidable with 78 undecidable, 149 of 269 (55%) (149/269 = 55.4%), 2 / 269. The corpus-scale rows (sources 1049 → 1052, registry 130 → 131, subtracted 236 → 237) are declared not enforced; main's own line was measured at b93069eab, a sha that is not an ancestor of the merge-base (a squashed branch head), so those deltas are not the PR's alone — the registry's +1 is main's own sys_seed_landed_key, first measured on this tree. The Measured on line names a06983b5c, the script's own HEAD read during the merge (first parent). Lint & Repo Gates, which runs the census gate, is green on the head.

② Semver level

Unchanged from 6695328434: three patch changesets with Clause-②: yes (narrowing), Metadata-protocol: none, the BREAKING banner, migration and ADR-0087 marker; the changeset file is byte-identical and the merge adds no spec path on the PR side. Check Changeset green on the head.

③ Boundary flags

④ Design judgment

The 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: claude/issue-30922-per-org-access-policy
Reviewed-by: session_01Q4EjjRhUZjdy8VXs6Kkur2

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2

维护者速读 · 冲突已解决,请再入队一次 · 2026-11-07T12:00Z

  • 情况: 你在 07:47Z 把本 PR 放进了合并队列。08:08Z 它因为和 main 冲突被移出:main 前进了 21 个提交,冲突出在审计统计生成的两个文件里。dev 合并了 main(b9c66ee9dd),取 main 的统计再重新生成,268 加本 PR 的 1 处,共 269。PR 本身的代码没有变,64 个文件里 62 个逐行一致。
  • 复核: 增量复核 PASS 6706658147。main 新进来的 21 个提交没有新开登录或组织切换路径,没有新的地方读取已退役的选项,也没有新增管理员判定。之前各轮复核都已通过:6695328434、6702665651。
  • check-runs(head b9c66ee9):50 项里 38 项 success、12 项跳过、0 项失败,当前状态 clean。
  • 需要你做的: 再点一次「Merge when ready」。本 PR 超过人工合并线,席位自己入队会被拒。

This branch has not been deployed

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

Labels

documentation Improvements or additions to documentation pm:awaiting-merge PR only: landing waits on the maintainer's own review and merge (SKILL.md 复核 ②); never on an issue size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant