Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
15ec27e
wip(plugin-auth): per-organization access policy
claude Nov 6, 2026
e2e2114
wip: tests for per-organization access policy
claude Nov 6, 2026
304afe5
wip: changeset
claude Nov 6, 2026
81c7b0d
wip: changeset disposition
claude Nov 6, 2026
ce38793
wip: fix tests, regen bundles, engine double ledger
claude Nov 6, 2026
9ad4f02
Merge origin/main into claude/issue-30922-per-org-access-policy
claude Nov 6, 2026
6a14995
test(plugin-auth): the account-event audit uses mfaRequirement
claude Nov 6, 2026
220f5d4
fix: gate findings — limit by presence, typed security slot, collecti…
claude Nov 6, 2026
9f82dfd
fix: ru/de/ko access policy copy, self-lock-out refusal type
claude Nov 6, 2026
e1edf07
test: verify pins and dogfood audit test use the MFA levels
claude Nov 6, 2026
4513cb9
test(verify): pay the harness's dynamic loads at collection
claude Nov 6, 2026
56fe7fd
Merge remote-tracking branch 'origin/main' into claude/issue-30922-pe…
claude Nov 6, 2026
d302a81
feat(plugin-auth): per-organization cap and switch judgement, roaming…
claude Nov 6, 2026
88cad54
chore(changeset): disclose the refused toggle write, the cap populati…
claude Nov 6, 2026
c25387c
i18n(platform-objects): zh-Hant copy for the retired require_mfa column
claude Nov 6, 2026
27d4828
fix(plugin-auth): the idle/absolute revocation keeps its own reason
claude Nov 6, 2026
2e1b12c
test(plugin-auth): the switch hook sweeps the target organization's cap
claude Nov 6, 2026
6b87c7c
docs(census): the tenant audit census counts the switch revocation's …
claude Nov 6, 2026
b0b494c
fix(plugin-auth): type the switch judgement's engine options
claude Nov 6, 2026
a06983b
Merge origin/main into claude/issue-30922-per-org-access-policy
claude Nov 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/30922-per-organization-access-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/service-settings': patch
'@objectstack/platform-objects': patch
---

feat(plugin-auth, service-settings)!: the access policy is set per organization, and the multi-factor requirement has three levels

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

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

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

The rule per key — a value weaker than the deployment's is refused at the save, and enforcement holds the floor whatever is stored:

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

What to do on upgrade:

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

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.
32 changes: 16 additions & 16 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **55 of the 266 sites are spelled that way**. A
forwarding shim cannot, and **55 of the 267 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

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

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **266** |
| 175 write call sites | quoted in the merged changeset | **267** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **189 of 266** decidable, **77** undecidable |
| 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, 110 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **190 of 267** decidable, **77** undecidable |
| 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, 110 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

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

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

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

| what | count |
| :--- | ---: |
| write call sites on the application surface | **266** |
| …whose object name is statically decidable | 189 |
| write call sites on the application surface | **267** |
| …whose object name is statically decidable | 190 |
| …whose object name is chosen at run time | 77 |
| …against an object with tenancy ENABLED | 183 |
| …against an object with tenancy ENABLED | 184 |
| …against an object that declares tenancy off | 6 |
| threading a tenant context | 203 |
| threading a tenant context | 204 |
| PROVABLY carrying none (options read, no context key) | **8** |
| …of those, against a decidably tenancy-enabled object | **2** |
| options argument UNREADABLE — may or may not carry one | 55 |
| …of those, against a decidably tenancy-enabled object | 29 |
| threading a decidably ELEVATED (`isSystem`) context | 148 |
| threading a decidably ELEVATED (`isSystem`) context | 149 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 110 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 203 |
| receiver carried a readable engine type | 204 |
| receiver erased, placed by the object NAME | 53 |
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

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

Measured on 2026-11-07 at `ce0325138`.
Measured on 2026-11-07 at `b0b494c04`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 1045 |
| tracked non-test sources scanned | 1049 |
| engine-shaped types recognised | 112 |
| declared objects in the registry | 130 |
| same-named calls subtracted as non-engine | 234 |
| same-named calls subtracted as non-engine | 235 |

{/* END GENERATED: tenant-audit-census */}
17 changes: 9 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 266 |
| Object name statically decidable | 189 |
| Write call sites | 267 |
| Object name statically decidable | 190 |
| Object name chosen at run time | 77 |
| Against a tenancy-enabled object | 183 |
| Against a tenancy-enabled object | 184 |
| Against an object declaring tenancy off | 6 |
| Threading a tenant context | 203 |
| Threading a tenant context | 204 |
| Provably carrying none | 8 |
| …and decidably tenancy-enabled | 2 |
| Options argument unreadable | 55 |
| …and decidably tenancy-enabled | 29 |
| Threading a decidably elevated context | 148 |
| Threading a decidably elevated context | 149 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 110 |

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

Measured on 2026-11-07 at `ce0325138`.
Measured on 2026-11-07 at `b0b494c04`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 1045 |
| tracked non-test sources scanned | 1049 |
| engine-shaped types recognised | 112 |
| declared objects in the registry | 130 |
| same-named calls subtracted as non-engine | 234 |
| same-named calls subtracted as non-engine | 235 |

## Every site

Expand Down Expand Up @@ -140,6 +140,7 @@ Measured on 2026-11-07 at `ce0325138`.
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_two_factor` | enabled | options unreadable | 1 |
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_user` | enabled | options unreadable | 3 |
| `packages/plugins/plugin-auth/src/auth-manager-sign-in-lockout.ts` | `update` | `sys_user` | enabled | options unreadable | 3 |
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_session` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `delete` | `sys_verification` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_verification` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | PROVABLY NONE | 1 |
Expand Down
13 changes: 8 additions & 5 deletions packages/cli/test/connect-agent-both-halves-wire.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,17 +260,20 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', (
// The Setup tree the platform admin is served BY THIS COMPOSITION: 9
// authored groups less `group_approvals` and `group_integrations`,
// which nothing in it contributes into and the filter collapses, plus
// the 23 entries `SETUP_NAV_CONTRIBUTIONS` carries, plus the MCP one.
// the 24 entries `SETUP_NAV_CONTRIBUTIONS` carries, plus the MCP one.
// (25 until #25098 moved the unfiltered Packages entry to Studio; 24
// until #30204 removed the Feature Flags entry with its namespace; 32
// until #30895 moved the MCP entry out of `group_integrations`, the
// one entry this composition put there.)
// one entry this composition put there; 31 until the organization
// Access policy entry, `nav_settings_access_policy`, joined
// Configuration.)
// `nav_settings_branding` is not among them, and not because of a
// tenancy wall (this harness boots none): since #24766 it is
// contributed by `@objectstack/service-settings` together with the
// `branding` namespace, offered only where no organization wall stands,
// and this harness composes platform-objects and the MCP bundle alone.
expect(allowedIds).toHaveLength(31);
expect(allowedIds).toHaveLength(32);
expect(allowedIds).toContain('nav_settings_access_policy');
expect(allowedIds).not.toContain('nav_settings_branding');
// The two per-user stores are reached from the User record page, not
// from Setup's menu (#23803).
Expand All @@ -293,8 +296,8 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', (
expect(withAccountHalf.statusCode).toBe(200);
expect(withoutAccountHalf.statusCode).toBe(200);
expect(navIds(withAccountHalf.body?.item)).toEqual(navIds(withoutAccountHalf.body?.item));
// 31: the composition the count in the test above states.
expect(navIds(withAccountHalf.body?.item)).toHaveLength(31);
// 32: the composition the count in the test above states.
expect(navIds(withAccountHalf.body?.item)).toHaveLength(32);

// The mechanism behind the invariance, asserted rather than assumed:
// the bundle aims exactly one contribution at `setup` and one at
Expand Down
6 changes: 6 additions & 0 deletions packages/platform-objects/src/apps/setup-nav.contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
// with the `branding` namespace, which is offered only where no
// organization wall stands (#24766) — so the entry lives and dies with it.
{ id: 'nav_settings_auth', type: 'url', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] },
// [ADR-0069 TL;DR 6] An organization's own sign-in restrictions, held to
// the deployment's Authentication values (the `access_policy` namespace,
// scope `tenant`). No `requiredPermissions`: like Localization, the
// manifest's `setup.access` read is the app's base permission, and an
// organization administrator writes it (`setup.write`).
{ id: 'nav_settings_access_policy', type: 'url', label: 'Access policy', url: '/apps/setup/system/settings/access_policy', icon: 'shield-check' },
// Every entry below opening a settings page carries that page's hub
// title, and every hub page has one (#27854): the sidebar and the
// "All Settings" hub are two lists of one set of pages. Pinned against
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -556,8 +556,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
help: "JSON-serialized organization metadata"
},
require_mfa: {
label: "Require Multi-Factor Auth",
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."
label: "Require Multi-Factor Auth (retired)",
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."
},
timezone: {
label: "Timezone",
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export const en: TranslationData = {
nav_settings_sms: { label: 'SMS Delivery' },
nav_settings_branding: { label: 'Branding' },
nav_settings_auth: { label: 'Authentication' },
nav_settings_access_policy: { label: 'Access policy' },
nav_settings_storage: { label: 'File Storage' },
nav_settings_ai: { label: 'AI & Embedder' },
nav_settings_knowledge: { label: 'Knowledge' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -556,8 +556,8 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
help: "Metadatos de la organización serializados en JSON."
},
require_mfa: {
label: "Exigir autenticación multifactor",
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."
label: "Exigir autenticación multifactor (retirado)",
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ó."
},
timezone: {
label: "Zona horaria",
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const esES: TranslationData = {
nav_settings_sms: { label: 'Envío de SMS' },
nav_settings_branding: { label: 'Marca' },
nav_settings_auth: { label: 'Autenticación' },
nav_settings_access_policy: { label: 'Política de acceso' },
nav_settings_storage: { label: 'Almacenamiento de archivos' },
nav_settings_ai: { label: 'IA y Embedder' },
nav_settings_knowledge: { label: 'Conocimiento' },
Expand Down
Loading
Loading