Skip to content

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

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

objectstack-fleet[bot] wants to merge 19 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 169 documentable anchor(s).

71 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 41dc302496e48ed23d3dcd23f61d6365448237eb.

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

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: packageId (4 routes)
  • 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 222 client-bound route-ledger rows — the other 159 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 159: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 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 — 26 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 41dc302496e48ed23d3dcd23f61d6365448237eb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a19d25a78cb29077ce587c341c7324ad8831a725 — the merge of head 2e1b12c26984e3e46d6f15f4370206d7e05f64a1 into base 41dc302496e48ed23d3dcd23f61d6365448237eb, 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 a19d25a78cb29077ce587c341c7324ad8831a725 && git checkout a19d25a78cb29077ce587c341c7324ad8831a725
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41dc302496e48ed23d3dcd23f61d6365448237eb 2e1b12c26984e3e46d6f15f4370206d7e05f64a1 && git checkout -B drift-repro 41dc302496e48ed23d3dcd23f61d6365448237eb && git merge --no-ff 2e1b12c26984e3e46d6f15f4370206d7e05f64a1

node scripts/docs-audit/affected-docs.mjs --json 41dc302496e48ed23d3dcd23f61d6365448237eb

⚠️ 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 41dc302496e48ed23d3dcd23f61d6365448237eb → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b0b494c04a92294308656bd183f2764c286e9665
Local-runs: none
Scope: .changeset/30922-per-organization-access-policy.md, content/docs/permissions/tenant-audit-census.mdx, docs/audits/2026-08-tenant-audit-write-call-sites.counts.md, packages/cli/test/connect-agent-both-halves-wire.pin.test.ts, packages/platform-objects/src/apps/translations/zh-Hant.objects.generated.ts, packages/platform-objects/src/apps/translations/zh-Hant.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.ts, packages/plugins/plugin-auth/src/organization-access-policy-binding.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/services/service-settings/src/manifests/access-policy.manifest.ts + packages/platform-objects/src/identity/sys-session.object.ts, packages/services/service-settings/src/settings-service.ts, packages/services/service-settings/src/settings-service.types.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/auth-manager-actor-guards.ts, packages/plugins/plugin-auth/package.json (better-auth 1.7.12)

Delta review on the prior record 6692495550 (FAIL on 4513cb9d42), rendered at 2026-11-06T20:56Z. Read-only: the rework commits d302a81e3e..b0b494c04a after the one merge of origin/main at 56fe7fdf74 (diff 56fe7fdf74..b0b494c04a, 16 files, what the merge brought from main excluded), the updated PR body and changeset, and the head's check-runs, read once. Nothing built or run. The ADR-0069 store wording is #31041 and not judged here. S6/S7 went to #31030 and are not re-judged.

Check-runs on b0b494c04a (read once): none existed at the read — NOT MEASURED. The queue guard still needs every check green; this record judges the code.

① Derived judgments

  • M1 — closed in code. packages/cli/test/connect-agent-both-halves-wire.pin.test.ts:273,297 now pin 32 and name nav_settings_access_policy, with the count comment carried forward. CI: NOT MEASURED.
  • M2 — closed in code. origin/main merged once at 56fe7fdf74; packages/platform-objects/src/apps/translations/zh-Hant.ts carries nav_settings_access_policy: 存取原則, matching the service-settings hub title, and the retired column's zh-Hant copy is regenerated. CI (setup-nav-hub-parity, check:app-nav-i18n): NOT MEASURED.
  • S1 — closed. enforceConcurrentCap now runs two caps over two populations: the deployment's (config.maxConcurrentSessions) counts every live session of the user, its old meaning, so the floor is never loosened; the organization's counts only rows whose active_organization_id is the target plus the kept session, and ends only those, oldest first. Swept at sign-in (after-hook, the new session kept) and at a switch (session.update.before, the switching session kept, keepSessionId). A strict organization's cap cannot be exceeded by opening sessions elsewhere and switching in; a strict-organization sign-in ends another organization's session only through the deployment cap, as before. Pinned on both populations and on the hook. One line worth knowing: the switch-time sweep runs in the before-hook, so if the switch's own row update then fails, the target's oldest session was already ended — in the policy's direction, not against it.
  • S2 — closed, with one pin owed (D2). A switch is judged against the target, in order, before it lands: the IP list (refuseUnadmittedScoping, only when the target differs from the current organization), then judgeSessionSwitch (idle and absolute from the sys_session row as it stands, ended in place with its own revoke_reason and the switch refused 401 UNAUTHENTICATED), then the cap. No heartbeat first: judgeSessionSwitch reads the row and the hook touches nothing; the test pins last_activity_at unchanged across a refused switch. The claim that the real /organization/set-active request runs no heartbeat rests on better-auth's sessionMiddleware reading the session through the base getSession, not the customSession-wrapped route; the vendored 1.7.12 source is not in this checkout and the pin is at hook level only (D2). On "idle = the session's own inactivity, so activity in another organization counts": acceptable, not a loophole. Idle is a property of the session (last_activity_at, the deployment's own rule reads the same column); the organization's limit binds from the moment the session enters it and on every request in it; tighten-only governs the limit's value, which no switch changes; what an organization cannot do is tell activity apart by organization, which no limit in the system does. A failed row lookup admits the switch, and the next request in the organization judges the same limits (enforceSessionControls) — said in the docblock.
  • S3 — closed. customSession passes door: { address } when the request came through the HTTP auth door (ctx.request, the address readRequestClientAddress() holds — the one the deployment gate judged); enforceSessionControls then asks judgeSessionScoping again when the organization's list is set, and ends the session in place with ip_not_allowed ahead of the timer reasons. Pinned end-to-end through handleRequest: a roaming read ends the session and it stays ended back inside the ranges. Cost: with a list set, one policy read (30 s cache) and one list match per auth-door session read, plus the sys_session row read the both-limits-off memo used to skip — the same read every deployment with a limit already pays; isPlatformAdminUserId is asked only on a refusal. Fail-closed on the address: an undetermined address under a set list is refused and the session ended. The policy read itself stays fail-open to the deployment floor (last cached policy, else the zero policy, warned once) — the PR's standing choice, and the deployment list at the door holds throughout. Data requests are judged by neither list, as before; the manifest and changeset now say so.
  • S4 — closed. isTenantLevelAdministrator is isPlatformAdminUserId, the predicate judgeSessionScoping asks; the save check reads the predicate input's ctx.userId (SettingsContext.userId, the field the door fills for user-scoped keys) and lifts only the address refusal (constraint.clientAddress), never the floor refusal. Pinned on a real SettingsService.
  • S5 — closed. The changeset's upgrade note and the PR body say a write of true/false is refused by the option table and name cloud settings-cross-org-doors.test.ts:533 (write) and :552 (read-back).
  • S8 — closed. assertMfaRequirementOptions refuses the retired mfaRequired key, and a non-level mfaRequirement, at construction and on every applyConfigPatch, naming the replacement; authConfig spreads this.options, so a plugin-level mfaRequired is refused the same way. The changeset says so.
  • New in the rework. Census 261 → 262: judgeSessionSwitch's sys_session update is one new write call site under SYSTEM_CTX (elevated); the ledger is regenerated with its "Measured on" line. Typed engine options (b0b494c04a): the two as any casts dropped — type-only. The revoke_reason slip: at the head enforceSessionControls writes its own reason (ip_not_allowed / absolute_max / idle_timeout) and enforceConcurrentCap writes concurrent_cap — reverted correctly (27d4828b2e).

② Semver level

Unchanged from 6692495550: three patch changesets, Clause-②: yes (narrowing), Metadata-protocol: none, BREAKING banner, migration and ADR-0087 marker — right. The rework adds no spec path; the manifest wording and the changeset grew prose only.

③ Boundary flags

④ Design judgment

The three design holes of the prior record are closed as read above: cap per population with a switch sweep (S1), switch judged against the target's list, limits and cap before it lands (S2), the organization list re-judged on every auth-door session read (S3). Staleness is now stated in the module header (30 s policy, 60 s flag). The roaming re-judgement, like the idle limit, ends the session on the detecting request and refuses the next — the ADR-0069 D4 shape.

Findings.

  • D1 (should-fix) — add ip_not_allowed to the published revoke_reason vocabulary in packages/platform-objects/src/identity/sys-session.object.ts (the field description), which the rework writes but does not declare.
  • D2 (should-fix) — pin "no heartbeat before the switch judgement" through the real /organization/set-active route, not only by calling hooks.session.update.before; if better-auth's session middleware ever runs customSession first, the idle rule at the switch is silently defeated.

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

VERDICT: PASS

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 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