Skip to content

Commit b5696b4

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

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/service-settings': patch
4+
'@objectstack/platform-objects': patch
5+
---
6+
7+
feat(plugin-auth, service-settings)!: the access policy is set per organization, and the multi-factor requirement has three levels
8+
9+
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`.
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (runtime-interface-only packages/plugins/plugin-auth/src/auth-manager.ts#AuthManagerOptions) the narrowed operands are a runtime option of the auth manager (`AuthManagerOptions.mfaRequired`, which the compiler names at every caller), a settings value (`auth.mfa_required`, rewritten from a stored toggle to a level by plugin-auth at boot) and a platform object column's editability (`sys_organization.require_mfa`, whose stored value plugin-auth moves into the organization's access policy at boot); no packages/spec schema, registered node type or ADR-0087 conversion surface names any of them, so there is nothing for `objectstack migrate meta` to rewrite -->
13+
14+
**BREAKING.** The access policy is set per organization (rulings of record on objectstack-ai/cloud#3668: 「支持按组织(推荐)」, 「可以,按三档做(推荐)」). Each organization sets its own allowed IP ranges, session idle timeout, absolute session lifetime, concurrent-session cap and multi-factor requirement in **Settings → Access policy** (the `access_policy` namespace, one row per organization, written by its administrators). The deployment's values in **Settings → Authentication** are the default and the floor: an organization may tighten and never loosen.
15+
16+
The rule per key — a value weaker than the deployment's is refused at the save, and enforcement holds the floor whatever is stored:
17+
18+
- `allowed_ip_ranges`: each range must lie inside a deployment range (any range when the deployment sets none), and the list must admit the address it is saved from. A request must pass both lists. A session is scoped to an organization only from an address its list admits: the sign-in is refused with `403 IP_NOT_ALLOWED` when the organization the session would be scoped to does not admit the address, and so is a switch of the active organization. A session the organization's list no longer admits (it roamed, or the list was tightened) is signed out on its next session read through the auth routes. Sign-in itself, before any organization is chosen, is judged by the deployment's list alone. A platform administrator is held by the deployment's list only, so an organization that locks out its own administrators can be reopened, and the save check does not refuse a platform administrator's save from outside an organization's list.
19+
- `session_idle_timeout_minutes`, `session_absolute_max_hours`: `0` keeps the deployment's limit; a positive value may not exceed the deployment's. The shorter of the two applies to every session scoped to the organization. A switch of the active organization is judged against the target organization's limits before it lands, from the session's last request in any organization; a session past either is signed out and the switch refused.
20+
- `max_concurrent_sessions_per_user`: `0` keeps the deployment's cap; a positive value may not exceed the deployment's. The deployment's cap still counts every session of the user, in any organization; an organization's cap counts only the user's sessions in that organization and signs out only those, oldest first. Both are checked at sign-in and when a session switches into the organization.
21+
- `mfa_required`: `off` keeps the deployment's level; a level may not be weaker than the deployment's. The stronger of the two applies. `administrators` holds platform administrators, the organization's own administrators (owner or admin membership) and delegated administrators (a permission set with an `adminScope`), and lets everyone else through.
22+
23+
What to do on upgrade:
24+
25+
- `auth.mfa_required` saved as on now reads `everyone`, and as off `off`. A stored toggle is rewritten at the first boot; the `OS_AUTH_MFA_REQUIRED` env override still reads `true`/`false` with a warning for one major release — set it to `off`, `administrators` or `everyone`. A **write** of `true` or `false` to `auth.mfa_required` is now refused by the setting's option table (`invalid_option`): write `everyone` or `off`. Consumer: objectstack-ai/cloud's `apps/cloud/test/settings-cross-org-doors.test.ts` (`:533` writes `true`, `:552` expects it back) must be rewritten to `everyone` when the framework pin moves past this release.
26+
- An organization whose `sys_organization.require_mfa` was on now carries `access_policy.mfa_required` set to `everyone`. Moved at the first boot (unless the organization already chose a level), and the column is cleared once the row lands; a move that fails is logged at `error` and retried at the next boot. The column is now readonly: a data-API write to it is refused like any other non-editable column.
27+
- A host that passed the auth manager or plugin option `mfaRequired: true` passes `mfaRequirement: 'everyone'` (or `'administrators'`) instead. A host still passing `mfaRequired` is refused at construction (and on `applyConfigPatch`) with an error naming the replacement, never run without the requirement.
28+
29+
What does not change: with no organization policy saved, every deployment enforces exactly what it enforced before, and the deployment's own keys keep their names.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **56 of the 268 sites are spelled that way**. A
135+
forwarding shim cannot, and **56 of the 269 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **268** |
200+
| 175 write call sites | quoted in the merged changeset | **269** |
201201
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **30** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **190 of 268** decidable, **78** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 148 decidably elevated, 0 decidably not, 112 undecidable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **191 of 269** decidable, **78** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 149 decidably elevated, 0 decidably not, 112 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -217,11 +217,11 @@ would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
148 of 268 (55%) as decidably elevated, with 112 more whose elevation is a
220+
149 of 269 (55%) as decidably elevated, with 112 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 268`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 269`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **30 further sites** have an options argument this
@@ -233,28 +233,28 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **268** |
237-
| …whose object name is statically decidable | 190 |
236+
| write call sites on the application surface | **269** |
237+
| …whose object name is statically decidable | 191 |
238238
| …whose object name is chosen at run time | 78 |
239-
| …against an object with tenancy ENABLED | 184 |
239+
| …against an object with tenancy ENABLED | 185 |
240240
| …against an object that declares tenancy off | 6 |
241-
| threading a tenant context | 204 |
241+
| threading a tenant context | 205 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244244
| options argument UNREADABLE — may or may not carry one | 56 |
245245
| …of those, against a decidably tenancy-enabled object | 30 |
246-
| threading a decidably ELEVATED (`isSystem`) context | 148 |
246+
| threading a decidably ELEVATED (`isSystem`) context | 149 |
247247
| threading a context that is decidably NOT elevated | 0 |
248248
| threading a context whose elevation is a run-time fact | 112 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 204 |
252+
| receiver carried a readable engine type | 205 |
253253
| receiver erased, placed by the object NAME | 54 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257-
| object name spelled inline | 121 |
257+
| object name spelled inline | 122 |
258258
| object name spelled through a `const` | 69 |
259259
| object name is an `object: string` parameter | 7 |
260260
| object name is some other run-time expression | 71 |
@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-11-07 at `b93069eab`.
310+
Measured on 2026-11-07 at `a06983b5c`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 1049 |
314+
| tracked non-test sources scanned | 1052 |
315315
| engine-shaped types recognised | 113 |
316-
| declared objects in the registry | 130 |
317-
| same-named calls subtracted as non-engine | 236 |
316+
| declared objects in the registry | 131 |
317+
| same-named calls subtracted as non-engine | 237 |
318318

319319
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 268 |
37-
| Object name statically decidable | 190 |
36+
| Write call sites | 269 |
37+
| Object name statically decidable | 191 |
3838
| Object name chosen at run time | 78 |
39-
| Against a tenancy-enabled object | 184 |
39+
| Against a tenancy-enabled object | 185 |
4040
| Against an object declaring tenancy off | 6 |
41-
| Threading a tenant context | 204 |
41+
| Threading a tenant context | 205 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 56 |
4545
| …and decidably tenancy-enabled | 30 |
46-
| Threading a decidably elevated context | 148 |
46+
| Threading a decidably elevated context | 149 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 112 |
4949

@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-11-07 at `b93069eab`.
93+
Measured on 2026-11-07 at `a06983b5c`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 1049 |
97+
| tracked non-test sources scanned | 1052 |
9898
| engine-shaped types recognised | 113 |
99-
| declared objects in the registry | 130 |
100-
| same-named calls subtracted as non-engine | 236 |
99+
| declared objects in the registry | 131 |
100+
| same-named calls subtracted as non-engine | 237 |
101101

102102
## Every site
103103

@@ -140,6 +140,7 @@ Measured on 2026-11-07 at `b93069eab`.
140140
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_two_factor` | enabled | options unreadable | 1 |
141141
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_user` | enabled | options unreadable | 3 |
142142
| `packages/plugins/plugin-auth/src/auth-manager-sign-in-lockout.ts` | `update` | `sys_user` | enabled | options unreadable | 3 |
143+
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_session` | enabled | elevated | 1 |
143144
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `delete` | `sys_verification` | enabled | elevated | 1 |
144145
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_verification` | enabled | elevated | 1 |
145146
| `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | PROVABLY NONE | 1 |

‎packages/cli/test/connect-agent-both-halves-wire.pin.test.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -260,17 +260,20 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', (
260260
// The Setup tree the platform admin is served BY THIS COMPOSITION: 9
261261
// authored groups less `group_approvals` and `group_integrations`,
262262
// which nothing in it contributes into and the filter collapses, plus
263-
// the 23 entries `SETUP_NAV_CONTRIBUTIONS` carries, plus the MCP one.
263+
// the 24 entries `SETUP_NAV_CONTRIBUTIONS` carries, plus the MCP one.
264264
// (25 until #25098 moved the unfiltered Packages entry to Studio; 24
265265
// until #30204 removed the Feature Flags entry with its namespace; 32
266266
// until #30895 moved the MCP entry out of `group_integrations`, the
267-
// one entry this composition put there.)
267+
// one entry this composition put there; 31 until the organization
268+
// Access policy entry, `nav_settings_access_policy`, joined
269+
// Configuration.)
268270
// `nav_settings_branding` is not among them, and not because of a
269271
// tenancy wall (this harness boots none): since #24766 it is
270272
// contributed by `@objectstack/service-settings` together with the
271273
// `branding` namespace, offered only where no organization wall stands,
272274
// and this harness composes platform-objects and the MCP bundle alone.
273-
expect(allowedIds).toHaveLength(31);
275+
expect(allowedIds).toHaveLength(32);
276+
expect(allowedIds).toContain('nav_settings_access_policy');
274277
expect(allowedIds).not.toContain('nav_settings_branding');
275278
// The two per-user stores are reached from the User record page, not
276279
// from Setup's menu (#23803).
@@ -293,8 +296,8 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', (
293296
expect(withAccountHalf.statusCode).toBe(200);
294297
expect(withoutAccountHalf.statusCode).toBe(200);
295298
expect(navIds(withAccountHalf.body?.item)).toEqual(navIds(withoutAccountHalf.body?.item));
296-
// 31: the composition the count in the test above states.
297-
expect(navIds(withAccountHalf.body?.item)).toHaveLength(31);
299+
// 32: the composition the count in the test above states.
300+
expect(navIds(withAccountHalf.body?.item)).toHaveLength(32);
298301

299302
// The mechanism behind the invariance, asserted rather than assumed:
300303
// the bundle aims exactly one contribution at `setup` and one at

‎packages/platform-objects/src/apps/setup-nav.contributions.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
141141
// with the `branding` namespace, which is offered only where no
142142
// organization wall stands (#24766) — so the entry lives and dies with it.
143143
{ id: 'nav_settings_auth', type: 'url', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] },
144+
// [ADR-0069 TL;DR 6] An organization's own sign-in restrictions, held to
145+
// the deployment's Authentication values (the `access_policy` namespace,
146+
// scope `tenant`). No `requiredPermissions`: like Localization, the
147+
// manifest's `setup.access` read is the app's base permission, and an
148+
// organization administrator writes it (`setup.write`).
149+
{ id: 'nav_settings_access_policy', type: 'url', label: 'Access policy', url: '/apps/setup/system/settings/access_policy', icon: 'shield-check' },
144150
// Every entry below opening a settings page carries that page's hub
145151
// title, and every hub page has one (#27854): the sidebar and the
146152
// "All Settings" hub are two lists of one set of pages. Pinned against

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -556,8 +556,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
556556
help: "JSON-serialized organization metadata"
557557
},
558558
require_mfa: {
559-
label: "Require Multi-Factor Auth",
560-
help: "Turn on to require every member of this organization to set up an authenticator app before they can access data. Needs two-factor authentication on for this deployment (OS_AUTH_TWO_FACTOR=true); while it is off, saving this as on is refused."
559+
label: "Require Multi-Factor Auth (retired)",
560+
help: "Retired. Set this organization's multi-factor requirement in Settings → Access policy. A value that was on here was moved there as \"everyone\" and cleared."
561561
},
562562
timezone: {
563563
label: "Timezone",

‎packages/platform-objects/src/apps/translations/en.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export const en: TranslationData = {
127127
nav_settings_sms: { label: 'SMS Delivery' },
128128
nav_settings_branding: { label: 'Branding' },
129129
nav_settings_auth: { label: 'Authentication' },
130+
nav_settings_access_policy: { label: 'Access policy' },
130131
nav_settings_storage: { label: 'File Storage' },
131132
nav_settings_ai: { label: 'AI & Embedder' },
132133
nav_settings_knowledge: { label: 'Knowledge' },

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -556,8 +556,8 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
556556
help: "Metadatos de la organización serializados en JSON."
557557
},
558558
require_mfa: {
559-
label: "Exigir autenticación multifactor",
560-
help: "Si es true, todos los miembros de esta organización deben registrar una aplicación de autenticación para acceder a los datos. Requiere que la autenticación de dos factores esté activada en esta implementación (OS_AUTH_TWO_FACTOR=true); mientras esté desactivada, guardarlo como true se rechaza."
559+
label: "Exigir autenticación multifactor (retirado)",
560+
help: "Retirado. Define el requisito multifactor de esta organización en Ajustes → Política de acceso. Un valor que estaba activado aquí se trasladó allí como «Todos» y se borró."
561561
},
562562
timezone: {
563563
label: "Zona horaria",

‎packages/platform-objects/src/apps/translations/es-ES.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export const esES: TranslationData = {
8686
nav_settings_sms: { label: 'Envío de SMS' },
8787
nav_settings_branding: { label: 'Marca' },
8888
nav_settings_auth: { label: 'Autenticación' },
89+
nav_settings_access_policy: { label: 'Política de acceso' },
8990
nav_settings_storage: { label: 'Almacenamiento de archivos' },
9091
nav_settings_ai: { label: 'IA y Embedder' },
9192
nav_settings_knowledge: { label: 'Conocimiento' },

0 commit comments

Comments
 (0)