Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/23142-settings-anonymous-floor.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- 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). -->

**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.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}) {
// `env: {}` so a real `OS_BRANDING_*` in the environment cannot lock a key
Expand Down Expand Up @@ -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<Captured> }> = [
{
// 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' } });
},
},
Expand All @@ -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', {
Expand Down Expand Up @@ -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' } });
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SettingsService>('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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
});

Expand All @@ -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);
});
});
Expand Down Expand Up @@ -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);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Loading
Loading