Skip to content

Commit 79bf748

Browse files
harden(keys): minting an API key requires an interactive session (#27536)
Dispatched by the W3-C3b QA run security path (detail withheld) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:创建 API key(`POST /api/v1/keys`)现在只接受登录会话发起的请求;用 API key 认证的请求一律 403 `PERMISSION_DENIED`,不写任何数据。 - 为什么改:安全加固。创建凭据属于凭据生命周期操作,应当由账号本人登录后完成,不应由一把 key 代办。 - 风险与回滚:风险在于曾用 API key 调用该接口的脚本会改为收到 403;控制台、Connect an Agent 页面和带会话的调用不受影响。回滚 = revert 本 PR。 - 先看:`packages/runtime/src/domains/keys.ts`(新增的会话判定,紧跟匿名判定之后、读取请求体之前);`packages/runtime/src/domains/keys-mint-interactive-session.pin.test.ts`;`packages/runtime/src/api-key-lifecycle.pin.test.ts` 中改写的 control 用例。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run --project local src/domains/keys-mint-interactive-session.pin.test.ts`,预期 14 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes `POST /api/v1/keys` mints only for a principal that a signed-in session admitted. - `packages/runtime/src/domains/keys.ts`: right after the anonymous decision (ADR-0138 D2) and before the body is read, the domain asks `HttpProtocolContext.admission.credential`, which the dispatcher's identity step writes from the shared resolver (`resolveAuthzContext`'s `credential`, via `onAdmission`). Anything other than `'session'` is refused `403`. That covers an API-key admission and also an absent admission, because a credential the door was never told about is not a session. The check is positive on purpose, so a credential kind added later is refused until it is admitted explicitly. - The refusal is domain-wide, so every verb from a key-admitted caller gets the 403 instead of the 405. A session-admitted caller still gets the 405 for a wrong verb. - Error code: the existing status-derived `PERMISSION_DENIED`. This route already answers it when the caller is not a member of their active organization. No new code, no ledger change. - The module header and one inline comment are updated to match. The comment had described the active organization as possibly coming from a minting key's stamp, and only a session reaches that line now. - Changeset: `@objectstack/runtime` patch. It names who is affected and tells embedders that call `HttpDispatcher.handleKeys` directly to record the admitting credential. ## Tests - New: `packages/runtime/src/domains/keys-mint-interactive-session.pin.test.ts` (14 cases). - Through `dispatch()`: session mint is 201 with one row pinned to the session user. An API-key admission in each accepted header spelling (`x-api-key`, `Authorization: ApiKey`, `Authorization: Bearer osk_…`) is refused 403 `PERMISSION_DENIED` with nothing written. A cookie beside the key does not lift the refusal. Every verb gets the 403. Controls: a session wrong verb gets 405, anonymous gets 401 `UNAUTHENTICATED`. - At `handleKeys`: no admission, or an `api_key` admission, is refused with the body unread and no service resolved. A `session` admission mints. - Fixture triage. Hand-built "signed-in" contexts in seven runtime test files never said which credential admitted them. Each one now declares `admission: { credential: 'session', authGate: undefined }`. No assertion moved. - One assertion was rewritten: the control `the same key, live, is served` in `api-key-lifecycle.pin.test.ts` used a live key to mint. It now pins what it exists to prove. The live key passes the identity step: `dispatch()` resolves, the admission is `api_key` and the principal is the key's owner. The door then answers with its own rule (403 `PERMISSION_DENIED`), never a lifecycle code. - Ablation, run once on the committed fix (subject reached through relative `src` imports, so no dist rebuild was involved). `node scripts/ablation-replace.mjs` replaced the session condition with `if (false)`: anchor 1 to 0, blob `2d6f7ad939f2` to `96dff2464c80`. Result: 11 of the 28 cases across the new pin file and the lifecycle pin failed (every refusal case and the rewritten control), and every control stayed green. Restore: blob equal to HEAD and `git diff HEAD` empty. <details><summary>Runtime suite readings</summary> - Before the fixture triage (first full run, fix only): `Test Files 7 failed | 464 passed (471)`, `Tests 32 failed | 7636 passed | 19 skipped`. All 32 failures were hand-built signed-in fixtures without an admission, plus the live-key mint control. - Affected files after the triage (11 files, including the hono every-verb integration and the account-posture pin): `Test Files 11 passed (11)`, `Tests 502 passed (502)`, lock VERDICT command-exit 0. - `pnpm --filter @objectstack/runtime typecheck`: exit 0. `check:test-typecheck` OK: 27 files, 190 errors, 68 pinned signatures, unchanged ledger. - Full suite on the final head: at `11fff72f`, `pnpm --filter @objectstack/runtime exec vitest run --project local` gave `Test Files 472 passed (472)` and `Tests 7682 passed | 19 skipped (7701)`, lock VERDICT command-exit 0. </details> ## Acceptance notes - `packages/qa/dogfood/test/authz-conformance.matrix.ts`: the `anonymous-deny-keys` row's note says "a signed-in caller reaches the mint exactly as before", which is still true for a session. This PR adds no row for the session requirement (outside this dispatch's file surface). Carrier: the dispatching seat. - The docs that teach minting (`content/docs/api/index.mdx`, `content/docs/ai/connect-mcp.mdx`, `content/docs/api/client-sdk.mdx`) already show session-based minting. None of them says a key cannot mint. A one-line mention there is a follow-up, outside this dispatch's file surface. Carrier: the dispatching seat. - No `ui/` change: the Console's mint calls (`AgentConnectSection.tsx`, the `create_api_key` action) send the session cookie. ## Verification <details><summary>Local gate families (dispatch-gates, final head)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 60 commands for the 10 changed paths. All 60 were run. 58 exited 0. - Two were NOT MEASURED (exit 3, prerequisite refusals unrelated to this diff): - `node scripts/check-plugin-teardown-shape.mjs --self-test` needs an unshallowed clone. It is checker health only. - `pnpm check:dual-build-cjs-loads` needs `dist/` for seven packages outside this diff's build closure. - `--ran` reconciliation: `60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`. - Every CI-run family (Lint and Repo Gates, TypeScript Type Check, Test Core, Build Core and the rest) is CI's to report on this PR. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcVkGJizGH7raJHhAvL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2cd9304 commit 79bf748

10 files changed

Lines changed: 310 additions & 8 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
harden(keys): minting an API key requires an interactive session
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`POST /api/v1/keys` now creates a key only for a caller that a signed-in session authenticated. A request authenticated with an API key (`x-api-key`, `Authorization: ApiKey …` or `Authorization: Bearer osk_…`) is refused `403 PERMISSION_DENIED` on every verb, and nothing is written. A session cookie sent beside the key does not change that: the key is the credential that authenticated the request.
11+
12+
- **Who is affected.** Only a script or integration that called `POST /api/v1/keys` (or the SDK's `client.keys.create()`) with an API key. Signing in and creating the key from the API Keys page or the Connect an Agent page, or calling the route with a session cookie or session bearer token, works as before. A key keeps acting as its owner for data, automation and MCP.
13+
- **What the refusal says.** The error envelope's `code` is `PERMISSION_DENIED`, the same code the route already answers when a caller is not a member of their active organization, and the message tells the caller to sign in and create the key there.
14+
- **Embedders calling `HttpDispatcher.handleKeys` directly.** The handler reads which credential authenticated the caller from `HttpProtocolContext.admission`, which `dispatch()` fills during its identity step. A context passed in with no `admission` is refused as well. Set `admission: { credential: 'session', authGate: undefined }` only for a principal your host authenticated with a signed-in session.

‎packages/runtime/src/api-key-lifecycle.pin.test.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ const caller = () => ({
7676
response: {},
7777
environmentId: undefined,
7878
executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [] },
79+
// A signed-in caller: the credential the dispatcher's identity step records.
80+
admission: { credential: 'session' as const, authGate: undefined },
7981
});
8082

8183
function responseOf(res: HttpDispatcherResult): NonNullable<HttpDispatcherResult['response']> {
@@ -242,11 +244,19 @@ describe('[#23367] the dispatcher face relays a key its lifecycle refuses, by br
242244
expect(inserted).toEqual([]);
243245
});
244246

245-
it('control: the same key, live, is served', async () => {
247+
it('control: the same key, live, passes the identity step — admitted as its owner, no lifecycle refusal', async () => {
246248
const { dispatcher, inserted } = dispatcherWithKey({ expires_at: '2999-01-01T00:00:00.000Z' });
247-
const result: any = await dispatcher.dispatch('POST', ROUTE, { name: 'x' }, {}, pctx(post({ 'x-api-key': KEY })));
248-
expect(result.response?.status).toBe(201);
249-
expect(inserted).toHaveLength(1);
249+
const ctx = pctx(post({ 'x-api-key': KEY }));
250+
// `dispatch()` RESOLVES: nothing branded leaves the identity step for a
251+
// live key, so the refusals above are the key's lifecycle and nothing else.
252+
const result: any = await dispatcher.dispatch('POST', ROUTE, { name: 'x' }, {}, ctx);
253+
expect(ctx.admission?.credential).toBe('api_key');
254+
expect(ctx.executionContext?.userId).toBe('u_owner');
255+
// What answers it is the mint door's own rule (a key does not mint keys),
256+
// never a lifecycle code.
257+
expect(result.response?.status).toBe(403);
258+
expect(result.response?.body?.error?.code).toBe('PERMISSION_DENIED');
259+
expect(inserted).toEqual([]);
250260
});
251261
});
252262

‎packages/runtime/src/domain-handler-registry.test.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,11 @@ describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => {
358358
expect(anonymousWrongMethod.response?.status).toBe(401);
359359
expect(anonymousWrongMethod.response?.body?.error?.code).toBe('UNAUTHENTICATED');
360360
// …and the 405 still answers a signed-in caller.
361-
const signedIn = { request: { headers: {} }, executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [] } };
361+
const signedIn = {
362+
request: { headers: {} },
363+
executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [] },
364+
admission: { credential: 'session', authGate: undefined },
365+
};
362366
const wrongMethod = await dispatcher.handleKeys('GET', undefined, signedIn as any);
363367
expect(wrongMethod.response?.status).toBe(405);
364368
// A claimed /keysfoo would now answer the floor's 401 (or the 405), so
@@ -375,7 +379,11 @@ describe('HttpDispatcher extracted domains (PR-3: keys/storage/ui)', () => {
375379
getObjects: vi.fn().mockReturnValue({}),
376380
registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) },
377381
};
378-
const context: any = { executionContext: { userId: 'caller-1' } };
382+
const context: any = {
383+
executionContext: { userId: 'caller-1' },
384+
// A signed-in caller: the credential the dispatcher's identity step records.
385+
admission: { credential: 'session', authGate: undefined },
386+
};
379387
// Direct delegate call — dispatch() would re-resolve identity off the
380388
// auth-less mock kernel and overwrite the seeded executionContext.
381389
const result = await makeDispatcher({ objectql }).handleKeys('POST', { name: 'CI Key', user_id: 'attacker' }, context);

‎packages/runtime/src/domains/domain-floor-anonymous-deny.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,12 @@ const guestEnvelope = () => ({
6161
},
6262
}) as unknown as HttpProtocolContext;
6363
const MEMBER_EC = { userId: 'usr_member', isSystem: false, positions: [], permissions: [], systemPermissions: [] };
64-
const member = () => ({ request: { headers: {} }, executionContext: { ...MEMBER_EC } }) as unknown as HttpProtocolContext;
64+
/** A signed-in member, admitted by a session (the credential the dispatcher's identity step records). */
65+
const member = () => ({
66+
request: { headers: {} },
67+
executionContext: { ...MEMBER_EC },
68+
admission: { credential: 'session', authGate: undefined },
69+
}) as unknown as HttpProtocolContext;
6570

6671
const ANONYMOUS_CONTEXTS: ReadonlyArray<readonly [string, () => HttpProtocolContext]> = [
6772
['an unresolved context', anonUnresolved],
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `POST /keys` mints only for a principal a signed-in session admitted.
5+
*
6+
* Minting is a credential-lifecycle act. The door reads which credential
7+
* admitted the caller from `HttpProtocolContext.admission`, which the
8+
* dispatcher's identity step writes from the shared resolver, and refuses
9+
* every other admission with `403 PERMISSION_DENIED` before the body is read
10+
* and before any service is resolved.
11+
*
12+
* ## What is pinned, through `dispatch()` (the real identity step)
13+
*
14+
* - a session-admitted caller mints: `201`, one row, pinned to that caller;
15+
* - an API-key-admitted caller is refused `403 PERMISSION_DENIED` and nothing
16+
* is written, in each spelling the key header accepts (`x-api-key`,
17+
* `Authorization: ApiKey`, `Authorization: Bearer osk_…`);
18+
* - a session cookie sent beside the key does not lift the refusal (the key
19+
* is the credential that admitted the caller);
20+
* - the refusal is domain-wide: every verb answers it, not the `405`;
21+
* - control: an anonymous caller still answers `401 UNAUTHENTICATED`.
22+
*
23+
* ## And at the handler (`handleKeys`)
24+
*
25+
* - an authenticated context with NO admission recorded is refused too: an
26+
* admitting credential the door was never told about is not a session;
27+
* - a refused caller's body is never read.
28+
*
29+
* Refusals assert the ADR-0112 envelope (`code` and `status`), never prose.
30+
*/
31+
32+
import { describe, expect, it, vi } from 'vitest';
33+
34+
import { hashApiKey } from '@objectstack/core';
35+
36+
import { deferRequestBody } from '../deferred-request-body.js';
37+
import { HttpDispatcher, type HttpProtocolContext } from '../http-dispatcher.js';
38+
39+
const ORIGIN = 'http://tenant.example';
40+
const KEY = 'osk_keys_mint_session_pin_0001';
41+
const KEY_OWNER = 'u_key_owner';
42+
const SESSION_USER = 'u_session_user';
43+
44+
function kernelWith(services: Record<string, unknown>): any {
45+
return {
46+
getState: () => 'running',
47+
getService: (n: string) => services[n] ?? null,
48+
getServiceAsync: async (n: string) => services[n] ?? null,
49+
context: { getService: (n: string) => services[n] ?? null },
50+
};
51+
}
52+
53+
/** A data engine that knows one live key (owned by `KEY_OWNER`) and records every insert. */
54+
function engine() {
55+
const keyHash = hashApiKey(KEY);
56+
const inserted: Array<{ object: string; row: Record<string, unknown> }> = [];
57+
const ql = {
58+
find: vi.fn(async (object: string, q: any) => (
59+
object === 'sys_api_key' && q?.where?.key === keyHash
60+
? [{ id: 'key_existing', key: keyHash, user_id: KEY_OWNER, revoked: false, scopes: '[]' }]
61+
: []
62+
)),
63+
insert: vi.fn(async (object: string, row: Record<string, unknown>) => {
64+
inserted.push({ object, row });
65+
return { id: `key_${inserted.length}` };
66+
}),
67+
getObjects: vi.fn().mockReturnValue({}),
68+
registry: {
69+
getObject: vi.fn().mockReturnValue(null),
70+
getRegisteredTypes: vi.fn().mockReturnValue([]),
71+
},
72+
};
73+
return { ql, inserted };
74+
}
75+
76+
/** An auth service whose session read answers `SESSION_USER` whenever a cookie is sent. */
77+
function authService() {
78+
const getSession = vi.fn(async ({ headers }: { headers: Headers }) => {
79+
if (!headers.get('cookie')) return null;
80+
return { user: { id: SESSION_USER }, session: { id: 'sess_1', userId: SESSION_USER } };
81+
});
82+
return { isAuthGateActive: () => false, getApi: async () => ({ getSession }) };
83+
}
84+
85+
function harness() {
86+
const { ql, inserted } = engine();
87+
const dispatcher = new HttpDispatcher(
88+
kernelWith({ objectql: ql, auth: authService() }),
89+
undefined,
90+
{ enforceProjectMembership: false },
91+
);
92+
(dispatcher as any).domainDeps.logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
93+
return { dispatcher, ql, inserted };
94+
}
95+
96+
const request = (method: string, headers: Record<string, string>) => new Request(`${ORIGIN}/api/v1/keys`, {
97+
method,
98+
headers: { 'Content-Type': 'application/json', ...headers },
99+
...(method === 'GET' || method === 'HEAD' ? {} : { body: JSON.stringify({ name: 'k' }) }),
100+
});
101+
102+
async function mint(headers: Record<string, string>, method = 'POST') {
103+
const h = harness();
104+
const ctx: HttpProtocolContext = { request: request(method, headers) };
105+
const result: any = await h.dispatcher.dispatch(method, '/keys', { name: 'k' }, {}, ctx);
106+
return { ...h, result, ctx };
107+
}
108+
109+
describe('POST /keys — minting requires an interactive session (through dispatch)', () => {
110+
it('a session-admitted caller mints: 201, one row pinned to the session user', async () => {
111+
const { result, inserted, ctx } = await mint({ cookie: 'session=live' });
112+
expect(ctx.admission?.credential).toBe('session');
113+
expect(result.response?.status).toBe(201);
114+
expect(typeof result.response?.body?.data?.key).toBe('string');
115+
expect(inserted).toHaveLength(1);
116+
expect(inserted[0].object).toBe('sys_api_key');
117+
expect(inserted[0].row.user_id).toBe(SESSION_USER);
118+
});
119+
120+
it.each([
121+
['x-api-key', { 'x-api-key': KEY }],
122+
['Authorization: ApiKey', { authorization: `ApiKey ${KEY}` }],
123+
['Authorization: Bearer', { authorization: `Bearer ${KEY}` }],
124+
])('an API-key-admitted caller (%s) is refused 403 PERMISSION_DENIED, nothing written', async (_label, headers) => {
125+
const { result, inserted, ctx } = await mint(headers);
126+
// The principal WAS resolved, by the key: this is the authorization
127+
// refusal, not the anonymous floor.
128+
expect(ctx.executionContext?.userId).toBe(KEY_OWNER);
129+
expect(ctx.admission?.credential).toBe('api_key');
130+
expect(result.response?.status).toBe(403);
131+
expect(result.response?.body?.success).toBe(false);
132+
expect(result.response?.body?.error?.code).toBe('PERMISSION_DENIED');
133+
expect(result.response?.body?.error?.httpStatus).toBe(403);
134+
expect(inserted).toHaveLength(0);
135+
});
136+
137+
it('a session cookie beside the key does not lift the refusal', async () => {
138+
const { result, inserted, ctx } = await mint({ 'x-api-key': KEY, cookie: 'session=live' });
139+
expect(ctx.admission?.credential).toBe('api_key');
140+
expect(result.response?.status).toBe(403);
141+
expect(result.response?.body?.error?.code).toBe('PERMISSION_DENIED');
142+
expect(inserted).toHaveLength(0);
143+
});
144+
145+
it.each(['GET', 'PUT', 'PATCH', 'DELETE'])('domain-wide: an API-key-admitted %s answers the same 403, not the 405', async (method) => {
146+
const { result, inserted } = await mint({ 'x-api-key': KEY }, method);
147+
expect(result.response?.status).toBe(403);
148+
expect(result.response?.body?.error?.code).toBe('PERMISSION_DENIED');
149+
expect(inserted).toHaveLength(0);
150+
});
151+
152+
it('control: a session-admitted wrong verb still answers 405', async () => {
153+
const { result } = await mint({ cookie: 'session=live' }, 'GET');
154+
expect(result.response?.status).toBe(405);
155+
expect(result.response?.body?.error?.code).toBe('METHOD_NOT_ALLOWED');
156+
});
157+
158+
it('control: an anonymous caller still answers 401 UNAUTHENTICATED', async () => {
159+
const { result, inserted } = await mint({});
160+
expect(result.response?.status).toBe(401);
161+
expect(result.response?.body?.error?.code).toBe('UNAUTHENTICATED');
162+
expect(inserted).toHaveLength(0);
163+
});
164+
});
165+
166+
describe('POST /keys — the handler judges the recorded admission (handleKeys)', () => {
167+
const signedIn = { userId: 'u1', isSystem: false, positions: [], permissions: [] };
168+
169+
function direct(admission: HttpProtocolContext['admission']) {
170+
const { ql, inserted } = engine();
171+
const services: Record<string, unknown> = { objectql: ql };
172+
const resolved: string[] = [];
173+
const kernel: any = {
174+
getService: (n: string) => { resolved.push(n); return services[n]; },
175+
getServiceAsync: async (n: string) => { resolved.push(n); return services[n]; },
176+
};
177+
let bodyReads = 0;
178+
const body = deferRequestBody({ get body() { bodyReads += 1; return { name: 'k' }; } });
179+
const ctx: HttpProtocolContext = {
180+
request: { headers: {} },
181+
executionContext: signedIn as any,
182+
...(admission ? { admission } : {}),
183+
};
184+
const dispatcher = new HttpDispatcher(kernel);
185+
return {
186+
// Lookups made while CONSTRUCTING the dispatcher are not the
187+
// handler's; only the ones `handleKeys` makes are counted.
188+
run: () => { resolved.length = 0; return dispatcher.handleKeys('POST', body, ctx); },
189+
inserted,
190+
resolved,
191+
bodyReads: () => bodyReads,
192+
};
193+
}
194+
195+
it('no admission recorded: refused 403 PERMISSION_DENIED, body unread, no service resolved', async () => {
196+
const d = direct(undefined);
197+
const res: any = await d.run();
198+
expect(res.response?.status).toBe(403);
199+
expect(res.response?.body?.error?.code).toBe('PERMISSION_DENIED');
200+
expect(d.bodyReads()).toBe(0);
201+
expect(d.resolved).toEqual([]);
202+
expect(d.inserted).toHaveLength(0);
203+
});
204+
205+
it('an api_key admission: refused 403 PERMISSION_DENIED, body unread, no service resolved', async () => {
206+
const d = direct({ credential: 'api_key', authGate: undefined });
207+
const res: any = await d.run();
208+
expect(res.response?.status).toBe(403);
209+
expect(res.response?.body?.error?.code).toBe('PERMISSION_DENIED');
210+
expect(d.bodyReads()).toBe(0);
211+
expect(d.resolved).toEqual([]);
212+
expect(d.inserted).toHaveLength(0);
213+
});
214+
215+
it('control: a session admission mints (201)', async () => {
216+
const d = direct({ credential: 'session', authGate: undefined });
217+
const res: any = await d.run();
218+
expect(res.response?.status).toBe(201);
219+
expect(d.bodyReads()).toBe(1);
220+
expect(d.inserted).toHaveLength(1);
221+
});
222+
});

‎packages/runtime/src/domains/keys.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
* caller and is NEVER read from the request body (no impersonation).
1313
* The anonymous decision is the domain's FIRST statement (ADR-0138 D2),
1414
* ahead of the method check, so every face answers an anonymous caller 401.
15+
* - Requires an INTERACTIVE SESSION: the principal must have been admitted by
16+
* a signed-in session (`HttpProtocolContext.admission.credential ===
17+
* 'session'`, written by the dispatcher's identity step from the shared
18+
* resolver). A principal an API key admitted, or one whose admitting
19+
* credential this door was never told, is refused `403 PERMISSION_DENIED`
20+
* right after the anonymous decision, before the body is read. A key acts
21+
* as its owner for data and automation; managing credentials stays with
22+
* the owner's own sign-in.
1523
* - Body is whitelisted to `name` (+ optional `expires_at`); any
1624
* `key` / `id` / `user_id` / `revoked` in the body is ignored, so a
1725
* caller cannot forge a known-secret or escalate.
@@ -72,6 +80,15 @@ const API_KEY_EXPIRY_TOO_LONG = { code: 'API_KEY_EXPIRY_TOO_LONG', status: 400 }
7280
*/
7381
const KEYS_ALLOWED_METHODS = 'POST';
7482

83+
/**
84+
* The refusal a principal not admitted by a signed-in session receives. It
85+
* names the remedy (sign in, then create the key) and nothing about the
86+
* credential presented.
87+
*/
88+
const KEYS_SESSION_REQUIRED_MESSAGE =
89+
'Creating an API key requires a signed-in session. Sign in and create the key from the API Keys '
90+
+ 'page or the Connect an Agent page; a request authenticated with an API key cannot create keys.';
91+
7592
/**
7693
* The legacy branch matched `=== '/keys' || startsWith('/keys/') ||
7794
* startsWith('/keys?')` — a segment match PLUS the query-string form some
@@ -115,6 +132,24 @@ export async function handleKeysRequest(
115132
response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }),
116133
};
117134
}
135+
// INTERACTIVE-SESSION REQUIREMENT — minting is a credential-lifecycle act,
136+
// so it is taken only from a principal a signed-in session admitted. The
137+
// credential is the one the shared resolver decided
138+
// (`resolveAuthzContext`'s `credential`), handed to this request by the
139+
// dispatcher's identity step (`onAdmission`); the envelope itself does not
140+
// carry it. Positive on purpose: an API-key admission is refused, and so
141+
// is an absent one, because a credential this door was never told about
142+
// is not a session. Domain-wide like the baseline above, so every verb
143+
// answers it, and ahead of the body read below. The code is the one the
144+
// status derives (`PERMISSION_DENIED`), the same answer the membership
145+
// refusal further down gives.
146+
if (context.admission?.credential !== 'session') {
147+
return {
148+
handled: true,
149+
response: deps.error(KEYS_SESSION_REQUIRED_MESSAGE, 403),
150+
};
151+
}
152+
118153
// [#24151] The body is read once the baseline above has admitted the
119154
// caller, never ahead of it (`../deferred-request-body.ts`).
120155
body = readDispatchedBody(body);
@@ -169,7 +204,7 @@ export async function handleKeysRequest(
169204
//
170205
// INHERITED from the caller's active organization (`ExecutionContext
171206
// .tenantId`, which the one shared resolver fills from the session's
172-
// `activeOrganizationId` or from the minting key's own stamp). Never a
207+
// `activeOrganizationId`; only a session reaches this line). Never a
173208
// body parameter: see the header.
174209
const activeOrganizationId = typeof ec.tenantId === 'string' && ec.tenantId.trim()
175210
? ec.tenantId.trim()

‎packages/runtime/src/domains/scoped-service-miss-attribution.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,8 @@ const orgLessCaller = (environmentId?: string): HttpProtocolContext => ({
295295
permissions: [],
296296
tenantId: undefined,
297297
},
298+
// A signed-in caller: the credential the dispatcher's identity step records.
299+
admission: { credential: 'session', authGate: undefined },
298300
} as unknown as HttpProtocolContext);
299301

300302
const mint = (d: HttpDispatcher, ctx: HttpProtocolContext) => d.handleKeys('POST', { name: 'agent' }, ctx);

0 commit comments

Comments
 (0)