Skip to content

Commit 0c6b7b8

Browse files
fix(runtime): a non-admin approver's decision from the request page reaches the approvals routes instead of answering 404 (#28708)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XC2DvEE5kBwmAEa8vy2MpWm)_ Fixes #28657 Clause-②: yes (widening: on `POST /api/v1/actions/:object/:action` and MCP `run_action`, a same-origin `type: 'api'` action whose subject row the caller's own scope does not deliver, and whose request needs no field of that row, is forwarded and answers its target's answer where it answered `404 RECORD_NOT_FOUND`) Metadata-protocol: none ## 维护者速读 - **改了什么**:动作门(`/api/v1/actions/...` 与 MCP `run_action`)在调用者读不到主记录时,不再先回 404;对不需要该记录字段的同源 `type:'api'` 动作照常转发,由目标接口自己鉴权并给出答复。 - **为什么改**:审批详情页的同意、驳回、评论、补充信息等按钮都是这种动作。普通成员按设计没有 `sys_approval_request` 的数据读权限,所以非管理员审批人在详情页做任何决定都被 404 挡住,18.3.0 发不了。 - **风险与回滚**:低。只动 api 转发这一支;目标接口用调用者自己的凭证鉴权,等同于调用者直接调它,不多泄露任何东西。需要记录字段才能拼请求的 api 动作、flow、脚本、声明式更新照旧回 404。回滚即还原 `refuseDeniedSubjectLoadBeforeDispatch` 两处调用。 - **先看**:`packages/runtime/src/action-execution.ts`(新的共享判定 `refuseDeniedSubjectLoadBeforeDispatch` / `apiActionRequestReadsSubjectRow` / `apiForwardSubjectRecord`);`packages/runtime/src/domains/actions.ts`(REST 门那一行);`packages/verify/src/action-door-api-forward-denied-subject.test.ts`(真实审批栈上的 HTTP 钉子)。 - **两分钟验证**:`pnpm --filter @objectstack/verify exec vitest run src/action-door-api-forward-denied-subject.test.ts`,预期 3/3 通过(main 上 2 个失败,都是 404 `RECORD_NOT_FOUND`)。 - **需要你做的**:无,席位落地。 ## What changes - `packages/runtime/src/action-execution.ts` - `refuseDeniedSubjectLoadBeforeDispatch(action, objectName, recordId, subject)`: contract point 3 as both doors' dispatch reads it. It returns without refusing exactly when the load was denied and `forwardsOnDeniedSubjectLoad(action)` holds; otherwise it is `refuseDeniedSubjectLoad`, unchanged. - `forwardsOnDeniedSubjectLoad(action)`: not a declarative update (`operation` before `type`), `type: 'api'`, a same-origin `target`, and `apiActionRequestReadsSubjectRow(action)` false. - `apiActionRequestReadsSubjectRow(action)`: true for a `{field}` path token in `target` other than `{id}`, or a `recordIdParam` read from a `recordIdField` other than `id`. Read off the declaration before substitution, so a token that only looks like a field errs toward the 404. - `apiForwardSubjectRecord(recordId, subject)`: the row the forward's request is built from. A delivered row as it was; on a denied load `{ id: recordId }` and nothing else. - `dispatchAdmittedBusinessAction` (MCP `run_action`) calls the two at the same point the REST door does. - `apiActionForwardRefusal` reads a target's bare string `error` (the approvals routes' `{ code, error }`) as the refusal's message, before the "its target answered HTTP N" fallback. This is the card's 400 prescription relay: it was this code path, so it rides along (ruling 6). - `packages/runtime/src/domains/actions.ts`: `dispatchAdmittedRestAction` calls `refuseDeniedSubjectLoadBeforeDispatch` where it called `refuseDeniedSubjectLoad`, and builds the api request from `apiForwardSubjectRecord`. - `.changeset/28657-api-action-forward-denied-subject.md`: `@objectstack/runtime` patch (`files[]` ships `dist`, which carries the change). How ruling 2 is enforced: the forward never sees the load's leftovers. `buildApiActionRequest` is handed `apiForwardSubjectRecord(...)`, which on a denied load is built from the routed `recordId` alone. A declaration that needs a row field is caught one step earlier by `apiActionRequestReadsSubjectRow`, so it is refused with today's 404 before the loop check, the injector or the builder run. Pinned by the helper case (a denied load whose leftover row carries `email` yields `{ id }` only) and by the door cases for `{email}` in the path and `recordIdField: 'email'` (404, no forward, on both doors). Untouched, as ruled: `pending-approver-read-grant.ts`, the member grant on `sys_approval_request`, `packages/spec/**`, `ui/**`, the flow, script/body and declarative-update branches, and `resume_run`. ## Tests <details><summary>The HTTP-layer pin on a real stack: red on base e4ec4f2, green on head</summary> `packages/verify/src/action-door-api-forward-denied-subject.test.ts` boots the real stack with `bootStack` (`requires: ['automation', 'triggers', 'approvals']`, the providers `serve` mounts for those tokens): the runtime dispatcher, the real approvals service and its decision routes, the real in-process forward. No fetch is doubled. A private object, a two-approver `unanimous` flow; the admin creates the record, and plain members act. 1. Control: the approver cannot read the request row through the data door (403 `PERMISSION_DENIED`). 2. The approver's `POST /api/v1/actions/sys_approval_request/approval_approve` `{"recordId":AREQ,"params":{"comment":…}}` answers 200; `pending_approvers` goes from both approvers to the other one; one `approve` row with the approver as `actor_id` and the comment; `GET /api/v1/approvals/requests/AREQ` reads `decision_progress` `{ behavior: 'unanimous', got: 1, need: 2 }`. 3. A member on no slate, through the same door, gets exactly the status and code the decision route answers them directly (403 `FORBIDDEN`), the route's own sentence as the message, and nothing moves. Base (`packages/runtime/src/action-execution.ts` and `domains/actions.ts` written from `e4ec4f22c5`, runtime rebuilt, `ablation-dist-preflight --absent` on the new helper's name): ``` × Approve answers 200 and the request moves: 1 of 2, the approver off the slate, an approve row × a member on no slate gets the decision route's own refusal through the door — never a 200, never the row AssertionError: {"success":false,"error":{"code":"RECORD_NOT_FOUND","message":"Record areq_… not found in sys_approval_request","httpStatus":404}}: expected 404 to be 200 AssertionError: {"success":false,"error":{"code":"RECORD_NOT_FOUND",…}}: expected 404 to be 403 Tests 2 failed | 1 passed (3) ``` Head: `Test Files 1 passed (1) · Tests 3 passed (3) · VERDICT command-exit 0`. Restore proven by blob hashes equal to HEAD, `git status --porcelain` empty, rebuild, and the preflight finding the marker in `dist/index.js` and `dist/index.cjs`. </details> <details><summary>Ablation: only the api-branch move reverted, the HTTP pin goes red</summary> `node scripts/ablation-replace.mjs --file packages/runtime/src/domains/actions.ts --anchor "actionExec.refuseDeniedSubjectLoadBeforeDispatch(actionDef, objectName, recordId, subject);" --replacement "actionExec.refuseDeniedSubjectLoad(objectName, recordId ?? 'ablation-28657', subject);" -- bash ablate-inner.sh` ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob a762e11 -> 3d61252a815c build exit 0 ✓ dist/: marker present in 2 built files -- the ablation is live in the artifact the suite consumes. × Approve answers 200 and the request moves: 1 of 2, the approver off the slate, an approve row × a member on no slate gets the decision route's own refusal through the door — never a 200, never the row Tests 2 failed | 1 passed (3) ablation-replace: ok restored: blob == HEAD (a762e11) and `git diff HEAD` is empty tree after restore (want empty): [] ✓ dist/: marker absent from all 12 built files verify pin (restored): Tests 3 passed (3) ``` </details> <details><summary>Runtime pins (doors over fakes): ruling 2, parity, the 400 relay</summary> New `describe('[#28657] …')` in `packages/runtime/src/domains/api-action-forward.test.ts`, 10 cases: - REST: the approve shape on a row the caller cannot read is forwarded to `/api/v1/approvals/requests/areq_hidden/approve` with the caller's bearer, and the target's 200 is the answer. - REST: the target's 403 `{ code: 'FORBIDDEN', error }` is the answer, its sentence as the message. - REST: a `recordIdParam` read from `id` is seeded from the routed id alone. - REST, ruling 2: `{email}` in the path and `recordIdField: 'email'` on a denied load answer 404 `RECORD_NOT_FOUND` and nothing is forwarded; the firing control on a readable row forwards with the row's field. - REST, ruling 6: a 400 `{ code: 'VALIDATION_FAILED', error }` is relayed with its sentence, not "its target answered HTTP 400". - MCP `run_action`: the same approve shape on a denied load is forwarded and answers `ok: true`; the two row-field shapes are thrown 404 `RECORD_NOT_FOUND` with nothing forwarded; the `{ code, error }` sentence rides the throw. - The shared helpers, case by case. On base (`-t 28657`): 7 failed, 3 passed. The 3 that pass on base are the ruling-2 404 cases and their firing control, which pin behaviour that must not change. On head: `Tests 68 passed (68)`. Ruling 3 (every non-api branch keeps the 404) stays pinned by the existing cases, all green on head: `action-door-record-load-denied.test.ts` "[#16370] flow door — a denied caller-scope load is refused before the run exists" (REST and MCP), "[#16370] script/body door — the same verdict, the same refusal" (REST and MCP), and `http-dispatcher.actions-type-dispatch.test.ts` "[#16370] a ROW-SCOPED flow whose record never loaded is REFUSED — no run is created". The declarative update is excluded by `forwardsOnDeniedSubjectLoad` (pinned in the helper case) and runs ahead of the call in both doors. MCP parity with `approval_approve` itself is not reachable: the `sys_approval_request` actions declare no `ai.exposed`, so `run_action` refuses them at the system-object exposure gate before the subject load, on base and on head alike. Parity is pinned on the same declaration shape on an exposed object. </details> <details><summary>Live probe: the card's reproduction on a stock showcase, step 7</summary> Boot (no `--ui`: the probe is HTTP only): `OS_PORT=PORT pnpm -C examples/app-showcase exec objectstack dev --seed-admin --no-compile -p PORT -d file:DIR/data.db`, after `pnpm exec turbo run build --filter='!@objectstack/docs'` (117 tasks). Then, with curl and an `Origin` header: admin `POST /api/v1/auth/sign-in/email`; `POST /api/v1/auth/organization/invite-member` `{"email":X,"role":"member"}`; X `POST /api/v1/auth/sign-up/email`; admin `POST /api/v1/data/sys_user_position` for `contributor` and `finance`; X `POST /api/v1/data/showcase_invoice` `{"name":…,"account":ACTIVE_ACCOUNT_ID,"owner":X_EMAIL}` and `PATCH …/ID` `{"status":"sent","issued_on":"2026-11-01"}`; then step 7. Base (runtime rebuilt from `e4ec4f22c5`, fresh database): ``` before: {"status":"pending","decision_progress":{"behavior":"unanimous","got":0,"need":2},"can_act":true} STEP 7 action door approve: 404 {"success":false,"error":{"code":"RECORD_NOT_FOUND","message":"Record areq_dab05fb4-… not found in sys_approval_request","httpStatus":404}} after: {"status":"pending","decision_progress":{"behavior":"unanimous","got":0,"need":2},"can_act":true} actions: [{"action":"submit",…}] ``` Head (fresh database): ``` before: {"status":"pending","decision_progress":{"behavior":"unanimous","got":0,"need":2},"can_act":true} STEP 7 action door approve: 200 {"success":true,"data":{"request":{"id":"areq_70509601-…","status":"pending",… after: {"status":"pending","decision_progress":{"behavior":"unanimous","got":1,"need":2},"pending":["(the other approver)"],"can_act":false} actions: [{"action":"submit",…},{"action":"approve","actor_id":"(X)"}] ``` X leaves `pending_approvers`, an `approve` row exists, and X's `can_act` is false, which is the card's step 6. </details> <details><summary>Packages, gates, lint</summary> - `pnpm --filter @objectstack/runtime test`: `Test Files 504 passed (504) · Tests 8196 passed | 19 skipped (8215)`. - `pnpm --filter @objectstack/runtime typecheck` and `pnpm --filter @objectstack/verify typecheck` (each includes `check:test-typecheck`, so the new test files are type-checked): exit 0. - `eslint --no-inline-config packages/runtime packages/verify`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` on the final head `f3768a46`: 61 commands, every one exit 0 (`--ran` reconciliation: "61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED"). The list with each exit code is in the card's report. </details> ## Acceptance notes - The approval actions are not `ai.exposed`, so an agent cannot run them through `run_action` before or after this change. Exposing them is a plugin-approvals decision outside this card. - The approvals routes answer `{ code, error }` rather than the ADR-0112 envelope. This PR only reads that sentence at the forward; the routes' own shape is unchanged. ## Verification - Real-stack HTTP pin (PR CI runs it in Test Core: `@objectstack/verify` is downstream of runtime): `pnpm --filter @objectstack/verify exec vitest run src/action-door-api-forward-denied-subject.test.ts`. - Live: the probe above, on the showcase. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mze6z6hhTUauqWMmZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c01690e commit 0c6b7b8

5 files changed

Lines changed: 540 additions & 5 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): a non-admin approver's decision from the request page reaches the approvals routes instead of answering 404 (#28657)
6+
7+
Clause-②: yes (widening: on `POST /api/v1/actions/:object/:action` and MCP `run_action`, a same-origin `type: 'api'` action whose subject row the caller's own scope does not deliver, and whose request needs no field of that row, is forwarded and answers its target's answer where it answered `404 RECORD_NOT_FOUND`)
8+
Metadata-protocol: none
9+
10+
The request page's decision actions (`approval_approve`, `approval_reject`, `approval_comment`, `approval_request_info`, and the submitter's `approval_remind` / `approval_recall`) are same-origin `type: 'api'` actions that forward to the approvals routes (`/api/v1/approvals/requests/{id}/approve` and the rest). The action door loaded the `sys_approval_request` row in the caller's data scope first, and a member has no data-door read on that object by design, so every decision a non-admin sent from the page answered `404 RECORD_NOT_FOUND` before the approvals route, which admits a pending approver itself, was ever asked.
11+
12+
- **Now.** When the caller-scope load does not deliver the row, a same-origin api action is forwarded anyway, and the door answers what the target answers: a pending approver's Approve answers 200 and the request moves; a member who is not on the slate gets the approvals route's own refusal. The target authorizes the caller with the caller's own credentials (ADR-0066 D4), the same request the caller could send it directly, so nothing new is disclosed.
13+
- **⛔ No row data the caller could not read reaches the request.** On a denied load the request is built from the `recordId`, the params and the caller only. A declaration whose request needs a field of the row (a `{field}` path token other than `{id}`, or a `recordIdParam` read from a `recordIdField` other than `id`) keeps the `404 RECORD_NOT_FOUND`, and nothing is forwarded.
14+
- **Unchanged.** The declarative update, `type: 'flow'` and script/body actions still refuse a denied load with `404 RECORD_NOT_FOUND` before anything trusted is built. The member data grant on `sys_approval_request` is not widened.
15+
- **Both doors, one rule.** The REST `/actions` door and the MCP `run_action` bridge call one shared function at the same point (`refuseDeniedSubjectLoadBeforeDispatch`, with `apiForwardSubjectRecord` building the request's row).
16+
- **The target's sentence is relayed.** A target that answers `{ code, error: '<sentence>' }`, as the approvals decision routes do, now has that sentence relayed as the refusal's message (for example, which decision outputs are required to approve), where the door answered "its target answered HTTP 400".

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

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1980,8 +1980,15 @@ export function apiActionRefusalMembers(answer: ApiActionForwardAnswer): Record<
19801980
*/
19811981
export function apiActionForwardRefusal(action: any, answer: ApiActionForwardAnswer): Error {
19821982
const body = answer.body;
1983+
// [#28657] Three places a target writes its sentence: the ADR-0112
1984+
// envelope's `error.message`, a bare `message` (better-auth), and a bare
1985+
// STRING `error` beside a top-level `code` — the approvals decision routes'
1986+
// `{ code, error }`, whose prescription (which decision outputs are
1987+
// required to approve, why this actor may not decide) was replaced by the
1988+
// fallback below before this read it.
19831989
const message: string =
19841990
(body && typeof body === 'object' && typeof body.error?.message === 'string' && body.error.message) ||
1991+
(body && typeof body === 'object' && typeof body.error === 'string' && body.error) ||
19851992
(body && typeof body === 'object' && typeof body.message === 'string' && body.message) ||
19861993
`Action '${action?.name ?? 'unknown'}' — its target answered HTTP ${answer.status}`;
19871994
const code: string =
@@ -3356,6 +3363,102 @@ export function refuseDeniedSubjectLoad(
33563363
throw recordNotFoundError(objectName, recordId ?? '');
33573364
}
33583365

3366+
/**
3367+
* Contract point 3 as the two action doors' dispatch reads it, for every
3368+
* branch: {@link refuseDeniedSubjectLoad}, except for a same-origin
3369+
* `type: 'api'` forward that needs nothing from the row.
3370+
*
3371+
* ## Why the api forward is different
3372+
*
3373+
* The 404 exists so that nothing TRUSTED is built for a caller who has not
3374+
* shown read access to the subject row: the RLS/FLS-bypassing `ctx.engine` /
3375+
* `ctx.api` of a script body, a persisted flow run, a declarative write. An
3376+
* api forward builds none of those. It hands its declared `target` the
3377+
* caller's OWN credentials, and the target authorizes the caller itself
3378+
* (ADR-0066 D4) — a request the caller could send to that target directly.
3379+
* Refusing it here put the generic data door's verdict in front of a door
3380+
* that answers the question itself, and wrongly: approval decisions are
3381+
* authorized by the approvals routes (a pending approver may decide), while a
3382+
* member has no data-door read on `sys_approval_request` by design. So a
3383+
* denied load no longer answers its own 404 ahead of the forward; the door
3384+
* forwards and relays what the target answers. A load that names nothing and
3385+
* a load that was filtered are the same answer to the target too, so this
3386+
* opens no existence oracle the target does not already answer.
3387+
*
3388+
* ## ⛔ No row data the caller could not read reaches the request
3389+
*
3390+
* On a denied load the request is built from the `recordId`, the params and
3391+
* the caller only ({@link apiForwardSubjectRecord}). A declaration whose
3392+
* request needs a field OF the row ({@link apiActionRequestReadsSubjectRow})
3393+
* cannot be built from those, so it keeps today's 404 `RECORD_NOT_FOUND`.
3394+
*
3395+
* Every other branch — the declarative update, the flow dispatch, the
3396+
* script/body dispatch — keeps the refusal unchanged. Both doors call this
3397+
* ONE function at the same point (the REST `/actions` door and the MCP
3398+
* `run_action` bridge), so the rule cannot hold at one and not the other.
3399+
*/
3400+
export function refuseDeniedSubjectLoadBeforeDispatch(
3401+
action: any,
3402+
objectName: string,
3403+
recordId: string | undefined,
3404+
subject: ActionSubjectRecordLoad,
3405+
): void {
3406+
if (subject.recordLoadDenied && forwardsOnDeniedSubjectLoad(action)) return;
3407+
refuseDeniedSubjectLoad(objectName, recordId, subject);
3408+
}
3409+
3410+
/**
3411+
* Does a denied subject load still forward this action? Exactly a same-origin
3412+
* `type: 'api'` action (the doors refuse an off-origin one earlier) whose
3413+
* request can be built without the row. A declarative update is never one,
3414+
* whatever its `type` says: `operation` before `type`.
3415+
*/
3416+
export function forwardsOnDeniedSubjectLoad(action: any): boolean {
3417+
return !isDeclarativeUpdateAction(action)
3418+
&& action?.type === 'api'
3419+
&& isSameOriginApiTarget(action?.target)
3420+
&& !apiActionRequestReadsSubjectRow(action);
3421+
}
3422+
3423+
/**
3424+
* Does this api action's request need a field OF the subject row — beyond the
3425+
* `id` the caller already named? Two places in the declaration read the row
3426+
* ({@link buildApiActionRequest}): a `{field}` path token in `target`, and a
3427+
* `recordIdParam` seeded from a `recordIdField`. `{id}` and a `recordIdField`
3428+
* of `id` (the default) are the routed `recordId` itself, so they do not.
3429+
*
3430+
* Read off the declaration, before any substitution: a token that only looks
3431+
* like a field (a `${name}` with no scope the substitution leaves in place)
3432+
* counts as one, which errs toward the 404, never toward a forward.
3433+
*/
3434+
export function apiActionRequestReadsSubjectRow(action: any): boolean {
3435+
const target: string = typeof action?.target === 'string' ? action.target : '';
3436+
for (const match of target.matchAll(/\{([a-z_][a-z0-9_]*)\}/gi)) {
3437+
if (match[1] !== 'id') return true;
3438+
}
3439+
const recordIdParam = typeof action?.recordIdParam === 'string' && action.recordIdParam ? action.recordIdParam : undefined;
3440+
if (recordIdParam) {
3441+
const field: string = typeof action?.recordIdField === 'string' && action.recordIdField ? action.recordIdField : 'id';
3442+
if (field !== 'id') return true;
3443+
}
3444+
return false;
3445+
}
3446+
3447+
/**
3448+
* The subject row an api forward's request is built from. A row the caller's
3449+
* own scope delivered, as it was; on a denied load, `{ id: recordId }` and
3450+
* nothing else — built from the id the caller named, ⛔ never from whatever
3451+
* the load left behind, so no field of a row the caller could not read can
3452+
* reach the request.
3453+
*/
3454+
export function apiForwardSubjectRecord(
3455+
recordId: string | undefined,
3456+
subject: ActionSubjectRecordLoad,
3457+
): Record<string, unknown> {
3458+
if (!subject.recordLoadDenied) return subject.record;
3459+
return recordId ? { id: recordId } : {};
3460+
}
3461+
33593462
/**
33603463
* [#15079] The prior/next value pair a successful declarative update hands back
33613464
* when the action declares `undoable: true` — the anchor that key never had.
@@ -3867,7 +3970,11 @@ async function dispatchAdmittedBusinessAction(deps: ActionExecutionDeps,
38673970
// persisted automation run exists and no trusted body has been entered
38683971
// when the refusal lands. `ok: true` on a row the caller cannot read is
38693972
// the defect; refusing after the run was created would only describe it.
3870-
refuseDeniedSubjectLoad(objectName, recordId, subject);
3973+
//
3974+
// [#28657] Except ahead of an api forward that needs nothing from the row
3975+
// (`refuseDeniedSubjectLoadBeforeDispatch`): the target authorizes the
3976+
// caller itself. The REST `/actions` door calls the same function.
3977+
refuseDeniedSubjectLoadBeforeDispatch(action, objectName, recordId, subject);
38713978

38723979
// ── api forward (#27506) ── a same-origin `type: 'api'` action is
38733980
// forwarded in-process to its declared `target` with the caller's own
@@ -3889,7 +3996,7 @@ async function dispatchAdmittedBusinessAction(deps: ActionExecutionDeps,
38893996
code: standardErrorCodeForHttpStatus(503), status: 503,
38903997
});
38913998
}
3892-
const built = buildApiActionRequest(action, { recordId, record, params, ec });
3999+
const built = buildApiActionRequest(action, { recordId, record: apiForwardSubjectRecord(recordId, subject), params, ec });
38934000
if ('message' in built) {
38944001
throw Object.assign(new Error(built.message), { code: standardErrorCodeForHttpStatus(built.status), status: built.status });
38954002
}

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -879,7 +879,13 @@ async function dispatchAdmittedRestAction(
879879
// audit line and the RLS/FLS-bypassing `ctx.engine` / `ctx.api` are
880880
// built below, and none of them may exist for a caller who has not
881881
// demonstrated read access to the subject row.
882-
actionExec.refuseDeniedSubjectLoad(objectName, recordId, subject);
882+
//
883+
// [#28657] Except ahead of an api forward that needs nothing from the
884+
// row: it builds none of that, and its target authorizes the caller
885+
// itself (ADR-0066 D4), so a denied load forwards and the target's
886+
// answer is this door's — see `refuseDeniedSubjectLoadBeforeDispatch`.
887+
// The MCP `run_action` bridge calls the same function at the same point.
888+
actionExec.refuseDeniedSubjectLoadBeforeDispatch(actionDef, objectName, recordId, subject);
883889

884890
// ── api forward (#27506, ruling 6499781492) ── a same-origin
885891
// `type: 'api'` action is forwarded IN-PROCESS to its declared
@@ -903,7 +909,11 @@ async function dispatchAdmittedRestAction(
903909
if (!inject) {
904910
return { handled: true, response: deps.error(actionExec.apiActionForwardUnavailableError(actionDef), 503) };
905911
}
906-
const built = actionExec.buildApiActionRequest(actionDef, { recordId, record, params: reqParams, ec });
912+
// [#28657] On a denied load the request is built from the
913+
// `recordId` alone — ⛔ never from a row the caller could not read.
914+
const built = actionExec.buildApiActionRequest(actionDef, {
915+
recordId, record: actionExec.apiForwardSubjectRecord(recordId, subject), params: reqParams, ec,
916+
});
907917
if ('message' in built) {
908918
return { handled: true, response: deps.error(built.message, built.status) };
909919
}

0 commit comments

Comments
 (0)