Skip to content

Commit 3c8cfd1

Browse files
os-zhuangclaude
andauthored
fix(rest,runtime): make api-exposure metadata fail-open observable (#3545) (#3567)
#3545 evaluated the residual risk of the API-exposure gate failing OPEN when object metadata can't be resolved. Conclusion: acceptable — the gate is a surface-area control, not the authz boundary (auth + CRUD/FLS/RLS enforce on the data call regardless), and failing closed would 405 every request during the cold-start window for no security gain. The one gap was that the fail-open was SILENT. - rest: loadObjectItems logs a THROWN metadata read (a real fault) while leaving a legitimately-empty registry silent — so a persistent outage, during which the gate allows every op unchecked, is diagnosable without cold-start false alarms. Behavior unchanged (still returns [] → gate abstains). - runtime: api-exposure.ts records the #3545 tiered decision in its contract doc (keep fail-open when the whole metadata service is unavailable; defer the narrow present-but-unreadable-policy widen — unreachable via Zod-validated registration — to the exposure-semantics window #3543). Tests: rest gate suite gains three #3545 cases (thrown read → fail-open + logged; empty registry → fail-open + silent; enforceApiAccess does not block on throw). Full rest.test.ts 171 pass; rest + runtime build (CJS/ESM/DTS) clean. Claude-Session: https://claude.ai/code/session_012L8EfEa157Pe6C73qRnaJH Co-authored-by: Claude <noreply@anthropic.com>
1 parent f5bfac8 commit 3c8cfd1

4 files changed

Lines changed: 125 additions & 1 deletion

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/rest": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(rest): make the API-exposure gate's metadata fail-open observable (#3545, #3391 follow-up)
7+
8+
The object API-exposure gate (`apiEnabled` / `apiMethods`) fails OPEN when object
9+
metadata can't be resolved, so a transient metadata outage doesn't 405 every
10+
request. #3545 evaluated the residual risk of that path and confirmed it is
11+
acceptable — the gate is a **surface-area control, not the authorization
12+
boundary**: every request still passes auth and the ObjectQL security middleware
13+
(CRUD / FLS / RLS) on the data call regardless of the gate's outcome, so a
14+
fail-open can never bypass data authorization.
15+
16+
The one gap was that the fail-open was **silent** — a persistent metadata fault
17+
(store down / corrupt schema doc), during which the gate allows every operation
18+
unchecked, looked identical to healthy operation.
19+
20+
- **rest** `loadObjectItems` now LOGS a *thrown* metadata read (a real fault)
21+
while leaving a legitimately-empty registry (a cold-start `[]`) silent — so a
22+
genuine outage is diagnosable without false alarms during normal startup. The
23+
behavior is unchanged (still returns `[]` → gate abstains → data path + security
24+
enforce).
25+
- **runtime** `api-exposure.ts` records the #3545 tiered decision in its
26+
contract doc: keep fail-open when the whole metadata service is unavailable
27+
(failing closed would break the cold-start window for no security gain); the
28+
narrow "object resolvable but its `enable` policy is present-yet-unreadable"
29+
widen (unreachable through Zod-validated registration) is deferred to the
30+
exposure-semantics window (#3543).
31+
32+
No contract or behavior change to the gate itself — observability + decision
33+
record only.

packages/rest/src/rest-server.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { prepareImportRequest, isMetaEnvelope } from './import-prepare.js';
4141

4242
// Node-safe logger — avoids importing 'console' which is absent from ES2020 lib typings.
4343
const logError = (...args: unknown[]) => (globalThis as any).console?.error(...args);
44+
const logWarn = (...args: unknown[]) => ((globalThis as any).console?.warn ?? (globalThis as any).console?.error)?.(...args);
4445

4546
/**
4647
* Metadata types whose user-facing labels are localized at the REST boundary
@@ -1159,7 +1160,23 @@ export class RestServer {
11591160
...(environmentId ? { environmentId } : {}),
11601161
});
11611162
return Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
1162-
} catch {
1163+
} catch (err) {
1164+
// [#3545] The API-exposure gate fails OPEN when object metadata can't
1165+
// be read: the exposure whitelist is a SURFACE-AREA control, not the
1166+
// authorization boundary (auth + CRUD/FLS/RLS still enforce on the
1167+
// data call, which needs the same metadata and surfaces the real
1168+
// error), and failing closed here would 405 every request during the
1169+
// normal cold-start window. But a THROWN read is a real fault
1170+
// (metadata store down / corrupt schema doc), NOT a legitimately-empty
1171+
// registry (a `[]` return, e.g. a fresh deployment) — so LOG it. Left
1172+
// silent, a persistent metadata outage, during which the gate allows
1173+
// every operation unchecked, is indistinguishable from healthy
1174+
// operation. Still returns `[]` (fail-open preserved). See #3545.
1175+
logWarn(
1176+
'[REST] api-exposure gate: object metadata read failed — failing open ' +
1177+
'(auth + CRUD/FLS/RLS still enforce on the data call)',
1178+
(err as Error)?.message ?? err,
1179+
);
11631180
return [];
11641181
}
11651182
}

packages/rest/src/rest.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2827,4 +2827,56 @@ describe('RestServer — object API exposure (apiEnabled / apiMethods)', () => {
28272827
expect(res.statusCode).toBe(404);
28282828
expect(protocol.createManyData).not.toHaveBeenCalled();
28292829
});
2830+
2831+
// [#3545] fail-open residual-risk decision: the exposure gate is a
2832+
// surface-area control (not the authz boundary — auth + CRUD/FLS/RLS still
2833+
// enforce on the data call), so an unresolvable metadata read fails OPEN to
2834+
// avoid 405ing every request during the cold-start window. The one change is
2835+
// OBSERVABILITY: a THROWN metadata read (a real fault) is logged, while a
2836+
// legitimately-empty registry stays silent — so a persistent outage, during
2837+
// which the gate silently allows every op, is no longer invisible.
2838+
describe('metadata-unavailable fail-open observability (#3545)', () => {
2839+
it('loadObjectItems fails OPEN (returns []) AND logs a THROWN metadata read', async () => {
2840+
const { rest, protocol } = setup(undefined);
2841+
protocol.getMetaItems = vi.fn().mockRejectedValue(new Error('metadata store down'));
2842+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
2843+
try {
2844+
const items = await (rest as any).loadObjectItems(protocol, undefined);
2845+
expect(items).toEqual([]); // fail-open preserved: gate abstains
2846+
expect(warnSpy).toHaveBeenCalled();
2847+
expect(String(warnSpy.mock.calls[0]?.[0] ?? '')).toContain('api-exposure gate');
2848+
} finally {
2849+
warnSpy.mockRestore();
2850+
}
2851+
});
2852+
2853+
it('loadObjectItems stays SILENT on a legitimately-empty registry (no false alarm)', async () => {
2854+
const { rest, protocol } = setup(undefined);
2855+
protocol.getMetaItems = vi.fn().mockResolvedValue([]); // empty, not thrown
2856+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
2857+
try {
2858+
const items = await (rest as any).loadObjectItems(protocol, undefined);
2859+
expect(items).toEqual([]);
2860+
expect(warnSpy).not.toHaveBeenCalled(); // distinguishes real fault from empty
2861+
} finally {
2862+
warnSpy.mockRestore();
2863+
}
2864+
});
2865+
2866+
it('enforceApiAccess does NOT block when the metadata read throws (fail-open)', async () => {
2867+
const { rest, protocol } = setup(undefined);
2868+
protocol.getMetaItems = vi.fn().mockRejectedValue(new Error('metadata store down'));
2869+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
2870+
try {
2871+
const res = makeRes();
2872+
const blocked = await (rest as any).enforceApiAccess(
2873+
{ params: { object: 'widget' } }, res, protocol, undefined, 'list',
2874+
);
2875+
expect(blocked).toBe(false); // request proceeds — data path + security enforce
2876+
expect(res.status).not.toHaveBeenCalled();
2877+
} finally {
2878+
warnSpy.mockRestore();
2879+
}
2880+
});
2881+
});
28302882
});

packages/runtime/src/api-exposure.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,28 @@
2727
* defaults (`apiEnabled` true, no `apiMethods`) and avoids breaking traffic when
2828
* metadata is briefly unavailable. The gate is a no-op for system/internal
2929
* contexts (callers pass `isSystem` and skip this check entirely).
30+
*
31+
* ## #3545 — fail-open residual-risk decision
32+
*
33+
* This gate is a SURFACE-AREA control (which API operations an object exposes),
34+
* NOT the authorization boundary — every request still passes auth and the
35+
* ObjectQL security middleware (CRUD / FLS / RLS) on the data call regardless of
36+
* the outcome here. So a fail-open on unresolvable metadata cannot bypass data
37+
* authorization; at worst an operation the author meant to HIDE from the API is
38+
* transiently reachable (still fully access-controlled). Given that, the tiered
39+
* decision is:
40+
* • Metadata service not ready / whole registry unavailable (cold start,
41+
* registration race, scoped kernel warming) → KEEP fail-open. Failing closed
42+
* would 405 every request during the normal startup window for no security
43+
* gain (the data call fails or is authorized independently). Callers that
44+
* read metadata (e.g. the REST `loadObjectItems`) LOG a thrown read so a
45+
* PERSISTENT outage is observable instead of a silent blanket-allow.
46+
* • Object resolvable but its `enable`/`apiMethods` is present-yet-unreadable
47+
* (a non-array policy) → `resolveEffectiveApiMethods` currently treats it as
48+
* `unrestricted` (silent widen). This path is unreachable through the
49+
* Zod-validated registration flow (only a raw/out-of-band metadata write
50+
* could produce it), so tightening it to fail-CLOSED is deferred to the
51+
* exposure-semantics window (#3543) rather than changed here unilaterally.
3052
*/
3153

3254
import {

0 commit comments

Comments
 (0)