From ec3f44b94414af667344b60c2af0dd6e9d30fdfc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 19:13:56 +0000 Subject: [PATCH 1/3] =?UTF-8?q?docs(adr):=20ADR-0069=20amended=20in=20plac?= =?UTF-8?q?e=20=E2=80=94=20per-organization=20overrides=20live=20in=20the?= =?UTF-8?q?=20settings=20tenant=20rung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TL;DR 6, D3, D5 and D7 named sys_organization.* columns as the per-organization store. Per the maintainer's ruling (ruling record 6692407743), the store is the tenant-scoped access_policy settings namespace, one sys_setting row per organization (ADR-0007 / ADR-0131 tenant rung). The tighten-only rule is unchanged; no other decision changes. Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- ...069-enterprise-authentication-hardening.md | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/adr/0069-enterprise-authentication-hardening.md b/docs/adr/0069-enterprise-authentication-hardening.md index e0b5b62b8d6..19b40c79d13 100644 --- a/docs/adr/0069-enterprise-authentication-hardening.md +++ b/docs/adr/0069-enterprise-authentication-hardening.md @@ -1,6 +1,6 @@ # ADR-0069: Enterprise authentication hardening — password policy, enforced MFA, SSO, session controls, network gating, and anti-brute-force, all enforcement-wired -**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. +**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; the tighten-only rule is unchanged and no other decision changes (TL;DR 6, D3, D5, D7); see the note under D7. **Deciders**: ObjectStack Protocol Architects **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) **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 @@ 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. 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. 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). -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). +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)*. --- @@ -82,7 +82,7 @@ Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom |---|---|---|---| | `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 | | `mfa_grace_period_days` | 7 | same | **[custom]+[field]** `sys_user.mfa_required_at`; allow a grace window to enroll before hard-blocking | -| `sys_organization.require_mfa` (per-org) | — | `customSession` org resolution | **[custom]** an org may *require* MFA above the global floor | +| `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 | > 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. > @@ -132,7 +132,7 @@ Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom | Setting | Default | Seam | Mechanism | |---|---|---|---| -| `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)* | +| `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)* | | `sys_user.allowed_ip_ranges` (optional override) | — | same | **[custom]** per-user override, evaluated before org | | `require_ip_match` (toggle) | off | session refresh | **[custom]** detect `request IP != session.ip_address` (already captured) -> re-verify or revoke | @@ -167,12 +167,29 @@ Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom - `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` (bounded) -- `sys_organization`: `require_mfa`, `allowed_ip_ranges` +- 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)* Plus admin-facing surface: a `sys_user` list-view filter for locked accounts + an **Unlock** action; a "force password reset" action. **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. +> **Where per-organization overrides live (amended 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)).** TL;DR 6, D3, D5 and this +> section named `sys_organization.*` columns as the per-organization store. The store is the settings **tenant rung** +> instead (ADR-0007's cascade at scope `tenant`; ADR-0131): a tenant-scoped settings namespace, `access_policy`, one +> `sys_setting` row per organization, carrying the organization's allowed IP ranges, its idle, absolute and concurrent +> session limits, and its MFA requirement. The deployment's `auth` values (scope `global`) are the default and the floor. +> The rule is unchanged: an organization may only *tighten*, never loosen, the deployment's floor. +> +> Why the settings rung and not columns: only a settings write predicate receives the saving request's transport, which +> the per-organization self-lock-out save guard needs (an organization's IP list must still admit the request that saves +> it), and it is the store organization administrators already write. `sys_organization.require_mfa` is retired — readonly, +> each `true` value migrated into `access_policy.mfa_required` as the requirement for every member (`everyone`) — and no +> `sys_organization.allowed_ip_ranges` field is added. No other decision changes; the dated implementation-status +> snapshots above and under *Phasing* are left as written. + --- ## Enforcement seam summary (where the code hooks) From 27e0710231f35229a8359dc7cb8bf4e2d98eb22e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 19:49:49 +0000 Subject: [PATCH 2/3] docs(adr): ADR-0069 D3 records the three MFA levels at the deployment and per organization D3's mfa_required changes from a global toggle to the levels off / administrators / everyone (administrators: ADR-0090 D12's tenant-level, organization-level and delegated administrators), on the maintainer's ruling (record 6684194038). The per-organization row takes the same levels under the tighten-only rule, a stronger level only. The dated status-line note gains one clause for the levels. Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- docs/adr/0069-enterprise-authentication-hardening.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0069-enterprise-authentication-hardening.md b/docs/adr/0069-enterprise-authentication-hardening.md index 19b40c79d13..3260d490a52 100644 --- a/docs/adr/0069-enterprise-authentication-hardening.md +++ b/docs/adr/0069-enterprise-authentication-hardening.md @@ -1,6 +1,6 @@ # ADR-0069: Enterprise authentication hardening — password policy, enforced MFA, SSO, session controls, network gating, and anti-brute-force, all enforcement-wired -**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; the tighten-only rule is unchanged and no other decision changes (TL;DR 6, D3, D5, D7); see the note under D7. +**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. **Deciders**: ObjectStack Protocol Architects **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) **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) @@ -80,9 +80,9 @@ Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom | Setting | Default | Seam | Mechanism | |---|---|---|---| -| `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 | +| `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 | | `mfa_grace_period_days` | 7 | same | **[custom]+[field]** `sys_user.mfa_required_at`; allow a grace window to enroll before hard-blocking | -| `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 | +| `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, a stronger level only (`off`, then `administrators`, then `everyone`) *(levels amended 2026-11-06 — see the status line)* | > 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. > From f3ce9c02b3c9947bb91ad8697a080de5d0cdcc95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 20:09:41 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(adr):=20ADR-0069=20wording=20=E2=80=94?= =?UTF-8?q?=20an=20organization's=20MFA=20level=20is=20never=20weaker;=20A?= =?UTF-8?q?DR-0131's=20citation=20named?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D3 per-organization row read "at least as strong as the deployment's, a stronger level only", two clauses that disagree on an equal level; it now reads "never a weaker one". The D7 note's ADR-0131 citation now says what it is cited for: the organization ownership of the settings row. Claude-Session: https://claude.ai/code/session_01Q4EjjRhUZjdy8VXs6Kkur2 Co-authored-by: Claude --- docs/adr/0069-enterprise-authentication-hardening.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/adr/0069-enterprise-authentication-hardening.md b/docs/adr/0069-enterprise-authentication-hardening.md index 3260d490a52..ad7f596e9e6 100644 --- a/docs/adr/0069-enterprise-authentication-hardening.md +++ b/docs/adr/0069-enterprise-authentication-hardening.md @@ -82,7 +82,7 @@ Legend: **[native]** = a better-auth config/plugin does the enforcing; **[custom |---|---|---|---| | `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 | | `mfa_grace_period_days` | 7 | same | **[custom]+[field]** `sys_user.mfa_required_at`; allow a grace window to enroll before hard-blocking | -| `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, a stronger level only (`off`, then `administrators`, then `everyone`) *(levels amended 2026-11-06 — see the status line)* | +| `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)* | > 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. > @@ -178,7 +178,8 @@ Plus admin-facing surface: a `sys_user` list-view filter for locked accounts + a > [#30922](https://github.com/objectstack-ai/objectstack/issues/30922), maintainer, verbatim: 「修订 ADR 写明存放位置(推荐)」; > implemented by PR [#31013](https://github.com/objectstack-ai/objectstack/pull/31013)).** TL;DR 6, D3, D5 and this > section named `sys_organization.*` columns as the per-organization store. The store is the settings **tenant rung** -> instead (ADR-0007's cascade at scope `tenant`; ADR-0131): a tenant-scoped settings namespace, `access_policy`, one +> instead (ADR-0007's cascade at scope `tenant`; ADR-0131 (organization ownership is total: each such row is owned by +> its organization, and only the settings global rung leaves `sys_setting`)): a tenant-scoped settings namespace, `access_policy`, one > `sys_setting` row per organization, carrying the organization's allowed IP ranges, its idle, absolute and concurrent > session limits, and its MFA requirement. The deployment's `auth` values (scope `global`) are the default and the floor. > The rule is unchanged: an organization may only *tighten*, never loosen, the deployment's floor.