Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions .changeset/30398-caller-address-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/plugin-auth': patch
---

feat(plugin-auth): a platform admin can read the address the allowed IP ranges save check will judge, before saving, at `GET /api/v1/auth/admin/client-address` (#30398)

Clause-②: yes (widening) — a new authenticated HTTP read on the auth mount, ledgered `server-only` in the plugin's auth route ledger; no spec schema, SDK method or package export moves.
Metadata-protocol: none

A save of `auth.allowed_ip_ranges` (ADR-0069 D5) is refused when the list leaves out the address the saving request comes from, and the refusal names that address. Until now that refusal was the only place the address appeared, so an administrator learned which address counts by being refused. The new read answers it beforehand.

- **The answer.** `200 { success: true, data: { clientAddress, trustProxy } }` with `Cache-Control: no-store`. `clientAddress` is the caller's address exactly as the save check and the sign-in gate judge it, through the same call the save check makes: the forwarded `X-Forwarded-For` / `X-Real-IP` address only when the stack declares `server.trustProxy`, the connection's peer address otherwise, in the same text (an IPv4-mapped peer reads `::ffff:127.0.0.1`, as the refusal names it). It is `null` when the server cannot establish an address; while any list is set, such a connection is refused at sign-in and any non-empty list at the save. `trustProxy` says whether forwarded headers counted.
- **Who may read it.** A platform admin, through the gate every ObjectStack `/admin/*` auth route uses: no session answers `401 UNAUTHENTICATED`, a session held by the authentication policy (an expired password, required two-factor not set up) answers `403` with that posture's code, and any other caller `403 PERMISSION_DENIED`. It is not served to anonymous callers, and `GET /api/v1/auth/config` is unchanged.
- **Closed query set.** The route takes no query parameters; any one is refused with `400 VALIDATION_ERROR`.
- **Behind the allow-list.** Like every auth route except the sign-in page's two render reads, the read is judged by the allow-list in force: a caller already outside it gets `403 IP_NOT_ALLOWED`.

The save check, the sign-in gate and the address rule are unchanged: the save check now reaches the rule through the helper the read shares, with the same inputs.
134 changes: 131 additions & 3 deletions packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,17 @@
* the deployment declares `trustProxy`. A list that includes it saves.
* - The recovery the refusal names: `OS_AUTH_ALLOWED_IP_RANGES` set empty
* opens the gate on the next boot over a stored list.
* - [#30398] The read an administrator asks before saving,
* `GET /api/v1/auth/admin/client-address`, answers the address that same
* save would be judged by: the plugin's real auth routes on a real Hono app,
* the transport peer supplied the way `@hono/node-server` supplies it.
*
* `@objectstack/service-settings` resolves to its `src/` here (this package's
* `vitest.config.ts`), so the routes and the service are the checkout's.
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { Hono } from 'hono';
import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts';
import {
SettingsService,
Expand Down Expand Up @@ -56,11 +61,18 @@ class MockHttp implements IHttpServer {
getInstance() { return null; }
}

type Transport = { headers?: Record<string, string>; remoteAddress?: string };

/** The session the read's platform-admin gate is handed: a platform admin unless a test says otherwise. */
const PLATFORM_ADMIN_SESSION = { user: { id: 'usr_admin', email: 'admin@example.com', isPlatformAdmin: true } };

interface Rig {
svc: SettingsService;
manager: AuthManager;
save(body: Record<string, unknown>, transport?: { headers?: Record<string, string>; remoteAddress?: string }):
Promise<{ status: number; body: any }>;
save(body: Record<string, unknown>, transport?: Transport): Promise<{ status: number; body: any }>;
/** `GET /api/v1/auth/admin/client-address` on the plugin's real auth routes. */
read(transport?: Transport, opts?: { session?: unknown; query?: string }):
Promise<{ status: number; body: any; cacheControl: string | null }>;
}

async function boot(
Expand Down Expand Up @@ -116,7 +128,28 @@ async function boot(
await http.routes.get('PUT /api/settings/:namespace')!(req, res);
return out;
};
return { svc, manager, save };

let app: Hono | undefined;
const read: Rig['read'] = async (transport = {}, opts = {}) => {
if (!app) {
app = new Hono();
(plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
}
const session = 'session' in opts ? opts.session : PLATFORM_ADMIN_SESSION;
// The gate reads the session through the auth service's in-process read.
const getApi = vi.spyOn(manager, 'getApi').mockResolvedValue({ getSession: async () => session } as any);
try {
const res = await app.request(
`http://localhost:3000/api/v1/auth/admin/client-address${opts.query ?? ''}`,
{ method: 'GET', headers: transport.headers ?? {} },
transport.remoteAddress ? { incoming: { socket: { remoteAddress: transport.remoteAddress } } } : {},
);
return { status: res.status, body: await res.json(), cacheControl: res.headers.get('cache-control') };
} finally {
getApi.mockRestore();
}
};
return { svc, manager, save, read };
}

const stored = async (svc: SettingsService) => (await svc.get('auth', 'allowed_ip_ranges', {})).value ?? null;
Expand Down Expand Up @@ -257,3 +290,98 @@ describe('#30320 an IPv6 range is judged at both doors as the range it names', (
expect(manager.isClientIpAllowed('2001:db8:1234::7')).toBe(true);
});
});

describe('#30398 the address read serves the address the save check judges', () => {
// TEST-NET-1: no address any case below connects from falls inside it, so a
// save of this list is always refused and its refusal names the judged address.
const NOBODY = '192.0.2.0/24';

/** One connection, asked at both doors: the read's answer and the save check's judged address. */
const bothDoors = async (rig: Rig, transport: Transport) => {
const read = await rig.read(transport);
expect(read.status, JSON.stringify(read.body)).toBe(200);
const save = await rig.save({ allowed_ip_ranges: NOBODY }, transport);
expect(save.status, JSON.stringify(save.body)).toBe(400);
const judged = save.body.error.details.fields.find((f: any) => f.field === 'allowed_ip_ranges').constraint.clientAddress;
return { shown: read.body.data, judged };
};

it('a direct peer: the read shows the peer address the save judges', async () => {
const { shown, judged } = await bothDoors(await boot(), { remoteAddress: '203.0.113.9' });
expect(shown).toEqual({ clientAddress: '203.0.113.9', trustProxy: false });
expect(judged).toBe(shown.clientAddress);
});

it('an IPv4-mapped peer: the read shows the mapped text the save judges, not a rewritten one', async () => {
const { shown, judged } = await bothDoors(await boot(), { remoteAddress: '::ffff:127.0.0.1' });
expect(shown).toEqual({ clientAddress: '::ffff:127.0.0.1', trustProxy: false });
expect(judged).toBe(shown.clientAddress);
});

it('trustProxy declared: the read shows the forwarded address the save judges', async () => {
const forwarded = { headers: { 'x-forwarded-for': '198.51.100.7, 10.0.0.2' }, remoteAddress: '10.0.0.1' };
const { shown, judged } = await bothDoors(await boot({ plugin: { trustProxy: true } }), forwarded);
expect(shown).toEqual({ clientAddress: '198.51.100.7', trustProxy: true });
expect(judged).toBe(shown.clientAddress);
});

it('trustProxy not declared: a forged X-Forwarded-For is ignored at both doors, the peer is shown', async () => {
const forged = { headers: { 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '198.51.100.8' }, remoteAddress: '10.0.0.1' };
const { shown, judged } = await bothDoors(await boot(), forged);
expect(shown).toEqual({ clientAddress: '10.0.0.1', trustProxy: false });
expect(judged).toBe(shown.clientAddress);
});

it('an address the server cannot establish reads null at both doors', async () => {
const { shown, judged } = await bothDoors(await boot(), {});
expect(shown).toEqual({ clientAddress: null, trustProxy: false });
expect(judged).toBeNull();
});

it('the answer is never stored by a cache', async () => {
const res = await (await boot()).read({ remoteAddress: '203.0.113.9' });
expect(res.status).toBe(200);
expect(res.cacheControl).toBe('no-store');
});

describe('who may read it: the platform-admin gate of every /admin mount, before any address is read', () => {
const refusedBeforeTheRead = async (session: unknown) => {
const rig = await boot();
const asked = vi.spyOn(rig.manager, 'trustsForwardedHeaders');
const res = await rig.read({ remoteAddress: '203.0.113.9' }, { session });
expect(asked).not.toHaveBeenCalled();
expect(res.body.success).toBe(false);
expect(res.body.data).toBeUndefined();
return res;
};

it('no session: 401 UNAUTHENTICATED', async () => {
const res = await refusedBeforeTheRead(null);
expect(res.status).toBe(401);
expect(res.body.error.code).toBe('UNAUTHENTICATED');
});

it('a signed-in caller who is not a platform admin: 403 PERMISSION_DENIED', async () => {
const res = await refusedBeforeTheRead({ user: { id: 'usr_member', email: 'member@example.com' } });
expect(res.status).toBe(403);
expect(res.body.error.code).toBe('PERMISSION_DENIED');
});

it("a held platform admin's session (ADR-0069 D3): 403 with the posture's own code", async () => {
const held = { user: { ...PLATFORM_ADMIN_SESSION.user, authGate: { code: 'MFA_REQUIRED', message: 'Set up two-factor authentication.' } } };
const res = await refusedBeforeTheRead(held);
expect(res.status).toBe(403);
expect(res.body.error.code).toBe('MFA_REQUIRED');
});
});

it('the query set is closed and empty: any parameter is refused 400 VALIDATION_ERROR before the address is read', async () => {
const rig = await boot();
const asked = vi.spyOn(rig.manager, 'trustsForwardedHeaders');
const res = await rig.read({ remoteAddress: '203.0.113.9' }, { query: '?address=198.51.100.7' });
expect(res.status).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
expect(res.body.data).toBeUndefined();
expect(asked).not.toHaveBeenCalled();
});
});
50 changes: 42 additions & 8 deletions packages/plugins/plugin-auth/src/allowed-ip-ranges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
* console, of a list that leaves out the address the saving request itself
* comes from. Saved, such a list refuses the administrator's own next
* sign-in, and nothing at the save said so.
* - {@link judgedClientAddress} is THE reading of a request's address for this
* setting, called by that save check and by the read that shows an
* administrator the address before they save (`GET /admin/client-address`,
* `client-address-read.ts`, #30398), so the address shown is the address
* judged.
*
* The address judged at the save is the one the gate judges at sign-in: the
* auth manager's {@link AuthManager.resolveClientAddress}, from the same two
Expand All @@ -32,6 +37,7 @@

import { resolveLocale } from '@objectstack/core';
import { ipMatchesRange, type AuthManager } from './auth-manager.js';
import { FORWARDED_CLIENT_ADDRESS_HEADERS } from './client-address-forwarding.js';

/** The settings env override that pins `auth.allowed_ip_ranges` (`OS_` + namespace + key). */
export const ALLOWED_IP_RANGES_ENV = 'OS_AUTH_ALLOWED_IP_RANGES';
Expand All @@ -49,22 +55,53 @@ export function parseAllowedIpRanges(raw: unknown): string[] {
}

/**
* The HTTP request a settings write arrived on, as the settings routes hand it
* to a write predicate (`SettingsWriteOptions.transport` in `service-settings`).
* The two facts a door holds about the HTTP request it serves: its headers and
* the transport's own peer address. The settings routes hand a write predicate
* this shape (`SettingsWriteOptions.transport` in `service-settings`).
*/
interface WriteTransport {
export interface RequestTransport {
headers?: Record<string, string | string[] | undefined>;
remoteAddress?: string;
}

/**
* [#30398] The address `auth.allowed_ip_ranges` judges for a request, or
* `undefined` when the server cannot establish one: the auth manager's
* {@link AuthManager.resolveClientAddress} (`server.trustProxy` read there),
* handed the request's peer address and only the forwarded-address headers that
* rule reads ({@link FORWARDED_CLIENT_ADDRESS_HEADERS}), the narrowing the
* settings service applies to the save check's input before it arrives here.
*
* Both doors that speak about this address call it: the save check below, and
* the read that serves the address to an administrator before a save
* (`client-address-read.ts`). One call, so what the read shows and what the
* save judges cannot drift apart.
*/
export function judgedClientAddress(
manager: Pick<AuthManager, 'resolveClientAddress'>,
transport: RequestTransport,
): string | undefined {
let headers: Record<string, string | string[] | undefined> | undefined;
if (transport.headers) {
headers = {};
for (const [name, value] of Object.entries(transport.headers)) {
if (FORWARDED_CLIENT_ADDRESS_HEADERS.includes(name.toLowerCase())) headers[name] = value;
}
}
return manager.resolveClientAddress({
...(headers ? { headers } : {}),
...(transport.remoteAddress ? { remoteAddress: transport.remoteAddress } : {}),
});
}

/**
* What this predicate reads of a settings write predicate's input. Structural,
* for the reason `SettingsReadSurface` is: plugin-auth does not depend on
* service-settings.
*/
export interface AllowedIpRangesWriteInput {
next: unknown;
transport?: WriteTransport;
transport?: RequestTransport;
locale?: string;
}

Expand Down Expand Up @@ -145,10 +182,7 @@ export function allowedIpRangesWritePredicate(
if (ranges.length === 0) return undefined;
const mgr = manager();
if (!mgr) return undefined;
const address = mgr.resolveClientAddress({
...(transport.headers ? { headers: transport.headers } : {}),
...(transport.remoteAddress ? { remoteAddress: transport.remoteAddress } : {}),
});
const address = judgedClientAddress(mgr, transport);
if (address && ranges.some((range) => ipMatchesRange(address, range))) return undefined;
const copy = copyFor(locale);
return {
Expand Down
24 changes: 24 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3622,6 +3622,30 @@ export class AuthPlugin implements Plugin {
}
});

// ────────────────────────────────────────────────────────────────────
// [#30398] ADR-0069 D5 — admin: the caller's own address as the
// `auth.allowed_ip_ranges` save check and the sign-in gate judge it, so
// Setup can show it before a save rather than the save refusal naming it
// after. The shared gate first, then the read, which takes the address from
// the save check's own call on this request's headers and transport peer
// (client-address-read.ts). Closed, empty query set; no body is read.
rawApp.get(`${basePath}/admin/client-address`, async (c: any) => {
try {
const actor = await gateAdmin(c);
if (actor instanceof Response) return actor;
const { runClientAddressRead } = await import('./client-address-read.js');
const answer = runClientAddressRead(this.authManager!, {
queries: (c.req.queries() ?? {}) as Record<string, string[]>,
transport: { headers: (c.req.header() ?? {}) as Record<string, string>, ...transportOf(c) },
});
return c.json(answer.body, answer.status as any, answer.headers);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
ctx.logger.error('[AuthPlugin] admin/client-address failed', err);
return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: err.message } }, 500);
}
});

// ────────────────────────────────────────────────────────────────────
// #2766 V1 — admin direct user management. `sys_user` CRUD is suppressed
// (managedBy better-auth), and until now the only add-a-teammate path was
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ describe('auth route ledger hygiene', () => {
// ruling of 2026-08-23).
const own = AUTH_ROUTE_LEDGER.filter((e) => e.source === 'objectstack').map((e) => e.route).sort();
expect(own).toEqual([
// [#30398] On this pin's own terms: `auth-plugin.ts` mounts it itself (a
// `rawApp.get` on the `${basePath}/admin/client-address` template, ahead
// of the catch-all), and the `live.has(route)` loop below holds it to the
// same proof — better-auth serves no address read.
'GET /api/v1/auth/admin/client-address',
'GET /api/v1/auth/bootstrap-status',
'GET /api/v1/auth/config',
// On this pin's own terms: `auth-plugin.ts` mounts it itself (a
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/plugin-auth/src/auth-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
// `requires` follows the add-member precedent: it names the better-auth
// plugin the route's WORK needs, not whether the mount is conditional —
// every one of these is mounted unconditionally on the raw app.
//
// #30398 — the tracker id stays HERE and never in the `note` string, per the
// #16678 note below.
{ route: 'GET /api/v1/auth/admin/client-address', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the console\'s Authentication settings page reads it beside the Allowed IP ranges field. Answers { clientAddress, trustProxy }: the caller\'s address exactly as the allowed_ip_ranges save check and the sign-in gate judge it (the save check\'s own call to AuthManager.resolveClientAddress, server.trustProxy deciding whether forwarded headers count), null when the server cannot establish one; Cache-Control: no-store; closed, empty query set; platform-admin gated (ADR-0068), ADR-0069 D5' },
{ route: 'POST /api/v1/auth/admin/import-users', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — objectui app-shell\'s identity-import wizard (views/identityImport.ts) posts it directly from the Users list; platform-admin gated (ADR-0068). A dedicated identity-import door rather than the generic sys_user import, because a direct ObjectQL write would bypass better-auth\'s password hashing and mint no sys_account credential' },
{ route: 'POST /api/v1/auth/admin/list-platform-admins', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_user list_platform_admins action posts it directly from the Users list; answers who holds platform-admin standing (the last-admin guard\'s enumerator, each id confirmed by hasPlatformAdminStanding), served only to a caller who holds that standing; reads no body. ADR-0095 D3' },
{ route: 'POST /api/v1/auth/admin/oauth2/toggle-disabled', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_oauth_application disable/enable actions post it directly; ObjectStack mount closing a vendor gap (better-auth\'s /admin/oauth2/update-client strips `disabled` from its body schema), platform-admin gated (ADR-0068)' },
Expand Down
Loading
Loading