Skip to content

Commit ac9376a

Browse files
os-trumpclaude
andauthored
service-storage / service-settings: an authorization-store outage reaches the wire as its declared 503 SERVICE_UNAVAILABLE (#16580)
* wip(#15999): relay the authz-store outage envelope in storage + settings routes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(#15999): pin the relayed outage envelope on the storage and settings doors Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(#15999): use the real ReadonlySet shape for secretKeysOf in the relay fixture Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * chore(#15999): changeset for the authz-store outage status relay Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 75c864e commit ac9376a

8 files changed

Lines changed: 712 additions & 41 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
"@objectstack/service-settings": patch
4+
---
5+
6+
An authorization-store OUTAGE now reaches the caller as the `503 SERVICE_UNAVAILABLE` it declares, on the storage download doors and on all four settings routes.
7+
8+
`AuthzStoreUnavailableError` exists so an outage is distinguishable from a capability denial on the wire: it declares `status: 503` and `code: SERVICE_UNAVAILABLE`, and every producer in this family already re-raises it rather than laundering it into a verdict. Two consumers then flattened it back, each in its own way, so the declared envelope never arrived.
9+
10+
**What changes on the wire.** Only on the path where the authorization store could not be READ — never when it legitimately returned no rows, and never for any other fault.
11+
12+
| door | before | after |
13+
| --- | --- | --- |
14+
| `GET /api/v1/storage/files/:fileId/url` | `403 FILE_DOWNLOAD_DENIED` / `403 ATTACHMENT_DOWNLOAD_DENIED` | `503 SERVICE_UNAVAILABLE` |
15+
| `GET /api/v1/storage/files/:fileId` | same 403, and no redirect | `503 SERVICE_UNAVAILABLE`, still no `Location` |
16+
| `GET /api/settings` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` |
17+
| `GET /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` |
18+
| `PUT /api/settings/:namespace` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` |
19+
| `POST /api/settings/:namespace/:actionId` | `500 INTERNAL_ERROR` | `503 SERVICE_UNAVAILABLE` |
20+
21+
The storage row is the one worth reading twice: an outage was answered as a **permission denial**, byte-indistinguishable from a genuine refusal, which is the precise confusion the loud-outage discipline exists to prevent. The message now names the object whose read failed and says in words that this is not a permission denial.
22+
23+
**What does NOT change.** The security posture is identical — these doors were already fail-CLOSED and still are, and the storage gate still mints no capability on an outage. Every other refusal keeps its status and code: `deny` is still `403`, `unauthenticated` still `401`, an unknown namespace still `404`, a forbidden settings context still `403`, and any fault that is not this branded outage still lands on the same untyped `500 INTERNAL_ERROR` tail it did before. The repair is scoped to the brand, not to "anything carrying a status".
24+
25+
**Why `patch` and not `minor`.** No API is added, removed or renamed; no exported signature moves; no authorable key changes. This is a released package delivering an envelope it already declared — a bug fix, which this repo bumps `patch`. The change *is* observable, which is why the FROM → TO table above is in the changeset body rather than encoded in the bump: a version number carries no mapping, and this text is what an upgrading consumer greps in `CHANGELOG.md`.
26+
27+
**If you branch on these statuses.** A client that treated the storage `403` as "this user may not have this file" was, during an outage, retrying or re-authenticating against a fault that no credential could fix; it should now treat `503` as retryable and leave the caller's permissions alone. A client that treated the settings `500` as an unrecoverable server error can now distinguish a transient store outage from a genuine internal fault.

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

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ import type { SettingsContext } from './settings-service.types.js';
8080
* need it and neither is visible from a response body:
8181
*
8282
* - the ADR-0112 envelope the broken-`tenancy` arm raises, which the settings
83-
* route layer flattens to `500 INTERNAL_ERROR` (see the 503 describe below);
83+
* route layer RELAYS as `503 SERVICE_UNAVAILABLE` since #15999 — it used to
84+
* flatten it to `500 INTERNAL_ERROR` (see the 503 describe below);
8485
* - the `tenantId` this seam RETURNS, which is the half of this card that is
8586
* not about admission at all.
8687
*/
@@ -608,21 +609,25 @@ describe('#15351 — decision 1 option A: a BROKEN tenancy service is an outage,
608609
it('the door does NOT admit, and nothing lands', async () => {
609610
const m = await mount({ kind: 'factory-throws' });
610611
const res = await put(m, KEY(RAW_EXMEMBER_KEY));
611-
// ⚠️ MEASURED, not endorsed: the settings route layer has no
612-
// `isAuthzStoreUnavailableError` arm, so the branded 503 the seam raises is
613-
// flattened into `500 INTERNAL_ERROR` here. That flattening is PRE-EXISTING
614-
// — #13279's permission-store re-raise already reached this same `else`
615-
// branch — and repairing the transport's envelope mapping is a different
616-
// defect from supplying the posture, so it is filed rather than ridden in.
617-
// What this card owns is pinned either way: the outage is NOT a quiet
618-
// admit.
619-
expect(res.status).toBe(500);
612+
// ⭐ THE 500 ARM RETIRED HERE (#15999 ruling item 3). This line read
613+
// `expect(res.status).toBe(500)` and carried a note saying so was MEASURED
614+
// and not endorsed: the settings route layer had no
615+
// `isAuthzStoreUnavailableError` arm, so the branded 503 the seam raises
616+
// was flattened into `500 INTERNAL_ERROR` by the same untyped `else` branch
617+
// #13279's permission-store re-raise already reached. All four route
618+
// catches now RELAY the declared envelope instead.
619+
//
620+
// What THIS card owns is unchanged and still asserted: the outage is not a
621+
// quiet admit, and nothing lands.
620622
expect(m.settingRows()).toHaveLength(0);
623+
expect(res.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
624+
expect((res.body as any)?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
621625
});
622626

623627
it('the GET listing is an outage too, never a silently empty list', async () => {
624628
const m = await mount({ kind: 'factory-throws' });
625629
const res = await listNs(m, KEY(RAW_MEMBER_KEY));
626-
expect(res.status).toBe(500);
630+
expect(res.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
631+
expect((res.body as any)?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
627632
});
628633
});
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#15999 ruling item 3] Every settings route relays an authorization-store
5+
* OUTAGE as the `503 SERVICE_UNAVAILABLE` the brand declares, instead of
6+
* flattening it into this layer's untyped `500 INTERNAL_ERROR` tail.
7+
*
8+
* ## What was measured
9+
*
10+
* `SettingsServicePlugin`'s `verifiedContextFromRequest` re-raises
11+
* `AuthzStoreUnavailableError` rather than returning an enforced-but-empty
12+
* context the routes would read as a denial (#13279). But it is called as
13+
* `await ctxOf(req)` from INSIDE each route's own `try`, so the brand was
14+
* caught here and re-encoded: `message` survived, `code` and `status` did not —
15+
* and those are the two a client branches on.
16+
*
17+
* ## The count on the card was wrong, and this file is why it matters
18+
*
19+
* The #15999 ruling says 「settings' three route catches」. Located by
20+
* PREDICATE — a `catch` around a `ctxOf(req)` whose exit is a denial or a
21+
* swallow — this registrar has **four**: `GET /api/settings`,
22+
* `GET /api/settings/:namespace`, `PUT /api/settings/:namespace` and
23+
* `POST /api/settings/:namespace/:actionId`. All four are driven below, so the
24+
* fourth cannot be the one nobody remembered.
25+
*
26+
* ## RELAY, not re-raise
27+
*
28+
* A bare re-raise escapes to the transport, which answers a bare
29+
* `500 INTERNAL_ERROR "No response from handler"` — losing the message the
30+
* flattening at least preserved — and the shared render that would give an
31+
* escaped ADR-0112 envelope its declared status is #16545 and has not landed.
32+
* A relay answers before the throw escapes, so it is correct today and stays
33+
* correct once #16545 lands. Same shape as `badRequest` in
34+
* `service-datasource`'s `admin-routes.ts` since #6504.
35+
*
36+
* ## Controls
37+
*
38+
* A layer that answered 503 for everything would pass an outage-only suite
39+
* while making every settings fault unreadable. So §1 drives the happy path and
40+
* §3 pins the relay's WIDTH: `SettingsForbiddenError` still answers its 403,
41+
* `UnknownNamespaceError` its 404, and a plain throw still lands on the untyped
42+
* `500 INTERNAL_ERROR` tail that this repair deliberately did NOT widen.
43+
*
44+
* ⛔ Nothing here asserts `toThrow()`: the unrepaired layer never threw — it
45+
* ANSWERED, with the wrong envelope. The claim is `status` + `code`.
46+
*/
47+
48+
import { describe, it, expect } from 'vitest';
49+
import {
50+
AuthzStoreUnavailableError,
51+
AUTHZ_STORE_UNAVAILABLE_CODE,
52+
AUTHZ_STORE_UNAVAILABLE_STATUS,
53+
} from '@objectstack/core';
54+
import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
55+
import { registerSettingsRoutes } from './settings-routes.js';
56+
import { SettingsForbiddenError, UnknownNamespaceError } from './settings-service.types.js';
57+
import type { SettingsContext } from './settings-service.types.js';
58+
59+
const BASE = '/api/settings';
60+
const NS = 'mail';
61+
62+
interface Captured { status: number; body: any }
63+
64+
/**
65+
* A `SettingsService` stand-in that would SUCCEED for every verb. It exists so
66+
* a green outage arm can only come from the context resolver — if the service
67+
* were the thing failing, every arm below would pass for the wrong reason.
68+
*/
69+
function permissiveService() {
70+
return {
71+
listManifests: () => [{ namespace: NS }],
72+
getNamespace: async () => ({ manifest: { namespace: NS }, values: {} }),
73+
// `ReadonlySet<string>` is the real `SettingsService` shape — an array
74+
// makes the redaction helpers' `.size` read `undefined` and the PUT door
75+
// answers 500, i.e. the control would fail for a fixture reason.
76+
secretKeysOf: () => new Set<string>(),
77+
setMany: async () => ({}),
78+
runAction: async () => ({ ok: true }),
79+
} as any;
80+
}
81+
82+
type Ctx = (req: IHttpRequest) => SettingsContext | Promise<SettingsContext>;
83+
84+
function mount(contextFromRequest: Ctx, service: any = permissiveService()) {
85+
const routes = new Map<string, RouteHandler>();
86+
const http = {
87+
get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); },
88+
post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); },
89+
put: (p: string, h: RouteHandler) => { routes.set(`PUT:${p}`, h); },
90+
delete: () => {},
91+
patch: () => {},
92+
use: () => {},
93+
listen: async () => {},
94+
close: async () => {},
95+
};
96+
registerSettingsRoutes(http as any, service, { basePath: BASE, contextFromRequest });
97+
return routes;
98+
}
99+
100+
/** The four doors this registrar mounts — the predicate-found census, not the card's count. */
101+
const DOORS = [
102+
{ name: 'GET /api/settings', key: `GET:${BASE}`, params: {}, body: undefined },
103+
{ name: 'GET /api/settings/:namespace', key: `GET:${BASE}/:namespace`, params: { namespace: NS }, body: undefined },
104+
{ name: 'PUT /api/settings/:namespace', key: `PUT:${BASE}/:namespace`, params: { namespace: NS }, body: { host: 'x' } },
105+
{
106+
name: 'POST /api/settings/:namespace/:actionId',
107+
key: `POST:${BASE}/:namespace/:actionId`,
108+
params: { namespace: NS, actionId: 'test' },
109+
body: {},
110+
},
111+
] as const;
112+
113+
async function drive(routes: Map<string, RouteHandler>, door: (typeof DOORS)[number]): Promise<Captured> {
114+
const handler = routes.get(door.key);
115+
if (!handler) throw new Error(`fixture: no handler for ${door.key}`);
116+
const captured: Captured = { status: 200, body: undefined };
117+
const res: any = {
118+
json(data: any) { captured.body = data; return res; },
119+
send() { return res; },
120+
status(code: number) { captured.status = code; return res; },
121+
header() { return res; },
122+
};
123+
const [method, path] = door.key.split(/:(.+)/);
124+
await handler(
125+
{ params: door.params, query: {}, body: door.body, headers: {}, method, path } as unknown as IHttpRequest,
126+
res as IHttpResponse,
127+
);
128+
return captured;
129+
}
130+
131+
const codeOf = (c: Captured) => c.body?.error?.code;
132+
const OUTAGE: Ctx = () => { throw new AuthzStoreUnavailableError('sys_user_permission_set'); };
133+
134+
// ---------------------------------------------------------------------------
135+
// §0 — The census control: the door table above IS the mounted surface.
136+
// ---------------------------------------------------------------------------
137+
138+
describe('[#15999] §0 — all four route catches exist and are the ones driven', () => {
139+
it('the registrar mounts exactly the four doors this file drives', () => {
140+
const routes = mount(() => ({ enforced: false }));
141+
expect([...routes.keys()].sort()).toEqual(DOORS.map((d) => d.key).sort());
142+
});
143+
});
144+
145+
// ---------------------------------------------------------------------------
146+
// §1 — Control: the doors still work when the context resolves.
147+
// ---------------------------------------------------------------------------
148+
149+
describe('[#15999] §1 — a resolvable context still reaches the service', () => {
150+
it.each(DOORS)('CONTROL · $name answers 200', async (door) => {
151+
const routes = mount(() => ({ enforced: false }));
152+
const res = await drive(routes, door);
153+
expect(res.status).toBe(200);
154+
expect(res.body?.success).toBe(true);
155+
});
156+
});
157+
158+
// ---------------------------------------------------------------------------
159+
// §2 — THE SUBJECT.
160+
// ---------------------------------------------------------------------------
161+
162+
describe('[#15999] §2 — an authorization-store outage reaches the caller as its declared envelope', () => {
163+
it.each(DOORS)('REPAIRED: $name answers 503 SERVICE_UNAVAILABLE — was 500 INTERNAL_ERROR', async (door) => {
164+
const routes = mount(OUTAGE);
165+
const res = await drive(routes, door);
166+
expect(res.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
167+
expect(res.status).toBe(503);
168+
expect(codeOf(res)).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
169+
// ⛔ The code that used to arrive, and that named the wrong component.
170+
expect(codeOf(res)).not.toBe('INTERNAL_ERROR');
171+
});
172+
173+
it('the operator still learns WHICH read failed, and that it is not a denial', async () => {
174+
const routes = mount(OUTAGE);
175+
const res = await drive(routes, DOORS[2]);
176+
expect(res.body?.error?.message).toContain('sys_user_permission_set');
177+
expect(res.body?.error?.message).toContain('not a permission denial');
178+
expect(res.body).toMatchObject({ success: false, error: { code: 'SERVICE_UNAVAILABLE' } });
179+
});
180+
181+
it('an async rejection is relayed too, not only a synchronous throw', async () => {
182+
// `contextFromRequest` is declared to return `SettingsContext | Promise<…>`
183+
// and the production resolver is async, so the rejected-promise path is the
184+
// one that actually runs.
185+
const routes = mount(async () => { throw new AuthzStoreUnavailableError('sys_permission_set_assignment'); });
186+
const res = await drive(routes, DOORS[0]);
187+
expect(res.status).toBe(503);
188+
expect(codeOf(res)).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
189+
});
190+
});
191+
192+
// ---------------------------------------------------------------------------
193+
// §3 — THE WIDTH PIN. Every other arm is untouched.
194+
// ---------------------------------------------------------------------------
195+
196+
describe('[#15999] §3 — the relay is scoped to the brand; every other arm is unchanged', () => {
197+
it.each(DOORS)('$name still answers 403 SETTINGS_FORBIDDEN for a forbidden context', async (door) => {
198+
const routes = mount(() => { throw new SettingsForbiddenError(NS, 'setup.access', 'read'); });
199+
const res = await drive(routes, door);
200+
expect(res.status).toBe(403);
201+
expect(codeOf(res)).toBe('SETTINGS_FORBIDDEN');
202+
});
203+
204+
it.each(DOORS)('$name still answers the untyped 500 INTERNAL_ERROR tail for a plain throw', async (door) => {
205+
const routes = mount(() => { throw new Error('some unrelated fault'); });
206+
const res = await drive(routes, door);
207+
expect(res.status).toBe(500);
208+
expect(codeOf(res)).toBe('INTERNAL_ERROR');
209+
// The message channel this layer already preserved is preserved still.
210+
expect(res.body?.error?.message).toBe('some unrelated fault');
211+
});
212+
213+
it('a look-alike carrying the declared status+code but NO brand is not relayed', async () => {
214+
// The brand survives module duplication where `instanceof` does not
215+
// (`authz-store-unavailable.ts` module doc); the converse is that a
216+
// look-alike without it is not this error.
217+
const routes = mount(() => {
218+
throw Object.assign(new Error('look-alike'), {
219+
status: AUTHZ_STORE_UNAVAILABLE_STATUS,
220+
code: AUTHZ_STORE_UNAVAILABLE_CODE,
221+
});
222+
});
223+
const res = await drive(routes, DOORS[1]);
224+
expect(res.status).toBe(500);
225+
expect(codeOf(res)).toBe('INTERNAL_ERROR');
226+
});
227+
228+
it('the namespace 404 arm is untouched — a service-thrown UnknownNamespaceError still wins', async () => {
229+
const service = permissiveService();
230+
service.getNamespace = async () => { throw new UnknownNamespaceError(NS); };
231+
const routes = mount(() => ({ enforced: false }), service);
232+
const res = await drive(routes, DOORS[1]);
233+
expect(res.status).toBe(404);
234+
expect(codeOf(res)).toBe('UNKNOWN_NAMESPACE');
235+
});
236+
});

0 commit comments

Comments
 (0)