Skip to content

Commit f5e23e2

Browse files
feat(plugin-auth)!: an organization administrator reads their judged client address; the console's retired MFA notices are gone (#31258)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X2ArVyEECU1nJDS63wR7Z1H)_ Fixes #31030 Clause-②: yes (narrowing) — the published `@object-ui/i18n` packs (all eleven) drop `console.organizationMfa.needsTwoFactor` and `.nowRequired`, which had no reader outside the removed notices. Both keys were added by the still-pending `.changeset/30295-org-mfa-notice-and-enroll-refusal.md` and never released, so no published version of the packs carries them. The widening half: a new served read, `GET /api/v1/auth/client-address`. Metadata-protocol: none Skills-docs: none — `skills/**` teaches no access-policy key, client-address read or console notice, and `content/docs/**` teaches no per-organization access policy; its `allowed_ip_ranges` section in `permissions/authentication.mdx` is the deployment's own list, whose platform-admin read it names correctly. ## 维护者速读 - 改了什么:组织管理员(在当前组织有 `setup.write`)可通过新接口 `GET /api/v1/auth/client-address` 看到服务器判定的自己的 IP;auth 插件自挂的路由在进程内读会话时也会按组织的 IP 白名单判定;控制台删掉了组织表单上针对已退役 `require_mfa` 列的两条提示及其文案。 - 为什么改:组织管理员保存「允许的 IP 范围」前,原本只能从保存被拒的报错里得知自己的地址(旧接口只对平台管理员开放);另外 auth 插件自挂路由读会话时不判组织 IP 名单,与 better-auth 自身路由的判定不一致。 - 风险与回滚:新接口只回答调用者自己的地址,查询参数一律拒收;中间件只给 `/api/v1/auth/*` 下的会话读带上地址,不在名单内的会话会在这些路由上被结束(`ip_not_allowed`)。回滚即还原本 PR。 - 先看:`packages/plugins/plugin-auth/src/auth-plugin.ts`(新路由与地址中间件)、`packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts`(会话读的 door 参数)、`packages/plugins/plugin-auth/src/client-address-read.ts`(准入判定)。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/org-admin-client-address-read.dogfood.test.ts`,预期 4 条全绿:组织管理员 200、普通成员 403 PERMISSION_DENIED、匿名 401、组织管理员读平台接口 403。 - 需要你做的:无,席位落地。 ## What changes **S7: an organization administrator reads their judged address.** `GET /api/v1/auth/client-address` (new, mounted by `auth-plugin.ts` ahead of the catch-all, beside the platform-admin `GET /api/v1/auth/admin/client-address`) answers the same `{ clientAddress, trustProxy }`: the address the `access_policy.allowed_ip_ranges` save check judges. The caller is resolved through core's `resolveRequestExecutionContext` with the settings door's parameter set, and admitted by `judgeOrganizationAddressReader` (`client-address-read.ts`): no caller is `401 UNAUTHENTICATED`; a held session is `403` with its posture's code (`judgeHeldSession`); a caller without `setup.write` in their current organization is `403 PERMISSION_DENIED`; an unreachable permission store is `503 SERVICE_UNAVAILABLE`. It answers the caller's own address only: the query set is closed and empty (`400 VALIDATION_ERROR` for any parameter), and the answer is `Cache-Control: no-store`. The route is a new row in `auth-route-ledger.ts` (objectstack-mount, server-only) and in the ledger conformance pin. **D3: an objectstack mount's in-process session read judges the organization's IP list.** A middleware on `${basePath}/*` establishes the request's address with `AuthManager.resolveClientAddress` (the D5 gate's rule: `server.trustProxy` decides whether forwarded headers count) and runs the rest of the request in a better-auth request state carrying it (`runAtMountDoor`, `request-client-address.ts`). `customSession` passes that address as the `door` to `enforceSessionControls` when no `ctx.request` is present (`readMountDoor`), so `gateAdmin`, the authorized-AI-assistant routes, session revoke, add member and the other routes that call `auth.api.getSession` in process end a session outside the organization's Allowed IP ranges (`revoke_reason: ip_not_allowed`), as better-auth's own routes already did. Outside a mount no door is open, so data requests outside the auth mount are judged by neither list, as before. Measured that the mount has an established address (peer, or forwarded under `trustProxy`), so the seat's ruling (pass the address) applies. **D2: no heartbeat before the switch judgement, through the real route.** A new pin drives `POST /organization/set-active` on a real better-auth instance: a session idle 30 minutes switching into an organization with a 10-minute idle limit is answered `401 UNAUTHENTICATED`, its row ends with `idle_timeout`, stays scoped to the old organization, and `last_activity_at` is unchanged; the control (idle 2 minutes) answers 200. **D1:** `sys_session.revoke_reason`'s description names `ip_not_allowed` (`sys-session.object.ts`); the six translation bundles carry it (en regenerated by `check-i18n-bundles.mjs --write`, the other five hand-edited the same way). **S6: the organization form's notices are removed.** `access_policy.mfa_required` has no reader a form notice would serve: its save refusal already names two-factor itself, and `sys_organization.require_mfa` is read-only and off the form, so nothing on the form turns the requirement on. Per the seat ruling, `organizationMfaNotices.ts`, its test, the `AppContent.tsx` call (the `findOne` of the stored column and the `description` spread on the save toast) and the two `console.organizationMfa.*` keys in all eleven packs are removed; the German quote-pairing census moves by one span. New test files: `packages/plugins/plugin-auth/src/organization-access-policy-doors.test.ts` (D3 on a real better-auth instance, D2 through the route; a new file because #31175 is in flight on `organization-access-policy.test.ts`) and `packages/qa/dogfood/test/org-admin-client-address-read.dogfood.test.ts` (S7 end to end on a booted stack). ## Why The card's acceptance: "An org admin reads their judged address; a plain member is refused." / "No dead notice code remains." D1, D2 and D3 are the should-fixes carried from the #31013 contract reviews. ## Tests - `allowed-ip-ranges.pin.test.ts`: the organization read answers what the platform read and the save's judged address answer across three transports (peer, forwarded under `trustProxy`, forged header without it); the platform route refuses a non-platform-admin `403 PERMISSION_DENIED`; anonymous `401 UNAUTHENTICATED`, plain member `403 PERMISSION_DENIED`, held `403 MFA_REQUIRED`, each refused before the address is read; store outage `503 SERVICE_UNAVAILABLE`; three query parameters each `400 VALIDATION_ERROR`; the mount middleware's door (peer, forwarded, forged, unestablished, and none outside a mount). - `organization-access-policy-doors.test.ts`: D3 (outside the list ends the session `ip_not_allowed` and the next read is null; an unestablished address is refused under a set list; no door, no IP judgement) and D2 (above). - Every refusal case asserts `code` and `status`. ## Acceptance notes - The console's Access policy page does not read the new route yet; this PR serves it. Seam: runtime `GET /api/v1/auth/client-address` → renderer SettingsView, `access_policy` namespace; consumer: none yet. - `organization-access-policy.test.ts` › "the absolute limit is judged too…" failed once in the full plugin-auth run on a loaded box and passes alone: its fixture creates the session exactly 60 minutes ago and the judgement is a strict `>` against a one-hour limit, so any elapsed millisecond between insert and judgement flips `admitted` to `ended`. Not touched here (#31175 holds that file). - `content/docs/**` teaches no per-organization access policy at all (only the deployment's own `allowed_ip_ranges`); noted, not changed here. ## Verification Final head `78ac3b00` (the branch merged with `origin/main` once, after #31194 changed plugin-auth). Every heavy run went through `scripts/pm/os-verify-lock.sh`. Head `4b3757cb` moves it by changeset prose only (the two removed keys were never released); on it `check-changeset-no-major --base origin/main` and `check-adr-0087-registration --base origin/main` exit 0. <details><summary>Red first and ablations</summary> - Red first (the new tests against the base sources): 12 failed. - `readMountDoor` in `customSession` replaced by `undefined`: 2 failed (`expected undefined to be 'ip_not_allowed'`), restored, `git diff HEAD` empty. - The mount middleware made to call bare `next()`: 3 failed (door undefined), restored. - The `setup.write` check disabled: the plain-member case red (the address was read), restored. - A heartbeat written before the switch judgement: the set-active case red (200, not 401), restored. A first attempt at this ablation was a no-op (its replacement contained the anchor, so the anchor count stayed 1); re-run with a replacement that does not contain it, which landed and went red. </details> <details><summary>Suites, type-checks, lint</summary> On `78ac3b00`: - `pnpm --filter @objectstack/plugin-auth test`: exit 0, Test Files 236 passed (236), Tests 4199 passed, 10 skipped. - From `ui/`, `pnpm exec vitest run packages/i18n/src/__tests__/ packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx`: exit 0, Test Files 99 passed (99), Tests 1756 passed, 13 skipped. On `0a6d22c6` (before the ledger-pin fix and the merge; neither touches these packages' sources): - `pnpm --filter @objectstack/plugin-auth typecheck` (with `tsconfig.examples.json` and `check:test-typecheck`): exit 0. - `pnpm --filter @objectstack/platform-objects test`: exit 0, Test Files 119 passed (119), Tests 1701 passed. `typecheck`: exit 0. - `pnpm --filter @object-ui/i18n run type-check` and `pnpm --filter @object-ui/app-shell run type-check` (after `pnpm --filter '@object-ui/app-shell^...' build`): exit 0 each. - Scoped lint: `eslint packages/plugins/plugin-auth/src packages/platform-objects/src` plus the new dogfood file `--no-inline-config`: exit 0, no output. From `ui/`, `eslint` on the edited `AppContent.tsx`, its test, the locale packs and the de-quote test: exit 0, 0 errors, 50 warnings, all on `AppContent.tsx` lines this PR does not edit. - The full plugin-auth run on `0a6d22c6` had two reds: the route-ledger conformance pin (fixed in `4fd8b6be` by naming the new route) and the boundary flake named under Acceptance notes; both files pass on `78ac3b00`. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `78ac3b00`: 86 commands, each run with stdin from `/dev/null` and its exit captured before any pipe: 86 exit 0. `--ran` with each line recorded as `COMMAND :: exit CODE`: "86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED (a DERIVED zero — all 86 recorded an exit code and none of them is 3)". The lock's verdict: `VERDICT command-exit 0`. On the earlier head (78 commands), four reds were fixed before this run: `check:engine-double-contract` (the new test's doubles, ledger rewritten with `--write`), `check:test-source-alias` (a dynamic `@objectstack/core` import in a test body made static; the edited AppContent test warms `@objectstack/lint` at module top), `check-plugin-teardown-shape --self-test` (exit 3 until the pinned fixture commit was fetched) and `check:dual-build-cjs-loads` (exit 3 until the closure was built). </details> <details><summary>Dogfood battery (final head)</summary> `78ac3b00` · after `pnpm exec turbo run build --concurrency=2 --filter='@objectstack/dogfood^...'` (exit 0) · `pnpm --filter @objectstack/dogfood test` under the lock: `VERDICT command-exit 0` · Test Files 436 passed, 3 skipped (439) · Tests 3613 passed, 41 skipped (3654) · no failures. The new file alone: Test Files 1 passed, Tests 4 passed. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (Except the maintainer digest, which os-dev.md writes in Chinese.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mEgSFeDT7pQA12if9)_ <!-- signed: domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ef2523 commit f5e23e2

34 files changed

Lines changed: 834 additions & 468 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/platform-objects': patch
4+
'@object-ui/i18n': patch
5+
'@object-ui/app-shell': patch
6+
---
7+
8+
feat(plugin-auth)!: an organization administrator reads the client address their access-policy list is judged by, and the console's notices over the retired `require_mfa` column are gone
9+
10+
Clause-②: yes (narrowing) — the published `@object-ui/i18n` packs (all eleven) drop the `console.organizationMfa.needsTwoFactor` and `.nowRequired` keys, which had no reader outside the removed notices. Both keys were added by the still-pending `.changeset/30295-org-mfa-notice-and-enroll-refusal.md` and never released, so no published version of the packs carries them. The widening half: a new served read, `GET /api/v1/auth/client-address`.
11+
Metadata-protocol: none
12+
Skills-docs: none — `skills/**` teaches no access-policy key, client-address read or console notice, and `content/docs/**` teaches no per-organization access policy; its `allowed_ip_ranges` section in `permissions/authentication.mdx` is the deployment's own list, whose platform-admin read it names correctly.
13+
14+
<!-- adr-0087: not-required (runtime-interface-only ui/packages/i18n/src/locales/en.ts#TranslationKeys) the key type of the console's own translation catalogue: no Zod schema, no spec declaration and no stored metadata reads it, so the compiler is the channel that reaches every caller -->
15+
16+
**An organization administrator sees their judged address before saving their organization's Allowed IP ranges.** A save of `access_policy.allowed_ip_ranges` that leaves out the saving request's own address is refused, and until now an organization administrator learned that address only from the refusal: `GET /api/v1/auth/admin/client-address` answers platform administrators alone. `GET /api/v1/auth/client-address` answers the same `{ clientAddress, trustProxy }`, the address the save check judges (`server.trustProxy` deciding whether forwarded headers count; `null` when the server cannot establish one), to the caller who may save that list: `setup.write` in their current organization, resolved the way the settings door resolves a saving caller. It is the caller's own address only; the query set is closed and empty (`400 VALIDATION_ERROR`), the answer is `Cache-Control: no-store`. Anonymous: `401 UNAUTHENTICATED`. A held session: `403` with its posture's code. A member without `setup.write`: `403 PERMISSION_DENIED`. An unreachable permission store: `503 SERVICE_UNAVAILABLE`. The console's Access policy page does not read it yet.
17+
18+
**The organization's IP list is judged on an objectstack mount's own session read too.** The routes the auth plugin mounts itself under `/api/v1/auth` (the platform-admin routes, the authorized-AI-assistant routes, session revoke, add member and the others that read the caller's session in process) now carry the address the deployment's IP gate established for the request to that read, so a session whose organization's Allowed IP ranges no longer admit it is ended there (`revoke_reason: ip_not_allowed`), as it already was on a session read through better-auth's own routes. Data requests outside the auth mount are judged by neither list, as before.
19+
20+
`sys_session.revoke_reason`'s published vocabulary (the field description) now names `ip_not_allowed`.
21+
22+
**BREAKING** (`@object-ui/i18n`, TypeScript only): the exported `TranslationKeys` (`typeof en`) loses the two `console.organizationMfa.*` keys, in all eleven packs (ar, de, en, es, fr, ja, ko, pt-BR, ru, zh-Hans, zh-Hant). A `t()` call that names one still compiles, since its key is a `string`, and reads the key back. Both keys come from the still-pending `.changeset/30295-org-mfa-notice-and-enroll-refusal.md` and were never released: a consumer of a published `@object-ui/i18n` never had them.
23+
24+
| Removed key | Replacement |
25+
|:---|:---|
26+
| `console.organizationMfa.needsTwoFactor` | none: `sys_organization.require_mfa` is read-only and off the organization form; the multi-factor requirement is the access policy's `mfa_required` level, whose save refusal names two-factor itself |
27+
| `console.organizationMfa.nowRequired` | none: the same column can no longer be turned on, so no save turns the requirement on from the organization form |

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export const arObjects: NonNullable<TranslationData['objects']> = {
369369
},
370370
revoke_reason: {
371371
label: "سبب الإلغاء",
372-
help: "سبب إلغاء الجلسة (idle_timeout، absolute_max، concurrent_cap، user_revoked، admin، organization_membership_ended، admin_password_reset، password_reset، …)."
372+
help: "سبب إلغاء الجلسة (idle_timeout، absolute_max، concurrent_cap، ip_not_allowed، user_revoked، admin، organization_membership_ended، admin_password_reset، password_reset، …)."
373373
},
374374
active_organization_id: {
375375
label: "المؤسسة النشطة"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
369369
},
370370
revoke_reason: {
371371
label: "Revoke Reason",
372-
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, admin_password_reset, password_reset, …)."
372+
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, ip_not_allowed, user_revoked, admin, organization_membership_ended, admin_password_reset, password_reset, …)."
373373
},
374374
active_organization_id: {
375375
label: "Active Organization"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
369369
},
370370
revoke_reason: {
371371
label: "Motivo de la revocación",
372-
help: "Por qué se revocó la sesión (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, admin_password_reset, password_reset, …)."
372+
help: "Por qué se revocó la sesión (idle_timeout, absolute_max, concurrent_cap, ip_not_allowed, user_revoked, admin, organization_membership_ended, admin_password_reset, password_reset, …)."
373373
},
374374
active_organization_id: {
375375
label: "Organización activa"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
369369
},
370370
revoke_reason: {
371371
label: "失効理由",
372-
help: "セッションが失効した理由(idle_timeout、absolute_max、concurrent_cap、user_revoked、admin、organization_membership_ended、admin_password_reset、password_reset など)。"
372+
help: "セッションが失効した理由(idle_timeout、absolute_max、concurrent_cap、ip_not_allowed、user_revoked、admin、organization_membership_ended、admin_password_reset、password_reset など)。"
373373
},
374374
active_organization_id: {
375375
label: "アクティブ組織"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
369369
},
370370
revoke_reason: {
371371
label: "撤销原因",
372-
help: "会话被撤销的原因(idle_timeout、absolute_max、concurrent_cap、user_revoked、admin、organization_membership_ended、admin_password_reset、password_reset 等)。"
372+
help: "会话被撤销的原因(idle_timeout、absolute_max、concurrent_cap、ip_not_allowed、user_revoked、admin、organization_membership_ended、admin_password_reset、password_reset 等)。"
373373
},
374374
active_organization_id: {
375375
label: "当前组织"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export const zhHantObjects: NonNullable<TranslationData['objects']> = {
369369
},
370370
revoke_reason: {
371371
label: "撤銷原因",
372-
help: "工作階段被撤銷的原因(idle_timeout、absolute_max、concurrent_cap、user_revoked、admin、organization_membership_ended、admin_password_reset、password_reset 等)。"
372+
help: "工作階段被撤銷的原因(idle_timeout、absolute_max、concurrent_cap、ip_not_allowed、user_revoked、admin、organization_membership_ended、admin_password_reset、password_reset 等)。"
373373
},
374374
active_organization_id: {
375375
label: "目前組織"

‎packages/platform-objects/src/identity/sys-session.object.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,13 @@ export const SysSession = ObjectSchema.create({
243243
// `password_reset` is its self-service counterpart: a completed password
244244
// reset ended the session (the reset doors in `INTERACTIVE_REVOKE_REASON`,
245245
// `plugin-auth/session-tombstone.ts`).
246+
// `ip_not_allowed` is a session control beside the timers: the
247+
// organization's own allowed IP ranges (`access_policy`, ADR-0069 TL;DR 6)
248+
// no longer admit the address the session was read from, judged on a
249+
// session read through a door (`enforceSessionControls`,
250+
// `plugin-auth/auth-manager-password-session-policy.ts`).
246251
description:
247-
'Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, '
252+
'Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, ip_not_allowed, user_revoked, '
248253
+ 'admin, organization_membership_ended, admin_password_reset, password_reset, …).',
249254
}),
250255

‎packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts‎

Lines changed: 165 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
* `GET /api/v1/auth/admin/client-address`, answers the address that same
3030
* save would be judged by: the plugin's real auth routes on a real Hono app,
3131
* the transport peer supplied the way `@hono/node-server` supplies it.
32+
* - Its organization twin, `GET /api/v1/auth/client-address`, answers the
33+
* same address to the caller who may save their organization's
34+
* `access_policy` list (`setup.write` in their current organization), and
35+
* refuses everyone else before any address is read.
3236
*
3337
* `@objectstack/service-settings` resolves to its `src/` here (this package's
3438
* `vitest.config.ts`), so the routes and the service are the checkout's.
@@ -42,8 +46,11 @@ import {
4246
builtinSettingsManifests,
4347
registerSettingsRoutes,
4448
} from '@objectstack/service-settings';
49+
import { AuthzStoreUnavailableError } from '@objectstack/core';
4550
import { AuthPlugin } from './auth-plugin.js';
4651
import type { AuthManager } from './auth-manager.js';
52+
import type { OrganizationAddressReaderContext } from './client-address-read.js';
53+
import { readMountDoor } from './request-client-address.js';
4754

4855
const OFFICE = '203.0.113.0/24';
4956
const ADMIN_CTX = { enforced: true, permissions: ['manage_platform_settings'], userId: 'usr_admin' };
@@ -76,10 +83,21 @@ interface Rig {
7683
logger: { info: Mock; error: Mock; warn: Mock; debug: Mock };
7784
save(body: Record<string, unknown>, transport?: Transport): Promise<{ status: number; body: any }>;
7885
/** `GET /api/v1/auth/admin/client-address` on the plugin's real auth routes. */
79-
read(transport?: Transport, opts?: { session?: unknown; query?: string }):
86+
read(transport?: Transport, opts?: { session?: unknown; query?: string; onSessionRead?: () => Promise<void> }):
87+
Promise<{ status: number; body: any; cacheControl: string | null }>;
88+
/**
89+
* `GET /api/v1/auth/client-address` on the same routes. `caller` is what the
90+
* route's resolution of its caller answers (the settings door's composition,
91+
* stubbed at that one seam): an organization administrator unless a test
92+
* says otherwise; an `Error` is thrown from it.
93+
*/
94+
orgRead(transport?: Transport, opts?: { caller?: OrganizationAddressReaderContext | Error; query?: string }):
8095
Promise<{ status: number; body: any; cacheControl: string | null }>;
8196
}
8297

98+
/** What the organization read's caller resolution answers for an organization administrator. */
99+
const ORG_ADMIN_CALLER: OrganizationAddressReaderContext = { systemPermissions: ['setup.access', 'setup.write'] };
100+
83101
async function boot(
84102
opts: { env?: Record<string, string | undefined>; svc?: SettingsService; plugin?: Record<string, unknown> } = {},
85103
): Promise<Rig> {
@@ -135,14 +153,23 @@ async function boot(
135153
};
136154

137155
let app: Hono | undefined;
138-
const read: Rig['read'] = async (transport = {}, opts = {}) => {
156+
const mounted = (): Hono => {
139157
if (!app) {
140158
app = new Hono();
141159
(plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
142160
}
161+
return app;
162+
};
163+
const read: Rig['read'] = async (transport = {}, opts = {}) => {
164+
const app = mounted();
143165
const session = 'session' in opts ? opts.session : PLATFORM_ADMIN_SESSION;
144166
// The gate reads the session through the auth service's in-process read.
145-
const getApi = vi.spyOn(manager, 'getApi').mockResolvedValue({ getSession: async () => session } as any);
167+
const getApi = vi.spyOn(manager, 'getApi').mockResolvedValue({
168+
getSession: async () => {
169+
await opts.onSessionRead?.();
170+
return session;
171+
},
172+
} as any);
146173
try {
147174
const res = await app.request(
148175
`http://localhost:3000/api/v1/auth/admin/client-address${opts.query ?? ''}`,
@@ -154,7 +181,25 @@ async function boot(
154181
getApi.mockRestore();
155182
}
156183
};
157-
return { svc, manager, logger: ctx.logger, save, read };
184+
const orgRead: Rig['orgRead'] = async (transport = {}, opts = {}) => {
185+
const app = mounted();
186+
const caller = 'caller' in opts ? opts.caller : ORG_ADMIN_CALLER;
187+
const resolve = vi.spyOn(plugin as any, 'resolveOrganizationSettingsCaller').mockImplementation(async () => {
188+
if (caller instanceof Error) throw caller;
189+
return caller;
190+
});
191+
try {
192+
const res = await app.request(
193+
`http://localhost:3000/api/v1/auth/client-address${opts.query ?? ''}`,
194+
{ method: 'GET', headers: transport.headers ?? {} },
195+
transport.remoteAddress ? { incoming: { socket: { remoteAddress: transport.remoteAddress } } } : {},
196+
);
197+
return { status: res.status, body: await res.json(), cacheControl: res.headers.get('cache-control') };
198+
} finally {
199+
resolve.mockRestore();
200+
}
201+
};
202+
return { svc, manager, logger: ctx.logger, save, read, orgRead };
158203
}
159204

160205
const stored = async (svc: SettingsService) => (await svc.get('auth', 'allowed_ip_ranges', {})).value ?? null;
@@ -391,6 +436,122 @@ describe('#30398 the address read serves the address the save check judges', ()
391436
});
392437
});
393438

439+
describe("a mount's in-process session read carries the address the D5 gate judged", () => {
440+
// `customSession` judges the organization's IP list on that read with the
441+
// door it finds open (`organization-access-policy-doors.test.ts`); this is
442+
// the mount half: the middleware opens it, for the read the route makes.
443+
const doorAtTheRead = async (rig: Rig, transport: Transport) => {
444+
let door: unknown = 'not read';
445+
const res = await rig.read(transport, { onSessionRead: async () => { door = await readMountDoor(); } });
446+
expect(res.status, JSON.stringify(res.body)).toBe(200);
447+
return door;
448+
};
449+
450+
it("the peer address, or the forwarded one under trustProxy, the read's own answer", async () => {
451+
expect(await doorAtTheRead(await boot(), { remoteAddress: '203.0.113.9' })).toEqual({ address: '203.0.113.9' });
452+
const forwarded = { headers: { 'x-forwarded-for': '198.51.100.7, 10.0.0.2' }, remoteAddress: '10.0.0.1' };
453+
expect(await doorAtTheRead(await boot({ plugin: { trustProxy: true } }), forwarded)).toEqual({ address: '198.51.100.7' });
454+
});
455+
456+
it('a forged forwarded header is not believed without trustProxy', async () => {
457+
const forged = { headers: { 'x-forwarded-for': '198.51.100.7' }, remoteAddress: '10.0.0.1' };
458+
expect(await doorAtTheRead(await boot(), forged)).toEqual({ address: '10.0.0.1' });
459+
});
460+
461+
it('an address the server cannot establish is still a door, with no address', async () => {
462+
expect(await doorAtTheRead(await boot(), {})).toEqual({ address: undefined });
463+
});
464+
465+
it('outside a mount no door is open', async () => {
466+
expect(await readMountDoor()).toBeUndefined();
467+
});
468+
});
469+
470+
describe('the organization administrator reads the address their access-policy save is judged by', () => {
471+
const NOBODY = '192.0.2.0/24';
472+
473+
it('an organization administrator reads the address the save check judges, the platform route\'s answer', async () => {
474+
for (const [plugin, transport] of [
475+
[{}, { remoteAddress: '203.0.113.9' }],
476+
[{ trustProxy: true }, { headers: { 'x-forwarded-for': '198.51.100.7, 10.0.0.2' }, remoteAddress: '10.0.0.1' }],
477+
[{}, {}],
478+
] as const) {
479+
const rig = await boot({ plugin });
480+
const read = await rig.orgRead(transport);
481+
expect(read.status, JSON.stringify(read.body)).toBe(200);
482+
expect(read.cacheControl).toBe('no-store');
483+
// The platform administrator's read of the same connection answers the same address…
484+
expect(read.body.data).toEqual((await rig.read(transport)).body.data);
485+
// …and it is the address a save from that connection is judged by.
486+
const save = await rig.save({ allowed_ip_ranges: NOBODY }, transport);
487+
expect(save.status, JSON.stringify(save.body)).toBe(400);
488+
const judged = save.body.error.details.fields.find((f: any) => f.field === 'allowed_ip_ranges').constraint.clientAddress;
489+
expect(read.body.data.clientAddress).toBe(judged);
490+
}
491+
});
492+
493+
it('the platform route still refuses that organization administrator, who is not a platform admin', async () => {
494+
const res = await (await boot()).read(
495+
{ remoteAddress: '203.0.113.9' },
496+
{ session: { user: { id: 'usr_org_admin', email: 'org-admin@example.com' } } },
497+
);
498+
expect(res.status).toBe(403);
499+
expect(res.body.error.code).toBe('PERMISSION_DENIED');
500+
});
501+
502+
describe('who may read it: the caller who may save the list, refused before any address is read', () => {
503+
const refusedBeforeTheRead = async (caller: OrganizationAddressReaderContext | undefined) => {
504+
const rig = await boot();
505+
const asked = vi.spyOn(rig.manager, 'trustsForwardedHeaders');
506+
const res = await rig.orgRead({ remoteAddress: '203.0.113.9' }, { caller });
507+
expect(asked).not.toHaveBeenCalled();
508+
expect(res.body.success).toBe(false);
509+
expect(res.body.data).toBeUndefined();
510+
return res;
511+
};
512+
513+
it('no principal: 401 UNAUTHENTICATED', async () => {
514+
const res = await refusedBeforeTheRead(undefined);
515+
expect(res.status).toBe(401);
516+
expect(res.body.error.code).toBe('UNAUTHENTICATED');
517+
});
518+
519+
it('a plain member, without setup.write in their organization: 403 PERMISSION_DENIED', async () => {
520+
const res = await refusedBeforeTheRead({ systemPermissions: ['setup.access'] });
521+
expect(res.status).toBe(403);
522+
expect(res.body.error.code).toBe('PERMISSION_DENIED');
523+
});
524+
525+
it("a held organization administrator (ADR-0069 D3): 403 with the posture's own code", async () => {
526+
const res = await refusedBeforeTheRead({
527+
...ORG_ADMIN_CALLER,
528+
authGate: { code: 'MFA_REQUIRED', message: 'Set up two-factor authentication.' },
529+
});
530+
expect(res.status).toBe(403);
531+
expect(res.body.error.code).toBe('MFA_REQUIRED');
532+
});
533+
});
534+
535+
it('an unreachable permission store is an outage, 503 SERVICE_UNAVAILABLE, never a refusal', async () => {
536+
const res = await (await boot()).orgRead({ remoteAddress: '203.0.113.9' }, { caller: new AuthzStoreUnavailableError('sys_user_permission_set') });
537+
expect(res.status).toBe(503);
538+
expect(res.body.error.code).toBe('SERVICE_UNAVAILABLE');
539+
expect(res.body.data).toBeUndefined();
540+
});
541+
542+
it('the query set is closed and empty: no parameter can name another address, user or organization', async () => {
543+
const rig = await boot();
544+
const asked = vi.spyOn(rig.manager, 'trustsForwardedHeaders');
545+
for (const query of ['?userId=usr_other', '?organizationId=org_other', '?address=198.51.100.7']) {
546+
const res = await rig.orgRead({ remoteAddress: '203.0.113.9' }, { query });
547+
expect(res.status).toBe(400);
548+
expect(res.body.error.code).toBe('VALIDATION_ERROR');
549+
expect(res.body.data).toBeUndefined();
550+
}
551+
expect(asked).not.toHaveBeenCalled();
552+
});
553+
});
554+
394555
describe('an allowed IP range whose prefix is not decimal digits within its family is refused at every save door and never matches at the gate', () => {
395556
// An entry is an address, or an address, `/` and a prefix length of one or
396557
// two decimal digits from 0 to 32 (IPv4) or one to three from 0 to 128

0 commit comments

Comments
 (0)