Repository navigation
Commit b5696b4
feat(plugin-auth, service-settings)!: the access policy is set per organization, with three MFA levels (#31013)
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 #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.
<details><summary>Ablations — one per rule, each red, restored, control
green</summary>
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).
</details>
## 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 #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 (#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](https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2)_
<!-- signed: repo:cloud#3 · hotlong · session_01Q4EjjRhUZjdy8VXs6Kkur2
-->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 5f00374 commit b5696b4
64 files changed
Lines changed: 2814 additions & 1248 deletions
File tree
- .changeset
- content/docs/permissions
- docs/audits
- packages
- cli/test
- platform-objects/src
- apps
- translations
- identity
- plugins/plugin-auth/src
- qa/dogfood/test
- services/service-settings/src
- manifests
- translations
- verify/src
- scripts
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
132 | 132 | | |
133 | 133 | | |
134 | 134 | | |
135 | | - | |
| 135 | + | |
136 | 136 | | |
137 | 137 | | |
138 | 138 | | |
| |||
197 | 197 | | |
198 | 198 | | |
199 | 199 | | |
200 | | - | |
| 200 | + | |
201 | 201 | | |
202 | | - | |
203 | | - | |
| 202 | + | |
| 203 | + | |
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
| |||
217 | 217 | | |
218 | 218 | | |
219 | 219 | | |
220 | | - | |
| 220 | + | |
221 | 221 | | |
222 | 222 | | |
223 | 223 | | |
224 | | - | |
| 224 | + | |
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
| |||
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
237 | | - | |
| 236 | + | |
| 237 | + | |
238 | 238 | | |
239 | | - | |
| 239 | + | |
240 | 240 | | |
241 | | - | |
| 241 | + | |
242 | 242 | | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | | - | |
| 246 | + | |
247 | 247 | | |
248 | 248 | | |
249 | 249 | | |
250 | 250 | | |
251 | 251 | | |
252 | | - | |
| 252 | + | |
253 | 253 | | |
254 | 254 | | |
255 | 255 | | |
256 | 256 | | |
257 | | - | |
| 257 | + | |
258 | 258 | | |
259 | 259 | | |
260 | 260 | | |
| |||
307 | 307 | | |
308 | 308 | | |
309 | 309 | | |
310 | | - | |
| 310 | + | |
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | | - | |
| 314 | + | |
315 | 315 | | |
316 | | - | |
317 | | - | |
| 316 | + | |
| 317 | + | |
318 | 318 | | |
319 | 319 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | | - | |
| 39 | + | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
| 97 | + | |
98 | 98 | | |
99 | | - | |
100 | | - | |
| 99 | + | |
| 100 | + | |
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
| |||
140 | 140 | | |
141 | 141 | | |
142 | 142 | | |
| 143 | + | |
143 | 144 | | |
144 | 145 | | |
145 | 146 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
260 | 260 | | |
261 | 261 | | |
262 | 262 | | |
263 | | - | |
| 263 | + | |
264 | 264 | | |
265 | 265 | | |
266 | 266 | | |
267 | | - | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
268 | 270 | | |
269 | 271 | | |
270 | 272 | | |
271 | 273 | | |
272 | 274 | | |
273 | | - | |
| 275 | + | |
| 276 | + | |
274 | 277 | | |
275 | 278 | | |
276 | 279 | | |
| |||
293 | 296 | | |
294 | 297 | | |
295 | 298 | | |
296 | | - | |
297 | | - | |
| 299 | + | |
| 300 | + | |
298 | 301 | | |
299 | 302 | | |
300 | 303 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
144 | 150 | | |
145 | 151 | | |
146 | 152 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
556 | 556 | | |
557 | 557 | | |
558 | 558 | | |
559 | | - | |
560 | | - | |
| 559 | + | |
| 560 | + | |
561 | 561 | | |
562 | 562 | | |
563 | 563 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
127 | 127 | | |
128 | 128 | | |
129 | 129 | | |
| 130 | + | |
130 | 131 | | |
131 | 132 | | |
132 | 133 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
556 | 556 | | |
557 | 557 | | |
558 | 558 | | |
559 | | - | |
560 | | - | |
| 559 | + | |
| 560 | + | |
561 | 561 | | |
562 | 562 | | |
563 | 563 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
86 | 86 | | |
87 | 87 | | |
88 | 88 | | |
| 89 | + | |
89 | 90 | | |
90 | 91 | | |
91 | 92 | | |
| |||
0 commit comments