Skip to content

Commit 20949e7

Browse files
fix(service-settings)!: the settings REST family refuses an anonymous caller with the shared 401 floor, mounted once (#23142) (#23171)
Fixes #23142 Clause-②: no (narrowing) — it narrows the anonymous accept set to the ADR-0056 D2 floor; no published schema or export moves. ## What changed `packages/services/service-settings/src/settings-routes.ts#registerSettingsRoutes` now mounts every route of the settings REST family through one door, `mountFamilyRoute`. For each request the door: 1. resolves the caller with `contextFromRequest`, unchanged. An authorization-store outage it raises still relays its own `503`. 2. asks the shared ADR-0056 D2 decision, `shouldDenyAnonymous` from `@objectstack/core`. A caller whose context names no `userId` gets `401 UNAUTHENTICATED` (`ANONYMOUS_DENY_STATUS` / `ANONYMOUS_DENY_CODE` / `ANONYMOUS_DENY_MESSAGE`), written through the family's existing `sendError` envelope, before any settings logic runs. 3. only then runs the route's own work with that same context. - **Mounted once for the family, not copied per handler.** Each route is now a `{ serve, refuse }` pair: `serve` is the old `try` body and `refuse` is the old `catch` arm, byte for byte. The four `relayAuthzStoreOutage` calls collapse into the door's one. - **A signed-in caller is served as before.** The per-namespace read and write capabilities still decide what it may see and change. - `isSystem` is not read: `SettingsContext` has no such field and inbound HTTP never carries one. The request path is not passed either, so a `basePath` moved under an allow-listed control-plane name inherits no exemption. - The 401 is written the way `service-datasource`'s admin family and `@objectstack/rest`'s package family already write it: `sendError` with the three shared constants. - No new error code, no spec key, no export or option change. This is the fourth door of the same shape. Ruling `6074960686` on #22146 settled the first three (#22430, #22431, #22432), and this change follows the same seam: the shared decision, called before any of the family's own logic. PR #23137 (`domain:spec`, the anonymous sweep) is red on this family alone; its everything-else case expects exactly this answer. That PR and its files are not touched here. ## Producer, measured before the fix On `origin/main` `02bec6ca86`: `git grep shouldDenyAnonymous` under `packages/services/service-settings/src` gives 0 hits, and `registerSettingsRoutes` gives its definition. The registrar's only identity input is `contextFromRequest`; the plugin's resolver (`verifiedContextFromRequest`) returns an `enforced` context with no `userId` for a caller with no session, and the four routes handed that straight to the service's capability checks. The landing is therefore the registrar itself, as the card said. The console reads the family only from signed-in views (the settings pages and the administrator timezone prompt, which waits for a user and an organization), and every in-repo dogfood caller sends a bearer token. No anonymous consumer was found. ## Pins New `settings-routes.anonymous-floor.pin.test.ts`: - **Census read off the registration.** Every route is captured from a mock HTTP server as the registrar registers it. Asserted: exactly 4 routes, equal to `SETTINGS_ROUTE_LEDGER`, and no middleware. A route added later is driven by every case below the day it lands, and the count turns red until this file is read. - **Every captured route, five anonymous context shapes:** no resolver wired; a resolver that resolved no principal, sync and async; capabilities with no principal; the in-process `enforced: false` marker with no principal. Plus a forged identity header. Each asserts `status` 401 and `error.code` `UNAUTHENTICATED` (with `success: false`, the shared message and no `data`), against a service that records and fails on any call, asserted untouched. - **A `basePath` the shared allow-list would exempt** (non-vacuity asserted with `isAuthGateAllowlisted`) is still refused on all four routes. - **Signed-in control per route:** `200`, and the service receives that caller's own context object. ## Fixture triage: tests that leaned on an anonymous caller The floor reads `userId` and nothing else, so each fixture without one was re-judged: - **The fixture modelled a signed-in caller and omitted the id.** It gains one: the admin providers in `settings-routes.test.ts` and `envelope.conformance.test.ts`, the read-only caller in `settings-routes.test.ts`, the resolving controls in `settings-routes.authz-outage-relay.test.ts`, and the fixtures in `settings-crypto-fail-closed.test.ts` and `localization-timezone-help.pin.test.ts`. An unknown-namespace write case that used the anonymous default moves to a signed-in caller, because an anonymous one no longer reaches the namespace lookup. - **The pin asserted the anonymous answer this change retires.** It now asserts the 401: the four anonymous cases in `settings-routes.test.ts`, the anonymous read and write in `envelope.conformance.test.ts`, the anonymous write through the plugin's own routes in `config-change-audit.test.ts` (both ledgers still untouched), and the six refused-key cases in `settings-admission-tenancy-posture.test.ts`. A refused API key resolves to no principal, which is an anonymous caller; the store-level "nothing lands" assertions are unchanged, and the file's docblock records why. - **Coverage the anonymous cases carried is re-homed.** The capability filter and the `403 SETTINGS_FORBIDDEN` refusal are now driven by a signed-in caller holding no capability: two new cases in `settings-routes.test.ts` and one in the envelope sweep, where the `SETTINGS_FORBIDDEN` details-slot case also moves to that caller. ## Ablation: the anonymous pins fail without the floor The implementation was committed first. At HEAD `3bb73c0cd1` the floor call was removed from the door with `scripts/ablation-replace.mjs`: anchor 1 to 0, blob `c4f12f1dd2b9` to `6e12080a10ea`, and an on-disk count of the floor call before / during / after of 1 / 0 / 1. The run has a `trap` restore on EXIT, INT and TERM against an absolute path. The whole package suite, mutated: ```text Test Files 5 failed | 38 passed (43) Tests 38 failed | 750 passed (788) AssertionError: expected 500 to be 401 // Object.is equality AssertionError: expected 403 to be 401 // Object.is equality AssertionError: expected 200 to be 401 // Object.is equality ``` - 25 of the new file's 30 cases went red: every anonymous case (4 routes × 5 shapes, 4 forged-header cases, the moved `basePath`). The census and the 4 signed-in controls stayed green. - The re-homed pins went red too: tenancy posture 6, settings-routes 4, envelope 2, config-change audit 1. - `500` is the untouchable service being reached. - Restore was proven by bytes, not by an exit code: the blob after restore equals HEAD's (`c4f12f1dd2b9`), and `git diff HEAD` is empty. ## Verification - `@objectstack/service-settings` suite: base `02bec6ca86` gave 42 files / 755 tests passed. This branch gives 43 files / 788 tests passed. `typecheck` (`tsc --noEmit`) exits 0, and `--listFiles` shows the new pin file compiled (43 test files in the program). - Dependency closure built first (`--filter '@objectstack/service-settings^...' build`), then the package itself. - Gates: re-derived with `scripts/pm/dispatch-gates.mjs --commands` from this branch's own change set. That gives 65 commands, the same set as the dispatch's reading. The results are in the report on the card. - `check:dual-build-cjs-loads` printed PREREQUISITE NOT MET: it reads every package's `dist/`, and this worktree built only the closure. **NOT MEASURED**, and left to CI. Narrowed instead to the changed package: `require` of its `dist/index.cjs` and `import` of `dist/index.js` both load. ## Acceptance notes - `content/docs/protocol/kernel/config-resolution.mdx` § HTTP Surface describes the family's capability gating but not the anonymous floor in front of it. It stays true, though incomplete. Not edited here; it is outside the claimed file surface. - The dogfood authorization conformance matrix draws its population from the REST and runtime route ledgers only, so this family's floor is ratcheted by its package-local pins and by PR #23137's live sweep, not by that matrix. - Not measured: whether a session held by an ADR-0069 auth policy (an expired password, pending MFA) reaches this family through some host middleware. The registrar does not consult that gate itself. Noted as an inference, not a finding. - `Clause-②` is copied verbatim from the claim, which carries no direction arm although its own prose says the change narrows. The changeset declares the narrowing as **BREAKING** (`minor`, launch-window convention), with an ADR-0087 `not-required (no-migration-prescription)` disposition, as the three sibling doors did. --- _Generated by [Claude Code](https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e81cae0 commit 20949e7

10 files changed

Lines changed: 509 additions & 98 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-settings': minor
3+
---
4+
5+
fix(service-settings)!: the settings REST family refuses an anonymous caller with the shared 401
6+
7+
Clause-②: no — it narrows the anonymous accept set to the ADR-0056 D2 floor; no published schema or export moves.
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A runtime authorization narrowing at the routes registerSettingsRoutes mounts, not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped, and a signed-in caller receives the same answers as before, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which callers the family serves: a caller whose resolved context names no user is now refused with the shared anonymous-deny 401 before any settings logic runs. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these routes and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
`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`.
14+
15+
- **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.
16+
- **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`.
17+
18+
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.

‎packages/services/service-settings/src/config-change-audit.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -547,18 +547,20 @@ describe('#8145 — an encrypted key never reaches the ledger in cleartext', ()
547547
* one layer down, where it actually exists, in the second case.
548548
*/
549549
describe('#8145 — a refused write emits NO config_change row', () => {
550-
it('an ANONYMOUS write over the plugin\'s own routes: 403, and BOTH ledgers untouched', async () => {
550+
it('an ANONYMOUS write over the plugin\'s own routes: 401, and BOTH ledgers untouched', async () => {
551551
// The plugin's `verifiedContextFromRequest` fails closed with no auth
552552
// service resolvable (Finding-1), so this is the deny an unauthenticated
553553
// caller really meets on a running server — reached through the routes the
554-
// plugin mounted itself, no context injected.
554+
// plugin mounted itself, no context injected. That context names no user,
555+
// so the family's anonymous floor (ADR-0056 D2) answers it before the
556+
// capability check this case used to meet (`403 SETTINGS_FORBIDDEN`).
555557
const boot = await bootPlugin({ userId: 'usr_admin' });
556558
const { state } = await put(boot.http, { workspace_name: 'never-written' });
557559

558560
// Both halves of the ADR-0112 envelope — a status-only assertion could not
559561
// tell this refusal from any other 4xx, and "it threw" is not a pin.
560-
expect(state.status).toBe(403);
561-
expect(state.body.error.code).toBe('SETTINGS_FORBIDDEN');
562+
expect(state.status).toBe(401);
563+
expect(state.body.error.code).toBe('UNAUTHENTICATED');
562564

563565
expect(boot.ledgerRows()).toHaveLength(0);
564566
expect(boot.settingAuditRows()).toHaveLength(0);

‎packages/services/service-settings/src/envelope.conformance.test.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,12 @@ interface Captured {
6060
body: any;
6161
}
6262

63-
/** An authorized admin: verified, holding the branding manifest's capabilities. */
64-
const admin = () => ({ enforced: true, permissions: ['setup.access', 'setup.write'] });
65-
/** Anonymous + enforced — the module's secure default. */
63+
/** An authorized admin: verified, signed in, holding the branding manifest's capabilities. */
64+
const admin = () => ({ enforced: true, permissions: ['setup.access', 'setup.write'], userId: 'usr_admin' });
65+
/** Anonymous + enforced — the module's secure default, refused by the family's anonymous floor. */
6666
const anon = () => ({ enforced: true });
67+
/** Signed in, holding no capability — the caller the manifest's own gate refuses. */
68+
const member = () => ({ enforced: true, permissions: [], userId: 'usr_member' });
6769

6870
function mount(contextFromRequest: any = admin, env: Record<string, string> = {}) {
6971
// `env: {}` so a real `OS_BRANDING_*` in the environment cannot lock a key
@@ -199,11 +201,22 @@ describe('settings envelope (#3843) — success bodies', () => {
199201
describe('settings envelope (#3843) — error bodies', () => {
200202
const CASES: Array<{ name: string; status: number; code: string; run: () => Promise<Captured> }> = [
201203
{
204+
// ADR-0056 D2 — the family's anonymous floor answers before the
205+
// manifest's capability gate, in the same declared envelope.
202206
name: 'anonymous read of a capability-gated namespace',
207+
status: 401,
208+
code: 'UNAUTHENTICATED',
209+
run: async () => {
210+
const { http } = mount(anon);
211+
return drive(http, 'GET /api/settings/:namespace', { params: { namespace: 'branding' } });
212+
},
213+
},
214+
{
215+
name: 'a signed-in read without the namespace\'s capability',
203216
status: 403,
204217
code: 'SETTINGS_FORBIDDEN',
205218
run: async () => {
206-
const { http } = mount(anon);
219+
const { http } = mount(member);
207220
return drive(http, 'GET /api/settings/:namespace', { params: { namespace: 'branding' } });
208221
},
209222
},
@@ -230,8 +243,8 @@ describe('settings envelope (#3843) — error bodies', () => {
230243
},
231244
{
232245
name: 'anonymous write',
233-
status: 403,
234-
code: 'SETTINGS_FORBIDDEN',
246+
status: 401,
247+
code: 'UNAUTHENTICATED',
235248
run: async () => {
236249
const { http } = mount(anon);
237250
return drive(http, 'PUT /api/settings/:namespace', {
@@ -338,7 +351,7 @@ describe('settings envelope (#4224) — the four ad-hoc keys travel in the decla
338351
details: { namespace: 'branding' },
339352
gone: ['namespace'],
340353
run: async () => {
341-
const { http } = mount(anon);
354+
const { http } = mount(member);
342355
return drive(http, 'GET /api/settings/:namespace', { params: { namespace: 'branding' } });
343356
},
344357
},

‎packages/services/service-settings/src/localization-timezone-help.pin.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,11 @@ beforeAll(async () => {
9696

9797
// The route the console's Localization page and the prompt read, mounted on
9898
// the service that boot registered, for an administrator holding the
99-
// namespace's read capability.
99+
// namespace's read capability. Signed in: a context with no `userId` is
100+
// refused 401 by the family's anonymous floor (ADR-0056 D2).
100101
const http = new MockHttp();
101102
registerSettingsRoutes(http, kernel.getService<SettingsService>('settings'), {
102-
contextFromRequest: () => ({ enforced: true, permissions: ['setup.access'] }),
103+
contextFromRequest: () => ({ enforced: true, permissions: ['setup.access'], userId: 'usr_admin' }),
103104
});
104105
const state: { status: number; body?: any } = { status: 200 };
105106
const res = {

‎packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@
3333
* | a CURRENT member's key (control) | 200, row lands | 200, row lands — unchanged |
3434
* | no credential (control) | 403 | 403 — unchanged |
3535
*
36+
* Since the family took the shared anonymous floor (ADR-0056 D2), every
37+
* refusal in that "after" column — the three `403`s and the empty listing —
38+
* answers `401 UNAUTHENTICATED`: a refused key resolves to NO principal (the
39+
* last case of the vetted-tenant block pins that), and a caller with no
40+
* principal is an anonymous one. What this file owns is unchanged — the
41+
* refusal itself, and zero rows in the store.
42+
*
3643
* ## The wiring ablation, held permanently
3744
*
3845
* 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
454461
const m = await mount({ kind: 'service', posture: 'isolated' });
455462
const res = await put(m, KEY(RAW_EXMEMBER_KEY));
456463

457-
expect(res.status).toBe(403);
458-
expect(res.body?.error?.code).toBe('SETTINGS_FORBIDDEN');
464+
// The refused key resolves to no principal, so the anonymous floor answers.
465+
expect(res.status).toBe(401);
466+
expect(res.body?.error?.code).toBe('UNAUTHENTICATED');
459467
// Read back FROM THE STORE, never off the response body.
460468
expect(m.settingRows()).toHaveLength(0);
461469
expect(m.auditRows()).toHaveLength(0);
@@ -464,25 +472,29 @@ describe('#15351 — under a wall-enforcing posture the settings doors refuse an
464472
it("refuses an ex-member's org-stamped key at GET /:namespace (the read leak)", async () => {
465473
const m = await mount({ kind: 'service', posture: 'isolated' });
466474
const res = await getNs(m, KEY(RAW_EXMEMBER_KEY));
467-
expect(res.status).toBe(403);
468-
expect(res.body?.error?.code).toBe('SETTINGS_FORBIDDEN');
475+
expect(res.status).toBe(401);
476+
expect(res.body?.error?.code).toBe('UNAUTHENTICATED');
477+
expect(res.body?.data).toBeUndefined();
469478
});
470479

471480
it('stops an ex-member enumerating the namespace through GET /api/settings', async () => {
472-
// The listing is not a refusal but a FILTER (`listManifests`): an enforced
473-
// caller sees only what it may read. With no capabilities resolved, that is
474-
// the empty list — pinned so the leak cannot come back as a 200 that still
481+
// The listing used to be a FILTER (`listManifests`) for this caller — an
482+
// enforced caller with no capabilities saw the empty list. The refused key
483+
// resolves to no principal, so the anonymous floor now refuses the listing
484+
// outright — pinned so the leak cannot come back as a 200 that still
475485
// names the namespace.
476486
const m = await mount({ kind: 'service', posture: 'isolated' });
477487
const res = await listNs(m, KEY(RAW_EXMEMBER_KEY));
478-
expect(res.status).toBe(200);
479-
expect(res.body).toMatchObject({ success: true, data: { manifests: [] } });
488+
expect(res.status).toBe(401);
489+
expect(res.body).toMatchObject({ success: false, error: { code: 'UNAUTHENTICATED' } });
490+
expect(res.body?.data).toBeUndefined();
480491
});
481492

482493
it('refuses an organization-less key where the posture requires one', async () => {
483494
const m = await mount({ kind: 'service', posture: 'isolated' });
484495
const res = await put(m, KEY(RAW_ORGLESS_KEY));
485-
expect(res.status).toBe(403);
496+
expect(res.status).toBe(401);
497+
expect(res.body?.error?.code).toBe('UNAUTHENTICATED');
486498
expect(m.settingRows()).toHaveLength(0);
487499
});
488500

@@ -504,7 +516,8 @@ describe('#15351 — under a wall-enforcing posture the settings doors refuse an
504516
it('CONTROL — no credential is still the anonymous refusal, and writes nothing', async () => {
505517
const m = await mount({ kind: 'service', posture: 'isolated' });
506518
const res = await put(m, {});
507-
expect(res.status).toBe(403);
519+
expect(res.status).toBe(401);
520+
expect(res.body?.error?.code).toBe('UNAUTHENTICATED');
508521
expect(m.settingRows()).toHaveLength(0);
509522
});
510523
});
@@ -553,7 +566,8 @@ describe('#15351 — the posture is the EFFECTIVE one, never the requested one',
553566
delete process.env.OS_TENANCY_POSTURE;
554567
const m = await mount({ kind: 'service', posture: 'isolated' });
555568
const res = await put(m, KEY(RAW_EXMEMBER_KEY));
556-
expect(res.status).toBe(403);
569+
expect(res.status).toBe(401);
570+
expect(res.body?.error?.code).toBe('UNAUTHENTICATED');
557571
expect(m.settingRows()).toHaveLength(0);
558572
});
559573
});

‎packages/services/service-settings/src/settings-crypto-fail-closed.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,8 @@ describe('#8026 — the refusal on the REST boundary', () => {
382382
const { svc, rowFor } = await bootProviderless();
383383
const http = new MockHttp();
384384
registerSettingsRoutes(http, svc, {
385-
contextFromRequest: () => ({ enforced: true, permissions: ['setup.access', 'setup.write'] }),
385+
// Signed in — the family's anonymous floor refuses a context with no `userId`.
386+
contextFromRequest: () => ({ enforced: true, permissions: ['setup.access', 'setup.write'], userId: 'usr_admin' }),
386387
});
387388

388389
const state: { status: number; body?: any } = { status: 200 };

0 commit comments

Comments
 (0)