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. 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' } }); 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); + }, + }); }