Skip to content

Commit af8448f

Browse files
fix(runtime): list_actions marks revoke_api_key unavailable to an API-key caller, with the API-key guard's own refusal (#28943) (#29036)
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Fixes #28943 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:用 API key 登录的调用方调用 MCP `list_actions` 时,`revoke_api_key` 不再显示为可运行。它带上 `unavailable`,内容是 API key 管理守卫自己的 `PERMISSION_DENIED` 和原句,与 `run_action` 的拒绝一致。 - **为什么改**:forward 凭据标记只问目标入口能否读取调用方的凭据。数据入口能读 API key,所以 `sys_api_key` 的 Revoke 被列为可运行;随后该对象的守卫 `apiKeyManagementCredentialRefusal` 以 403 拒绝写入。这是 18.3.0 RC 跑测 #28942 中唯一的 FAIL。 - **风险与回滚**:低。守卫本身、错误码、读 `sys_api_key`、其他对象,以及 session 调用方的列表和运行都不变。REST 动作入口和 MCP `run_action` 对同一请求改为在转发前给出同样的 403 和原句,不再转发后才被拒。回滚即还原本 PR。 - **先看**:`packages/runtime/src/action-execution.ts` 的 `apiForwardDataDoorGuardRefusal` 和 `apiForwardDataDoorOperation`。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-forward.test.ts -t 28943`,预期 5 个测试全绿。 - **需要你做的**:无,席位落地。 ## Measure (step 1, on `main` f7c545a) - **Routes the guard refuses to an API-key caller:** every non-read data-door operation on `sys_api_key`: create, update (`PATCH` and `PUT`), delete, `bulk` (the batch routes), `import`, `clone`, and any write verb a door adds later. The guard is asked at every per-object gate: the runtime dispatcher's `refuseUnexposedDataAction`, `RestServer.enforceApiAccess` (and per op on its cross-object batch), and the stdio MCP write arms. The `/api/v1/keys` mint is a separate session-only refusal in `domains/keys.ts`, with its own sentence. - **Actions whose `api` target lands on one:** `sys_api_key.revoke_api_key` and `sys_api_key.restore_api_key` (both `PATCH /api/v1/data/sys_api_key/{id}`), plus `create_api_key` on the `/keys` mint. No action in `examples/` or hotcrm targets either (the one `examples/app-todo` hit is an e2e that runs an undeclared `rotate` and expects the refusal). - **Offered as runnable to an `osk_` caller on `main`:** `revoke_api_key` only. It is the one with `ai.exposed`; `restore_api_key` and `create_api_key` declare no `ai` block, so `actionAiExposureError` leaves them out of `list_actions`. ## What changes **One source.** The guard's predicate, `apiKeyManagementCredentialRefusal`, is already exported from `@objectstack/core`, and `action-execution.ts` already imports it for its per-object gate. So there is no dependency cycle, and no new export on any package root. The marker now calls it directly. 1. `apiForwardDataDoorOperation(method, path)` (new, module-private) reads the data-door request a forward makes: the object segment of `/api/v1[/environments/:id]/data/:object[/…]`, and the operation in the doors' own names. `GET`/`HEAD`, `POST /:object/query` and `POST /:object/:id/page` are reads; `PATCH`/`PUT` is `update`, `DELETE` is `delete`, `POST` to the collection is `create`, and any other `POST` is named by its last segment. Only the read-or-write reading is load-bearing, because the guard refuses every operation it does not list as a read. 2. `apiForwardDataDoorGuardRefusal(kind, action, target)` (new, module-private) asks the guard about that request when the caller's credential is `api_key`, and returns the guard's own `status` (403), `code` (`PERMISSION_DENIED`) and `message`. Nothing is restated. 3. `apiForwardCredentialRefusal`: where the target's door accepts the credential, it now returns that answer instead of `null`. The door table, the identity and OAuth refusals, and the agent forward credential are untouched. Both readers of `apiForwardCredentialRefusal` pick this up with no change of their own: - the MCP `list_actions` mark (`domains/mcp.ts`, comment only), which carries the guard's code and sentence as `unavailable`; - `forwardApiAction`, behind the REST action door and MCP `run_action`, which now answers that refusal before forwarding. Before, it forwarded and relayed the data door's 403 with the same code and sentence. The listing judges the declared target (`{id}` unfilled) and the run door judges the filled path. Both read `action.method`, and the object segment is never a token, so they judge the same request. ⛔ The guard's refusal is unchanged, and there is no new error code, no `packages/spec/**` change and no new gate. Reads of `sys_api_key`, every other object, and the session caller's listing and run are unchanged. **Changeset: patch** on `@objectstack/runtime` (`Metadata-protocol: none` gives patch). ## Tests New describe `[#28943]` in `packages/runtime/src/domains/api-action-forward.test.ts`. The harness `boot` gains `extraObjects`, which serves a `sys_api_key` carrying `revoke_api_key` exactly as declared, and a `crm_case` carrying the same `PATCH` shape as the control. | Test | base f7c545a | head | |---|---|---| | `list_actions` under an API key: `revoke_api_key` carries `unavailable` = the guard's code and sentence | red | green | | MCP `run_action` and the REST action door under an API key: 403, `PERMISSION_DENIED`, the listed sentence; nothing forwarded | red | green | | Request by request: each `sys_api_key` write is refused, its reads are not, a neighbouring object name is not; session and no-credential callers are refused nowhere | red | green | | Control: under a session, `revoke_api_key` is listed with no mark, and `run_action` forwards `PATCH /api/v1/data/sys_api_key/key_1` with `revoked: true` | green | green | | Control: the same data-door write on `crm_case` under an API key is listed with no mark and forwarded, the key carried across | green | green | The two controls are green on base by design: they pin what must not change. **Ablation:** `scripts/ablation-replace.mjs` replaced the marker's read of the guard with `return null`. The mutation landed (anchor 1 to 0), and the three pins went red (3 failed, 2 passed). The restore was clean: the blob equals HEAD and `git diff HEAD` is empty. <details><summary>Suite, typecheck, lint, gates</summary> On head `c3b472f4`, after a whole-repo `pnpm build` under the verify lock (exit 0): - the full `@objectstack/runtime` suite under the lock: 8272 passed, 19 skipped, exit 0. `@objectstack/core` is not touched; - `pnpm typecheck` in full for runtime: exit 0, including `check:test-typecheck` (ledger unchanged); - `eslint --no-inline-config src` for runtime: exit 0; - `dispatch-gates.mjs --commands` derived 61 commands (including `check:route-ledger-census`), each run with its exit captured before any pipe, all exit 0. `--ran`: 61 derived, 61 run, 0 NOT-MEASURED. A live MCP probe on a showcase boot was not run; the in-process doors above are the evidence. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5oTRCUNQ4EA3YV84sZ Co-authored-by: Claude <noreply@anthropic.com>
1 parent a1ae469 commit af8448f

4 files changed

Lines changed: 242 additions & 9 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): `list_actions` no longer offers `revoke_api_key` as runnable to a caller signed in with an API key. The action is marked `unavailable` with the API-key management guard's own `PERMISSION_DENIED` and sentence, the refusal `run_action` gives (#28943)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The forward's credential marker (`apiForwardCredentialRefusal`) only asked whether the target's door reads the caller's credential. The data door reads an API key, so `sys_api_key`'s Revoke was listed as runnable to an `osk_` caller. The row's per-object gate (`@objectstack/core` `apiKeyManagementCredentialRefusal`) then refused the write with `403 PERMISSION_DENIED`. The marker now also asks that guard about the data-door request a forward makes (the object, and the operation its method and path name). The guard is the one source: the listing's `unavailable` entry carries the guard's code and sentence, and the REST action door and MCP `run_action` answer that same refusal before forwarding instead of after. The guard's refusal is unchanged, and so are reads of `sys_api_key`, every other object, and the session caller's listing and run.

‎packages/runtime/src/action-execution.ts‎

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1651,14 +1651,19 @@ export function apiForwardCredentialKind(requestContext: HttpProtocolContext | u
16511651
* A forward refusal: the status and standard code, and the sentence that says
16521652
* why. `401` when the target's door cannot read the caller's credential (the
16531653
* status the target would have answered); `403` when the scope-family rule
1654-
* refuses an agent the consent did not grant the forward's family.
1654+
* refuses an agent the consent did not grant the forward's family, or when the
1655+
* target's door accepts the credential and its per-object gate refuses the
1656+
* request for it ({@link apiForwardDataDoorGuardRefusal}, that gate's own code
1657+
* and sentence).
16551658
*/
16561659
export type ApiForwardCredentialRefusal = { status: 401 | 403; code: string; message: string };
16571660

16581661
/**
16591662
* The refusal for running `action` by forward with this caller's credential —
1660-
* `null` when the target's door accepts it ({@link API_FORWARD_DOOR_ACCEPTS}),
1661-
* or when the action is not a same-origin `type: 'api'` action at all.
1663+
* `null` when the target's door accepts it ({@link API_FORWARD_DOOR_ACCEPTS})
1664+
* and the target's per-object gate would not refuse the request for it
1665+
* ({@link apiForwardDataDoorGuardRefusal}), or when the action is not a
1666+
* same-origin `type: 'api'` action at all.
16621667
*
16631668
* `target` is the forward's path, which decides the door; the listing passes
16641669
* the declared `target`, whose door prefix is the same before its tokens are
@@ -1678,7 +1683,7 @@ export function apiForwardCredentialRefusal(
16781683
const kind = apiForwardCredentialKind(requestContext);
16791684
if (!kind) return null;
16801685
const door = apiForwardTargetDoor(target);
1681-
if (API_FORWARD_DOOR_ACCEPTS[door].has(kind)) return null;
1686+
if (API_FORWARD_DOOR_ACCEPTS[door].has(kind)) return apiForwardDataDoorGuardRefusal(kind, action, target);
16821687
const path = (typeof action?.target === 'string' ? action.target : target).split('?')[0]!;
16831688
// [ADR-0090 D10 rule 5] The two flipped cells: an agent principal is carried
16841689
// by a forward credential, bounded by the scope-family rule.
@@ -1692,6 +1697,62 @@ export function apiForwardCredentialRefusal(
16921697
return { status: 401, code: standardErrorCodeForHttpStatus(401), message };
16931698
}
16941699

1700+
/**
1701+
* [#28943] The data-door operation a forward to `/api/v1/data/<object>[/…]`
1702+
* asks, by the door's own operation names — `undefined` for a path the data
1703+
* door does not serve. A safe method (`GET`, `HEAD`) and the door's two reads
1704+
* by `POST` (`/:object/query`, `/:object/:id/page`) are reads; `PATCH` and
1705+
* `PUT` are `update`, `DELETE` is `delete`, `POST` to the collection is
1706+
* `create`, and every other `POST` (`batch`, `createMany`, `import`, `clone`,
1707+
* …) is a write the door names on its own route. Only the read-or-write
1708+
* reading is load-bearing: the per-object rule this feeds refuses every
1709+
* operation it does not list as a read.
1710+
*/
1711+
function apiForwardDataDoorOperation(method: string, path: string): { object: string; operation: string } | undefined {
1712+
const m = /^\/api\/v1(?:\/environments\/[^/]+)?\/data\/([^/?#]+)(?:\/([^?#]*))?/.exec(path);
1713+
if (!m) return undefined;
1714+
const object = m[1]!;
1715+
const rest = (m[2] ?? '').replace(/\/+$/, '').split('/').filter(Boolean);
1716+
const verb = method.toUpperCase();
1717+
if (verb === 'GET' || verb === 'HEAD') return { object, operation: rest.length ? 'get' : 'list' };
1718+
if (verb === 'PATCH' || verb === 'PUT') return { object, operation: 'update' };
1719+
if (verb === 'DELETE') return { object, operation: 'delete' };
1720+
if (rest.length === 0) return { object, operation: 'create' };
1721+
if (rest.length === 1 && rest[0] === 'query') return { object, operation: 'list' };
1722+
if (rest.length === 2 && rest[1] === 'page') return { object, operation: 'get' };
1723+
return { object, operation: rest[rest.length - 1]! };
1724+
}
1725+
1726+
/**
1727+
* [#28943] A forward the target's door ACCEPTS the caller's credential for,
1728+
* which the target's per-object gate then refuses for that credential: today
1729+
* one rule, a write to `sys_api_key` from a principal an API key admitted
1730+
* (`@objectstack/core` `apiKeyManagementCredentialRefusal`, the predicate
1731+
* every data door's gate asks, read here and never restated). The refusal is
1732+
* the rule's own: its `403`, its `PERMISSION_DENIED`, its sentence. So
1733+
* `list_actions` marks `revoke_api_key` unavailable to an `osk_` caller with
1734+
* the answer the target gives, and the run doors answer that same refusal
1735+
* before forwarding instead of after. A session caller, and a caller whose
1736+
* door recorded no credential, are never refused here (the rule is positive
1737+
* on `api_key`).
1738+
*
1739+
* `method` is the forward's (`action.method`, default `POST`, as
1740+
* {@link buildApiActionRequest} reads it), so the listing (the declared
1741+
* `target`, tokens unfilled) and the run door (the filled path) judge the same
1742+
* request: the object segment is never a token.
1743+
*/
1744+
function apiForwardDataDoorGuardRefusal(
1745+
kind: ApiForwardCredentialKind,
1746+
action: any,
1747+
target: string,
1748+
): ApiForwardCredentialRefusal | null {
1749+
if (kind !== 'api_key') return null;
1750+
const request = apiForwardDataDoorOperation(String(action?.method || 'POST'), target);
1751+
if (!request) return null;
1752+
const refusal = apiKeyManagementCredentialRefusal({ ...request, credential: kind });
1753+
return refusal ? { status: refusal.status, code: refusal.code, message: refusal.message } : null;
1754+
}
1755+
16951756
/**
16961757
* [ADR-0090 D10 rule 5, the scope-family rule] Whether an agent's consented
16971758
* scopes cover a forward outside MCP — `null` when they do, else the refusal,

‎packages/runtime/src/domains/api-action-forward.test.ts‎

Lines changed: 164 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
import { FORWARD_CREDENTIAL_HEADER, redeemForwardCredential } from '@objectstack/rest';
4949
import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api';
5050
import { ActionActivationProjection } from '@objectstack/objectql';
51+
import { apiKeyManagementCredentialRefusal, API_KEY_MANAGEMENT_SESSION_REQUIRED_MESSAGE } from '@objectstack/core';
5152

5253
const OBJECT = 'crm_lead';
5354
const BAN_LEAD = {
@@ -112,6 +113,8 @@ function boot(opts: {
112113
/** What the target answers: status, JSON body, extra response headers. */
113114
answer?: { status?: number; body?: unknown; text?: string; headers?: Array<[string, string]> };
114115
noServer?: boolean;
116+
/** [#28943] Objects served beside `crm_lead` (by name), e.g. a `sys_api_key` carrying its revoke action. */
117+
extraObjects?: any[];
115118
} = {}) {
116119
const disabled = new Set(opts.disabled ?? []);
117120
const injected: Injected[] = [];
@@ -129,14 +132,15 @@ function boot(opts: {
129132
const server = { getRawApp: () => ({ request }) };
130133
const executeAction = vi.fn(async () => ({ ran: 'script' }));
131134
const objectDef = { name: OBJECT, actions: [BAN_LEAD, REMOTE_PING, DELETE_LEAD, RECALC_LEAD, APPROVE_LEAD, NOTE_OWNER, FLAG_BY_EMAIL, PING_LOCAL] };
135+
const served: Record<string, any> = Object.fromEntries([objectDef, ...(opts.extraObjects ?? [])].map((o) => [o.name, o]));
132136
const ql: any = {
133137
executeAction,
134-
getSchema: (name: string) => (name === OBJECT ? objectDef : undefined),
135-
registry: { getObject: (name: string) => (name === OBJECT ? objectDef : undefined), getItem: () => undefined },
138+
getSchema: (name: string) => served[name],
139+
registry: { getObject: (name: string) => served[name], getItem: () => undefined },
136140
// The subject rows the doors load under the caller's scope.
137141
find: vi.fn(async (_object: string, q: any) => {
138142
const id = q?.where?.id;
139-
return id === 'u_target' || id === 'lead 7' ? [{ id, email: 'target@example.test' }] : [];
143+
return id === 'u_target' || id === 'lead 7' || id === 'key_1' ? [{ id, email: 'target@example.test' }] : [];
140144
}),
141145
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
142146
isActionEnabled: (name: string) => !disabled.has(name),
@@ -147,8 +151,8 @@ function boot(opts: {
147151
load: vi.fn(async () => null),
148152
loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })),
149153
loadMany: vi.fn(async () => []),
150-
listObjects: vi.fn(async () => [objectDef]),
151-
getObject: vi.fn(async () => objectDef),
154+
listObjects: vi.fn(async () => Object.values(served)),
155+
getObject: vi.fn(async (name?: string) => (name && served[name]) || objectDef),
152156
};
153157
const resolve = (n: string) =>
154158
n === 'objectql' || n === 'data' ? ql
@@ -1000,6 +1004,161 @@ describe('[#28178] the credential × target-door table: an OAuth caller with no
10001004
});
10011005
});
10021006

1007+
// [#28943] The 18.3.0 RC's one FAIL: `list_actions` offered `revoke_api_key`
1008+
// to an API-key caller as runnable, because the table above only asks whether
1009+
// the target's DOOR reads the credential, and the data door reads an API key.
1010+
// The `sys_api_key` row's per-object gate then refuses every write from a
1011+
// key-admitted principal (`@objectstack/core` `apiKeyManagementCredentialRefusal`,
1012+
// the signed-in-session rule `/keys` also keeps), so `run_action` answered
1013+
// `403 PERMISSION_DENIED`. The marker now reads that rule itself: the listing
1014+
// carries the rule's own code and sentence, the run doors answer that refusal
1015+
// before forwarding, and the session caller's listing and run are unchanged.
1016+
describe('[#28943] an API-key caller is never offered a `sys_api_key` write as runnable: the mark is the guard\'s own refusal', () => {
1017+
/** `@objectstack/platform-objects` `sys_api_key`'s Revoke, as declared. */
1018+
const REVOKE_API_KEY = {
1019+
name: 'revoke_api_key', label: 'Revoke API Key', objectName: 'sys_api_key', type: 'api', method: 'PATCH',
1020+
target: '/api/v1/data/sys_api_key/{id}', bodyExtra: { revoked: true },
1021+
ai: { exposed: true, description: 'Revoke an API key.', requiresConfirmation: true },
1022+
};
1023+
const API_KEY_OBJECT = { name: 'sys_api_key', actions: [REVOKE_API_KEY] };
1024+
/** The same shape on an object no per-object rule guards: the control. */
1025+
const CLOSE_CASE = {
1026+
name: 'close_case', label: 'Close Case', objectName: 'crm_case', type: 'api', method: 'PATCH',
1027+
target: '/api/v1/data/crm_case/{id}', bodyExtra: { status: 'closed' },
1028+
ai: { exposed: true, description: 'Close the case.' },
1029+
};
1030+
const CASE_OBJECT = { name: 'crm_case', actions: [CLOSE_CASE] };
1031+
const bootWithKeys = (opts: Parameters<typeof boot>[0] = {}) => boot({ ...opts, extraObjects: [API_KEY_OBJECT, CASE_OBJECT] });
1032+
1033+
const KEY_CTX = (): HttpProtocolContext => {
1034+
const ctx: any = CTX({ 'x-api-key': 'osk_member_key' });
1035+
delete ctx.request.headers.authorization;
1036+
delete ctx.request.headers.cookie;
1037+
ctx.admission = { credential: 'api_key', authGate: undefined };
1038+
return ctx as HttpProtocolContext;
1039+
};
1040+
const SESSION_CTX = (): HttpProtocolContext =>
1041+
({ ...CTX(), admission: { credential: 'session', authGate: undefined } } as unknown as HttpProtocolContext);
1042+
const runViaMcp = async (h: ReturnType<typeof boot>, ctx: HttpProtocolContext, objectName: string, name: string, recordId: string) => {
1043+
const deps: any = {
1044+
resolveService: async (_ctx: unknown, service: string) =>
1045+
service === 'metadata' ? h.metadata
1046+
: service === 'automation' ? null
1047+
: service === 'http.server' ? { getRawApp: () => ({ request: h.request }) }
1048+
: h.ql,
1049+
getObjectQL: async () => h.ql,
1050+
};
1051+
return invokeBusinessAction(deps, ctx as any, name, { objectName, recordId, confirm: true }, {
1052+
driver: h.ql,
1053+
ec: { userId: 'u_member', tenantId: 'org_1', systemPermissions: [] },
1054+
getMeta: () => h.metadata,
1055+
callData: async (_a: string, p: any) => ({ object: p?.object, id: p?.id, record: { id: p?.id } }),
1056+
});
1057+
};
1058+
const listFor = async (h: ReturnType<typeof boot>, ctx: HttpProtocolContext): Promise<Record<string, any>> =>
1059+
Object.fromEntries(((await (h.dispatcher as any).buildMcpBridge(ctx).listActions()) as any[]).map((a) => [a.name, a]));
1060+
1061+
/** What the guard itself answers for this request: the one source of the code and the sentence. */
1062+
const GUARD = apiKeyManagementCredentialRefusal({ object: 'sys_api_key', operation: 'update', credential: 'api_key' })!;
1063+
1064+
it('`list_actions` under an API key marks `revoke_api_key` with the guard\'s own code and sentence', async () => {
1065+
const listed = await listFor(bootWithKeys(), KEY_CTX());
1066+
1067+
expect(listed[REVOKE_API_KEY.name]).toBeDefined();
1068+
expect(listed[REVOKE_API_KEY.name].unavailable).toEqual({ code: GUARD.code, reason: GUARD.message });
1069+
expect(listed[REVOKE_API_KEY.name].unavailable).toEqual({
1070+
code: 'PERMISSION_DENIED', reason: API_KEY_MANAGEMENT_SESSION_REQUIRED_MESSAGE,
1071+
});
1072+
});
1073+
1074+
it('MCP `run_action` and the REST action door under an API key answer that same refusal, and nothing is forwarded', async () => {
1075+
const h = bootWithKeys();
1076+
const listed = await listFor(h, KEY_CTX());
1077+
1078+
const thrown = await runViaMcp(h, KEY_CTX(), 'sys_api_key', REVOKE_API_KEY.name, 'key_1').catch((e) => e);
1079+
expect(thrown).toBeInstanceOf(Error);
1080+
expect((thrown as any).status).toBe(403);
1081+
expect((thrown as any).code).toBe(listed[REVOKE_API_KEY.name].unavailable.code);
1082+
expect(String(thrown.message)).toBe(listed[REVOKE_API_KEY.name].unavailable.reason);
1083+
1084+
const res = await h.dispatcher.handleActions(`/sys_api_key/${REVOKE_API_KEY.name}`, 'POST', { recordId: 'key_1' }, KEY_CTX());
1085+
expect(statusOf(res)).toBe(403);
1086+
expect(codeOf(res)).toBe('PERMISSION_DENIED');
1087+
expect(messageOf(res)).toBe(API_KEY_MANAGEMENT_SESSION_REQUIRED_MESSAGE);
1088+
1089+
expect(h.request).not.toHaveBeenCalled();
1090+
});
1091+
1092+
it('control: under a signed-in session `revoke_api_key` is listed with no mark, and `run_action` revokes', async () => {
1093+
const h = bootWithKeys({ answer: { body: { success: true, data: { id: 'key_1', revoked: true } } } });
1094+
1095+
const listed = await listFor(h, SESSION_CTX());
1096+
expect(listed[REVOKE_API_KEY.name]).toBeDefined();
1097+
expect(listed[REVOKE_API_KEY.name].unavailable).toBeUndefined();
1098+
1099+
const result: any = await runViaMcp(h, SESSION_CTX(), 'sys_api_key', REVOKE_API_KEY.name, 'key_1');
1100+
expect(result.ok).toBe(true);
1101+
expect(h.injected).toHaveLength(1);
1102+
expect(h.injected[0].url).toBe('http://app.example.test/api/v1/data/sys_api_key/key_1');
1103+
expect(h.injected[0].init.method).toBe('PATCH');
1104+
expect(JSON.parse(String(h.injected[0].init.body))).toMatchObject({ revoked: true });
1105+
});
1106+
1107+
it('control: the same data-door write on an object no rule guards stays runnable under an API key', async () => {
1108+
const h = bootWithKeys({ answer: { body: { success: true, data: { id: 'key_1', status: 'closed' } } } });
1109+
1110+
const listed = await listFor(h, KEY_CTX());
1111+
expect(listed[CLOSE_CASE.name]).toBeDefined();
1112+
expect(listed[CLOSE_CASE.name].unavailable).toBeUndefined();
1113+
1114+
const result: any = await runViaMcp(h, KEY_CTX(), 'crm_case', CLOSE_CASE.name, 'key_1');
1115+
expect(result.ok).toBe(true);
1116+
expect(h.injected[0].url).toBe('http://app.example.test/api/v1/data/crm_case/key_1');
1117+
expect(headersOf(h.injected[0].init)['x-api-key']).toBe('osk_member_key');
1118+
});
1119+
1120+
it('the reading, request by request: every `sys_api_key` write the guard refuses is refused, its reads are not, and only for a key', () => {
1121+
const api = (method: string, target: string) => ({ name: 'probe', type: 'api', method, target });
1122+
const refused = (ctx: HttpProtocolContext, method: string, target: string) => {
1123+
const r = apiForwardCredentialRefusal(ctx, api(method, target));
1124+
if (r === null) return 'ok';
1125+
expect(r).toEqual({ status: 403, code: GUARD.code, message: GUARD.message });
1126+
return 'refused';
1127+
};
1128+
const requests: Array<[string, string]> = [
1129+
['POST', '/api/v1/data/sys_api_key'],
1130+
['PATCH', '/api/v1/data/sys_api_key/{id}'],
1131+
['PUT', '/api/v1/data/sys_api_key/k1'],
1132+
['DELETE', '/api/v1/data/sys_api_key/k1'],
1133+
['POST', '/api/v1/data/sys_api_key/batch'],
1134+
['POST', '/api/v1/data/sys_api_key/k1/clone'],
1135+
['PATCH', '/api/v1/environments/env_1/data/sys_api_key/k1?x=1'],
1136+
['GET', '/api/v1/data/sys_api_key'],
1137+
['GET', '/api/v1/data/sys_api_key/k1'],
1138+
['POST', '/api/v1/data/sys_api_key/query'],
1139+
['POST', '/api/v1/data/sys_api_key/k1/page'],
1140+
['PATCH', '/api/v1/data/sys_api_key_audit/k1'],
1141+
];
1142+
const verdicts = (ctx: () => HttpProtocolContext) => requests.map(([m, t]) => `${m} ${t} ${refused(ctx(), m, t)}`);
1143+
expect(verdicts(KEY_CTX)).toEqual([
1144+
'POST /api/v1/data/sys_api_key refused',
1145+
'PATCH /api/v1/data/sys_api_key/{id} refused',
1146+
'PUT /api/v1/data/sys_api_key/k1 refused',
1147+
'DELETE /api/v1/data/sys_api_key/k1 refused',
1148+
'POST /api/v1/data/sys_api_key/batch refused',
1149+
'POST /api/v1/data/sys_api_key/k1/clone refused',
1150+
'PATCH /api/v1/environments/env_1/data/sys_api_key/k1?x=1 refused',
1151+
'GET /api/v1/data/sys_api_key ok',
1152+
'GET /api/v1/data/sys_api_key/k1 ok',
1153+
'POST /api/v1/data/sys_api_key/query ok',
1154+
'POST /api/v1/data/sys_api_key/k1/page ok',
1155+
'PATCH /api/v1/data/sys_api_key_audit/k1 ok',
1156+
]);
1157+
// A session caller, and a caller whose door recorded no credential, keep today's answer everywhere.
1158+
for (const ctx of [SESSION_CTX, CTX]) expect(verdicts(ctx).filter((v) => v.endsWith('refused'))).toEqual([]);
1159+
});
1160+
});
1161+
10031162
describe('[ADR-0090 D10 rule 5, #28367] the two flipped cells: an OAuth agent is forwarded with a forward credential, under the scope-family rule', () => {
10041163
/** The agent principal the MCP door assembles from a verified OAuth access token. */
10051164
const AGENT_CTX = (scopes: string[] = ['actions:execute', 'data:read', 'data:write'], over: Record<string, unknown> = {}): HttpProtocolContext => {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,9 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon
888888
// an API key) is LISTED, marked with the refusal `run_action`
889889
// would answer — the same table, the same sentence — so an
890890
// agent can explain the gap instead of discovering it.
891+
// [#28943] …and one the target's per-object gate refuses for
892+
// that credential (a `sys_api_key` write under an API key),
893+
// marked with that gate's own code and sentence.
891894
const refusal = actionExec.apiForwardCredentialRefusal(context, action);
892895
out.push({
893896
...actionExec.summarizeAction(deps, action, obj, objectName, flow ?? undefined),

0 commit comments

Comments
 (0)