You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit af8448f
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
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',()=>{
1004
1163
/** The agent principal the MCP door assembles from a verified OAuth access token. */
0 commit comments