Skip to content

Commit dc7f5c4

Browse files
feat(console): the Access policy page shows the address the server judges beside Allowed IP ranges (#31308)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XLcKzrqfQ4AkFLygR53Jiuf)_ Fixes #31260 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:控制台「设置 → 访问策略」页(`access_policy` 命名空间)在「允许的 IP 范围」字段下方显示服务器判定的本次连接地址,读自组织管理员可调用的 `GET /api/v1/auth/client-address`;`trustProxy` 为 false 时那句话说明「服务器不信任代理头,在代理之后这是代理的地址」。「身份验证」页照旧读平台管理员接口 `/admin/client-address`。 - 为什么改:组织的 IP 白名单保存时,若名单不含保存请求自己的地址会被拒;此前组织管理员只能从拒绝信息里得知自己的地址,存在把自己锁在组织外的风险。#31258 已提供服务端接口,本 PR 是控制台一半。 - 风险与回滚:只改 `@object-ui/console` 的设置页(patch);复用已有三条提示文案,不新增翻译键;读取失败(401/403/404/网络/形状不符)不显示提示、不弹 toast,页面与今天一致。回滚即 revert 本 PR。 - 先看:`ui/apps/console/src/pages/settings/SettingsView.tsx`(`CLIENT_ADDRESS_ROUTES` 与读取 effect)、同目录 `api.ts` 的 `getJudgedClientAddress(route)`、测试文件新增的 `describe`。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/pages/settings/__tests__/SettingsView.clientAddress-30313.test.tsx`,预期 18 条全绿(其中 9 条为本 PR 新增的访问策略页用例)。 - 需要你做的:无,席位落地(须在 #31258 合入 main、本分支 rebase 之后)。 ## What changes - **`api.ts`.** `getJudgedClientAddress` takes the route it reads, typed `JudgedClientAddressRoute`: `'/admin/client-address'` (platform administrators, the `auth` namespace) or `'/client-address'` (a caller holding `setup.write` in their current organization, the `access_policy` namespace). Both routes answer the same `{ success: true, data: { clientAddress, trustProxy } }` from the same `runClientAddressRead` on the server, so the envelope unwrap and the shape check are unchanged; the malformed-body message names the route that was read. No `@objectstack/client` method is added: the settings pages read `/api/v1/auth` with this module's raw `fetch`, as the Authentication page already did. - **`SettingsView.tsx`.** The single `auth` constant becomes `CLIENT_ADDRESS_ROUTES`, a map from namespace to route (`auth` and `access_policy`), and `clientAddressRouteOf(namespace)` picks the read. The read effect, the namespace tag on the answer, the note under `allowed_ip_ranges` and the failure handling (a rejected read draws nothing) are the existing ones. - **Copy.** The three existing `console.settingsView.clientAddress*` sentences are reused unchanged. Each holds for the organization list: its save check is the same self-lock-out predicate as the deployment list's (`allowedIpRangesWritePredicate` behind `accessPolicyWritePredicate` for `allowed_ip_ranges`), judged against the same address, so "a list that leaves it out cannot be saved from here", the proxy sentence with `trustProxy` off, and "a non-empty list cannot be saved" with no address are all true there. No i18n key is added, removed or reworded. - **Changeset** `.changeset/31260-access-policy-client-address.md`, `@object-ui/console` patch (the console ships `dist`, the built SPA), with both declaration lines. ## Tests A new top-level `describe` in the page's existing harness, `SettingsView.clientAddress-30313.test.tsx` (the whole page over a stubbed `fetch` and the real `../api` module, expected sentences read from the shipped `en` pack), 9 cases: - `trustProxy: false`: the note under Allowed IP ranges is the direct sentence (the proxy note), on that row only, named by the textarea's `aria-describedby`; exactly one read, at `/api/v1/auth/client-address`, with `credentials: 'include'`; the platform route is never asked. - `trustProxy: true`: the via-proxy sentence. `clientAddress: null`: the unknown sentence. - Five failed reads, each leaving the page as it was (no note, no `aria-describedby`, no load-error card, no toast): 403 `PERMISSION_DENIED`, 401 `UNAUTHENTICATED`, 404, a body missing `clientAddress`, a network failure. - The Authentication page still reads the platform route and never the organization one. ## Acceptance notes - **Docs.** `content/docs/permissions/authentication.mdx` describes the Authentication page's note (the deployment list); `content/docs/**` teaches no per-organization access policy at all (noted on #31258 too), so no docs page names Setup → Access policy. Not changed here: outside the claim's file surface. ## Verification On head `bd882bc8` (base `7c523aa1`); every exit code captured before any pipe. <details><summary>Rendered tests: 38/38 files, 322/322 tests</summary> - `pnpm --dir ui exec vitest run apps/console/src/pages/settings/` under the verification lock: `Test Files 38 passed (38)`, `Tests 322 passed (322)`, VERDICT command-exit 0. - The edited file alone, verbose: `Tests 18 passed (18)`, the 9 new cases listed by name. - `pnpm --filter @object-ui/console run type-check` (after `pnpm --filter '@object-ui/console^...' build`): VERDICT command-exit 0. - `eslint` on the three edited source files from `ui/`: exit 0, 0 errors (5 warnings, each on a `main` line this PR does not edit). - `pnpm --dir ui check:new-line-citations`: 0 new citations, exit 0. </details> <details><summary>Ablation: the read removed, 8 red, restored to the HEAD blob</summary> `node scripts/ablation-replace.mjs --delete` on the map entry `['access_policy', '/client-address'],` in `SettingsView.tsx` (console tests import the page from `src/`, so no build sits between mutation and run): anchor 1 → 0, blob `975d9c33` → `94aac0b5`; the edited test file then failed 8 and passed 10 (every new access-policy case red, the Authentication cases green). Restored: blob == HEAD (`975d9c33`), `git diff HEAD` empty. </details> <details><summary>Derived gates: 35 of 35, all exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 35 commands, each run with stdin from `/dev/null` (`pnpm --dir ui exec vitest run scripts/` under the lock: `Test Files 170 passed | 2 skipped`, `Tests 4996 passed | 2 skipped`). `--ran`: "35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED". </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [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_01FEkQ5o7LUAiCRZ7CgL8CKp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 45540d5 commit dc7f5c4

4 files changed

Lines changed: 164 additions & 18 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
feat(console): the Access policy page shows the address the server judges beside Allowed IP ranges
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An organization's `access_policy.allowed_ip_ranges` save is refused when the list leaves out the saving request's own address, and until now **Setup → Access policy** told the organization administrator that address only in the refusal. The page now reads `GET /api/v1/auth/client-address` (the organization administrator's read, answered to a caller holding `setup.write` in their current organization) and draws the note the Authentication page already draws under its own Allowed IP ranges: the address, and whether it came from a trusted proxy's forwarded headers or, with `trustProxy` off, from the connection itself (behind a proxy, the proxy's address); when the server cannot establish an address, that a non-empty list cannot be saved from here. The Authentication page keeps reading the platform administrator's route. A caller the route refuses, a server without the route, or any other failed read draws no note and no toast, and the page is otherwise unchanged. No translation key is added: the three existing note sentences are reused.

‎ui/apps/console/src/pages/settings/SettingsView.tsx‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
runSettingsAction,
2626
saveSettingsNamespace,
2727
type JudgedClientAddress,
28+
type JudgedClientAddressRoute,
2829
} from './api';
2930
import { resolveLabel, type SettingsNamespacePayload, type Specifier } from './types';
3031
import { useSettingsLabel, type SettingsLabelHelpers } from './useSettingsLabel';
@@ -52,14 +53,24 @@ function evalVisibility(expr: string | undefined, data: Record<string, unknown>)
5253
}
5354

5455
/**
55-
* The row the judged client address is drawn beside (objectstack#30313): the
56-
* `auth` namespace's Allowed IP ranges. A save of a list that leaves this
56+
* The rows the judged client address is drawn beside (objectstack#30313): the
57+
* Allowed IP ranges of the deployment (`auth`, platform administrators) and of
58+
* an organization (`access_policy`, organization administrators), each with the
59+
* route gated to whoever may save that list. A save of a list that leaves this
5760
* address out is refused (`invalid_value`, `constraint.clientAddress`), so the
5861
* admin is shown it before saving rather than learning it from the refusal.
5962
*/
60-
const CLIENT_ADDRESS_NAMESPACE = 'auth';
63+
const CLIENT_ADDRESS_ROUTES: ReadonlyMap<string, JudgedClientAddressRoute> = new Map<string, JudgedClientAddressRoute>([
64+
['auth', '/admin/client-address'],
65+
['access_policy', '/client-address'],
66+
]);
6167
const CLIENT_ADDRESS_KEY = 'allowed_ip_ranges';
6268

69+
/** The judged-address route of `namespace`, or undefined when its page draws no address. */
70+
function clientAddressRouteOf(namespace: string | undefined): JudgedClientAddressRoute | undefined {
71+
return namespace === undefined ? undefined : CLIENT_ADDRESS_ROUTES.get(namespace);
72+
}
73+
6374
/**
6475
* The note under Allowed IP ranges, one whole sentence per state so a pack
6576
* words each its own way: no address the server can establish (any non-empty
@@ -441,20 +452,21 @@ export function SettingsView() {
441452
}, [namespace]);
442453

443454
/**
444-
* The caller's address as the server judges it, on the `auth` page only
445-
* (objectstack#30313), or null: not that page, not read yet, or the read
446-
* failed. A failure draws no note rather than a guess: the route is
447-
* platform-admin gated and absent on a server older than objectstack#30398,
448-
* and a refused save still names the address in the row.
455+
* The caller's address as the server judges it, on a page that draws it
456+
* ({@link CLIENT_ADDRESS_ROUTES}), or null: another page, not read yet, or
457+
* the read failed. A failure draws no note rather than a guess: each route is
458+
* gated to whoever may save its list and absent on an older server, and a
459+
* refused save still names the address in the row.
449460
*/
450461
const [judgedRead, setJudgedRead] = useState<{
451462
namespace: string;
452463
judged: JudgedClientAddress;
453464
} | null>(null);
454465
useEffect(() => {
455-
if (namespace !== CLIENT_ADDRESS_NAMESPACE) return;
466+
const route = clientAddressRouteOf(namespace);
467+
if (!namespace || !route) return;
456468
let live = true;
457-
getJudgedClientAddress().then(
469+
getJudgedClientAddress(route).then(
458470
(judged) => {
459471
if (live) setJudgedRead({ namespace, judged });
460472
},
@@ -832,7 +844,7 @@ export function SettingsView() {
832844
error={key ? fieldErrors[key] : undefined}
833845
note={
834846
judgedAddress &&
835-
namespace === CLIENT_ADDRESS_NAMESPACE &&
847+
clientAddressRouteOf(namespace) !== undefined &&
836848
key === CLIENT_ADDRESS_KEY
837849
? clientAddressNote(judgedAddress, t)
838850
: isStoredJsonText(spec, resolved)

‎ui/apps/console/src/pages/settings/__tests__/SettingsView.clientAddress-30313.test.tsx‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,117 @@ describe('SettingsView — Allowed IP ranges shows the address the server judges
233233
expect(addressReads()).toHaveLength(1);
234234
});
235235
});
236+
237+
/**
238+
* objectstack#31260 — the organization's Allowed IP ranges (Setup → Access
239+
* policy, the `access_policy` namespace) shows the same note, read from the
240+
* route an organization administrator may call, `GET /api/v1/auth/client-address`
241+
* (gated to `setup.write` in the caller's current organization, the list's own
242+
* write permission). Its save check judges the same address the same way, so the
243+
* three sentences hold for it unchanged. A refused or failed read leaves the page
244+
* as it was: no note and no toast.
245+
*/
246+
describe('SettingsView — Access policy shows the address the server judges (objectstack#31260)', () => {
247+
const ORG_ADDRESS_URL = '/api/v1/auth/client-address';
248+
249+
const ACCESS_POLICY = {
250+
manifest: {
251+
namespace: 'access_policy',
252+
version: 1,
253+
label: 'Access policy',
254+
scope: 'tenant',
255+
specifiers: [
256+
{ type: 'textarea', key: 'allowed_ip_ranges', label: 'Allowed IP ranges', description: 'CIDR ranges.' },
257+
{ type: 'number', key: 'session_idle_timeout_minutes', label: 'Idle timeout (minutes)' },
258+
],
259+
},
260+
values: {
261+
allowed_ip_ranges: { value: null, source: 'default', locked: false },
262+
session_idle_timeout_minutes: { value: null, source: 'default', locked: false },
263+
},
264+
};
265+
266+
/** The organization route's reads; the platform route's URL does not end with it. */
267+
const orgReads = () => fetchMock.mock.calls.filter(([url]) => String(url).endsWith(ORG_ADDRESS_URL));
268+
269+
/** Serve the namespace page, and answer the organization route with `address`. */
270+
function serveOrg(page: unknown, address: () => Promise<Response>) {
271+
fetchMock.mockImplementation(async (url: string) => {
272+
if (String(url).endsWith(ORG_ADDRESS_URL)) return address();
273+
if (String(url).endsWith(CLIENT_ADDRESS_URL)) return refused(403, 'PERMISSION_DENIED');
274+
return ok(page);
275+
});
276+
}
277+
278+
it('draws the address under the list with the proxy sentence when trustProxy is off, from the organization route', async () => {
279+
serveOrg(ACCESS_POLICY, async () => ok({ clientAddress: '203.0.113.7', trustProxy: false }));
280+
renderAt('access_policy');
281+
282+
const note = await screen.findByTestId('settings-field-note');
283+
expect(note.textContent).toBe(sentence('clientAddressDirect', '203.0.113.7'));
284+
expect(rowOf('allowed_ip_ranges')!.contains(note)).toBe(true);
285+
expect(rowOf('session_idle_timeout_minutes')!.querySelector('[data-testid="settings-field-note"]')).toBeNull();
286+
const box = rowOf('allowed_ip_ranges')!.querySelector('textarea')!;
287+
expect(box.getAttribute('aria-describedby')).toBe(note.id);
288+
289+
// One read, at the organization route, with the session cookie; the
290+
// platform-admin route is never asked from this page.
291+
expect(orgReads()).toHaveLength(1);
292+
expect(orgReads()[0][1]).toMatchObject({ credentials: 'include' });
293+
expect(addressReads()).toHaveLength(0);
294+
});
295+
296+
it('says the address came through the trusted proxy when trustProxy is on', async () => {
297+
serveOrg(ACCESS_POLICY, async () => ok({ clientAddress: '198.51.100.20', trustProxy: true }));
298+
renderAt('access_policy');
299+
300+
const note = await screen.findByTestId('settings-field-note');
301+
expect(note.textContent).toBe(sentence('clientAddressViaProxy', '198.51.100.20'));
302+
});
303+
304+
it('says no address could be established when the server answers null', async () => {
305+
serveOrg(ACCESS_POLICY, async () => ok({ clientAddress: null, trustProxy: false }));
306+
renderAt('access_policy');
307+
308+
const note = await screen.findByTestId('settings-field-note');
309+
expect(note.textContent).toBe(sentence('clientAddressUnknown'));
310+
});
311+
312+
it.each([
313+
['a caller without setup.write (403)', async () => refused(403, 'PERMISSION_DENIED')],
314+
['no session (401)', async () => refused(401, 'UNAUTHENTICATED')],
315+
[
316+
'a server without the route (404)',
317+
async () =>
318+
({
319+
ok: false,
320+
status: 404,
321+
statusText: 'Not Found',
322+
json: async () => ({ success: false, error: { code: 'NOT_FOUND', message: 'Not found.' } }),
323+
}) as unknown as Response,
324+
],
325+
['a body that is not the declared shape', async () => ok({ trustProxy: false })],
326+
['a network failure', async () => Promise.reject(new TypeError('Failed to fetch'))],
327+
])('leaves the page as it was on %s: no note, no toast', async (_what, answer) => {
328+
serveOrg(ACCESS_POLICY, answer);
329+
renderAt('access_policy');
330+
331+
await screen.findByText('Allowed IP ranges');
332+
await waitFor(() => expect(orgReads()).toHaveLength(1));
333+
// Let the rejected read settle before asserting its absence.
334+
await new Promise((r) => setTimeout(r, 0));
335+
expect(screen.queryByTestId('settings-field-note')).toBeNull();
336+
expect(screen.queryByText(/Failed to load settings/)).toBeNull();
337+
expect(toastError).not.toHaveBeenCalled();
338+
expect(rowOf('allowed_ip_ranges')!.querySelector('textarea')!.hasAttribute('aria-describedby')).toBe(false);
339+
});
340+
341+
it('the Authentication page still reads the platform route, never the organization one', async () => {
342+
serve(AUTH, async () => ok({ clientAddress: '203.0.113.7', trustProxy: false }));
343+
renderAt('auth');
344+
345+
await screen.findByTestId('settings-field-note');
346+
expect(addressReads()).toHaveLength(1);
347+
expect(orgReads()).toHaveLength(0);
348+
});
349+
});

‎ui/apps/console/src/pages/settings/api.ts‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,10 @@ export async function runSettingsAction(
214214

215215
/**
216216
* The caller's address as the server judges it (objectstack#30313), read from
217-
* `GET /api/v1/auth/admin/client-address` (objectstack#30398).
217+
* one of two routes that answer it alike ({@link JudgedClientAddressRoute}).
218218
*
219-
* `clientAddress` is the address the `auth.allowed_ip_ranges` save check and
219+
* `clientAddress` is the address the `allowed_ip_ranges` save checks (the
220+
* deployment's `auth` list and an organization's `access_policy` list) and
220221
* the sign-in gate judge this connection by (the save check's own call), or
221222
* `null` when the server cannot establish one. `trustProxy` says whether
222223
* forwarded headers counted (`server.trustProxy`). ⛔ The console never works
@@ -231,13 +232,22 @@ export interface JudgedClientAddress {
231232
const AUTH_BASE = `${SERVER_URL}/api/v1/auth`;
232233

233234
/**
234-
* Read {@link JudgedClientAddress}. The route is platform-admin gated, so a
235-
* caller who is not one is refused (`401`/`403`) and this throws like any other
235+
* The route under `/api/v1/auth` a page reads the judged address from, each
236+
* gated to whoever may save the list it informs: `/admin/client-address` to a
237+
* platform administrator (the `auth` namespace), `/client-address` to a caller
238+
* holding `setup.write` in their current organization (the `access_policy`
239+
* namespace).
240+
*/
241+
export type JudgedClientAddressRoute = '/admin/client-address' | '/client-address';
242+
243+
/**
244+
* Read {@link JudgedClientAddress} from `route`. A caller the route's gate
245+
* refuses (`401`/`403`), or a server without the route, throws like any other
236246
* failed read; so does a body that is not the declared shape. The caller
237247
* decides what a failure shows.
238248
*/
239-
export async function getJudgedClientAddress(): Promise<JudgedClientAddress> {
240-
const res = await fetch(`${AUTH_BASE}/admin/client-address`, {
249+
export async function getJudgedClientAddress(route: JudgedClientAddressRoute): Promise<JudgedClientAddress> {
250+
const res = await fetch(`${AUTH_BASE}${route}`, {
241251
credentials: 'include',
242252
headers: { Accept: 'application/json' },
243253
});
@@ -250,7 +260,7 @@ export async function getJudgedClientAddress(): Promise<JudgedClientAddress> {
250260
!(address === null || (typeof address === 'string' && address.length > 0))
251261
) {
252262
throw new Error(
253-
'Malformed response from GET /api/v1/auth/admin/client-address: expected an object carrying `clientAddress` (a string or null) and `trustProxy` (a boolean).',
263+
`Malformed response from GET /api/v1/auth${route}: expected an object carrying \`clientAddress\` (a string or null) and \`trustProxy\` (a boolean).`,
254264
);
255265
}
256266
return { clientAddress: address, trustProxy: body.trustProxy };

0 commit comments

Comments
 (0)