Skip to content

Commit 62e2b6d

Browse files
fix(core): a rejecting sys_api_key read is the store outage (503), not "no key", so a cookie beside the key never answers for it (#24397)
Fixes #24299 Clause-②: no (narrowing) (a key whose read fails is no longer taken as "no key", so the request no longer falls on to another credential; owes one `CONTRACT_REVIEW_TIER` review before the queue) ## What changes `resolveApiKeyAdmission` (`packages/core/src/security/api-key.ts`) read the key's `sys_api_key` row inside a bare `catch` that answered `{ outcome: 'none' }` ("this request carries no key") and logged nothing. That `catch` now raises the branded `AuthzStoreUnavailableError('sys_api_key', err)`, the answer the same function already gave when its lifecycle-policy source (`api_key_policy`) or owner-posture source (`auth_gate`) rejects. The driver's error is kept as `cause`. One read failure stays "no key": a `sys_api_key` table that was never provisioned, where no key can exist. That is the boundary the 2026-08-30 ruling (option A) draws for `resolveAuthzContext`'s `tryFind`, asked through the same shared `isMissingTableError(err, 'sys_api_key')`. No second classifier was added. The other `outcome: 'none'` exits are unchanged and pinned: no key header, no engine, an engine with no `find`, no matching row, a row with no `user_id`. No edit to `resolve-authz-context.ts` (fenced for PR #24373, `epic:#15194`) was needed. The throw leaves `resolveApiKeyAdmission` before the session arm runs, and every door already relays the branded outage as `503`. ## Measured first (M1), before the code changed The same harness was run on three doors that admit keys, on `origin/main` `d65826fa7c` and again after the change. Each case is either the engine's `sys_api_key` read rejecting with every other table healthy (`key read rejects`), or every read rejecting (`all reject`). The key belongs to `u_key` and the cookie to a different user, `u_cookie`. | door | fault | caller | before | after | |:--|:--|:--|:--|:--| | REST `GET /api/v1/data/:object` (real route, single-kernel wiring) | key read rejects | key only | `401 UNAUTHENTICATED` | `503 SERVICE_UNAVAILABLE` | | | | key + other user's cookie | **`200`, served as `u_cookie`** | `503`, data service never called | | | | session only | `200` as `u_cookie` | unchanged | | | all reject | key only / key + cookie / session | `401` / `503` / `503` | `503` / `503` / `503` | | runtime dispatcher (`resolveRequestScope` + `GET /api/v1/automation/_status`) | key read rejects | key only | wire `401`, scope guest | wire `503`, raises `sys_api_key` | | | | key + other user's cookie | **scope ran as `u_cookie`** (wire `501`, admitted) | wire `503`, raises `sys_api_key` | | | | session only | scope `u_cookie` | unchanged | | settings `GET /api/settings` (booted `SettingsServicePlugin`) | key read rejects | key only | `401 UNAUTHENTICATED` | `503 SERVICE_UNAVAILABLE` | | | | key + other user's cookie | `200` (the session read answered) | `503`, session never read | | | | session only | `200` | unchanged | **The cookie-beside-a-failed-key reading is now measured. A request that led with a key was admitted as the principal of a cookie it did not lead with,** on the REST data door and the runtime dispatcher, and served on the settings door. The triage grade (6249028135) named this reading as the point to re-raise; that call is the lane's. Hypotheses: H1 held (the throw alone gives every door measured `503`, through the handling the lifecycle-policy outage already takes). H2 held (the key-plus-cookie case answers `503` and the session is never read). ## Pins - `packages/core/src/security/api-key-read-outage.test.ts` (new): a rejecting read raises the branded outage (`code`, `status`, `object`, `cause`). Through `resolveAuthzContext`, a key plus a cookie rejects and `getSession` is called zero times. A never-provisioned table (SQLite and Postgres spellings) is still `none`. A missing-table phrase naming another relation stays loud. The other `none` exits are unchanged, and a control admits the healthy key. - `packages/rest/src/rest-api-key-read-outage.pin.test.ts` (new, door pin): the real REST data route. Key only answers `503 SERVICE_UNAVAILABLE`. Key plus cookie answers `503` and the data service is never called. Session only is unchanged. Controls: a healthy key is served as its owner, and an unprovisioned table answers `401`. - `packages/services/service-settings/src/settings-routes.api-key-read-outage.pin.test.ts` (new, door pin): `GET /api/settings` on the booted plugin, with the same rows as the REST pin. The session read count is 0 for key plus cookie. - `packages/runtime/src/http-dispatcher.tenancy-posture-outage.test.ts` (flipped by the sweep): the one existing pin that asserted the old answer. "A plain engine fault under a key degrades to a guest" now asserts the branded `sys_api_key` outage, with the superseded assertion quoted. Its intent, that every non-branded identity fault degrades to anonymous, moves to a session read that throws, which still resolves a guest. Pin sweep: I searched the error code and message across the repo for pins with the same meaning, and ran the suites of every package whose tests send an API key while a read rejects (list below). Only the runtime pin above asserted the old answer. No existing pin asserted `401` on a door for a key whose read fails. `authz-store-unavailable.test.ts`'s `TRANSPORT_LEDGER` needs no row: it enumerates callers of `resolveAuthzContext`, and none was added. ## Verification record Final head `3d826adf2` (merge base `c76e84922`). - **Suites** (after the change, before the merge of `origin/main` `c76e8492`): - `@objectstack/core`: 104 files, 2551 tests passed. - `@objectstack/rest`: 295 files, 6051 passed and 326 skipped. - `@objectstack/service-settings`: 50 files, 876 passed. - `@objectstack/service-storage`: 60 files, 1041 passed. - `@objectstack/service-datasource`: 45 files, 838 passed. - `@objectstack/runtime`: 1 failed and 420 passed (421 files). The one red was the predicted pin above; after the flip that file reads 11 passed. - Targeted files in `cloud-connection` (41), `plugin-sharing` (31), `organizations` (32) and `plugin-auth` (11): all passed. - **Ablation** (declared direction RED). `throw new AuthzStoreUnavailableError('sys_api_key', err);` was replaced by a `none` answer carrying the marker `ABLATION-24299`, through `scripts/ablation-replace.mjs` (marker 1, anchor 0). `@objectstack/core` was then rebuilt, and `ablation-dist-preflight` found the marker in 2 built files. Result: 8 red, every control green. - core pin: 3 failed, 3 passed. - REST pin: 2 failed, 3 passed. - settings pin: 2 failed, 3 passed. - runtime pin: 1 failed, 10 passed. Restore: blob equals HEAD (`13e1cdc2035`), `git diff HEAD` is empty, core was rebuilt, and the `--absent` preflight exited 0 with the tree clean. A first attempt used a comment marker, which the build strips. Its preflight refused, so that leg's readings were discarded and the leg was re-run with a string marker. - **Typecheck**, after the merge of `origin/main`: core, rest, runtime and service-settings all exit 0. `--listFiles` shows each new test compiled: `tsconfig.test.json` for core, rest and runtime, `tsconfig.json` for settings. - **Gates**, on `3d826adf2`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths derived 69 commands. All 69 exited 0, and each exit code was captured before any pipe. `--ran` reads 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. - The first pass, before the last test commit, turned up three real findings in the new tests, all corrected before that commit: `check-rest-log-spy-declared` (the REST pin now declares `OS_REST_LOG`), `check:objectql-double-limit` (a `find` double the probe could not drive), and `check:where-matcher` (two matchers that read a combinator as a field; both now refuse). - `check:dual-build-cjs-loads` first exited 3 because some dist was missing. After a whole-tree build (76 of 76 tasks) it exited 0. - Also: `eslint --no-inline-config` over the 5 changed `.ts` files exited 0, and `check-platform-checklist --changed-from origin/main` exited 0. ## Acceptance notes - `resolveApiKeyPrincipal`, the principal-or-nothing collapse, now raises the outage too instead of answering `undefined`. Its docblock says so. Its only in-repo users are re-exports. No caller in this repo catches its throw differently from the admission's. - The storage family's ADR-0069 gate reader calls `resolveApiKeyAdmission` directly. It had the same fall-through: with `none`, it went on to read the session's posture. The throw now reaches its door as the branded outage, which its comment already says the door relays as `503`. Its suite is green. - One changeset, on `@objectstack/core` (`minor`, breaking, `no-migration-prescription`). The rest, runtime and settings diffs are tests only, which no package publishes. --- _Generated by [Claude Code](https://claude.ai/code/session_01BXWZRDo9sKLCG7VehArSHR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d8e11b5 commit 62e2b6d

6 files changed

Lines changed: 517 additions & 9 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
fix(core)!: an API key whose `sys_api_key` read fails is answered as the store outage (`503 SERVICE_UNAVAILABLE`), never as "no key", so the request no longer falls on to a cookie sent beside the key
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes or stores moves: the change is the answer a request gets when the engine's read of `sys_api_key` throws. No spec key, export, option, object definition or stored row is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. -->
10+
11+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
`resolveApiKeyAdmission` (and `resolveApiKeyPrincipal` and `resolveAuthzContext`, which call it) read the key's row in a bare `catch` that answered "this request carries no key", and logged nothing. Two wrong answers followed on every door that admits API keys:
14+
15+
- **A key-only caller got `401 UNAUTHENTICATED`** during the outage, where a session caller got `503 SERVICE_UNAVAILABLE` for the same fault.
16+
- **A cookie sent beside the key answered for the request.** "No key" sent the request on to the session arm, so a request that led with a key was served as the cookie's user. Measured on the REST data door and the runtime dispatcher (served as the cookie's user) and on the settings door (`200`).
17+
18+
The read now raises the branded `AuthzStoreUnavailableError` (object `sys_api_key`, the driver's error kept as `cause`), the answer the same function already gave when its lifecycle-policy or owner-posture source failed. Every door relays it as `503 SERVICE_UNAVAILABLE`, and the session is never read in the key's place.
19+
20+
- **Unchanged.** A `sys_api_key` table that was never provisioned is still "no key": no key can exist there. This is the boundary `resolveAuthzContext` already draws for its permission reads (the shared `isMissingTableError`). No key header, no engine, no matching row and a row with no `user_id` also still answer "no key".
21+
22+
What to do. Nothing, unless a client treated `401` during a database outage as "the key is bad": it now sees `503` and should retry, not rotate the key.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24299] A `sys_api_key` read that THROWS is the branded store outage at the
5+
* shared key-admission seam (`resolveApiKeyAdmission`), never "no key".
6+
*
7+
* Before: the read sat in a bare `catch { return { outcome: 'none' }; }`. A key
8+
* holder got the anonymous floor's `401` where a session caller got `503`, and
9+
* `none` let the request fall on to the session arm, so a cookie sent beside
10+
* the key answered for it (measured: served as the cookie's user).
11+
*
12+
* What these pin:
13+
* - a rejecting read raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`
14+
* / 503) naming `sys_api_key`, with the driver's error kept as `cause`;
15+
* - through `resolveAuthzContext`, a cookie beside that key never answers:
16+
* the session read is never asked;
17+
* - a NEVER-PROVISIONED `sys_api_key` is still "no key", the boundary the
18+
* 2026-08-30 ruling (option A) draws for `tryFind` — and a missing-table
19+
* phrase naming another relation stays loud;
20+
* - the other `none` exits are unchanged: no header, no engine, no row, a
21+
* row with no `user_id`.
22+
*/
23+
24+
import { describe, expect, it } from 'vitest';
25+
26+
import { hashApiKey, resolveApiKeyAdmission } from './api-key.js';
27+
import {
28+
AUTHZ_STORE_UNAVAILABLE_CODE,
29+
AUTHZ_STORE_UNAVAILABLE_STATUS,
30+
isAuthzStoreUnavailableError,
31+
} from './authz-store-unavailable.js';
32+
import { resolveAuthzContext } from './resolve-authz-context.js';
33+
34+
const RAW = 'osk_read_outage_key';
35+
const OWNER = 'usr_key_owner';
36+
const KEY_ROW = { id: 'key_1', key: hashApiKey(RAW), user_id: OWNER, revoked: false, scopes: '[]' };
37+
const headers = (extra: Record<string, string> = {}) => ({ 'x-api-key': RAW, ...extra });
38+
39+
/** `sys_api_key` reads throw `fault()`; every other table is empty. */
40+
function qlWhoseKeyReadThrows(fault: () => unknown) {
41+
return {
42+
find: async (object: string) => {
43+
if (object === 'sys_api_key') throw fault();
44+
return [];
45+
},
46+
};
47+
}
48+
49+
/** Equality and `$in` — the two shapes the resolver's reads use here; any other operator is refused, never matched. */
50+
function matchesWhere(row: any, where: any): boolean {
51+
for (const [field, cond] of Object.entries(where ?? {})) {
52+
if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`);
53+
if (cond !== null && typeof cond === 'object') {
54+
const ops = Object.keys(cond as object);
55+
if (ops.length !== 1 || ops[0] !== '$in' || !Array.isArray((cond as any).$in)) {
56+
throw new Error(`fixture where-matcher: unsupported operator shape on '${field}'`);
57+
}
58+
if (!(cond as any).$in.includes(row[field])) return false;
59+
continue;
60+
}
61+
if (row[field] !== cond) return false;
62+
}
63+
return true;
64+
}
65+
66+
/** `sys_api_key` holds `rows`, matched by `where` and bounded by `limit`. */
67+
function qlWithKeyRows(rows: any[]) {
68+
return {
69+
find: async (object: string, q: any = {}) => {
70+
const table = object === 'sys_api_key' ? rows : [];
71+
const found = table.filter((r) => matchesWhere(r, q?.where));
72+
return typeof q?.limit === 'number' ? found.slice(0, q.limit) : found;
73+
},
74+
};
75+
}
76+
77+
const settle = (p: Promise<unknown>) => p.then((v) => ({ ok: true as const, v }), (e) => ({ ok: false as const, e }));
78+
79+
describe('[#24299] a rejecting sys_api_key read is the store outage, not "no key"', () => {
80+
it('raises AuthzStoreUnavailableError (SERVICE_UNAVAILABLE / 503) naming sys_api_key, the driver error kept as cause', async () => {
81+
const driverFault = new Error('connection terminated unexpectedly');
82+
const r = await settle(resolveApiKeyAdmission(qlWhoseKeyReadThrows(() => driverFault), headers(), Date.now(), undefined, undefined, undefined));
83+
expect(r.ok, 'the admission RESOLVED — the failed read was answered as a verdict').toBe(false);
84+
const e = (r as any).e;
85+
expect(isAuthzStoreUnavailableError(e)).toBe(true);
86+
expect(e.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
87+
expect(e.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
88+
expect(e.object).toBe('sys_api_key');
89+
expect(e.cause).toBe(driverFault);
90+
});
91+
92+
it('through resolveAuthzContext, a cookie beside the key never answers: the session is never read', async () => {
93+
let sessionReads = 0;
94+
const r = await settle(resolveAuthzContext({
95+
ql: qlWhoseKeyReadThrows(() => new Error('engine down')),
96+
headers: headers({ cookie: 'session=other-user' }),
97+
getSession: async () => { sessionReads += 1; return { user: { id: 'usr_cookie' }, session: { userId: 'usr_cookie' } } as any; },
98+
userAuthGates: undefined,
99+
apiKeyLifecycle: undefined,
100+
}));
101+
expect(r.ok, 'the context RESOLVED — the request fell on to another credential').toBe(false);
102+
expect(isAuthzStoreUnavailableError((r as any).e)).toBe(true);
103+
expect((r as any).e.object).toBe('sys_api_key');
104+
expect(sessionReads).toBe(0);
105+
});
106+
107+
it('a NEVER-PROVISIONED sys_api_key is still "no key" (ruling 2026-08-30, option A)', async () => {
108+
for (const fault of [
109+
() => new Error('no such table: sys_api_key'),
110+
() => Object.assign(new Error('relation "sys_api_key" does not exist'), { code: '42P01' }),
111+
]) {
112+
const a = await resolveApiKeyAdmission(qlWhoseKeyReadThrows(fault), headers(), Date.now(), undefined, undefined, undefined);
113+
expect(a).toEqual({ outcome: 'none' });
114+
}
115+
});
116+
117+
it('a missing-table phrase naming ANOTHER relation is no evidence about sys_api_key, and stays loud', async () => {
118+
const r = await settle(resolveApiKeyAdmission(
119+
qlWhoseKeyReadThrows(() => new Error('no such table: main.some_other_base')),
120+
headers(), Date.now(), undefined, undefined, undefined,
121+
));
122+
expect(r.ok).toBe(false);
123+
expect(isAuthzStoreUnavailableError((r as any).e)).toBe(true);
124+
});
125+
});
126+
127+
describe('[#24299] the other "none" exits are unchanged', () => {
128+
it('no key header, no engine, an engine with no find, no row, and a row with no user_id each answer none', async () => {
129+
const args = [Date.now(), undefined, undefined, undefined] as const;
130+
expect(await resolveApiKeyAdmission(qlWithKeyRows([KEY_ROW]), {}, ...args)).toEqual({ outcome: 'none' });
131+
expect(await resolveApiKeyAdmission(undefined, headers(), ...args)).toEqual({ outcome: 'none' });
132+
expect(await resolveApiKeyAdmission({}, headers(), ...args)).toEqual({ outcome: 'none' });
133+
expect(await resolveApiKeyAdmission(qlWithKeyRows([]), headers(), ...args)).toEqual({ outcome: 'none' });
134+
expect(await resolveApiKeyAdmission(qlWithKeyRows([{ ...KEY_ROW, user_id: undefined }]), headers(), ...args)).toEqual({ outcome: 'none' });
135+
});
136+
137+
it('control: the same key, read healthily, is admitted as its owner', async () => {
138+
const a = await resolveApiKeyAdmission(qlWithKeyRows([KEY_ROW]), headers(), Date.now(), undefined, undefined, undefined);
139+
expect(a.outcome).toBe('admitted');
140+
expect((a as any).principal.userId).toBe(OWNER);
141+
});
142+
});

‎packages/core/src/security/api-key.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type { TenancyPosture } from '@objectstack/spec/security';
2828
import type { ApiKeyLifecyclePolicy } from '@objectstack/spec/contracts';
2929

3030
import { accountScopeAuthGate, type AuthGate, type UserAuthGateSource } from './auth-gate.js';
31+
import { isMissingTableError } from '@objectstack/types';
3132
import { AuthzStoreUnavailableError } from './authz-store-unavailable.js';
3233

3334
/** Default visible prefix for generated keys (helps users identify a key). */
@@ -587,10 +588,12 @@ export function effectiveTenancyPosture(
587588
* the verdict of {@link resolveApiKeyAdmission}, collapsed to a principal.
588589
*
589590
* Fail-closed: returns `undefined` for a missing key, an unusable data engine,
590-
* a lookup error, a key that is unknown / owner-less, a key its own lifecycle
591+
* a key that is unknown / owner-less, a key its own lifecycle
591592
* refuses (revoked / expired / inactive — `lifecycle`), a key the tenancy
592593
* posture refuses, and a key whose OWNER holds an `account`-scope ADR-0069
593-
* posture (`held`).
594+
* posture (`held`). A `sys_api_key` read that THROWS is no answer at all: it
595+
* raises the branded `AuthzStoreUnavailableError` (#24299), as an unreadable
596+
* lifecycle-policy or posture source already does.
594597
*
595598
* ⚠️ The collapse is lossy by construction: a principal-or-nothing answer cannot
596599
* carry the posture's code, so a host that admits keys through this function
@@ -696,8 +699,22 @@ export async function resolveApiKeyAdmission(
696699
limit: 1,
697700
context: { isSystem: true },
698701
});
699-
} catch {
700-
return { outcome: 'none' };
702+
} catch (err) {
703+
// [#24299] A read that was ISSUED and THREW is the branded store outage,
704+
// never "no key". `none` here sent a key holder the anonymous floor's
705+
// `401` where a session caller got `503`, and let the request fall on to
706+
// the session arm, so a cookie sent beside the key answered for a
707+
// credential the caller did not lead with (measured: served as the
708+
// cookie's user on the REST data door and the runtime dispatcher).
709+
//
710+
// The one read failure that IS a true "no key" is a `sys_api_key` table
711+
// that was never provisioned: no key can exist there, so nothing was
712+
// withheld. That is the boundary the 2026-08-30 ruling (option A) draws
713+
// for `resolveAuthzContext`'s `tryFind`, asked through the same
714+
// `isMissingTableError` predicate — ⛔ never a second classifier here;
715+
// its signed-off false-positive risk is written beside `tryFind`.
716+
if (isMissingTableError(err, 'sys_api_key')) return { outcome: 'none' };
717+
throw new AuthzStoreUnavailableError('sys_api_key', err);
701718
}
702719
if (rows && (rows as any).value) rows = (rows as any).value;
703720
const row = Array.isArray(rows) ? rows[0] : undefined;
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24299] The REST data door answers a `sys_api_key` read that REJECTS as the
5+
* store outage — `503 SERVICE_UNAVAILABLE`, the answer a session caller
6+
* already got for the same fault — on the real `GET /api/v1/data/:object`.
7+
*
8+
* Measured on the unrepaired tree through this harness, `sys_api_key` reads
9+
* rejecting and every other table healthy:
10+
*
11+
* caller | before | after
12+
* --------------------------------|-----------------------------|------------------------
13+
* key only | 401 UNAUTHENTICATED | 503 SERVICE_UNAVAILABLE
14+
* key + another user's cookie | 200, served AS the cookie's | 503, the service is
15+
* | user | never called
16+
* session only | 200 as the cookie's user | unchanged
17+
*
18+
* The middle row is the defect's worse half: `resolveApiKeyAdmission` answered
19+
* the failed read "no key", so the request fell on to the session arm and a
20+
* credential the caller did not lead with answered for it.
21+
*
22+
* Controls: a healthy key is served as its owner, and a NEVER-PROVISIONED
23+
* `sys_api_key` stays "no key" (the anonymous floor's 401), the boundary the
24+
* 2026-08-30 ruling (option A) draws.
25+
*/
26+
27+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
28+
import { hashApiKey } from '@objectstack/core';
29+
import { RestServer } from './rest-server.js';
30+
31+
const KEY = 'osk_rest_read_outage_pin';
32+
const KEY_OWNER = 'u_key_owner';
33+
const COOKIE_USER = 'u_cookie';
34+
const DATA_COLLECTION = '/api/v1/data/:object';
35+
36+
type KeyRead = 'healthy' | 'rejects' | 'unprovisioned';
37+
38+
/** Equality and `$in` — the two shapes the resolver's reads use here; any other operator is refused, never matched. */
39+
function matchesWhere(row: any, where: any): boolean {
40+
for (const [field, cond] of Object.entries(where ?? {})) {
41+
if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`);
42+
if (cond !== null && typeof cond === 'object') {
43+
const ops = Object.keys(cond as object);
44+
if (ops.length !== 1 || ops[0] !== '$in' || !Array.isArray((cond as any).$in)) {
45+
throw new Error(`fixture where-matcher: unsupported operator shape on '${field}'`);
46+
}
47+
if (!(cond as any).$in.includes(row[field])) return false;
48+
continue;
49+
}
50+
if (row[field] !== cond) return false;
51+
}
52+
return true;
53+
}
54+
55+
function makeQl(keyRead: KeyRead) {
56+
const tables: Record<string, any[]> = {
57+
sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: KEY_OWNER, revoked: false, scopes: '[]' }],
58+
sys_member: [],
59+
sys_user: [{ id: KEY_OWNER }, { id: COOKIE_USER }],
60+
sys_user_permission_set: [],
61+
sys_permission_set: [],
62+
};
63+
return {
64+
find: async (object: string, q: any = {}) => {
65+
if (object === 'sys_api_key' && keyRead === 'rejects') throw new Error('connection terminated unexpectedly');
66+
if (object === 'sys_api_key' && keyRead === 'unprovisioned') throw new Error('no such table: sys_api_key');
67+
const rows = (tables[object] ?? []).filter((row: any) => matchesWhere(row, q?.where));
68+
return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows;
69+
},
70+
update: async () => ({}),
71+
};
72+
}
73+
74+
/** The single-kernel wiring: an auth provider whose session read answers the cookie's user, and the objectql provider. */
75+
function setup(keyRead: KeyRead) {
76+
const servedAs: Array<string | undefined> = [];
77+
const protocol: any = {
78+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }),
79+
getMetaTypes: vi.fn().mockResolvedValue([]),
80+
getMetaItems: vi.fn().mockResolvedValue([]),
81+
getMetaItem: vi.fn().mockResolvedValue({}),
82+
findData: vi.fn(async (r: any) => { servedAs.push(r?.context?.userId); return { value: [], total: 0 }; }),
83+
};
84+
const server: any = {
85+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
86+
listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
87+
};
88+
const authServiceProvider = async () => ({
89+
api: {
90+
getSession: async ({ headers }: { headers: Headers }) => (
91+
headers.get('cookie') ? { user: { id: COOKIE_USER }, session: { userId: COOKIE_USER } } : null
92+
),
93+
},
94+
});
95+
const ql = makeQl(keyRead);
96+
const rest = new RestServer(server, protocol, {} as any, undefined, undefined, undefined, authServiceProvider, async () => ql);
97+
rest.registerRoutes();
98+
return { rest, servedAs };
99+
}
100+
101+
async function get(rest: any, headers: Record<string, string>) {
102+
const res: any = { statusCode: 200, body: undefined };
103+
res.status = (c: number) => { res.statusCode = c; return res; };
104+
res.json = (b: any) => { res.body = b; return res; };
105+
res.header = () => res; res.setHeader = () => {}; res.write = () => {}; res.end = () => {}; res.send = () => {};
106+
const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === DATA_COLLECTION);
107+
if (!route) throw new Error(`GET ${DATA_COLLECTION} route not registered`);
108+
await route.handler({ method: 'GET', path: '/api/v1/data/account', params: { object: 'account' }, query: {}, headers }, res);
109+
return { status: res.statusCode as number, body: res.body };
110+
}
111+
112+
// This file silences the REST fault log it does not assert on, so it declares the level it runs at.
113+
beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); });
114+
afterAll(() => { vi.unstubAllEnvs(); });
115+
116+
let errorSpy: ReturnType<typeof vi.spyOn>;
117+
let warnSpy: ReturnType<typeof vi.spyOn>;
118+
beforeEach(() => {
119+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
120+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
121+
});
122+
afterEach(() => { errorSpy.mockRestore(); warnSpy.mockRestore(); });
123+
124+
describe('[#24299] REST data door — a rejecting sys_api_key read is the store outage', () => {
125+
it('key only: 503 SERVICE_UNAVAILABLE, not the anonymous floor\'s 401', async () => {
126+
const { rest, servedAs } = setup('rejects');
127+
const { status, body } = await get(rest, { 'x-api-key': KEY });
128+
expect(status).toBe(503);
129+
expect(body?.code).toBe('SERVICE_UNAVAILABLE');
130+
expect(servedAs).toEqual([]);
131+
});
132+
133+
it('key + another user\'s cookie: 503, and the request is never served as the cookie\'s user', async () => {
134+
const { rest, servedAs } = setup('rejects');
135+
const { status, body } = await get(rest, { 'x-api-key': KEY, cookie: 'session=other-user' });
136+
expect(status).toBe(503);
137+
expect(body?.code).toBe('SERVICE_UNAVAILABLE');
138+
expect(servedAs, 'the data service was called — a credential the caller did not lead with answered').toEqual([]);
139+
});
140+
141+
it('session only: unchanged — the same fault does not touch a caller who sends no key', async () => {
142+
const { rest, servedAs } = setup('rejects');
143+
const { status } = await get(rest, { cookie: 'session=other-user' });
144+
expect(status).toBe(200);
145+
expect(servedAs).toEqual([COOKIE_USER]);
146+
});
147+
148+
it('control: a healthy key read is served as the key\'s owner, cookie or not', async () => {
149+
const { rest, servedAs } = setup('healthy');
150+
expect((await get(rest, { 'x-api-key': KEY })).status).toBe(200);
151+
expect((await get(rest, { 'x-api-key': KEY, cookie: 'session=other-user' })).status).toBe(200);
152+
expect(servedAs).toEqual([KEY_OWNER, KEY_OWNER]);
153+
});
154+
155+
it('control: a never-provisioned sys_api_key is still "no key" — the anonymous floor\'s 401', async () => {
156+
const { rest, servedAs } = setup('unprovisioned');
157+
const { status, body } = await get(rest, { 'x-api-key': KEY });
158+
expect(status).toBe(401);
159+
expect(body?.code).toBe('UNAUTHENTICATED');
160+
expect(servedAs).toEqual([]);
161+
});
162+
});

0 commit comments

Comments
 (0)