From 340e8144182ff3f657df1dc96acc623a6c92b782 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:34:01 +0000 Subject: [PATCH 1/3] fix(service-settings): the settings REST family takes the shared anonymous floor, mounted once Every route of the family is now registered through one door that resolves the caller, refuses an anonymous one with the shared ADR-0056 D2 decision (401 UNAUTHENTICATED), and only then runs the route. Each route's own error mapping is unchanged. Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude --- .../service-settings/src/settings-routes.ts | 192 ++++++++++++------ 1 file changed, 132 insertions(+), 60 deletions(-) diff --git a/packages/services/service-settings/src/settings-routes.ts b/packages/services/service-settings/src/settings-routes.ts index fca93a01f3b..c4f12f1dd2b 100644 --- a/packages/services/service-settings/src/settings-routes.ts +++ b/packages/services/service-settings/src/settings-routes.ts @@ -11,6 +11,11 @@ * The route layer is a thin wrapper that maps thrown service errors * into proper HTTP status codes; all business logic lives in * `SettingsService`. + * + * Every route of the family is mounted through ONE door (`mountFamilyRoute` + * inside {@link registerSettingsRoutes}), which resolves the caller, refuses an + * anonymous one with the platform's shared `401 UNAUTHENTICATED` (ADR-0056 D2, + * ADR-0138 D2), and only then hands the request to the route. */ import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; @@ -19,7 +24,17 @@ import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@ob // (`src` under vitest aliases, `dist` under the published `exports`), so two // copies of the class make `instanceof` answer FALSE for a genuine instance. // See `packages/core/src/security/authz-store-unavailable.ts`. -import { isAuthzStoreUnavailableError } from '@objectstack/core'; +// +// ADR-0056 D2 — the anonymous floor is the platform's ONE shared decision and +// its ONE status / code / message, imported rather than restated, so this +// family cannot drift from the other HTTP seams on who is refused or how. +import { + ANONYMOUS_DENY_CODE, + ANONYMOUS_DENY_MESSAGE, + ANONYMOUS_DENY_STATUS, + isAuthzStoreUnavailableError, + shouldDenyAnonymous, +} from '@objectstack/core'; // The declared envelope is written in ONE place for the whole platform (#3973). // Its `extra` is `ApiError`'s own optional fields, so the undeclared siblings // #4224 retired from this module cannot come back through it either. @@ -50,9 +65,13 @@ export interface SettingsRoutesOptions { * `permissions` reflect real capabilities and are never spoofable. * * [Finding-1] The default is SECURE: it trusts NO identity header and yields - * an anonymous, `enforced` context (deny protected reads + all writes). The - * old default trusted `x-user-id` / `x-permissions` headers, which let an - * unauthenticated client forge any identity and write platform settings. + * an anonymous, `enforced` context, which the family's anonymous floor + * refuses with `401` on every route. The old default trusted `x-user-id` / + * `x-permissions` headers, which let an unauthenticated client forge any + * identity and write platform settings. + * + * The resolved `userId` is what admits a caller past that floor (ADR-0056 + * D2): a context with no `userId` is anonymous, whatever else it carries. */ contextFromRequest?: (req: IHttpRequest) => SettingsContext | Promise; } @@ -62,6 +81,21 @@ export interface SettingsRoutesOptions { // wire a verified `contextFromRequest` (the plugin does). const defaultContext = (_req: IHttpRequest): SettingsContext => ({ enforced: true }); +/** + * One route of the settings family, split at the seam the family door needs. + * + * - `serve` is the route's own work, handed the caller's resolved context. It + * runs only for a caller the anonymous floor admitted. + * - `refuse` maps anything thrown — by the context resolver or by `serve` — to + * this route's envelope. It is the route's own `catch` arm, unchanged; the + * door calls it for every throw except an authorization-store outage, which + * the door relays itself ({@link relayAuthzStoreOutage}). + */ +interface SettingsFamilyRoute { + serve(req: IHttpRequest, res: IHttpResponse, ctx: SettingsContext): Promise; + refuse(res: IHttpResponse, err: any): void; +} + /** * [#15999, ruling item 3] Relay an authorization-store OUTAGE as the envelope * it declares, instead of flattening it into this layer's untyped `500 @@ -70,7 +104,8 @@ const defaultContext = (_req: IHttpRequest): SettingsContext => ({ enforced: tru * `SettingsServicePlugin`'s `verifiedContextFromRequest` already re-raises the * brand rather than returning an enforced-but-empty context the routes would * read as a denial (commit 6a180e42d). But it is called as `await ctxOf(req)` from INSIDE - * each route's own `try`, so until now the brand was caught here and re-encoded + * a `try` in this layer (each route's own, until the family door took the call + * over), so until #15999 the brand was caught here and re-encoded * — `message` survived, `code` and `status` did not, and those are the two a * client branches on. The declared `503` / `SERVICE_UNAVAILABLE` never reached * the caller. @@ -87,9 +122,9 @@ const defaultContext = (_req: IHttpRequest): SettingsContext => ({ enforced: tru * `status` / `code` are read OFF the error rather than written as digits: the * envelope answered is the one the producer declared. * - * Written once and called by all four route catches — this registrar's four - * `catch` blocks are one decision reached four ways, and a copy per handler is - * exactly how a family drifts apart on the arm that matters least often. + * Written once and called once, from the family door's `catch` — the four + * routes' catches were one decision reached four ways, and a copy per handler + * is exactly how a family drifts apart on the arm that matters least often. * * ⛔ Scoped to the brand. Every other throw keeps its existing arm, including * the untyped `500` tail — widening this to "anything carrying a status" would @@ -112,33 +147,72 @@ export function registerSettingsRoutes( const base = opts.basePath ?? '/api/settings'; const ctxOf = opts.contextFromRequest ?? defaultContext; - http.get(base, (async (req, res) => { - try { - const ctx = await ctxOf(req); + /** + * The family's ONE door — every route below is mounted through it, so the + * anonymous floor is mounted once for the family rather than copied into + * each handler. A route registered on `http` directly would skip it, which + * is why the floor's pins enumerate the routes off the registration itself. + * + * In order, for every request: + * + * 1. Resolve the caller (`contextFromRequest`). This is identity resolution, + * not settings logic; an authorization-store outage it raises is relayed + * as its own `503` (#15999), and any other throw takes the route's own + * `refuse` arm exactly as it did before the door existed. + * 2. ADR-0056 D2 / ADR-0138 D2: the shared `!userId && !isSystem → 401` + * decision, BEFORE any settings logic runs. An anonymous caller learns + * nothing about the family — not which namespaces exist (the `404` it + * used to get for an unknown one), not which are protected (the `403`), + * not that the index is empty (the `200`). + * 3. The route's own work (`serve`), with that same context. + * + * `isSystem` is deliberately not read: `SettingsContext` carries no such + * field and inbound HTTP never holds one, so the only way past the floor is a + * resolved `userId`. The request PATH is not passed either: the family is + * never a control-plane path, and passing it would let a `basePath` moved + * under an allow-listed prefix inherit an exemption the family never declared. + */ + const mountFamilyRoute = (verb: 'get' | 'put' | 'post', path: string, route: SettingsFamilyRoute): void => { + http[verb](path, (async (req, res) => { + try { + const ctx = await ctxOf(req); + if (shouldDenyAnonymous({ userId: ctx?.userId, method: req.method })) { + sendError(res, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE); + return; + } + await route.serve(req, res, ctx); + } catch (err: any) { + if (relayAuthzStoreOutage(res, err)) return; + route.refuse(res, err); + } + }) satisfies RouteHandler); + }; + + mountFamilyRoute('get', base, { + async serve(_req, res, ctx) { const manifests = service.listManifests(ctx); sendOk(res, { manifests }); - } catch (err: any) { - if (relayAuthzStoreOutage(res, err)) return; + }, + refuse(res, err) { if (err instanceof SettingsForbiddenError) { sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else { sendError(res, 500, 'INTERNAL_ERROR', err?.message ?? 'Failed to list manifests'); } - } - }) satisfies RouteHandler); + }, + }); - http.get(`${base}/:namespace`, (async (req, res) => { - const ns = req.params.namespace; - try { - const ctx = await ctxOf(req); + mountFamilyRoute('get', `${base}/:namespace`, { + async serve(req, res, ctx) { + const ns = req.params.namespace; const payload = await service.getNamespace(ns, ctx); // #7522 — redact BEFORE the payload leaves the process. `values..value` // and every `cascadeChain` entry of a secret-backed key are masked; // `source`, `locked` and `lockedReason` are untouched, so the console's // "configured" state and the env-lock affordances read the same as before. sendOk(res, { ...payload, values: redactSecretValues(payload.values, service.secretKeysOf(ns)) }); - } catch (err: any) { - if (relayAuthzStoreOutage(res, err)) return; + }, + refuse(res, err) { if (err instanceof SettingsForbiddenError) { sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else if (err instanceof UnknownNamespaceError) { @@ -146,34 +220,33 @@ export function registerSettingsRoutes( } else { sendError(res, 500, 'INTERNAL_ERROR', err?.message ?? 'Failed to read namespace'); } - } - }) satisfies RouteHandler); + }, + }); - http.put(`${base}/:namespace`, (async (req, res) => { - const ns = req.params.namespace; - let body = (req.body ?? {}) as Record; - // DX symmetry: GET returns `{ values: { key: { value, source, … } } }`. - // Accept that same envelope on PUT (sole top-level `values` object) so a - // caller can write back exactly what it read instead of tripping a - // confusing UNKNOWN_KEY('values'). Per-key, unwrap the read-shape - // `{ value, … }` wrapper to the bare value; flat `{ key: value }` bodies - // (and a manifest that genuinely declares a `values` key alongside others) - // are untouched. - if ( - Object.keys(body).length === 1 && - body.values && typeof body.values === 'object' && !Array.isArray(body.values) - ) { - const inner = body.values as Record; - body = Object.fromEntries( - Object.entries(inner).map(([k, v]) => - v && typeof v === 'object' && !Array.isArray(v) && 'value' in (v as object) - ? [k, (v as { value: unknown }).value] - : [k, v], - ), - ); - } - try { - const ctx = await ctxOf(req); + mountFamilyRoute('put', `${base}/:namespace`, { + async serve(req, res, ctx) { + const ns = req.params.namespace; + let body = (req.body ?? {}) as Record; + // DX symmetry: GET returns `{ values: { key: { value, source, … } } }`. + // Accept that same envelope on PUT (sole top-level `values` object) so a + // caller can write back exactly what it read instead of tripping a + // confusing UNKNOWN_KEY('values'). Per-key, unwrap the read-shape + // `{ value, … }` wrapper to the bare value; flat `{ key: value }` bodies + // (and a manifest that genuinely declares a `values` key alongside others) + // are untouched. + if ( + Object.keys(body).length === 1 && + body.values && typeof body.values === 'object' && !Array.isArray(body.values) + ) { + const inner = body.values as Record; + body = Object.fromEntries( + Object.entries(inner).map(([k, v]) => + v && typeof v === 'object' && !Array.isArray(v) && 'value' in (v as object) + ? [k, (v as { value: unknown }).value] + : [k, v], + ), + ); + } // #7522 — the echoed-mask no-op. GET now answers a secret with the mask; // a form that submits back what it read means "unchanged", so the key is // dropped rather than persisted as the mask's literal text. Resolved here @@ -189,8 +262,8 @@ export function registerSettingsRoutes( // entries the caller never submitted (an upper-scope secret it may not // have set). Same boundary, same redaction. sendOk(res, { values: redactSecretValues(result, secretKeys) }); - } catch (err: any) { - if (relayAuthzStoreOutage(res, err)) return; + }, + refuse(res, err) { if (err instanceof SettingsForbiddenError) { sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else if (err instanceof SettingsLockedError) { @@ -229,13 +302,12 @@ export function registerSettingsRoutes( } else { sendError(res, 500, 'INTERNAL_ERROR', err?.message ?? 'Failed to write namespace'); } - } - }) satisfies RouteHandler); + }, + }); - http.post(`${base}/:namespace/:actionId`, (async (req, res) => { - const { namespace, actionId } = req.params; - try { - const ctx = await ctxOf(req); + mountFamilyRoute('post', `${base}/:namespace/:actionId`, { + async serve(req, res, ctx) { + const { namespace, actionId } = req.params; const result = await service.runAction(namespace, actionId, req.body, ctx); // The 200/400 split is PRE-EXISTING and preserved verbatim: an action // that ran and reported `ok: false` is still answered 400. Whether a @@ -254,8 +326,8 @@ export function registerSettingsRoutes( details: result, }); } - } catch (err: any) { - if (relayAuthzStoreOutage(res, err)) return; + }, + refuse(res, err) { if (err instanceof SettingsForbiddenError) { sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else if (err instanceof UnknownNamespaceError) { @@ -263,6 +335,6 @@ export function registerSettingsRoutes( } else { sendError(res, 500, 'INTERNAL_ERROR', err?.message ?? 'Action failed'); } - } - }) satisfies RouteHandler); + }, + }); } From d5562fd61442684ab2131edad5e3cc355ab285d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:39:41 +0000 Subject: [PATCH 2/3] test(service-settings): pin the settings family's anonymous floor per route; re-home fixtures that relied on an anonymous caller A new pin sweeps every route read off the registration (count asserted) with five anonymous context shapes and a forged identity header, asserting 401 UNAUTHENTICATED in the declared envelope with the service never reached, plus a signed-in control per route. Fixtures that modelled a signed-in caller without a userId now name one; pins that asserted the anonymous 403/404/empty-list answers now assert the 401, and the capability refusals they covered are re-driven by a signed-in caller without the capability. Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude --- .../src/config-change-audit.test.ts | 10 +- .../src/envelope.conformance.test.ts | 27 +- .../localization-timezone-help.pin.test.ts | 5 +- ...settings-admission-tenancy-posture.test.ts | 38 ++- .../src/settings-crypto-fail-closed.test.ts | 3 +- ...ettings-routes.anonymous-floor.pin.test.ts | 241 ++++++++++++++++++ ...settings-routes.authz-outage-relay.test.ts | 10 +- .../src/settings-routes.test.ts | 63 ++++- 8 files changed, 359 insertions(+), 38 deletions(-) create mode 100644 packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts diff --git a/packages/services/service-settings/src/config-change-audit.test.ts b/packages/services/service-settings/src/config-change-audit.test.ts index a7a2692444b..4b427c9e273 100644 --- a/packages/services/service-settings/src/config-change-audit.test.ts +++ b/packages/services/service-settings/src/config-change-audit.test.ts @@ -547,18 +547,20 @@ describe('#8145 — an encrypted key never reaches the ledger in cleartext', () * one layer down, where it actually exists, in the second case. */ describe('#8145 — a refused write emits NO config_change row', () => { - it('an ANONYMOUS write over the plugin\'s own routes: 403, and BOTH ledgers untouched', async () => { + it('an ANONYMOUS write over the plugin\'s own routes: 401, and BOTH ledgers untouched', async () => { // The plugin's `verifiedContextFromRequest` fails closed with no auth // service resolvable (Finding-1), so this is the deny an unauthenticated // caller really meets on a running server — reached through the routes the - // plugin mounted itself, no context injected. + // plugin mounted itself, no context injected. That context names no user, + // so the family's anonymous floor (ADR-0056 D2) answers it before the + // capability check this case used to meet (`403 SETTINGS_FORBIDDEN`). const boot = await bootPlugin({ userId: 'usr_admin' }); const { state } = await put(boot.http, { workspace_name: 'never-written' }); // Both halves of the ADR-0112 envelope — a status-only assertion could not // tell this refusal from any other 4xx, and "it threw" is not a pin. - expect(state.status).toBe(403); - expect(state.body.error.code).toBe('SETTINGS_FORBIDDEN'); + expect(state.status).toBe(401); + expect(state.body.error.code).toBe('UNAUTHENTICATED'); expect(boot.ledgerRows()).toHaveLength(0); expect(boot.settingAuditRows()).toHaveLength(0); diff --git a/packages/services/service-settings/src/envelope.conformance.test.ts b/packages/services/service-settings/src/envelope.conformance.test.ts index 3c0a1a36021..013fb98fc39 100644 --- a/packages/services/service-settings/src/envelope.conformance.test.ts +++ b/packages/services/service-settings/src/envelope.conformance.test.ts @@ -60,10 +60,12 @@ interface Captured { body: any; } -/** An authorized admin: verified, holding the branding manifest's capabilities. */ -const admin = () => ({ enforced: true, permissions: ['setup.access', 'setup.write'] }); -/** Anonymous + enforced — the module's secure default. */ +/** An authorized admin: verified, signed in, holding the branding manifest's capabilities. */ +const admin = () => ({ enforced: true, permissions: ['setup.access', 'setup.write'], userId: 'usr_admin' }); +/** Anonymous + enforced — the module's secure default, refused by the family's anonymous floor. */ const anon = () => ({ enforced: true }); +/** Signed in, holding no capability — the caller the manifest's own gate refuses. */ +const member = () => ({ enforced: true, permissions: [], userId: 'usr_member' }); function mount(contextFromRequest: any = admin, env: Record = {}) { // `env: {}` so a real `OS_BRANDING_*` in the environment cannot lock a key @@ -199,11 +201,22 @@ describe('settings envelope (#3843) — success bodies', () => { describe('settings envelope (#3843) — error bodies', () => { const CASES: Array<{ name: string; status: number; code: string; run: () => Promise }> = [ { + // ADR-0056 D2 — the family's anonymous floor answers before the + // manifest's capability gate, in the same declared envelope. name: 'anonymous read of a capability-gated namespace', + status: 401, + code: 'UNAUTHENTICATED', + run: async () => { + const { http } = mount(anon); + return drive(http, 'GET /api/settings/:namespace', { params: { namespace: 'branding' } }); + }, + }, + { + name: 'a signed-in read without the namespace\'s capability', status: 403, code: 'SETTINGS_FORBIDDEN', run: async () => { - const { http } = mount(anon); + const { http } = mount(member); return drive(http, 'GET /api/settings/:namespace', { params: { namespace: 'branding' } }); }, }, @@ -230,8 +243,8 @@ describe('settings envelope (#3843) — error bodies', () => { }, { name: 'anonymous write', - status: 403, - code: 'SETTINGS_FORBIDDEN', + status: 401, + code: 'UNAUTHENTICATED', run: async () => { const { http } = mount(anon); return drive(http, 'PUT /api/settings/:namespace', { @@ -338,7 +351,7 @@ describe('settings envelope (#4224) — the four ad-hoc keys travel in the decla details: { namespace: 'branding' }, gone: ['namespace'], run: async () => { - const { http } = mount(anon); + const { http } = mount(member); return drive(http, 'GET /api/settings/:namespace', { params: { namespace: 'branding' } }); }, }, diff --git a/packages/services/service-settings/src/localization-timezone-help.pin.test.ts b/packages/services/service-settings/src/localization-timezone-help.pin.test.ts index ed8d1573598..fff03784724 100644 --- a/packages/services/service-settings/src/localization-timezone-help.pin.test.ts +++ b/packages/services/service-settings/src/localization-timezone-help.pin.test.ts @@ -96,10 +96,11 @@ beforeAll(async () => { // The route the console's Localization page and the prompt read, mounted on // the service that boot registered, for an administrator holding the - // namespace's read capability. + // namespace's read capability. Signed in: a context with no `userId` is + // refused 401 by the family's anonymous floor (ADR-0056 D2). const http = new MockHttp(); registerSettingsRoutes(http, kernel.getService('settings'), { - contextFromRequest: () => ({ enforced: true, permissions: ['setup.access'] }), + contextFromRequest: () => ({ enforced: true, permissions: ['setup.access'], userId: 'usr_admin' }), }); const state: { status: number; body?: any } = { status: 200 }; const res = { diff --git a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts index c684fda6143..a7555e3c24e 100644 --- a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts +++ b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts @@ -33,6 +33,13 @@ * | a CURRENT member's key (control) | 200, row lands | 200, row lands — unchanged | * | no credential (control) | 403 | 403 — unchanged | * + * Since the family took the shared anonymous floor (ADR-0056 D2), every + * refusal in that "after" column — the three `403`s and the empty listing — + * answers `401 UNAUTHENTICATED`: a refused key resolves to NO principal (the + * last case of the vetted-tenant block pins that), and a caller with no + * principal is an anonymous one. What this file owns is unchanged — the + * refusal itself, and zero rows in the store. + * * ## The wiring ablation, held permanently * * The three `mount()` shapes below differ in NOTHING but whether this seam can @@ -454,8 +461,9 @@ describe('#15351 — under a wall-enforcing posture the settings doors refuse an const m = await mount({ kind: 'service', posture: 'isolated' }); const res = await put(m, KEY(RAW_EXMEMBER_KEY)); - expect(res.status).toBe(403); - expect(res.body?.error?.code).toBe('SETTINGS_FORBIDDEN'); + // The refused key resolves to no principal, so the anonymous floor answers. + expect(res.status).toBe(401); + expect(res.body?.error?.code).toBe('UNAUTHENTICATED'); // Read back FROM THE STORE, never off the response body. expect(m.settingRows()).toHaveLength(0); expect(m.auditRows()).toHaveLength(0); @@ -464,25 +472,29 @@ describe('#15351 — under a wall-enforcing posture the settings doors refuse an it("refuses an ex-member's org-stamped key at GET /:namespace (the read leak)", async () => { const m = await mount({ kind: 'service', posture: 'isolated' }); const res = await getNs(m, KEY(RAW_EXMEMBER_KEY)); - expect(res.status).toBe(403); - expect(res.body?.error?.code).toBe('SETTINGS_FORBIDDEN'); + expect(res.status).toBe(401); + expect(res.body?.error?.code).toBe('UNAUTHENTICATED'); + expect(res.body?.data).toBeUndefined(); }); it('stops an ex-member enumerating the namespace through GET /api/settings', async () => { - // The listing is not a refusal but a FILTER (`listManifests`): an enforced - // caller sees only what it may read. With no capabilities resolved, that is - // the empty list — pinned so the leak cannot come back as a 200 that still + // The listing used to be a FILTER (`listManifests`) for this caller — an + // enforced caller with no capabilities saw the empty list. The refused key + // resolves to no principal, so the anonymous floor now refuses the listing + // outright — pinned so the leak cannot come back as a 200 that still // names the namespace. const m = await mount({ kind: 'service', posture: 'isolated' }); const res = await listNs(m, KEY(RAW_EXMEMBER_KEY)); - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ success: true, data: { manifests: [] } }); + expect(res.status).toBe(401); + expect(res.body).toMatchObject({ success: false, error: { code: 'UNAUTHENTICATED' } }); + expect(res.body?.data).toBeUndefined(); }); it('refuses an organization-less key where the posture requires one', async () => { const m = await mount({ kind: 'service', posture: 'isolated' }); const res = await put(m, KEY(RAW_ORGLESS_KEY)); - expect(res.status).toBe(403); + expect(res.status).toBe(401); + expect(res.body?.error?.code).toBe('UNAUTHENTICATED'); expect(m.settingRows()).toHaveLength(0); }); @@ -504,7 +516,8 @@ describe('#15351 — under a wall-enforcing posture the settings doors refuse an it('CONTROL — no credential is still the anonymous refusal, and writes nothing', async () => { const m = await mount({ kind: 'service', posture: 'isolated' }); const res = await put(m, {}); - expect(res.status).toBe(403); + expect(res.status).toBe(401); + expect(res.body?.error?.code).toBe('UNAUTHENTICATED'); expect(m.settingRows()).toHaveLength(0); }); }); @@ -553,7 +566,8 @@ describe('#15351 — the posture is the EFFECTIVE one, never the requested one', delete process.env.OS_TENANCY_POSTURE; const m = await mount({ kind: 'service', posture: 'isolated' }); const res = await put(m, KEY(RAW_EXMEMBER_KEY)); - expect(res.status).toBe(403); + expect(res.status).toBe(401); + expect(res.body?.error?.code).toBe('UNAUTHENTICATED'); expect(m.settingRows()).toHaveLength(0); }); }); diff --git a/packages/services/service-settings/src/settings-crypto-fail-closed.test.ts b/packages/services/service-settings/src/settings-crypto-fail-closed.test.ts index d6ed7ac8bb5..58fc0427462 100644 --- a/packages/services/service-settings/src/settings-crypto-fail-closed.test.ts +++ b/packages/services/service-settings/src/settings-crypto-fail-closed.test.ts @@ -382,7 +382,8 @@ describe('#8026 — the refusal on the REST boundary', () => { const { svc, rowFor } = await bootProviderless(); const http = new MockHttp(); registerSettingsRoutes(http, svc, { - contextFromRequest: () => ({ enforced: true, permissions: ['setup.access', 'setup.write'] }), + // Signed in — the family's anonymous floor refuses a context with no `userId`. + contextFromRequest: () => ({ enforced: true, permissions: ['setup.access', 'setup.write'], userId: 'usr_admin' }), }); const state: { status: number; body?: any } = { status: 200 }; diff --git a/packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts b/packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts new file mode 100644 index 00000000000..0a94fca5415 --- /dev/null +++ b/packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0056 D2 / ADR-0138 D2 — the settings REST family answers an anonymous + * caller with the platform's shared `401 UNAUTHENTICATED`, on every route, + * before any settings logic runs. + * + * ## What was open + * + * `registerSettingsRoutes` mounted its routes with no anonymous floor. An + * unauthenticated request was answered by the family's own authorization + * logic instead: an empty index (`200`), a capability refusal (`403`), or — for + * a namespace that does not exist — `404`, which told an anonymous caller which + * namespaces do. ADR-0138 D2 says everything outside its door classes "answers + * 401 (UNAUTHENTICATED)", and the settings family is in none of them. + * + * ## The census comes from the registration, not from a list typed here + * + * Every route is captured off a mock `IHttpServer` as `registerSettingsRoutes` + * registers it, and every captured route is driven. A route added to the + * family later is therefore driven anonymously the day it lands: mounted + * through the family's door it answers 401 and this file stays green; mounted + * on `http` directly, past the door, it reaches the service and this file goes + * red — twice, once on the count below and once on the sweep. + * + * ## What each refusal asserts + * + * The ADR-0112 envelope — `status` AND `error.code` — plus the shared message, + * no `data`, and a service that was never called. A bare `toThrow()` would be + * no pin at all: the unfloored family never threw, it ANSWERED. + * + * ## Controls + * + * A floor that refused everybody would pass every refusal here. So the same + * routes are driven for a signed-in caller, and each one reaches the service + * with that caller's context and answers `200` exactly as before. + */ + +import { describe, expect, it } from 'vitest'; +import { + ANONYMOUS_DENY_CODE, + ANONYMOUS_DENY_MESSAGE, + ANONYMOUS_DENY_STATUS, + isAuthGateAllowlisted, +} from '@objectstack/core'; +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { registerSettingsRoutes, type SettingsRoutesOptions } from './settings-routes.js'; +import { SETTINGS_ROUTE_LEDGER } from './settings-route-ledger.js'; +import type { SettingsContext } from './settings-service.types.js'; + +const NS = 'branding'; + +interface Registered { + verb: string; + path: string; + handler: RouteHandler; +} + +/** Captures EVERY registration the registrar makes, whatever the verb. */ +function capture(service: unknown, opts: SettingsRoutesOptions = {}) { + const routes: Registered[] = []; + const middleware: unknown[] = []; + const add = (verb: string) => (path: string, handler: RouteHandler) => { + routes.push({ verb, path, handler }); + }; + const http = { + get: add('GET'), + post: add('POST'), + put: add('PUT'), + delete: add('DELETE'), + patch: add('PATCH'), + use: (...args: unknown[]) => { middleware.push(args); }, + listen: async () => {}, + close: async () => {}, + }; + registerSettingsRoutes(http as any, service as any, opts); + return { routes, middleware }; +} + +/** + * A service any call into which is recorded, and fails. If an anonymous + * request reached it, the `calls` list says so and the answer is the route's + * `500` tail, never a `401` — so a floor that let one through cannot pass. + */ +function untouchableService() { + const calls: string[] = []; + const service = new Proxy( + {}, + { + get(_target, prop) { + if (prop === 'then') return undefined; + return (..._args: unknown[]) => { + calls.push(String(prop)); + throw new Error(`service.${String(prop)} was reached by an anonymous request`); + }; + }, + }, + ); + return { service, calls }; +} + +/** A service that would SUCCEED for every route, recording the context it was handed. */ +function permissiveService() { + const seen: Array<{ method: string; ctx: SettingsContext }> = []; + const service = { + listManifests: (ctx: SettingsContext) => { seen.push({ method: 'listManifests', ctx }); return [{ namespace: NS }]; }, + getNamespace: async (_ns: string, ctx: SettingsContext) => { + seen.push({ method: 'getNamespace', ctx }); + return { manifest: { namespace: NS }, values: {} }; + }, + // `ReadonlySet` is the real shape the redaction helpers read. + secretKeysOf: () => new Set(), + setMany: async (_ns: string, _values: unknown, ctx: SettingsContext) => { seen.push({ method: 'setMany', ctx }); return {}; }, + runAction: async (_ns: string, _id: string, _input: unknown, ctx: SettingsContext) => { + seen.push({ method: 'runAction', ctx }); + return { ok: true }; + }, + }; + return { service, seen }; +} + +/** Request params for a registered path: every `:param` gets a value. */ +function paramsFor(path: string): Record { + const params: Record = {}; + for (const seg of path.split('/')) { + if (seg.startsWith(':')) params[seg.slice(1)] = seg === ':namespace' ? NS : 'test'; + } + return params; +} + +async function drive(route: Registered, headers: Record = {}) { + const captured: { status: number; body: any } = { status: 200, body: undefined }; + const res: any = { + json(data: unknown) { captured.body = data; return res; }, + send() { return res; }, + status(code: number) { captured.status = code; return res; }, + header() { return res; }, + }; + await route.handler( + { + params: paramsFor(route.path), + query: {}, + body: route.verb === 'GET' ? undefined : { workspace_name: 'anonymous-write' }, + headers, + method: route.verb, + path: route.path, + } as unknown as IHttpRequest, + res as IHttpResponse, + ); + return captured; +} + +function expectAnonymousRefusal(answer: { status: number; body: any }) { + expect(answer.status).toBe(ANONYMOUS_DENY_STATUS); + expect(answer.status).toBe(401); + expect(answer.body?.success).toBe(false); + expect(answer.body?.error?.code).toBe(ANONYMOUS_DENY_CODE); + expect(answer.body?.error?.code).toBe('UNAUTHENTICATED'); + expect(answer.body?.error?.message).toBe(ANONYMOUS_DENY_MESSAGE); + expect(answer.body?.data).toBeUndefined(); +} + +/** + * Every shape a context with no principal arrives in. Each one is anonymous: + * the floor reads `userId` and nothing else, so neither held capabilities nor + * the in-process `enforced: false` marker stands in for a signed-in caller. + */ +const ANONYMOUS_CONTEXTS: Array<{ name: string; opts: SettingsRoutesOptions }> = [ + { name: 'the secure default (no resolver wired)', opts: {} }, + { name: 'a resolver that resolved no principal', opts: { contextFromRequest: () => ({ enforced: true }) } }, + { name: 'an async resolver that resolved no principal', opts: { contextFromRequest: async () => ({ enforced: true }) } }, + { + name: 'capabilities with no principal', + opts: { contextFromRequest: () => ({ enforced: true, permissions: ['setup.access', 'setup.write'] }) }, + }, + { name: 'the in-process marker with no principal', opts: { contextFromRequest: () => ({ enforced: false }) } }, +]; + +describe('the settings family census — read off the registration', () => { + it('registers exactly four routes, the ledgered ones, and no middleware past them', () => { + const { routes, middleware } = capture(untouchableService().service); + // ⭐ A count, on purpose: a fifth route has to come through this file. + expect(routes).toHaveLength(4); + expect(routes.map((r) => `${r.verb} ${r.path}`).sort()).toEqual( + SETTINGS_ROUTE_LEDGER.map((row) => row.route).sort(), + ); + expect(middleware).toHaveLength(0); + }); +}); + +describe('ADR-0056 D2 — an anonymous caller is refused 401 on every route of the family', () => { + const census = capture(untouchableService().service).routes.map((r) => `${r.verb} ${r.path}`); + + for (const shape of ANONYMOUS_CONTEXTS) { + it.each(census)(`%s · ${shape.name} → 401 UNAUTHENTICATED, the service never reached`, async (key) => { + const { service, calls } = untouchableService(); + const route = capture(service, shape.opts).routes.find((r) => `${r.verb} ${r.path}` === key)!; + expectAnonymousRefusal(await drive(route)); + expect(calls).toEqual([]); + }); + } + + it.each(census)('%s · a forged identity header is not an identity → 401', async (key) => { + const { service, calls } = untouchableService(); + const route = capture(service).routes.find((r) => `${r.verb} ${r.path}` === key)!; + expectAnonymousRefusal( + await drive(route, { 'x-user-id': 'usr_forged', 'x-permissions': 'setup.access,setup.write' }), + ); + expect(calls).toEqual([]); + }); + + it('a basePath under an allow-listed control-plane name inherits no exemption', async () => { + // The floor is asked WITHOUT the request path, so where a host mounts the + // family cannot exempt it. Non-vacuity: the shared allow-list really would + // exempt this path if it were asked. + const basePath = '/api/v1/auth/settings'; + expect(isAuthGateAllowlisted(basePath, 'GET')).toBe(true); + const { service, calls } = untouchableService(); + const { routes } = capture(service, { basePath }); + expect(routes).toHaveLength(4); + for (const route of routes) expectAnonymousRefusal(await drive(route)); + expect(calls).toEqual([]); + }); +}); + +describe('CONTROL — a signed-in caller is served as before', () => { + const census = capture(permissiveService().service).routes.map((r) => `${r.verb} ${r.path}`); + + it.each(census)('%s · signed in → 200, and the service sees that caller', async (key) => { + const { service, seen } = permissiveService(); + const signedIn: SettingsContext = { enforced: true, userId: 'usr_admin', permissions: ['setup.access', 'setup.write'] }; + const route = capture(service, { contextFromRequest: () => signedIn }).routes.find( + (r) => `${r.verb} ${r.path}` === key, + )!; + const answer = await drive(route); + expect(answer.status).toBe(200); + expect(answer.body?.success).toBe(true); + expect(seen).toHaveLength(1); + expect(seen[0]!.ctx).toBe(signedIn); + }); +}); diff --git a/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts b/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts index 14e9bf4cc58..f0b0208c620 100644 --- a/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts +++ b/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts @@ -130,6 +130,12 @@ async function drive(routes: Map, door: (typeof DOORS)[num const codeOf = (c: Captured) => c.body?.error?.code; const OUTAGE: Ctx = () => { throw new AuthzStoreUnavailableError('sys_user_permission_set'); }; +/** + * A context that RESOLVES, for a signed-in caller. It names a `userId` because + * the family's anonymous floor (ADR-0056 D2) refuses a context without one + * before the route runs, and these controls exist to reach the service. + */ +const RESOLVED: Ctx = () => ({ enforced: false, userId: 'usr_control' }); // --------------------------------------------------------------------------- // §0 — The census control: the door table above IS the mounted surface. @@ -148,7 +154,7 @@ describe('[#15999] §0 — all four route catches exist and are the ones driven' describe('[#15999] §1 — a resolvable context still reaches the service', () => { it.each(DOORS)('CONTROL · $name answers 200', async (door) => { - const routes = mount(() => ({ enforced: false })); + const routes = mount(RESOLVED); const res = await drive(routes, door); expect(res.status).toBe(200); expect(res.body?.success).toBe(true); @@ -228,7 +234,7 @@ describe('[#15999] §3 — the relay is scoped to the brand; every other arm is it('the namespace 404 arm is untouched — a service-thrown UnknownNamespaceError still wins', async () => { const service = permissiveService(); service.getNamespace = async () => { throw new UnknownNamespaceError(NS); }; - const routes = mount(() => ({ enforced: false }), service); + const routes = mount(RESOLVED, service); const res = await drive(routes, DOORS[1]); expect(res.status).toBe(404); expect(codeOf(res)).toBe('UNKNOWN_NAMESPACE'); diff --git a/packages/services/service-settings/src/settings-routes.test.ts b/packages/services/service-settings/src/settings-routes.test.ts index 70e2327c3eb..d788fe15256 100644 --- a/packages/services/service-settings/src/settings-routes.test.ts +++ b/packages/services/service-settings/src/settings-routes.test.ts @@ -50,7 +50,10 @@ function makeReqRes(opts: { params?: Record; body?: any; headers // [Finding-1] An authorized admin context (verified, holds the branding // manifest's setup.access/setup.write capabilities). The production plugin // derives this from the verified session/API-key; here we inject it directly. -const adminProvider = () => ({ enforced: true, permissions: ['setup.access', 'setup.write'] }); +// It names its `userId`: a context without one is ANONYMOUS, and the family's +// anonymous floor refuses it 401 before any capability is consulted (ADR-0056 +// D2) — see `settings-routes.anonymous-floor.pin.test.ts`. +const adminProvider = () => ({ enforced: true, permissions: ['setup.access', 'setup.write'], userId: 'usr_admin' }); describe('settings-routes', () => { it('GET /api/settings → manifests', async () => { @@ -94,7 +97,9 @@ describe('settings-routes', () => { it('PUT 404 for unknown namespace', async () => { const http = new MockHttp(); const svc = new SettingsService(); - registerSettingsRoutes(http, svc); + // A signed-in caller: an anonymous one is refused 401 before the namespace + // is looked up, so it can no longer learn which namespaces exist. + registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider }); const h = http.routes.get('PUT /api/settings/:namespace')!; const { req, res, state } = makeReqRes({ params: { namespace: 'nope' }, body: { a: 1 } }); await h(req, res); @@ -148,8 +153,13 @@ describe('settings-routes', () => { // ── [Finding-1] the DEFAULT (no verified provider) is SECURE ────────────── // Header-trusted identity is gone; an unauthenticated request can neither - // enumerate protected namespaces nor write them. - it('anonymous GET /api/settings hides manifests that require a read capability', async () => { + // enumerate protected namespaces nor write them. Since the family took the + // shared anonymous floor (ADR-0056 D2), the default context is refused 401 + // UNAUTHENTICATED on every route before the service is consulted — the + // per-route sweep is `settings-routes.anonymous-floor.pin.test.ts`; the + // capability filter these tests used to reach anonymously is driven below + // by a SIGNED-IN caller who holds no capability. + it('anonymous GET /api/settings is refused 401 UNAUTHENTICATED, with no manifest list', async () => { const http = new MockHttp(); const svc = new SettingsService(); svc.registerManifest(brandingSettingsManifest); // requires setup.access @@ -158,15 +168,44 @@ describe('settings-routes', () => { const h = http.routes.get('GET /api/settings')!; const { req, res, state } = makeReqRes(); await h(req, res); + expect(state.status).toBe(401); + expect(state.body.error.code).toBe('UNAUTHENTICATED'); + expect(state.body.data).toBeUndefined(); + }); + + it('a signed-in caller with no capability sees no manifest that requires one', async () => { + const http = new MockHttp(); + const svc = new SettingsService(); + svc.registerManifest(brandingSettingsManifest); // requires setup.access + registerSettingsRoutes(http, svc, { contextFromRequest: () => ({ enforced: true, permissions: [], userId: 'usr_member' }) }); + + const h = http.routes.get('GET /api/settings')!; + const { req, res, state } = makeReqRes(); + await h(req, res); + expect(state.status).toBe(200); expect(state.body.data.manifests.length).toBe(0); }); - it('anonymous GET /api/settings/:ns is DENIED (403), not served', async () => { + it('anonymous GET /api/settings/:ns is refused 401 UNAUTHENTICATED, not served', async () => { const http = new MockHttp(); const svc = new SettingsService({ env: {} }); svc.registerManifest(brandingSettingsManifest); registerSettingsRoutes(http, svc); + const h = http.routes.get('GET /api/settings/:namespace')!; + const { req, res, state } = makeReqRes({ params: { namespace: 'branding' } }); + await h(req, res); + expect(state.status).toBe(401); + expect(state.body.error.code).toBe('UNAUTHENTICATED'); + expect(state.body.data).toBeUndefined(); + }); + + it('a signed-in caller without the read capability is DENIED (403), not served', async () => { + const http = new MockHttp(); + const svc = new SettingsService({ env: {} }); + svc.registerManifest(brandingSettingsManifest); + registerSettingsRoutes(http, svc, { contextFromRequest: () => ({ enforced: true, permissions: [], userId: 'usr_member' }) }); + const h = http.routes.get('GET /api/settings/:namespace')!; const { req, res, state } = makeReqRes({ params: { namespace: 'branding' } }); await h(req, res); @@ -174,7 +213,7 @@ describe('settings-routes', () => { expect(state.body.error.code).toBe('SETTINGS_FORBIDDEN'); }); - it('anonymous PUT /api/settings/:ns is DENIED (403) — the unauthenticated write hole is closed', async () => { + it('anonymous PUT /api/settings/:ns is refused 401 UNAUTHENTICATED — the unauthenticated write hole is closed', async () => { const http = new MockHttp(); const svc = new SettingsService({ env: {} }); svc.registerManifest(brandingSettingsManifest); @@ -183,8 +222,9 @@ describe('settings-routes', () => { const h = http.routes.get('PUT /api/settings/:namespace')!; const { req, res, state } = makeReqRes({ params: { namespace: 'branding' }, body: { workspace_name: 'pwn' } }); await h(req, res); - expect(state.status).toBe(403); - expect(state.body.error.code).toBe('SETTINGS_FORBIDDEN'); + expect(state.status).toBe(401); + expect(state.body.error.code).toBe('UNAUTHENTICATED'); + expect((await svc.get('branding', 'workspace_name')).source).toBe('default'); }); it('a spoofed x-user-id / x-permissions header grants NOTHING (default ignores identity headers)', async () => { @@ -200,14 +240,17 @@ describe('settings-routes', () => { headers: { 'x-user-id': 'attacker', 'x-permissions': 'setup.write,setup.access' }, }); await h(req, res); - expect(state.status).toBe(403); + // The forged id is not an identity: the caller is anonymous, and the floor says so. + expect(state.status).toBe(401); + expect(state.body.error.code).toBe('UNAUTHENTICATED'); + expect((await svc.get('branding', 'workspace_name')).source).toBe('default'); }); it('a caller holding only read (setup.access) may read but NOT write', async () => { const http = new MockHttp(); const svc = new SettingsService({ env: {} }); svc.registerManifest(brandingSettingsManifest); - registerSettingsRoutes(http, svc, { contextFromRequest: () => ({ enforced: true, permissions: ['setup.access'] }) }); + registerSettingsRoutes(http, svc, { contextFromRequest: () => ({ enforced: true, permissions: ['setup.access'], userId: 'usr_reader' }) }); const read = http.routes.get('GET /api/settings/:namespace')!; const r1 = makeReqRes({ params: { namespace: 'branding' } }); From 3bb73c0cd18aafe10a918fe30d377a87d44cc334 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:41:26 +0000 Subject: [PATCH 3/3] chore(changeset): service-settings anonymous floor (minor, breaking accept-set narrowing) Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude --- .changeset/23142-settings-anonymous-floor.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/23142-settings-anonymous-floor.md diff --git a/.changeset/23142-settings-anonymous-floor.md b/.changeset/23142-settings-anonymous-floor.md new file mode 100644 index 00000000000..c64771be017 --- /dev/null +++ b/.changeset/23142-settings-anonymous-floor.md @@ -0,0 +1,18 @@ +--- +'@objectstack/service-settings': minor +--- + +fix(service-settings)!: the settings REST family refuses an anonymous caller with the shared 401 + +Clause-②: no — it narrows the anonymous accept set to the ADR-0056 D2 floor; no published schema or export moves. + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +`registerSettingsRoutes` mounts the settings REST family. Until now it ran no anonymous floor: a caller with no session was answered by the family's own capability logic, with a status other than `401`. ADR-0056 D2 denies anonymous callers by default, and ADR-0138 D2 says a surface outside its door classes answers `401`. + +- **Refused now:** every route of the family answers a caller with no user `401` with code `UNAUTHENTICATED`, in the declared error envelope, before the settings service is consulted. The decision is the platform's shared one (`shouldDenyAnonymous`), mounted once for the whole family. +- **Unchanged:** a signed-in caller is served exactly as before, and the per-namespace read and write capabilities still decide what it may see and change. An authorization-store outage still answers its `503`. + +What changes for you. Sign in before calling the settings routes. If you mount `registerSettingsRoutes` yourself with your own `contextFromRequest`, it must put the caller's `userId` on the context it returns: a context without one is anonymous now, whatever capabilities it carries.