Skip to content

Commit 7faa237

Browse files
docs(adr): ADR-0069 amended in place — per-organization overrides live in the settings tenant rung (#31041)
Refs #30922 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:只改 ADR-0069 的文字(就地修订,状态行追加一条 2026-11-06 的「Amended in place」):写明「组织级访问策略覆盖值」存放在设置的租户层(`access_policy` 命名空间,每个组织一行 `sys_setting`),而不是 `sys_organization.*` 字段;涉及 TL;DR 6、D3、D5、D7 四处,并在 D7 末尾加一段说明;另外 D3 的双重验证要求从「开关」改为三档 `off` / `administrators` / `everyone`(不强制 / 管理员必须开 / 全员必须开,管理员即 ADR-0090 D12 的租户级、组织级和委派管理员),部署级是默认值和下限,组织级同样三档、不得弱于部署级(可相同或更强)。 - **为什么改**:执行你在 #30922 上的裁决「修订 ADR 写明存放位置(推荐)」;PR #31013 把这些值存在设置租户层,ADR 原文仍写组织表字段,两者不一致;三档双重验证依据你在 cloud#3668 的裁决「可以,按三档做(推荐)」,一并记入 ADR,免得再开第二个受管 PR。 - **风险与回滚**:零运行时风险,只有文字,没有代码、spec、文档站改动;「组织只能收紧、不能放宽部署下限」这条规则原意不变,其他决策不变。回滚 = revert 这一个提交。 - **先看**:`docs/adr/0069-enterprise-authentication-hardening.md` 的 D7 末尾新增说明段(风险最高:它写了存放位置和理由);其次 D3、D5 两张表里改名的那一行,以及状态行末尾。 - **两分钟验证**:`git diff origin/main -- docs/adr/0069-*` 应只见状态行末尾、TL;DR 6、D3 两行(存放位置与三档)、D5 一行、D7 一行的改写,加 D7 末尾 1 段新增说明;`pnpm check:adr-index` 应报告 `docs/adr/README.md` 为最新。 - **需要你做的**:审阅后亲手合并(Tier H,草稿状态,席位不会转 ready、不入队、不开 auto-merge)。 ## What changes ADR-0069 is amended in place on the maintainer's ruling recorded on #30922 (ruling record [`6692407743`](#30922 (comment)), maintainer, verbatim: 「修订 ADR 写明存放位置(推荐)」). Per-organization access-policy overrides live in the settings tenant rung of ADR-0007 / ADR-0131: the tenant-scoped `access_policy` namespace, one `sys_setting` row per organization, as PR #31013 implements. TL;DR 6, D3, D5 and D7 named `sys_organization.*` columns. D3's `mfa_required` also changes from a global toggle to the three levels `off` / `administrators` / `everyone`, at the deployment and per organization, on the maintainer's ruling G2 on objectstack-ai/cloud#3668 (verbatim 「可以,按三档做(推荐)」). The tighten-only rule is unchanged in meaning, and no other decision changes. The diff touches only `docs/adr/0069-enterprise-authentication-hardening.md`. `pnpm adr:index` ran and left `docs/adr/README.md` unchanged (the index shows a truncated status prefix, which this note does not reach), so the README is not in the diff. The status line stays on line 3, inside the index's 40-line header window. | Passage | Before | After | |:---|:---|:---| | Status line (appended) | no 2026-11-06 note | "**Amended in place** (2026-11-06, ruling record `6692407743` on #30922, maintainer, verbatim 「修订 ADR 写明存放位置(推荐)」; implemented by PR #31013): per-organization overrides live in the settings tenant rung (the tenant-scoped `access_policy` namespace, one `sys_setting` row per organization), not in `sys_organization.*` columns, and D3's `mfa_required` is three levels, `off` / `administrators` / `everyone`, at the deployment (the default and the floor) and per organization (ruling G2 on objectstack-ai/cloud#3668, record `6684194038`, verbatim 「可以,按三档做(推荐)」); the tighten-only rule is unchanged and no other decision changes (TL;DR 6, D3, D5, D7); see the note under D7." | | TL;DR 6 | "per-org overrides (`sys_organization.*`) are explicit and additive (an org can only *tighten*, never loosen, the global floor)" | "per-org overrides are explicit and additive (an org can only *tighten*, never loosen, the global floor) and live in the settings **tenant rung**: the tenant-scoped `access_policy` namespace, one `sys_setting` row per organization", with a store-amended marker | | D3, deployment row | "`mfa_required` (global toggle)"; "When `mfa_required`, a session for a user ..." | "`mfa_required` (deployment level: `off` / `administrators` / `everyone`)" with a levels-amended marker; "When the level covers the user (`everyone`: every user; `administrators`: the tenant-level, organization-level and delegated administrators of ADR-0090 D12), a session for a user ...". Default `off`, seam unchanged. | | D3, per-org row, setting cell | `sys_organization.require_mfa` (per-org) | `access_policy.mfa_required` (per-org, settings tenant rung), with a marker that the column is retired. Seam cell unchanged. The mechanism cell gains "on the same three levels: `off` (nothing beyond the deployment's) or a level at least as strong as the deployment's, never a weaker one (`off`, then `administrators`, then `everyone`)". | | D5, first row, setting cell | `sys_organization.allowed_ip_ranges` (CIDR[]) | `access_policy.allowed_ip_ranges` (CIDR[], per-org, settings tenant rung), with a marker. Seam and mechanism cells are unchanged. | | D7, field list | "`sys_organization`: `require_mfa`, `allowed_ip_ranges`" | "per-organization overrides: no `sys_organization` field; they live in the `access_policy` settings namespace", with a marker | | D7, new closing note | (none) | Records where the values live and that the deployment's `auth` values are the default and the floor. Restates the tighten-only rule. Gives the reason: only a settings write predicate receives the saving request's transport, which the per-organization self-lock-out save guard needs, and the settings rung is the store organization administrators already write. Records that `sys_organization.require_mfa` is retired (readonly, migrated) and that no `sys_organization.allowed_ip_ranges` field is added. | ## Acceptance notes - The dated implementation-status snapshots are left as written, because they record what was true on their dates: D7's "Implementation status (2026-07-04)" paragraph and the *Phasing* table's P1 and P2 rows still say `sys_organization.*`. The new note says so. - D3 records the three MFA levels at the deployment and per organization, on the maintainer's ruling G2 on objectstack-ai/cloud#3668 (record [`6684194038`](objectstack-ai/cloud#3668 (comment)), verbatim 「可以,按三档做(推荐)」). The status-line note gains one clause for them. The per-organization row allows a level at least as strong as the deployment's, never a weaker one. - On purpose, nothing about PR #31013's in-rework mechanics is described here (the concurrent cap, the organization-switch rules). ## Verification All readings were taken at head `f3ce9c02b3`, where the gates were re-run after the wording commit (review 6694338252 S1/S2). dispatch-gates warned that the tree is 6 or more commits behind origin/main and that `scripts/regen-artifacts.mjs` (not in this diff) changed across that range. The derived list was identical to the first run's. main was not merged into the open PR (AGENTS.md section 10); CI runs on the merge ref. <details><summary>Derived gate families (dispatch-gates --commands, 21) — 20 exit 0, 1 NOT MEASURED</summary> - `node scripts/check-adr-links.mjs` (and `--self-test`): exit 0 / exit 0 - `node scripts/check-closing-keyword-parity.mjs` (and `--self-test`): exit 0 / exit 0 - `node scripts/check-comment-mask-corpus.mjs`: exit 0 - `node scripts/ci/plan-test-core-shards.mjs --self-test`: exit 0 - `pnpm check:adr-anchors`, `check:adr-index`, `check:adr-symbol-anchors`, `check:ci-filter-parity`, `check:closed-ceremony-text`, `check:cross-package-test-inputs`, `check:doc-authoring`, `check:driver-memory-census`, `check:gitlink-declared`, `check:nul-bytes`, `check:pm-governed-merges`, `check:pm-prior-rulings`, `check:refd-timer-probe`, `check:watch-hint-literal`: exit 0 each - `pnpm --filter @objectstack/lint run check:doc-formula-expressions`: exit 3, PREREQUISITE NOT MET (`@objectstack/formula` / `@objectstack/lint` not built). NOT MEASURED locally, a declared narrowing. Two 9-minute queue attempts for the prerequisite build on the shared verify lock timed out (exit 99) behind another run. CI's `Lint & Repo Gates` runs this family. - `dispatch-gates --ran` reconciliation: "21 derived famil(ies) accounted for — 20 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)", exit 0. </details> <details><summary>ADR index</summary> `node scripts/adr-index.mjs`: exit 0, "adr-index: unchanged docs/adr/README.md (237 lines)." </details> Docs only, under the `docs/adr/**` fast path: nothing publishes, so no changeset is added and this PR carries `skip-changeset`. --- _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 8d13c0b commit 7faa237

1 file changed

Lines changed: 24 additions & 6 deletions

File tree

‎docs/adr/0069-enterprise-authentication-hardening.md‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0069: Enterprise authentication hardening — password policy, enforced MFA, SSO, session controls, network gating, and anti-brute-force, all enforcement-wired
22

3-
**Status**: Accepted — **P1 + P2 implemented** (2026-07-04); P3 partially landed (see Addendum). Original proposal 2026-06-24. Session-of-record question settled 2026-08-04 (#4785): sessions live in `sys_session`; the kernel `cache` service backs D2's rate-limit counters only. · **Amended** (2026-10-14, ruling C′ on [#23287](https://github.com/objectstack-ai/objectstack/issues/23287), [`6159450584`](https://github.com/objectstack-ai/objectstack/issues/23287#issuecomment-6159450584), maintainer 「同意」) — D3 states what the gate holds for an API key, for password expiry and for a forced reset; see the note under D3. · **Amended** (2026-10-20, PR [#24795](https://github.com/objectstack-ai/objectstack/pull/24795), no decision changes) — D5's client-address mechanism follows the shared `server.trustProxy` rule; see the note under D5.
3+
**Status**: Accepted — **P1 + P2 implemented** (2026-07-04); P3 partially landed (see Addendum). Original proposal 2026-06-24. Session-of-record question settled 2026-08-04 (#4785): sessions live in `sys_session`; the kernel `cache` service backs D2's rate-limit counters only. · **Amended** (2026-10-14, ruling C′ on [#23287](https://github.com/objectstack-ai/objectstack/issues/23287), [`6159450584`](https://github.com/objectstack-ai/objectstack/issues/23287#issuecomment-6159450584), maintainer 「同意」) — D3 states what the gate holds for an API key, for password expiry and for a forced reset; see the note under D3. · **Amended** (2026-10-20, PR [#24795](https://github.com/objectstack-ai/objectstack/pull/24795), no decision changes) — D5's client-address mechanism follows the shared `server.trustProxy` rule; see the note under D5. · **Amended in place** (2026-11-06, ruling record [`6692407743`](https://github.com/objectstack-ai/objectstack/issues/30922#issuecomment-6692407743) on [#30922](https://github.com/objectstack-ai/objectstack/issues/30922), maintainer, verbatim: 「修订 ADR 写明存放位置(推荐)」; implemented by PR [#31013](https://github.com/objectstack-ai/objectstack/pull/31013)) — per-organization overrides live in the settings tenant rung (the tenant-scoped `access_policy` namespace, one `sys_setting` row per organization), not in `sys_organization.*` columns, and D3's `mfa_required` is three levels, `off` / `administrators` / `everyone`, at the deployment (the default and the floor) and per organization (ruling G2 on [objectstack-ai/cloud#3668](https://github.com/objectstack-ai/cloud/issues/3668#issuecomment-6684194038), record `6684194038`, maintainer, verbatim: 「可以,按三档做(推荐)」); the tighten-only rule is unchanged and no other decision changes (TL;DR 6, D3, D5, D7); see the note under D7.
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (**the governing constraint** — a security property that isn't enforced at runtime is forbidden; no toggle may be a "false surface"), [ADR-0007](./0007-settings-manifest-and-kv-store.md) (settings manifest + cascade KV store), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (`sys_role` is platform-native, decoupled from better-auth; org scoping), [ADR-0066](./0066-unified-authorization-model.md) (capability/assignment split), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (`current_user` contract, built-in roles)
66
**Consumers**: `@objectstack/plugin-auth` (better-auth wiring, `bindAuthSettings`/`applyConfigPatch`, auth route middleware), `@objectstack/service-settings` (`auth.manifest.ts`), `@objectstack/platform-objects` (identity objects `sys_user`/`sys_session`/`sys_account`), `@objectstack/rest` (auth request middleware seam), `../objectui` (settings UI rendering)
@@ -16,7 +16,7 @@
1616
3. **[new]** Add the identity fields that back enforcement: `sys_user.{password_changed_at, failed_login_count, locked_until, last_login_at, last_login_ip, mfa_required_at}`, `sys_session.{last_activity_at, revoked_at, revoke_reason}`, `sys_account.previous_password_hashes`. Without these there is nothing to enforce against.
1717
4. **[ruled]** **SAML is out of better-auth core** (1.6.x exposes `genericOAuth` + `oidcProvider`, no `saml` plugin) -> scope SAML as **P3 / external** (assess `@better-auth/sso` or a custom plugin); ship **generic OIDC** (already wired via `genericOAuth`) as the v1 enterprise-SSO answer.
1818
5. **[ruled]** Phased delivery: **P1** password policy + account lockout + enforced MFA; **P2** session controls + IP allowlist + rate-limit tuning; **P3** SSO expansion (OIDC trust-list UI, SAML assessment). SCIM/bulk-provisioning is a **separate** ADR (directory sync != auth hardening).
19-
6. **[ruled]** Settings scope follows ADR-0007: security knobs are **`global`** scope, `manage_platform_settings` read/write; per-org overrides (`sys_organization.*`) are explicit and additive (an org can only *tighten*, never loosen, the global floor).
19+
6. **[ruled]** Settings scope follows ADR-0007: security knobs are **`global`** scope, `manage_platform_settings` read/write; per-org overrides are explicit and additive (an org can only *tighten*, never loosen, the global floor) and live in the settings **tenant rung**: the tenant-scoped `access_policy` namespace, one `sys_setting` row per organization *(store amended 2026-11-06; this item named `sys_organization.*` columns — see the note under D7)*.
2020

2121
---
2222

@@ -80,9 +80,9 @@ Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom
8080

8181
| Setting | Default | Seam | Mechanism |
8282
|---|---|---|---|
83-
| `mfa_required` (global toggle) | off | post-`/sign-in` session gate | **[custom]** `twoFactor` plugin already provides TOTP + backup codes (opt-in via `sys_user.two_factor_enabled` + `sys_two_factor`). When `mfa_required`, a session for a user with `two_factor_enabled = false` is marked **un-stepped-up** and gated out of all protected resources until enrollment + verification |
83+
| `mfa_required` (deployment level: `off` / `administrators` / `everyone`) *(levels amended 2026-11-06; was a global toggle — see the status line)* | off | post-`/sign-in` session gate | **[custom]** `twoFactor` plugin already provides TOTP + backup codes (opt-in via `sys_user.two_factor_enabled` + `sys_two_factor`). When the level covers the user (`everyone`: every user; `administrators`: the tenant-level, organization-level and delegated administrators of [ADR-0090](./0090-permission-model-v2-concept-convergence.md) D12), a session for a user with `two_factor_enabled = false` is marked **un-stepped-up** and gated out of all protected resources until enrollment + verification |
8484
| `mfa_grace_period_days` | 7 | same | **[custom]+[field]** `sys_user.mfa_required_at`; allow a grace window to enroll before hard-blocking |
85-
| `sys_organization.require_mfa` (per-org) | — | `customSession` org resolution | **[custom]** an org may *require* MFA above the global floor |
85+
| `access_policy.mfa_required` (per-org, settings tenant rung) *(store amended 2026-11-06; was `sys_organization.require_mfa`, now retired — see the note under D7)* | — | `customSession` org resolution | **[custom]** an org may *require* MFA above the global floor, on the same three levels: `off` (nothing beyond the deployment's) or a level at least as strong as the deployment's, never a weaker one (`off`, then `administrators`, then `everyone`) *(levels amended 2026-11-06 — see the status line)* |
8686

8787
> better-auth makes MFA *available*; making it *required* is the missing enforcement. Gate at the session-validation seam so a half-authenticated (password-only) session cannot read data.
8888
>
@@ -132,7 +132,7 @@ Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom
132132

133133
| Setting | Default | Seam | Mechanism |
134134
|---|---|---|---|
135-
| `sys_organization.allowed_ip_ranges` (CIDR[]) | — | auth request middleware (Hono, **before** the better-auth handler in `plugin-auth` route registration) | **[custom]** extract client IP (`x-forwarded-for` / `cf-connecting-ip`, trust-proxy aware); reject login/session outside the org's ranges with `IP_NOT_ALLOWED` *(client-address mechanism amended 2026-10-20 — see the note below)* |
135+
| `access_policy.allowed_ip_ranges` (CIDR[], per-org, settings tenant rung) *(store amended 2026-11-06; was `sys_organization.allowed_ip_ranges` — see the note under D7)* | — | auth request middleware (Hono, **before** the better-auth handler in `plugin-auth` route registration) | **[custom]** extract client IP (`x-forwarded-for` / `cf-connecting-ip`, trust-proxy aware); reject login/session outside the org's ranges with `IP_NOT_ALLOWED` *(client-address mechanism amended 2026-10-20 — see the note below)* |
136136
| `sys_user.allowed_ip_ranges` (optional override) | — | same | **[custom]** per-user override, evaluated before org |
137137
| `require_ip_match` (toggle) | off | session refresh | **[custom]** detect `request IP != session.ip_address` (already captured) -> re-verify or revoke |
138138

@@ -167,12 +167,30 @@ Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom
167167
- `sys_user`: `password_changed_at`, `failed_login_count`, `locked_until`, `last_login_at`, `last_login_ip`, `mfa_required_at`
168168
- `sys_session`: `last_activity_at`, `revoked_at`, `revoke_reason`
169169
- `sys_account`: `previous_password_hashes` (bounded)
170-
- `sys_organization`: `require_mfa`, `allowed_ip_ranges`
170+
- per-organization overrides: no `sys_organization` field — they live in the `access_policy` settings namespace *(amended 2026-11-06; this line named `sys_organization`: `require_mfa`, `allowed_ip_ranges` — see the note below)*
171171

172172
Plus admin-facing surface: a `sys_user` list-view filter for locked accounts + an **Unlock** action; a "force password reset" action.
173173

174174
**Implementation status (2026-07-04):** all `sys_user` (incl. `last_login_at`/`last_login_ip`), `sys_session`, `sys_account`, and `sys_organization.require_mfa` fields are landed, plus the `unlock_user` admin action. `last_login_at`/`last_login_ip` are stamped on every successful `/sign-in/email` by `AuthManager.stampLastLogin` (after-hook, independent of lockout config). **Not landed:** a per-record `sys_organization.allowed_ip_ranges` (and optional `sys_user.allowed_ip_ranges` override) — the IP allow-list currently exists only as the **global** `auth.allowed_ip_ranges` setting (D5); per-org/per-user IP scoping is tracked as remaining P2 work.
175175

176+
> **Where per-organization overrides live (amended 2026-11-06: ruling record
177+
> [`6692407743`](https://github.com/objectstack-ai/objectstack/issues/30922#issuecomment-6692407743) on
178+
> [#30922](https://github.com/objectstack-ai/objectstack/issues/30922), maintainer, verbatim: 「修订 ADR 写明存放位置(推荐)」;
179+
> implemented by PR [#31013](https://github.com/objectstack-ai/objectstack/pull/31013)).** TL;DR 6, D3, D5 and this
180+
> section named `sys_organization.*` columns as the per-organization store. The store is the settings **tenant rung**
181+
> instead (ADR-0007's cascade at scope `tenant`; ADR-0131 (organization ownership is total: each such row is owned by
182+
> its organization, and only the settings global rung leaves `sys_setting`)): a tenant-scoped settings namespace, `access_policy`, one
183+
> `sys_setting` row per organization, carrying the organization's allowed IP ranges, its idle, absolute and concurrent
184+
> session limits, and its MFA requirement. The deployment's `auth` values (scope `global`) are the default and the floor.
185+
> The rule is unchanged: an organization may only *tighten*, never loosen, the deployment's floor.
186+
>
187+
> Why the settings rung and not columns: only a settings write predicate receives the saving request's transport, which
188+
> the per-organization self-lock-out save guard needs (an organization's IP list must still admit the request that saves
189+
> it), and it is the store organization administrators already write. `sys_organization.require_mfa` is retired — readonly,
190+
> each `true` value migrated into `access_policy.mfa_required` as the requirement for every member (`everyone`) — and no
191+
> `sys_organization.allowed_ip_ranges` field is added. No other decision changes; the dated implementation-status
192+
> snapshots above and under *Phasing* are left as written.
193+
176194
---
177195

178196
## Enforcement seam summary (where the code hooks)

0 commit comments

Comments
 (0)