Skip to content

Commit 60dac8b

Browse files
fix(runtime): a declarative update action refuses a caller with no edit grant 403 before the record lookup, as the data door does (#30878)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DEFxACyPwHxUwJy1F7vBLFD)_ domain:server#1 · os-elon-musk · session_016WV4czxVZYV4CcMiCbofw3 Fixes #30848 Clause-②: no Metadata-protocol: none Claim: comment 6679693194 (branch `claude/project-thread-y8qx4d`). ## 维护者速读 - 改了什么:声明式更新动作(`operation: 'update'`,REST `/actions` 与 MCP `run_action`)在按"读不到这条记录"回 404 之前,先问调用者对该对象有没有编辑权;没有就回 403 `PERMISSION_DENIED`,与数据接口(`PATCH /data`、MCP `update_record`)对同一次写入的回答一致。 - 为什么改:只读无编辑权的用户改一条看不到的记录(或不存在的 id),动作门答 404、数据门答 403,同一次写入两个答案;文档写明"拒绝先于记录查找"、声明式更新"与用户编辑完全一致"。场景卡 #30802 因此无法写出唯一的预期拒绝。 - 风险与回滚:只在记录加载被拒时多问一次安全服务的 `explain(update)`,正常路径不变;有编辑权的人仍对"看不到的行"和"不存在的 id"回同一个 404,不形成存在性探测。单 PR 可整体回滚。 - 先看:`packages/runtime/src/action-execution.ts`(`refuseUngrantedDeclarativeUpdate` 与 `executeDeclarativeUpdateAction` 中它的调用点)、`packages/runtime/src/declarative-update-refusal-order.pin.test.ts`。 - 两分钟验证:`cd packages/runtime && pnpm exec vitest run --project local src/declarative-update-refusal-order.pin.test.ts`,预期 20/20 通过。 - 需要你做的:无,席位落地。 ## What changes - New `refuseUngrantedDeclarativeUpdate` in `@objectstack/runtime` (`action-execution.ts`). `executeDeclarativeUpdateAction`, the executor both action doors call (REST `/actions` and MCP `run_action`), now calls it before it consumes the subject-load verdict (`refuseDeniedSubjectLoad`), and only when that load was denied. - The ask is the security service's `explain({ object, operation: 'update' })`, the contract's "would the middleware allow this operation?" answer, computed by the enforcement walk. The REST import-template gate already asks `create` the same way. No row is named in the ask, so the answer cannot depend on whether the id names a row. Nothing is re-derived from permission sets, and nothing changes in `plugin-security`. - A denial, or an `explain` that throws, refuses with the data door's own refusal: `403 PERMISSION_DENIED` with the middleware's user-facing sentence (`permission_denied` via `renderOperationMessage`, the caller's locale, the deployment's i18n override). With no security service, or one without `explain`, nothing is refused here, as at the data door. - A delivered row is unchanged: the write that follows carries the caller's identity and the data plane answers its own 403. A caller who may update the object keeps the not-found answer, one answer for a hidden row and a missing id. - Docblocks on `executeDeclarativeUpdateAction` updated to name the new order. ### The line the card asked for On `main` `a2f523aa`, `invokeBusinessAction` loads the subject at `packages/runtime/src/action-execution.ts:4406` (`const subject = await loadActionSubjectRecord(…)`), and the declarative branch refuses on that load's verdict at `:4169` (`refuseDeniedSubjectLoad(objectName, recordId, subject)` in `executeDeclarativeUpdateAction`) before the write, the one place the object-level edit permission was asked, is attempted. The REST `/actions` door follows the same sequence (`domains/actions.ts`, its own load, then the same executor). ### Other subject-loading paths (checked, not changed) - There is no declarative delete: `ActionSchema.operation` is `z.enum(['update'])`. - The other `refuseDeniedSubjectLoad` callers are read admissions, not writes performed as the caller: the flow and script/body dispatch (`refuseDeniedSubjectLoadBeforeDispatch`), the api forward that needs the row, and the flow start record keys (`refuseUnreadableFlowStartRecords`). No data door performs the same write for the same caller, so there is no second answer to disagree with. Their 404 is the read-admission answer by design (#15079, #16370). They do not share this bug, and no follow-up is listed. ## Why `content/docs/protocol/kernel/http-protocol.mdx`, "Refusals come before the record lookup": a caller who may not update the object gets the same 403 whether or not the record id names a record. `content/docs/protocol/objectui/actions.mdx`: a declarative update fires with the caller's permissions "exactly as for a user edit". Both sentences were false for the action door. The finding came from #30802 / PR #30846 (`travel_expense` op 4), which needs one expected refusal for this operation. ## Tests `packages/runtime/src/declarative-update-refusal-order.pin.test.ts` boots one kernel (the plugin set `unresolved-object-name.actions-door.pin.test.ts` uses: ObjectQL, sqlite-wasm, Hono, the app, platform objects, auth, security, sharing, REST, dispatcher). Object `pin_step` is private OWD with a declarative `decide` action. Two invited members: a reader (default set: read + create) and an editor (an assigned set: read + create + edit). Each member owns one row. - Matrix: {reader, editor} × {own row, the other member's row, a missing id} × four doors: `PATCH /data`, the MCP bridge's `update` (what `update_record` calls), `POST /actions/pin_step/decide/:id`, and the MCP bridge's `runAction` (what `run_action` calls). The MCP leg is driven through the real `/mcp` route with a stand-in `mcp` service that calls the two bridge members and reports the thrown `status` and `code`. The tools' own envelope rendering is `@objectstack/mcp`'s and is not under test here. Expected: the reader gets 403 `PERMISSION_DENIED` on all three rows at every door; the editor gets 200 on its own row and 404 `RECORD_NOT_FOUND` on the other two at every door. A refused write leaves the row untouched. - Existence-oracle equality: within each door and for each caller, the hidden row and the missing id get the same answer, body included, once the id is written out (8 cases). For the reader, the visible row's refusal at each door is that same answer too, sentence included (4 cases). So the action door's 403 carries the data plane's sentence. - Preconditions: each member reads its own row and gets 404 on the other's; the administrator reads both. <details><summary>Baseline on main's action door (the re-measure)</summary> The pin run against `origin/main`'s `action-execution.ts` (test file kept): 2 failed / 14 passed of the first 16 cases. Both failures were reader × hidden row and reader × missing id, at exactly two doors: ``` "action MCP": expected { code: PERMISSION_DENIED, status: 403 } received { code: RECORD_NOT_FOUND, status: 404 } "action REST": expected { code: PERMISSION_DENIED, status: 403 } received { code: RECORD_NOT_FOUND, status: 404 } "data MCP": { code: PERMISSION_DENIED, status: 403 } (as expected) ``` Every other cell, including editor × hidden/missing (404 at all doors) and both visible rows, already agreed. This is the card's table (filer's probe on `c17719dd`) on current main. The filer's probe script needs PR #30846's scenario harness worktree, so the re-measure went through this booted pin instead. </details> <details><summary>Ablation (restored: blob equals HEAD, git diff HEAD empty)</summary> - Mutation via `scripts/ablation-replace.mjs`: the verdict is consumed before the ask (`refuseDeniedSubjectLoad(…); await refuseUngrantedDeclarativeUpdate(…)`), i.e. the old order. The anchor went 1 → 0, and the blob went `97cc3fe3` → `7e1ef3a8`. The suite imports runtime `src/` directly, so no dist rebuild is needed. - Result: 4 failed / 16 passed. Reader × hidden and reader × missing are red in the matrix, and the reader's visible-vs-missing equality is red at `action REST` and `action MCP`. Every editor row and every data-door row stays green. - Restore: blob after restore `97cc3fe3` equals HEAD, and `git diff HEAD` is empty. - The first ablation attempt was a no-op and is not counted: its replacement still contained the anchor, so `ablation-replace` refused before running anything (anchor 1 → 1). The second attempt is the reading above. </details> ## Acceptance notes - The ask is object-level `update` with no payload, so two pre-lookup refusals the data door gives a caller who holds the update grant are not asked here: a field-level write refusal on a field the payload names (middleware step 2.5), and a read-only external datasource's write guard. For such a caller writing a hidden row or a missing id, the action door still answers 404 where the data door answers 403. Both answers stay the same for the hidden row and the missing id, so there is no oracle. This is not measured here, and the pinned matrix does not reach it. - A caller whose read of the object is refused but who holds edit: the data door answers the read refusal. The action door asks only `update`, so it keeps 404 for that caller. This is not measured here, and it needs a permission set granting edit without read. - `explain` denies a caller whose permission sets resolve empty. Since ADR-0056 D2 the middleware's CRUD gate does too, so this matches. ## Verification All on head `2dc4bdfa`, except where a line names `ad748b98`. That earlier head differs only in docblocks and the changeset prose, plus one line removed from the new function: the `isSystem` short-circuit, dropped so that `explain` answers a system context as the middleware does. - `pnpm --filter @objectstack/runtime typecheck`: exit 0 (2dc4bdf). - Focused: the new pin plus `action-declarative-update.test.ts`, `action-invocation-audit.test.ts` and `action-declarative-update-stale-summary.test.ts`: 4 files, 90 tests passed (2dc4bdf). - `@objectstack/runtime` local project, whole suite: 550 files, 8801 passed, 19 skipped (ad748b9). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 60 families, all run on 2dc4bdf with exit 0; `--ran` reconciliation: 60 derived, 60 run, 0 NOT-MEASURED. The first pass on ad748b9 had one red, `check:system-context-census` (a new `isSystem` read in a symbol no census row names), cleared by dropping that line. It also had one exit 3, `check:dual-build-cjs-loads` (PREREQUISITE NOT MET, no whole-tree dist), which exits 0 on 2dc4bdf. - `pnpm lint`: exit 0 (2dc4bdf). - Changeset: `@objectstack/runtime` patch, carrying both declaration lines. - Head `5f17e7c8` (one commit on `2dc4bdfa`, test-only): the merge queue's Dogfood Regression Gate (3/3) caught `showcase-anonymous-deny-surfaces.dogfood.test.ts` pinning the old 404 for the no-edit member (run 42192137004). The case now asserts this PR's 403 `PERMISSION_DENIED` for the member, keeps #7867's ghost-id 404 with the dev administrator who holds the edit grant, and compares the action with the PATCH for both callers. Dogfood, full suite unsharded on `5f17e7c8`: 423 files passed and 1 failed, 3537 tests passed and 2 failed. Both failures were `catalog-activation-door.dogfood.test.ts` :149 and :167 timing out at 5000 ms under box load; that file passed 10/10 alone on the same head, and the fixed file passed 80/80. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4czxVZYV4CcMiCbofw3 <!-- signed: domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98f640d commit 60dac8b

4 files changed

Lines changed: 519 additions & 17 deletions

File tree

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+
fix(runtime): a declarative update action refuses a caller with no edit grant `403 PERMISSION_DENIED`, as the data door does, whether or not the record exists or is visible to them
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An `operation: 'update'` action (REST `POST /actions/:object/:action/:id`, MCP `run_action`) now judges the caller's update permission on the object before it answers on the record lookup, the order the data door already keeps ("Refusals come before the record lookup"). A caller who may read the object but holds no edit grant on it, writing a record their access hides or an id that names no record, was answered `404 RECORD_NOT_FOUND` by the action and `403 PERMISSION_DENIED` by `PATCH /data` and MCP `update_record` for the same write. Both doors now answer `403 PERMISSION_DENIED`, with the data door's sentence.
11+
12+
A caller who may update the object keeps `404 RECORD_NOT_FOUND`, one answer for a hidden record and a missing id, so neither door tells a caller whether a record it cannot see exists. A record the caller can read but not edit was already answered `403` by both doors and is unchanged.
13+
14+
No request that succeeded is refused, and no shape changes: for a caller who may not update the object, one refusal's code moves from `404 RECORD_NOT_FOUND` to the data door's `403 PERMISSION_DENIED`.

‎packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts‎

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -581,8 +581,18 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
581581
// ⛔ The case above is left as it was. Narrowing IT to 404 would make this
582582
// file's anonymity proof fail for reasons belonging to another proof, which
583583
// is what its own comment warns against.
584-
it('[#7867] a member hitting the action with a NONEXISTENT record id gets 404 RECORD_NOT_FOUND', async () => {
585-
const r = await stack.apiAs(memberToken, 'POST', ACTION, { params: {} });
584+
//
585+
// [#30848] `showcase_mark_done` is a declarative update (`operation:
586+
// 'update'`), and a declarative update is judged as the write it performs:
587+
// a caller who may not update the object is refused before the record is
588+
// looked up, as the data door's PATCH refuses it. The member holds no edit
589+
// grant on `showcase_task`, so its answer for the ghost id is that refusal,
590+
// 403 PERMISSION_DENIED, and no longer the lookup's 404. #7867's proof, that
591+
// a ghost id answers the lookup's 404 rather than a 400 from further down
592+
// the pipeline, is carried by a caller who passes that refusal: the dev
593+
// administrator, who holds the edit grant.
594+
it('[#7867] a caller holding the edit grant hitting the action with a NONEXISTENT record id gets 404 RECORD_NOT_FOUND', async () => {
595+
const r = await stack.apiAs(adminToken, 'POST', ACTION, { params: {} });
586596

587597
expect(r.status, 'a ghost record id must answer 404, not a 400 from further down the pipeline').toBe(404);
588598
const body = (await r.json()) as Record<string, unknown>;
@@ -596,32 +606,52 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () =>
596606
expect(String(err.message ?? '')).not.toMatch(/HookConditionError/);
597607
});
598608

599-
it('[#7867] …the SAME answer the REST data surface gives for that same id', async () => {
609+
it('[#30848] a member with no edit grant hitting the action with a NONEXISTENT record id gets 403 PERMISSION_DENIED', async () => {
610+
const r = await stack.apiAs(memberToken, 'POST', ACTION, { params: {} });
611+
612+
expect(r.status, 'a caller who may not update the object is refused the write before the lookup').toBe(403);
613+
const body = (await r.json()) as Record<string, unknown>;
614+
const err = (isRecord(body.error) ? body.error : {}) as Record<string, unknown>;
615+
expect(err.code).toBe('PERMISSION_DENIED');
616+
expect(err.httpStatus).toBe(403);
617+
// Still never the 400 shapes #7867 removed.
618+
expect(String(err.message ?? '')).not.toMatch(/not bound for this operation/);
619+
expect(String(err.message ?? '')).not.toMatch(/HookConditionError/);
620+
});
621+
622+
it('[#7867, #30848] …the SAME answer the REST data surface gives that caller for that same id', async () => {
600623
// The comparison that made #7867 measurable in the first place: one id, one
601624
// object, one process, one second — and two surfaces that disagreed
602625
// (`/actions` → 400, `/data` → 404). Both are asserted here, so they cannot
603626
// drift apart again without this gate saying so.
604627
//
605-
// The data surface that answers this the way the action does is the record
606-
// READ: the action loads its record through the read door, and the member
607-
// reads `showcase_task`. A PATCH is not that surface for this caller: an
608-
// update by id is judged before the record is looked up, as a delete is, and
609-
// the member holds no edit grant on `showcase_task` — so it is refused the
610-
// write whatever the id (`update-by-id-refusal-order.dogfood.test.ts`).
628+
// The data surface that answers this the way the action does is the
629+
// record's UPDATE, the write the action performs: an update by id is
630+
// judged before the record is looked up, as a delete is
631+
// (`update-by-id-refusal-order.dogfood.test.ts`). For the member, who holds
632+
// no edit grant on `showcase_task`, both refuse the write whatever the id;
633+
// for the administrator, who holds it, both answer the lookup's not-found.
611634
const ghostId = ACTION.slice(ACTION.lastIndexOf('/') + 1);
612-
const rest = await stack.apiAs(memberToken, 'GET', `/data/showcase_task/${ghostId}`);
613635

614-
expect(rest.status).toBe(404);
615-
const body = (await rest.json()) as Record<string, unknown>;
616636
// `@objectstack/rest` answers the flat envelope; the dispatcher-mounted
617637
// `/actions` answers its own wrapper. Different shells, one code — each read
618638
// in its own shape, with no `??` chain across the two (#5632's rule, which
619639
// this file already enforces for the 401 bodies).
620-
expect(body.code).toBe('RECORD_NOT_FOUND');
621-
622-
const patch = await stack.apiAs(memberToken, 'PATCH', `/data/showcase_task/${ghostId}`, { done: true });
623-
expect(patch.status).toBe(403);
624-
expect(((await patch.json()) as Record<string, unknown>).code).toBe('PERMISSION_DENIED');
640+
const memberPatch = await stack.apiAs(memberToken, 'PATCH', `/data/showcase_task/${ghostId}`, { done: true });
641+
expect(memberPatch.status).toBe(403);
642+
expect(((await memberPatch.json()) as Record<string, unknown>).code).toBe('PERMISSION_DENIED');
643+
const memberAction = await stack.apiAs(memberToken, 'POST', ACTION, { params: {} });
644+
expect(memberAction.status).toBe(403);
645+
const memberActionBody = (await memberAction.json()) as Record<string, unknown>;
646+
expect(((isRecord(memberActionBody.error) ? memberActionBody.error : {}) as Record<string, unknown>).code).toBe('PERMISSION_DENIED');
647+
648+
const adminPatch = await stack.apiAs(adminToken, 'PATCH', `/data/showcase_task/${ghostId}`, { done: true });
649+
expect(adminPatch.status).toBe(404);
650+
expect(((await adminPatch.json()) as Record<string, unknown>).code).toBe('RECORD_NOT_FOUND');
651+
const adminAction = await stack.apiAs(adminToken, 'POST', ACTION, { params: {} });
652+
expect(adminAction.status).toBe(404);
653+
const adminActionBody = (await adminAction.json()) as Record<string, unknown>;
654+
expect(((isRecord(adminActionBody.error) ? adminActionBody.error : {}) as Record<string, unknown>).code).toBe('RECORD_NOT_FOUND');
625655
});
626656

627657
// ── /automation (dispatcher-mounted; runtime domains/automation.ts) ─────

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel';
2424
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
2525
import type { IHttpServer, IObjectQLEngine, InternalFieldEvaluationQuery, QueryEvaluationGate, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts';
2626
import { standardErrorCodeForHttpStatus, type MetadataProtocol } from '@objectstack/spec/api';
27+
import { renderOperationMessage } from '@objectstack/spec/system';
2728
// [#15942 / #16293] The confirmation member's SPELLING is the contract, so it
2829
// is imported, never hand-spelled: the door that refuses and the client that
2930
// retries have to agree on it, and two doors each writing their own 'confirm'
@@ -3709,6 +3710,88 @@ export function refuseDeniedSubjectLoad(
37093710
throw recordNotFoundError(objectName, recordId ?? '');
37103711
}
37113712

3713+
/**
3714+
* [#30848] The declarative update's object-level update refusal, judged before
3715+
* its subject-load verdict is: the data door's order, "the update is judged
3716+
* before the record is looked up" (`http-protocol.mdx`).
3717+
*
3718+
* ## What was wrong
3719+
*
3720+
* A caller who may read an object but holds no edit grant on it, writing a
3721+
* row their scope hides (or an id that names nothing), was answered
3722+
* `404 RECORD_NOT_FOUND` by a declarative `operation: 'update'` action,
3723+
* through REST `/actions` and MCP `run_action` both, while the data door
3724+
* answered the same write `403 PERMISSION_DENIED` (`PATCH /data`, MCP
3725+
* `update_record`). The action door consumed {@link refuseDeniedSubjectLoad}'s
3726+
* 404 first, so the write, which would have been refused for the object
3727+
* before any row was read, was never asked. On a row the caller can see, the
3728+
* write is asked and both doors already answered 403.
3729+
*
3730+
* ## The ask, and why it is not a second decision
3731+
*
3732+
* Asked only when the subject load was denied: on a delivered row the write
3733+
* below carries the caller's identity and the data plane answers its own
3734+
* refusal, the same one this ask stands in for. The question is the security
3735+
* service's `explain` for `update` on the object, the contract's "would the
3736+
* middleware allow this operation?" bottom line, computed by the enforcement
3737+
* walk and not re-derived here from permission sets (the REST import-template
3738+
* gate asks `create` the same way). ⛔ No row is named: the answer must not
3739+
* depend on whether the id names a row, or the door becomes an existence
3740+
* oracle.
3741+
*
3742+
* - `allowed` ⇒ no refusal here; the caller who may update the object keeps
3743+
* the not-found answer, one answer for a hidden row and a missing id.
3744+
* - a denial, or an `explain` that throws ⇒ the data door's own refusal:
3745+
* `403 PERMISSION_DENIED` with the middleware's user-facing sentence
3746+
* (`permission_denied`, the caller's locale, the deployment's override).
3747+
* A throw is a denial, never read as a grant.
3748+
* - no security service, or one without `explain` ⇒ no refusal here: no
3749+
* permission set exists to deny with, as the data door. (A system context
3750+
* is `explain`'s own bypass, as it is the middleware's: asked, not skipped.)
3751+
*/
3752+
export async function refuseUngrantedDeclarativeUpdate(
3753+
deps: ActionExecutionDeps,
3754+
requestContext: HttpProtocolContext | undefined,
3755+
objectName: string,
3756+
ec: any,
3757+
envId?: string,
3758+
): Promise<void> {
3759+
let security: { explain?: (request: { object: string; operation: string }, context?: any) => Promise<{ allowed?: boolean } | undefined> } | undefined;
3760+
try {
3761+
security = await deps.resolveService(requestContext as HttpProtocolContext, 'security', envId);
3762+
} catch {
3763+
security = undefined;
3764+
}
3765+
if (!security || typeof security.explain !== 'function') return;
3766+
let allowed: boolean;
3767+
try {
3768+
allowed = (await security.explain({ object: objectName, operation: DECLARATIVE_UPDATE_OPERATION }, ec))?.allowed === true;
3769+
} catch {
3770+
allowed = false;
3771+
}
3772+
if (allowed) return;
3773+
let translate: ((key: string, locale: string, params?: Record<string, unknown>) => string) | undefined;
3774+
try {
3775+
const i18n: { t?: unknown } | null | undefined = await deps.resolveService(requestContext as HttpProtocolContext, 'i18n', envId);
3776+
if (i18n && typeof i18n.t === 'function') {
3777+
const t = i18n.t as (key: string, locale: string, params?: Record<string, unknown>) => string;
3778+
translate = (key, locale, params) => t.call(i18n, key, locale, params);
3779+
}
3780+
} catch {
3781+
translate = undefined;
3782+
}
3783+
const message = renderOperationMessage(
3784+
{ messageKey: 'permission_denied' },
3785+
{ locale: typeof ec?.locale === 'string' ? ec.locale : undefined, translate },
3786+
);
3787+
throw Object.assign(new Error(message), {
3788+
name: 'PermissionDeniedError',
3789+
code: 'PERMISSION_DENIED',
3790+
status: 403,
3791+
statusCode: 403,
3792+
});
3793+
}
3794+
37123795
/**
37133796
* Contract point 3 as the two action doors' dispatch reads it, for every
37143797
* branch: {@link refuseDeniedSubjectLoad}, except for a same-origin
@@ -4088,6 +4171,11 @@ function declarativeUpdateRefusal(message: string, status: number): Error {
40884171
* inventing a 403 for the pair would answer a question the platform declines to
40894172
* answer everywhere else. See the note on `loadActionSubjectRecord`.
40904173
*
4174+
* [#30848] Except for a caller who may not update the OBJECT at all: the data
4175+
* door refuses that write `403` before it looks the record up, so the pair
4176+
* gets that 403 here too ({@link refuseUngrantedDeclarativeUpdate}), one
4177+
* answer for both members of the pair, and the pair stays undistinguished.
4178+
*
40914179
* ## What is NOT checked here
40924180
*
40934181
* `visible` — contract point 6. It is a per-record UI predicate the client
@@ -4166,6 +4254,14 @@ export async function executeDeclarativeUpdateAction(
41664254
// the flow door and the script/body door now call too. Behaviour here is
41674255
// unchanged to the byte — what changed is that this is no longer the only
41684256
// door that reads the verdict, so the rule cannot be inert two doors over.
4257+
//
4258+
// [#30848] …but the object-level update refusal comes first, as at the
4259+
// data door: a caller with no edit grant gets the write's 403 whether or
4260+
// not the id names a row it can read. Asked only on a denied load: a
4261+
// delivered row reaches the write below, which answers that 403 itself.
4262+
if (subject.recordLoadDenied) {
4263+
await refuseUngrantedDeclarativeUpdate(_deps, wiring.requestContext, objectName, ec, envId);
4264+
}
41694265
refuseDeniedSubjectLoad(objectName, recordId, subject);
41704266

41714267
// ── contract point 2: ONE data-plane update, AS THE CALLER ───────────────

0 commit comments

Comments
 (0)