Skip to content

Commit 5d4f2aa

Browse files
fix(core)!: an API key authenticates only as an owner who exists (#30783)
Clause-②: yes (narrowing) — a request authenticated by an API key whose owner has no user record is answered as a key that does not exist (the generic `401 UNAUTHENTICATED`), at every door that accepts a key, and never falls through to a session sent beside it. The widening half: the exported `ApiKeyRefusalReason` union gains `'owner_absent'`. Metadata-protocol: none Skills-docs: updated here — `content/docs/permissions/authentication.mdx` states the rule; the dogfood pin `api-key-owner-exists.dogfood.test.ts` exercises it. ## 维护者速读 - 改了什么:API key 只能以"仍然存在的所有者"身份通过认证。所有者没有用户记录的 key,在所有接受 key 的入口(REST 数据读写、key 管理写入、MCP 等)一律按"不存在的 key"作答(401),不会回落到同一请求携带的会话;key 行本身不改写、不吊销。 - 为什么改:落实 maintainer ruling 2026-11-05 (security#1 decision card) 的选项 B。key 是铸造者本人的凭据,所有者不存在,key 就不代表任何人。 - 风险与回滚:不新增任何读取,复用封禁检查本来就读的那一行 `sys_user`;没有 `sys_user` 表的存储会拒绝所有 key(有意为之,changeset 已写明);依赖此类 key 的集成会收到 401;回滚即还原本 PR。 - 先看:`packages/core/src/security/api-key.ts`(准入处 owner 读取之后新增的 `owner_absent` 段)、`packages/core/src/security/api-key-banned-owner.test.ts`、`packages/qa/dogfood/test/api-key-owner-exists.dogfood.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/api-key-owner-exists.dogfood.test.ts`,预期 3/3 通过。 - 需要你做的:无,席位落地。 ## What changes - **The rule.** An API key is a credential of the user who minted it, so it authenticates only as an owner who exists. A key whose owner has no user record is refused where a key becomes its owner (`resolveApiKeyAdmission`, the shared key-admission seam in `@objectstack/core` that every key-accepting door uses) as `refused` with the new reason `owner_absent`. On the wire it gets the answer a key that does not exist gets (`anonymousEntry` in `resolve-authz-context.ts`): the same status, code, message and `WWW-Authenticate`, on the REST data door (read and write), on the key-management write door (`PATCH /api/v1/data/sys_api_key/{id}`) and on the MCP door. A session sent beside the key never answers for it. - **Order and cost.** The key's own lifecycle (revoked / expired / inactive) still answers first with its located `403`. The owner question comes next, before the ban, and uses the same owner read the ban check already makes, so no read is added. A key whose owner exists is unaffected. - **Failure modes.** An owner read that throws is still the branded store outage (`503`). A store with no `sys_user` table has no owner who exists, so it refuses every API key. This is deliberate, and it is the same answer as a table that holds no row for the owner. - **What the operator sees.** Each refused request logs one server-side line through the existing refusal logger, `[security] API key refused (owner_absent)`, naming the key's row id and the owner id, so the key to revoke is named. The caller is told nothing beyond the generic `401`. - **Remedy for an affected integration.** Mint a replacement key while signed in as an existing user, and revoke the orphan keys in **Setup → API Keys**. - Docs: `content/docs/permissions/authentication.mdx` (API key section) states the rule. - Changeset: `.changeset/api-key-owner-must-exist.md` (`@objectstack/core`, patch, BREAKING narrowing banner, ADR-0087 `not-required (no-migration-prescription)`). Only `@objectstack/core` source moves; every other package in the diff changes tests only. ## Tests - `packages/core/src/security/api-key-banned-owner.test.ts` (26). Two pins flip: - "a key whose owner row is absent keeps today's answer" now reads "a key whose owner does not exist is refused (owner_absent), with no principal"; - "a store with no sys_user table holds no ban" now reads "a store with no sys_user table has no owner who exists: the key is refused as owner_absent". New cases: - the owner is read by id, as the system, once (the read the ban takes); - a refusal writes nothing; - the key's own lifecycle answers first; - an owner read that throws is still `503`; - control: the same key is admitted once its owner exists; - `resolveAuthzContext` resolves the same context a made-up key resolves and logs exactly one refusal line naming the key row and the owner; - the refusal never falls through to a session sent beside the key; - on a face that serves the guest, an absent owner's key and an unknown key get the same guest entry. - `packages/qa/dogfood/test/api-key-owner-exists.dogfood.test.ts` (3, showcase + MCP). - Control: while its owner exists, the key reads (200), writes (201) and opens an MCP session (200), and its own key change is refused `403 PERMISSION_DENIED`, as every key-authenticated key change is, with the key row unchanged. - With the owner's user record absent and the key row left in place, every door answers the key exactly as it answers a made-up key, comparing status, code, message and `WWW-Authenticate`. The doors are data read, data write, key change (`PATCH /data/sys_api_key/{id}`), MCP initialize, and a read with an administrator session sent beside the key. The refused write lands nothing, the key row is unchanged, and another member's key keeps working. - Control: once the owner's record is back, the same key is served again and the key row is not revoked. - Fake-engine fixtures that serve a key now also serve its owner's user row, through each fake's own `where` filter. The rule is never loosened inside a fake. This covers four core test files and 24 consumer test files across `mcp`, `plugin-sharing`, `rest`, `runtime`, `service-datasource`, `service-settings` and `service-storage`. `packages/runtime/src/security/resolve-execution-context.test.ts` types one fixture's table map. That repairs a recorded index-signature error, and the masked implicit-any it hid, so `packages/runtime/test-typecheck-debt.json` is re-recorded down by both. ## Acceptance notes - **Premise check.** The one published mint path, `POST /keys` (`packages/runtime/src/domains/keys.ts`), writes `user_id` from the signed-in session caller and requires a session credential. No seed or dataset writes `sys_api_key`. So no shipped path mints a key for a user who does not exist. - **Key change door, before this change.** A key-authenticated write to `sys_api_key` was already refused (`403 PERMISSION_DENIED`) for every key credential. The new pin shows that such a key now gets the made-up key's `401` there, before that check is reached. - **A key row that names no owner at all** (an empty `user_id`) still resolves as `none`, unchanged. - **A refused key with a session sent beside it answers `401`**, because the refused arm never consults the session. A made-up key with a session beside it falls through to the session. This is the existing refused-arm design, the same as for a banned owner's key today. ## Verification Head `c534ccc5e1`: the change, with `main` merged in at `9b24f3ca87` (a clean merge commit, no conflict). The merge changes nothing under `packages/core`, `packages/mcp`, `service-datasource` or `service-storage`. `packages/core/src/security/api-key.ts` has the same blob (`10cc26fd83fc`) at this head, at `d6d9728906` and at `f45f9c0083`, where the ablation was read. Every reading below was taken at `c534ccc5e1` after a full `pnpm build` of the merged tree (`Tasks: 118 successful, 118 total`, under the verify lock), unless it names another head. <details><summary>Dogfood battery</summary> At `c534ccc5e1`: `pnpm --filter @objectstack/dogfood test` under the verify lock on a shared box, with no shard and no path, after the full build. Verdict `command-exit 0`. - Test Files 424 passed | 3 skipped (427) - Tests 3535 passed | 41 skipped (3576) - No failures. None of `api-key-owner-exists.dogfood.test.ts`, `mcp-stdio-package-object-discovery.test.ts` or `mcp-stdio-runtime-object-exposure.test.ts` carries a skip guard, so all three are among the passed files. Earlier head: at `8444aac000` the battery had two red MCP stdio files (`mcp-stdio-package-object-discovery.test.ts`, `mcp-stdio-runtime-object-exposure.test.ts`). Each fake engine answered the key row but not its owner row. `d6d9728906` makes each fake answer the key's owner row, filtered by the id the read asks for, and they are green above. </details> <details><summary>Typecheck of the consumer packages</summary> At `c534ccc5e1`, under the verify lock, each `pnpm --filter PKG run typecheck`, with its script echoed in the log. Where the package's script declares them, this includes `check:test-typecheck` and the scripts/examples programs. - `@objectstack/core`: exit 0 - `@objectstack/runtime`: exit 0 - `@objectstack/rest`: exit 0 - `@objectstack/mcp`: exit 0 - `@objectstack/plugin-sharing`: exit 0 - `@objectstack/service-datasource`: exit 0 - `@objectstack/service-settings`: exit 0 - `@objectstack/service-storage`: exit 0 - `@objectstack/dogfood` (`--max-old-space-size=8192`): exit 0 9 of 9 exit 0. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `c534ccc5e1` derived 98 commands against merge base `9b24f3ca8` (37 paths, +517 / -50). Each was run on its own with stdin closed, and its exit code was recorded before any pipe. All 98 exit 0. - `check:type-check-debt` ran under the verify lock: 0 raw tsc errors, none above its record. - `--ran`: 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN. </details> <details><summary>Package suites and CI</summary> - CI at `c534ccc5e1`: 65 check-runs, 57 success, 8 skipped, 0 failure. The required contexts `Lint & Repo Gates`, `TypeScript Type Check`, `Test Core` (8/8 shards), `Build Core`, `Governed Surface Queue Guard`, `Type Check UI`, `Test UI` (16/16), `Build & E2E UI` and `Live E2E UI` are success. `Lint UI` is skipped. - Local package suites were not re-run at this head. NOT MEASURED: `rest`, `plugin-sharing`, `service-settings` and `runtime` (the packages the merge touched). Reason: three verify-lock attempts got no turn (queue-timeout, exit 99), behind another run that held the lock for over 27 minutes. `Test Core` above runs their full suites at this head. - Local suites at earlier heads, from before this merge: `core` 142/142 files, 3233 tests, and `runtime` 549/549 files, 9949 passed, 19 skipped, both at `8444aac000`. Also `rest` 395/395, `mcp` 56/56, `plugin-sharing` 67/67, `service-datasource` 72/72, `service-settings` 70/70 and `service-storage` 73/73 test files. The `core`, `mcp`, `service-datasource` and `service-storage` sources are unchanged by the merge. - Packages that read API keys through fakes but whose tests did not change (`cloud-connection`, `plugin-auth`, `plugin-hono-server`, `organizations`, `plugin-approvals`, `plugin-audit`, `plugin-security`, `plugin-webhooks`, `service-automation`, `trigger-schedule`, `client`): their key-fixture files passed at an earlier head of this branch. `Test Core` runs their full suites at this head. - Test budget: `api-key-owner-exists.dogfood.test.ts` measured 7.17 s and 7.27 s module duration (two local runs, shared box). That is under the 10 s line, so the file declares no budget. </details> <details><summary>Ablation (one-shot, not committed)</summary> Read at `f45f9c0083`. `api-key.ts` has the same blob at this head. Mutation `if ((!owner || typeof owner !== 'object') && (owner as any)?.__ablation_marker_r10 === 'never') {` through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `10cc26fd83fc` to `5d35544ec77d`); `pnpm --filter @objectstack/core build`; `ablation-dist-preflight.mjs @objectstack/core __ablation_marker_r10`: marker present in 2 built files. - unit `api-key-banned-owner.test.ts`: `Tests 7 failed | 19 passed (26)`, the seven being the owner-absent pins - dogfood `api-key-owner-exists.dogfood.test.ts`: `Tests 1 failed | 2 passed (3)` (the door case: `data read: expected { status: 200 … } to deeply equal { status: 401 … }`) Restore: blob after restore `10cc26fd83fc` equals HEAD and `git diff HEAD` is empty. Core was rebuilt, and the `--absent` preflight read the marker absent from all 14 built files, with the tree clean. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg <!-- signed: security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 780c6c3 commit 5d4f2aa

37 files changed

Lines changed: 517 additions & 50 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core)!: an API key authenticates only as an owner who exists
6+
7+
Clause-②: yes (narrowing) — a request authenticated by an API key whose owner has no user record is answered as a key that does not exist (the generic `401 UNAUTHENTICATED`), at every door that accepts a key, and never falls through to a session sent beside it. The widening half: the exported `ApiKeyRefusalReason` union gains `'owner_absent'`.
8+
Metadata-protocol: none
9+
Skills-docs: updated here — `content/docs/permissions/authentication.mdx` states the rule; the dogfood pin `api-key-owner-exists.dogfood.test.ts` exercises it.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and nothing at rest changes: the owner row the key admission already reads decides it, and no key row is revoked or rewritten. -->
12+
13+
**BREAKING** accept-set narrowing at every key-admitting door (`@objectstack/core`): an API key authenticates only as an owner who exists.
14+
15+
- **The rule.** An API key is a credential of the user who minted it. A key whose owner has no user record is a credential of no one: it is refused where a key becomes its owner (the shared key-admission seam), with the answer a key that does not exist gets, on the REST data door (read and write), the MCP door and every other door that resolves a key. It never falls through to a session sent beside it.
16+
- **A store with no `sys_user` table refuses every API key.** No owner exists in such a store, so no key authenticates there. This is deliberate, and it is the same answer as a store whose `sys_user` table holds no row for the key's owner.
17+
- **What the operator sees.** Each refused request logs one server-side line, `[security] API key refused (owner_absent)`, naming the key's row id and the owner id, so the key to revoke is named. The caller is told nothing beyond the generic `401`.
18+
- **Unchanged.** A key's own lifecycle still answers first: a revoked, expired or inactive key keeps its located `403`. An owner read that fails still answers `503`. A key whose owner exists is unaffected, and no read is added: the decision uses the owner read the ban check already makes.
19+
- **Who moves.** An integration running on a key whose owner has no user record gets `401`.
20+
21+
**The remedy.** Mint a replacement key while signed in as an existing user, and revoke the orphan keys in **Setup → API Keys**.

‎content/docs/permissions/authentication.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,13 @@ often before the account-level lock engages.
649649
> administrator's included. This is why a non-compliant user can sign in but
650650
> can't read or change data, or administer the platform, until they fix the
651651
> issue.
652+
>
653+
> **An API key authenticates only as an owner who exists.** A key whose owner
654+
> has no user record is answered on every face that admits keys exactly as a
655+
> key that does not exist (the generic `401 UNAUTHENTICATED`), a session sent
656+
> beside it never answers for it, and the server logs the refused key's row id
657+
> and owner id. The remedy is a new key minted by an existing user; revoke the
658+
> old key in **Setup → API Keys**.
652659
653660
#### Saving, clearing and lockout recovery
654661

‎packages/core/src/security/api-key-banned-owner.test.ts‎

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@
1313
* - not a revocation: nothing is written while the ban holds, and the same
1414
* key is admitted again once it is lifted;
1515
* - the key's own lifecycle still answers first;
16-
* - an owner read that throws is the branded store outage; a store with no
17-
* `sys_user` table holds no ban.
16+
* - an owner read that throws is the branded store outage.
17+
*
18+
* And, on the same owner read: an API key authenticates only as an owner who
19+
* exists (maintainer ruling 2026-11-05, option B). A key whose owner the read
20+
* does not find — a store with no `sys_user` table included — is `refused`
21+
* (`owner_absent`) exactly as a banned owner's key is: no principal, no
22+
* fall-through to the session path, the anonymous entry an unknown key gets,
23+
* nothing written, and one operator line naming the key row and the owner.
1824
*/
1925

2026
import { describe, it, expect, vi } from 'vitest';
@@ -141,9 +147,12 @@ describe('resolveApiKeyAdmission — a banned owner\'s key is refused while the
141147
expect((await admit(ql)).outcome).toBe('lifecycle');
142148
});
143149

144-
it('a key whose owner row is absent keeps today\'s answer — no row holds no ban', async () => {
150+
it('a key whose owner does not exist is refused (owner_absent), with no principal', async () => {
145151
const { ql } = makeQl(null);
146-
expect((await admit(ql)).outcome).toBe('admitted');
152+
const admission = await admit(ql);
153+
expect(admission).toMatchObject({ outcome: 'refused', reason: 'owner_absent', keyId: 'key_1', userId: OWNER });
154+
expect(admission).not.toHaveProperty('principal');
155+
expect(await resolveApiKeyPrincipal(ql, headers(), NOW, undefined, undefined, undefined)).toBeUndefined();
147156
});
148157

149158
it('an owner read that throws is the branded store outage, never an admission', async () => {
@@ -159,13 +168,56 @@ describe('resolveApiKeyAdmission — a banned owner\'s key is refused while the
159168
expect(err.code).toBe('SERVICE_UNAVAILABLE');
160169
});
161170

162-
it('a store with no sys_user table holds no ban', async () => {
163-
const { ql } = makeQl({ id: OWNER, banned: true });
171+
it('a store with no sys_user table has no owner who exists: the key is refused as owner_absent', async () => {
172+
const { ql } = makeQl({ id: OWNER, banned: false });
164173
const find = ql.find.bind(ql);
165174
ql.find = async (object: string, opts: any) => {
166175
if (object === 'sys_user') throw new Error('no such table: sys_user');
167176
return find(object, opts);
168177
};
178+
expect(await admit(ql)).toMatchObject({ outcome: 'refused', reason: 'owner_absent', keyId: 'key_1', userId: OWNER });
179+
});
180+
});
181+
182+
describe('resolveApiKeyAdmission — an API key authenticates only as an owner who exists', () => {
183+
it('reads the owner by id, as the system, once — the same read the ban takes', async () => {
184+
const { ql, reads } = makeQl(null);
185+
await admit(ql);
186+
const ownerReads = reads.filter((r) => r.object === 'sys_user');
187+
expect(ownerReads).toHaveLength(1);
188+
expect(ownerReads[0].opts).toMatchObject({ where: { id: OWNER }, limit: 1, context: { isSystem: true } });
189+
});
190+
191+
it('a refusal writes nothing: the key row is not revoked and its use is not stamped', async () => {
192+
const { ql, tables, update } = makeQl(null);
193+
const keyRowBefore = { ...tables.sys_api_key[0] };
194+
expect((await admit(ql)).outcome).toBe('refused');
195+
expect(update).not.toHaveBeenCalled();
196+
expect(tables.sys_api_key[0]).toEqual(keyRowBefore);
197+
});
198+
199+
it('the key\'s own lifecycle answers first: a revoked key whose owner does not exist is refused as revoked', async () => {
200+
const { ql } = makeQl(null, { revoked: true });
201+
expect((await admit(ql)).outcome).toBe('lifecycle');
202+
});
203+
204+
it('an owner read that throws is still the branded store outage, never a refusal or an admission', async () => {
205+
const { ql } = makeQl(null);
206+
const find = ql.find.bind(ql);
207+
ql.find = async (object: string, opts: any) => {
208+
if (object === 'sys_user') throw new Error('connection reset');
209+
return find(object, opts);
210+
};
211+
const err = await admit(ql).catch((e) => e);
212+
expect(isAuthzStoreUnavailableError(err)).toBe(true);
213+
expect(err.status).toBe(503);
214+
expect(err.code).toBe('SERVICE_UNAVAILABLE');
215+
});
216+
217+
it('control: the same key is admitted once its owner exists', async () => {
218+
const { ql, tables } = makeQl(null);
219+
expect((await admit(ql)).outcome).toBe('refused');
220+
tables.sys_user.push({ id: OWNER });
169221
expect((await admit(ql)).outcome).toBe('admitted');
170222
});
171223
});
@@ -210,6 +262,42 @@ describe('resolveAuthzContext — a banned owner\'s key resolves as a key that d
210262
}
211263
});
212264

265+
it('a key whose owner does not exist resolves the same context a made-up key resolves, and the operator line names the key and the owner', async () => {
266+
const { ql } = makeQl(null);
267+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
268+
try {
269+
const refusalLines = () => warn.mock.calls.flat().map(String).filter((l) => l.includes('API key refused'));
270+
const absent = await resolve(ql, KEY);
271+
expect(refusalLines()).toHaveLength(1);
272+
const madeUp = await resolve(ql, MADE_UP_KEY);
273+
expect(absent.userId).toBeUndefined();
274+
expect(absent.credential).toBeUndefined();
275+
expect(absent).toEqual(madeUp);
276+
// One line, server-side only, naming the reason, the key row id and the owner id.
277+
const [line] = refusalLines();
278+
expect(line).toContain('(owner_absent)');
279+
expect(line).toContain('key=key_1');
280+
expect(line).toContain(`principal=${OWNER}`);
281+
// The made-up key is not a refusal: it adds no line.
282+
expect(refusalLines()).toHaveLength(1);
283+
} finally {
284+
warn.mockRestore();
285+
}
286+
});
287+
288+
it('a key whose owner does not exist never falls through to a session sent beside it', async () => {
289+
const { ql } = makeQl(null);
290+
const getSession = vi.fn(async () => ({ user: { id: COOKIE_USER }, session: { userId: COOKIE_USER } }));
291+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
292+
try {
293+
const authz = await resolve(ql, KEY, getSession);
294+
expect(authz.userId).toBeUndefined();
295+
expect(getSession).not.toHaveBeenCalled();
296+
} finally {
297+
warn.mockRestore();
298+
}
299+
});
300+
213301
it('control: a non-banned owner\'s key resolves the owner, credential api_key', async () => {
214302
const { ql } = makeQl({ id: OWNER, banned: false });
215303
const authz = await resolve(ql, KEY);
@@ -238,10 +326,10 @@ describe('a refused key gets the anonymous entry an unknown key gets, on a face
238326
const ORG = 'org_only';
239327

240328
/** A guest anchor that grants something, in the one organization the guest entry derives. */
241-
function guestServingQl(keyRow: Record<string, unknown>, owner: Record<string, unknown>) {
329+
function guestServingQl(keyRow: Record<string, unknown>, owner: Record<string, unknown> | null) {
242330
const tables: Record<string, any[]> = {
243331
sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]', ...keyRow }],
244-
sys_user: [owner],
332+
sys_user: owner ? [owner] : [],
245333
sys_member: [],
246334
sys_organization: [{ id: ORG }],
247335
sys_metadata_activation: [],
@@ -293,4 +381,15 @@ describe('a refused key gets the anonymous entry an unknown key gets, on a face
293381
expect(banned.userId).toBeUndefined();
294382
expect(banned).toEqual(unknown);
295383
});
384+
385+
it('a key whose owner does not exist and an unknown key get the same guest entry', async () => {
386+
const ql = guestServingQl({}, null);
387+
const absent = await quietly(() => onGuestFace(ql, KEY));
388+
const unknown = await onGuestFace(ql, MADE_UP_KEY);
389+
// Non-vacuous: the guest anchor really grants something here.
390+
expect(unknown.permissions).toEqual([GUEST_SET]);
391+
expect(unknown.tenantId).toBe(ORG);
392+
expect(absent.userId).toBeUndefined();
393+
expect(absent).toEqual(unknown);
394+
});
296395
});

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,15 @@ interface Recorded { object: string; data: Record<string, unknown> }
5757
function makeQl(row: Record<string, unknown>, opts: { failUpdate?: boolean } = {}) {
5858
const updates: Recorded[] = [];
5959
const rows: any[] = [{ id: KEY_ID, key: hashApiKey(RAW), prefix: 'osk_lifecycl', user_id: OWNER, revoked: false, ...row }];
60+
// The key's owner exists: an API key authenticates only as an owner who exists.
61+
const tables: Record<string, any[]> = { sys_api_key: rows, sys_user: [{ id: OWNER }] };
6062
const ql = {
6163
updates,
6264
find: async (object: string, findOpts: any) => {
63-
if (object !== 'sys_api_key') return [];
65+
const table = tables[object];
66+
if (!table) return [];
6467
const where = findOpts?.where ?? {};
65-
const matched = rows.filter((r) => Object.entries(where).every(([k, v]) => {
68+
const matched = table.filter((r) => Object.entries(where).every(([k, v]) => {
6669
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
6770
return r[k] === v;
6871
}));

‎packages/core/src/security/api-key-read-outage.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,16 @@ function matchesWhere(row: any, where: any): boolean {
6363
return true;
6464
}
6565

66-
/** `sys_api_key` holds `rows`, matched by `where` and bounded by `limit`. */
66+
/**
67+
* `sys_api_key` holds `rows`, matched by `where` and bounded by `limit`;
68+
* `sys_user` holds the owners `rows` name (an API key authenticates only as an
69+
* owner who exists), matched the same way.
70+
*/
6771
function qlWithKeyRows(rows: any[]) {
72+
const owners = [...new Set(rows.map((r) => r.user_id).filter((id) => typeof id === 'string' && id))].map((id) => ({ id }));
6873
return {
6974
find: async (object: string, q: any = {}) => {
70-
const table = object === 'sys_api_key' ? rows : [];
75+
const table = object === 'sys_api_key' ? rows : object === 'sys_user' ? owners : [];
7176
const found = table.filter((r) => matchesWhere(r, q?.where));
7277
return typeof q?.limit === 'number' ? found.slice(0, q.limit) : found;
7378
},

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,30 @@ import {
1313
effectiveTenancyPosture,
1414
} from './api-key.js';
1515

16+
/**
17+
* The owners the key rows name, as `sys_user` rows: an API key authenticates
18+
* only as an owner who exists, so a fixture whose keys should authenticate
19+
* says their owners exist. Pass `users` to `makeQl` to say otherwise.
20+
*/
21+
function ownersOf(rows: any[]): any[] {
22+
return [...new Set(rows.map((r) => r.user_id).filter((id) => typeof id === 'string' && id))].map((id) => ({ id }));
23+
}
24+
1625
/**
1726
* In-memory sys_api_key store exposing the `find` shape the verifier uses.
1827
*
1928
* [commit 4c9780c7a] `limit` is ENFORCED — presence, not truthiness, so `limit: 0` returns
2029
* nothing rather than everything. A double that drops the bound makes any limit
2130
* change on this read green by construction; the verifier reads with `limit: 1`.
2231
*/
23-
function makeQl(rows: any[]) {
32+
function makeQl(rows: any[], users: any[] = ownersOf(rows)) {
33+
const tables: Record<string, any[]> = { sys_api_key: rows, sys_user: users };
2434
return {
2535
find: async (object: string, opts: any) => {
26-
if (object !== 'sys_api_key') return [];
36+
const table = tables[object];
37+
if (!table) return [];
2738
const where = opts?.where ?? {};
28-
const matched = rows.filter((r) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; }));
39+
const matched = table.filter((r) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; }));
2940
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
3041
},
3142
};

0 commit comments

Comments
 (0)