Skip to content

Commit 009da14

Browse files
fix(plugin-security, objectql): a row-level check holds for every row of an array insert and a predicate update (#19988)
Fixes #19950 Fixes #19964 Clause-②: no (narrowing) ## What this fixes A row-level security `check` (declared on the policy, or defaulted from its `using`) is the write-side half of the policy: a row the check refuses is never stored (ADR-0058 D4: "on the write pre-image path that already exists for by-id writes … and on the AST-injected bulk path"). The write gate enforced it for a single-row insert and a by-id update, and not for the two multi-row write shapes: - **#19964, array insert.** Step 3.6 excluded an array payload, so no judgement was installed and every row was stored unjudged, including under configurations that refuse every single-row insert. - **#19950, predicate update** (`multi: true`, no row address). Step 3.6 skipped the new-row check and logged "governed by the using-scoped where". A policy that declares only `check` scopes nothing, and a scoped `where` says nothing about the new row in any case. The skip was unconditional, so it also covered a declared `check` that differs from `using` and a `check` defaulted from `using`. Both shapes are now judged row by row with the existing refusal (`PERMISSION_DENIED` / 403, nothing stored). One failing row refuses the whole write. The judgement is the existing `satisfiesCheck` over `matchesFilterCondition`: no predicate is compiled differently, and nothing is lowered into the `where`, so no compile surface moves. ## Landing: two packages, and why the engine is one of them - `packages/plugins/plugin-security/src/security-plugin.ts`, step 3.6 plus the seam declaration and the post-`next()` fail-closed guard (generalised from "the insert" to the operation). The by-id update branch is unchanged. The `explainAccessForCaller` wiring is not touched. - `packages/objectql/src/engine.ts`, the predicate-update branch of `update()` (`domain:engine`), plus the seam's doc comments. **Why the producer is the engine (measured, not assumed):** the rows a predicate update changes are the rows the middleware-COMPOSED AST selects. That AST is complete only after every middleware has run: step 3 of this middleware composes its own scope after step 3.6, and plugin-sharing composes its editable-rows filter onto the same AST (`sharing-plugin.ts:1405`), in plugin order. The suggested route, reading "under the caller's context" in the middleware, was measured two ways and does not hold: - A caller-context read applies READ scope and field masking. Where the read scope is narrower than the write scope, written rows go unjudged (fail-open). Where it is wider, rows that will not be written get judged (false refusals). Ablation 3 below shows the second: a judgement that is not over the actual matched rows falsely refuses the USING-only in-scope control. - The engine already holds the exact set: the D7 matched-row read (`readPriorRows`, bound to the composed AST), which the ruling says is read once and reused, and which already serves validation, the `readonlyWhen` strip and both per-row hook phases. So the security layer installs its judgement on the existing `OperationContext.postHookWriteImageCheck` seam (the one #19952 built for inserts), and the engine calls it on the predicate branch. It hands over every matched row merged with the payload (the same shape as the per-row `afterUpdate` `result`), placed after `assertNoStrictDrops()`, where the payload is final. That placement follows the insert seam's contract review: "the row the seam judges must be the row that is stored". The readonly strips run earlier on this branch, so a pre-strip placement would judge values that never land. ## Mechanism hypotheses (dispatch Section 2), as measured on `2c1011b01b` 1. **Held.** Line 3000 carried `!Array.isArray(opCtx.data)`. Lines 3078-3083 set `postImage = null` for `extractSingleId(opCtx) == null` and logged "governed by the using-scoped where". 2. **Held.** `engine.ts` (the `postHookWriteImageCheck` call in `insert()`) hands `evaluate` every live row of an array insert. The array fix is plugin-side only. 3. **Refined.** The memoized `getCallerPreImage` is by-id and caller-context, so it is not reusable per row for the reasons above. The engine's memo serves instead, at no extra read wherever per-row hooks already read it. 4. **Held.** The skip was unconditional. A cell pins a `using` plus a differing `check`. One further hole, found and closed: the middleware treats a falsy scalar id (`''`, `0`) as a row address, and the engine does not (`resolveEngineUpdateDispatch`). A falsy payload id therefore carried a bulk update past the per-row judgement, admitted on a change-set-only image. The seam is now installed whenever the engine will not treat the write as addressing one row. The falsy case keeps its by-id judgement too, so it only refuses more. ## Surface beyond the claim, with reasons - `packages/objectql/src/engine.ts`: see above (cross-lane, `domain:engine`). - Existing plugin-security tests: `check-only-write-scope.test.ts` and `security-plugin.test.ts` carry engine doubles that must now honour the seam on a predicate update, the way the real engine does. Otherwise the fail-closed guard refuses them, which is the intended behaviour. One test title and comment said step 3.6 "declines to check" the bulk path; it now says 3.6 can refuse a bulk write but never scope one. That pin still discriminates a site-1 revert, now by refusal. Two comment-only edits (`rls-check-defaults-to-using.test.ts`, `rls-phantom-column-negation.test.ts`) stated the array exclusion as a fact. - **A pending release note, corrected in place: `.changeset/rls-check-defaults-to-using.md`** (from #19952, not yet released). Its "What does not change" list said bulk updates "are not checked row by row", which this PR makes false. The bullet now says their new rows are checked row by row too, by this PR's entry. `check-empty-changeset` is RED on this by design: it is the DELIBERATE CORRECTION class (ruling D on #17712). Its prescribed remedy is to keep the correction and get it confirmed on the PR; restoring the file would publish a false sentence. Under the landing rule #19970 set, 「DELIBERATE CORRECTION 红:同 head 达档复核 PASS 记录即确认,⛔ 不等维护者」, the confirmation is a same-head at-tier review record with a PASS verdict, not the maintainer. The red is expected until that record is on the PR for the landing head. ## Behaviour that changes (all in the refusing direction) - A predicate update under a check-only policy is refused when any matched row's new image fails the check. - A predicate update that moves a matched row out of a policy's `using`, when no applicable policy declares `check`, is refused: the defaulted check, which is the answer the by-id update has given since #19952. Triage's "已声明 `using` 的策略,行为保持不变" is held as: in-scope bulk updates under a `using` policy are admitted and scoped exactly as before (pinned). Only a bulk write that moves rows out of the `using` is newly refused, on the same terms as by-id. The seat confirmed this reading: by-id and bulk are two implementations of one operation and must not disagree (`RowLevelSecurityPolicySchema.check` 「defaults to USING clause if not specified」; ADR-0058 D4). - An array insert is refused when any row fails, including every configuration that already refused each single insert. - A host that installs the judgement on a predicate update and never runs it is refused (403, `error` log), as an insert already is. ## Tests **Patch round 2 (head `3247efeecd`, after merging `origin/main` `9bfbacbf8b` as merge commit `938b2acfde`):** `rls-check-multi-row-writes.test.ts` 32 passed (32); plugin-security suite 123 files, 2348 tests passed; objectql re-run because #19979 touched that package: local project 155 files / 2434 tests + 154 files / 2758 tests, repo project 1 file / 5 tests; `typecheck` green for objectql and plugin-security (VERDICT command-exit 0 each). **Patch round 1 (head `9fff66cfdc`, after merging `origin/main` `3fd3a4f91b` as merge commit `a5ca1db166`):** `rls-check-multi-row-writes.test.ts` 32 passed (32); plugin-security suite 123 files, 2348 tests passed (VERDICT command-exit 0 each). The merge brought no change under `packages/objectql` or `packages/plugins/plugin-security`, so the objectql suite was not re-run; its last run is the one below, on a byte-identical `engine.ts`. **Round 1 (head `6d28dfe98d`):** New: `packages/plugins/plugin-security/src/rls-check-multi-row-writes.test.ts`, 32 cells on driver-sql (better-sqlite3) and driver-sqlite-wasm, real `SecurityPlugin` + `ObjectQL`. - Failing first, on the unmodified tree: 18 red and 12 green (the controls). Every negative cell failed as "admitted" (`expected true to be false`). In the unresolvable-policy cells the single-insert leg was refused and only the array leg was admitted. - #19950 cells: the check-only repro; per row not per change set (a matched row failing on an unchanged field refuses the whole write); `using` plus a differing `check`; fail-closed (an inner middleware strips the seam, and the write is refused with "the update on 'qa_ticket' was executed without the row-level CHECK being evaluated"); falsy payload id. Controls: over-fix admit, USING-only in-scope admit and scoped, `using`+`check` in-scope admit, by-id unchanged, and USING-only move-out refused on both bulk and by-id. - #19964 cells: the `[admitted, refused]` repro; over-fix admit; single insert unchanged; three refuse-every-insert configurations (an unresolvable sole `using` on `insert` and on `all`, an unresolvable declared `check`). - Every refusal asserts `code` `PERMISSION_DENIED`, `status` 403 and the developer half naming the gate and verb, then reads the stored rows back under a system context. Suites: plugin-security 123 files, 2348 tests pass. objectql 309 files, 5182 tests pass (local project in two halves, plus the repo project). `typecheck` is green for both packages (plugin-security test-layer debt: 0 files, 0 errors). Ablations: each committed first, mutated through `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed), restored with blob equal to HEAD and an empty `git diff HEAD`. Resolution path: the plugin is imported relatively, and `@objectstack/objectql` is aliased to `src/index.ts` in this package's `vitest.config.ts`, so no `dist/` sits between the mutation and the test. | # | mutation | result | |---|---|---| | 1 | restore the non-array guard for inserts | 8 red: the 4 array-insert negative cells x 2 drivers | | 2 | never install the seam on a predicate update | 12 red: the 6 bulk negative cells x 2 | | 3 | engine judges the payload alone, not the matched rows | 6 red: the per-row cell, the falsy-id cell, and the USING-only in-scope control (falsely refused) | | 4 | install only when the id is null (falsy counts as by-id) | 2 red: the falsy-id cell, admitted | | 5 | engine never calls the seam | 16 red: refusals now carry the not-evaluated message, controls refused | | 6 | disable the post-`next()` fail-closed guard | 2 red: the fail-closed cell, admitted | ## Gates **Patch round 2 (head `3247efeecd`):** the four comment lines this change rewrote now cite the surviving record, commit `a016f08b8a` (the insert-side check), instead of a card that answers 404, and say in words that the original card no longer resolves. `GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs` probes the board and exits 0: "every citation this change adds resolves (or is a declared cross-repo reference)", with 9 judged, 9 resolving and 0 unresolved added. `dispatch-gates --commands` derived the same 68 families from the same 9 paths against merge base `9bfbacbf8`. All 68 were run; `--ran` answers "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN". 67 exit 0. `check-empty-changeset` exits 1 on `.changeset/rls-check-defaults-to-using.md` only. **Patch round 1 (head `9fff66cfdc`):** `dispatch-gates --commands` derived the same 68 families from the same 9 paths, now against merge base `3fd3a4f91`. All 68 were run; `--ran` answers "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN". 67 exit 0. `check-empty-changeset` exits 1 on `.changeset/rls-check-defaults-to-using.md` only (the deliberate correction under "Surface beyond the claim"). The changeset gates the seat named: `check-adr-0087-registration --base origin/main` exits 0 ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition"), `check-changeset-no-major --base origin/main` exits 0, and `pnpm check:changeset-gate-self-tests` exits 0. **Round 1 (head `6d28dfe98d`):** - `node scripts/pm/dispatch-gates.mjs --commands` derived 68 families from the 9 changed paths. All 68 were run with exit codes recorded. `--ran` answers "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN". - 67 exit 0. One exits 1: `check-empty-changeset`, the deliberate correction above. - Three first answered `PREREQUISITE NOT MET` (exit 3): `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`. They pass after the workspace closure build. `check-engine-split-ratio` passes after deepening the shallow clone to its window. - Lint, as a proven narrowing: `eslint --no-inline-config --format json` over the 7 changed `.ts` files reports 7 files linted, 0 errors and 0 warnings. All 7 fall in the config's `**/*.{ts,…}` block. The config never enables type-aware linting (`eslint.config.mjs:326-328`), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. ## Acceptance notes - **Refusal cost on the predicate path.** The judgement runs where the payload is final, after the per-row `beforeUpdate` hooks and after the credential channel (`encryptSecretFields`), which runs above the strips on this branch. A refused bulk update whose payload carries a `secret` field has therefore already minted its `sys_secret` row. A validation refusal two lines below pays the same cost today. Moving the credential channel below the strips is a separate engine change. A `check` naming a secret field judges the stored reference. - **Image timing differs between the two update paths.** A predicate update is judged on the post-hook image; a by-id update is still judged in the middleware on the pre-hook change set merged with the caller-visible pre-image. Where a `beforeUpdate` hook rewrites a checked field, the bulk path is the stricter of the two. The by-id path is untouched here. - **Read cost.** On an object with no per-row hooks, a checked predicate update now reads its matched rows. The read is unbounded by the per-row hook ceiling, which applies only when hooks dispatch. On a kernel with the usual global hooks the read already happens and is shared. - **Partial-row array insert** (`__partialRowErrors`): a failing row refuses the whole call rather than being reported per row. - **Version skew.** A plugin-security built from this change, run over an engine without the predicate-path call, refuses checked bulk updates (fail-closed). Both packages carry the changeset. - `.changeset/19950-rls-check-multi-row-writes.md` is declared a narrowing, per the seat's ruling and following #19952: `minor` for `@objectstack/plugin-security` and `@objectstack/objectql`, a `!` headline, `Clause-②: no (narrowing)`, an ADR-0087 `not-required (no-migration-prescription)` disposition, and a **BREAKING** paragraph listing the newly refused writes and the remedy (declare `check` on the policy, or fix the data). - `origin/main` was merged twice with merge commits, both clean with no regeneration owed: at `3fd3a4f91b` (`a5ca1db166`) and at `9bfbacbf8b` (`938b2acfde`). PR #19984 (#19963, the explain wiring in the same file) had not landed by the second merge. - Patch round 2: citation fix only (four comment lines in `security-plugin.ts`); no behaviour change. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 615c085 commit 009da14

9 files changed

Lines changed: 604 additions & 72 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
fix(plugin-security, objectql)!: a row-level `check` now holds for every row a multi-row write stores — an array insert and a predicate (`multi: true`) update (#19950, #19964)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) an enforcement change on the write gate: no authorable key, spelling or stored shape moves, so a stored `sys_metadata` row needs no conversion and an upgrader has nothing to hand-edit. The remedy for a newly refused write is to declare `check` on the policy, or to fix the data. -->
11+
12+
**BREAKING**: this narrows the set of writes the write gate accepts. A multi-row write that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, as the using-defaulted check did (#19942).
13+
14+
A row-level security `check` (declared on the policy, or defaulted from its `using`) is the write-side half of the policy: a row the check refuses is never stored. The write gate enforced it for a single-row insert and a by-id update, but not for the two multi-row write shapes. An **array insert** (`insert(object, [rows])`, which the create-many data route calls) installed no check, so its rows were stored unjudged. A **predicate update** (`update(object, changes, { where, multi: true })`) never judged its new rows. The gate assumed a `using`-scoped `where` covered the write, but a policy that declares only `check` scopes nothing, and a scoped `where` says nothing about the new row in any case.
15+
16+
Both shapes are now judged row by row. An array insert judges each row on the image the `beforeInsert` chain produced. A predicate update judges each row the write selects on its new image: the matched row merged with the final payload. The engine (`@objectstack/objectql`) supplies those rows through the seam the insert check already uses (`OperationContext.postHookWriteImageCheck`). It runs the judgement on the predicate path over the rows its composed query selects, reusing the matched-row read that path already makes.
17+
18+
**Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. One failing row refuses the whole write. There is no transition switch.
19+
20+
- **A predicate update under a policy that declares `check`**, when any matched row's new image fails that check, including when the policy has no `using` at all.
21+
- **A predicate update that moves a matched row out of a policy's `using`**, when no applicable policy declares `check`. The `using` is the defaulted check; a by-id update already gives this answer.
22+
- **An array insert** when any row fails the check. This includes every configuration that already refused each single insert, such as a `using` or `check` that does not compile.
23+
- **A predicate update on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused as an insert already is, with an `error` log saying the check was not evaluated.
24+
25+
**Remedy.** To let a write store a row outside a policy's scope, declare a `check` on that policy that admits it; otherwise fix the data the write carries.
26+
27+
**What does not change.**
28+
29+
- A by-id update and a single-row insert are judged exactly as before.
30+
- A predicate update still touches only the rows its scoped `where` selects. The check refuses a write; it never changes which rows are selected.
31+
- A predicate update or array insert whose rows all pass is admitted as before.
32+
- A system-context write is not gated.

‎.changeset/rls-check-defaults-to-using.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,5 @@ The published contract has always said this. `RowLevelSecurityPolicySchema.check
2424
- If any applicable policy declares `check`, only the declared checks decide, exactly as before. A policy with only a `using` alongside them adds nothing to the check.
2525
- The platform's ownership floor (`owner_only_writes`) is part of a defaulted check only when the by-id write gate kept it for that write. A record share at edit depth, a `public_read_write` object, or a covering controlled-by-parent master gate still replaces the floor. Those writes are not refused again on the new row.
2626
- `select` policies never gate a write's new row.
27-
- Bulk updates without a single id are still scoped by the `using` where clause and are not checked row by row.
27+
- Bulk updates without a single id are still scoped by the `using` where clause. Their new rows are now checked row by row as well, by the separate multi-row entry (#19950).
2828
- The `modifyAllRecords` bypass on private and platform-global objects still skips the check.

‎packages/objectql/src/engine.ts‎

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2224,8 +2224,16 @@ export interface OperationContext {
22242224
* against `opCtx.data`, and {@link ObjectQL.insert} calls it once the
22252225
* `beforeInsert` chain has produced the row — before the first producer with
22262226
* a side effect (the secret channel, the autonumber, the statement), so a
2227-
* refusal still costs nothing. `update` needs no seam: that path already
2228-
* merges its pre-image with the change set, which is the same proposition.
2227+
* refusal still costs nothing. An ARRAY insert is one operation: every live
2228+
* row is judged in the same call.
2229+
*
2230+
* [#19950] A PREDICATE `update` (`multi: true`, no row address) uses the same
2231+
* seam. The middleware cannot know which rows the write will change: they
2232+
* are the rows the COMPOSED AST selects, and that AST is complete only after
2233+
* every middleware has run. So {@link ObjectQL.update} calls it on that path,
2234+
* once the payload is final, with every matched row merged with the payload.
2235+
* A by-id `update` is never handed to the seam: the middleware judges that
2236+
* one row itself, by merging its pre-image with the change set.
22292237
*
22302238
* ABSENT is the ordinary state — no enforcement layer is mounted, or the
22312239
* write is one it does not gate. The engine never invents one.
@@ -2237,11 +2245,12 @@ export interface OperationContext {
22372245
* [#16608] The judgement {@link OperationContext.postHookWriteImageCheck}
22382246
* carries, and the acknowledgement its installer reads back.
22392247
*
2240-
* `evaluate` receives the rows exactly as the `beforeInsert` chain left them —
2241-
* the images the driver is about to be handed — and REFUSES by throwing. It is
2242-
* called at most once per operation, and only for rows still live (a row the
2243-
* declared-field door culled from a partial batch is never judged: it will not
2244-
* be written).
2248+
* `evaluate` receives the images the driver is about to store, and REFUSES by
2249+
* throwing. On an `insert` those are the rows exactly as the `beforeInsert`
2250+
* chain left them, only the live ones (a row the declared-field door culled
2251+
* from a partial batch is never judged: it will not be written). On a
2252+
* predicate `update` they are the matched rows, each merged with the final
2253+
* payload. It is called at most once per operation.
22452254
*
22462255
* `honoured` is set by the engine immediately before `evaluate` runs. It exists
22472256
* so the installer can fail CLOSED on a seam that was never called: an
@@ -13222,6 +13231,56 @@ export class ObjectQL implements IObjectQLEngine {
1322213231
// caller is told before N rows are written with a column missing
1322313232
// — the failure mode a bulk write makes N times larger.
1322413233
assertNoStrictDrops();
13234+
// ── [#19950] The post-image seam on the PREDICATE path ─────────
13235+
//
13236+
// An enforcement layer's write `check` must hold for EVERY row a
13237+
// write stores (ADR-0058 D4: "and on the AST-injected bulk
13238+
// path"). For a by-id update the enforcement middleware can judge
13239+
// the new row itself: it knows the one row and reads it. For a
13240+
// predicate update it cannot: the rows are the ones the
13241+
// middleware-COMPOSED AST selects, and that AST is complete only
13242+
// once every middleware has run (the enforcement layer's own
13243+
// scope, a sharing layer's editable-rows filter, the tenant
13244+
// wall). So the layer installs its judgement on
13245+
// `opCtx.postHookWriteImageCheck`, as it does for an insert, and
13246+
// the engine hands it the rows here.
13247+
//
13248+
// Each image is one matched row merged with the payload, the
13249+
// row `updateMany` is about to produce, and it is the same shape
13250+
// the per-row `afterUpdate` context calls `result`
13251+
// (`buildPerRowAfterContexts`). The rows come from the D7 read,
13252+
// the one `readPriorRows` memo that validation, the
13253+
// `readonlyWhen` strip and both hook phases share, bound to the
13254+
// same composed AST the statement binds. That is the one read the
13255+
// ruling allows, never a second fetch.
13256+
//
13257+
// Placement: the payload is FINAL here. The per-row
13258+
// `beforeUpdate` chain, the hand-back, both readonly strips and
13259+
// the strict-drop refusal have all run, and nothing below
13260+
// changes a value before the statement. The seam judges the rows
13261+
// that will be stored, which is the rule the insert seam was
13262+
// held to. The credential channel (`encryptSecretFields`) runs
13263+
// above on this branch, so a refused write that carried a secret
13264+
// field has already minted its `sys_secret` row. A validation
13265+
// refusal two lines down already pays the same cost here, and
13266+
// moving that channel is a separate change. A `check` naming a
13267+
// secret field judges the stored reference.
13268+
//
13269+
// `honoured` is set BEFORE `evaluate`, exactly as on the insert
13270+
// path: it answers "did the seam run", never "did the write
13271+
// pass". Zero matched rows is an empty judgement, not a skipped
13272+
// one.
13273+
const predicateImageCheck = opCtx.postHookWriteImageCheck;
13274+
if (predicateImageCheck) {
13275+
predicateImageCheck.honoured = true;
13276+
const matchedRows = (await readPriorRows()) ?? [];
13277+
const payload = hookContext.input.data as Record<string, unknown>;
13278+
await predicateImageCheck.evaluate(
13279+
matchedRows.map(
13280+
(row) => coerceBooleanFields(updateSchema as any, { ...row, ...payload } as any) as Record<string, unknown>,
13281+
),
13282+
);
13283+
}
1322513284
// [#3106] Same enforcement the single-id branch runs at its
1322613285
// `evaluateValidationRules` call, applied per matched row: any
1322713286
// error-severity violation rejects the WHOLE batch before

‎packages/plugins/plugin-security/src/check-only-write-scope.test.ts‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -289,12 +289,23 @@ function makeEngine() {
289289
},
290290
// Both write verbs open with the PRODUCER's own dispatch predicate
291291
// (`assertEngine*Dispatch`), never a hand-mirrored guard.
292-
async update(object: string, data: any, options?: any) {
292+
//
293+
// [#19950] `opCtx` is the operation the middleware chain ran on, as the
294+
// real engine holds it. On the PREDICATE path the engine hands an
295+
// installed write-image check every matched row merged with the payload
296+
// before it writes, so this double does the same: a double that skipped
297+
// it would be refused, fail-closed, by the security middleware.
298+
async update(object: string, data: any, options?: any, opCtx?: any) {
293299
const dispatch = assertEngineUpdateDispatch(data, options);
294300
const rows = (tables[object] ??= []);
295301
const targets = dispatch.kind === 'by-id'
296302
? rows.filter((r) => r.id === dispatch.id)
297303
: rows.filter((r) => matches(r, options?.where));
304+
const seam = dispatch.kind === 'by-id' ? undefined : opCtx?.postHookWriteImageCheck;
305+
if (seam) {
306+
seam.honoured = true;
307+
await seam.evaluate(targets.map((r) => ({ ...r, ...data })));
308+
}
298309
for (const r of targets) Object.assign(r, data);
299310
return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
300311
},
@@ -379,7 +390,7 @@ async function makeStack(opts: { orgScoping?: boolean } = {}): Promise<Stack> {
379390
await sharingMw(opCtx, async () => {
380391
if (opCtx.operation === 'delete') await engine.delete(opCtx.object, opCtx.options);
381392
else if (opCtx.operation === 'insert') await engine.insert(opCtx.object, opCtx.data);
382-
else await engine.update(opCtx.object, opCtx.data, opCtx.options);
393+
else await engine.update(opCtx.object, opCtx.data, opCtx.options, opCtx);
383394
reached = true;
384395
});
385396
});
@@ -594,11 +605,13 @@ describe('[#8059 site 1] Layer 1 actually DERIVES for a check-only policy — th
594605
let stack: Stack;
595606
beforeEach(async () => { stack = await makeStack(); });
596607

597-
it('the bulk UPDATE path touches only READABLE rows — a path step 3.6 explicitly declines to check', async () => {
598-
// Step 3.6 logs "not post-image validated" and skips for a write with no
599-
// single id, so belt 2 contributes NOTHING here by its own construction.
608+
it('the bulk UPDATE path touches only READABLE rows — a path step 3.6 can refuse but never scope', async () => {
609+
// Since #19950 step 3.6 judges every row a bulk update stores, but a check
610+
// can only REFUSE a write; it cannot choose which rows the write touches.
600611
// The only thing that can scope this write is Layer 1 injected into the
601-
// AST — i.e. the derivation. On a site-1 revert both rows are rewritten.
612+
// AST — i.e. the derivation. On a site-1 revert the match set grows to the
613+
// other contributor's row, whose new image fails the owner check, so the
614+
// whole write is refused and the caller's own row is not rewritten either.
602615
const opCtx: any = {
603616
object: 'qa_invoice',
604617
operation: 'update',
@@ -609,7 +622,7 @@ describe('[#8059 site 1] Layer 1 actually DERIVES for a check-only policy — th
609622
};
610623
const securityMw = stack.engine._middlewares[0];
611624
await securityMw(opCtx, async () => {
612-
await stack.engine.update(opCtx.object, opCtx.data, { ...opCtx.options, where: opCtx.ast.where, multi: true });
625+
await stack.engine.update(opCtx.object, opCtx.data, { ...opCtx.options, where: opCtx.ast.where, multi: true }, opCtx);
613626
});
614627
expect(stack.rows('qa_invoice').find((r) => r.id === INVOICE_C2.id)?.subject).toBe('bulk-edit');
615628
expect(
@@ -632,7 +645,7 @@ describe('[#8059 site 1] Layer 1 actually DERIVES for a check-only policy — th
632645
};
633646
const securityMw = stack.engine._middlewares[0];
634647
await securityMw(opCtx, async () => {
635-
await stack.engine.update(opCtx.object, opCtx.data, { ...opCtx.options, where: opCtx.ast.where, multi: true });
648+
await stack.engine.update(opCtx.object, opCtx.data, { ...opCtx.options, where: opCtx.ast.where, multi: true }, opCtx);
636649
});
637650
expect(stack.rows('qa_invoice').find((r) => r.id === INVOICE_C2.id)?.subject).toBe('own-bulk-edit');
638651
});

‎packages/plugins/plugin-security/src/rls-check-defaults-to-using.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ const attempt = async (run: () => Promise<unknown>): Promise<Outcome> => {
164164
}
165165
};
166166

167-
/** ⚠️ A SINGLE object, never an array: step 3.6 skips a bulk payload. */
167+
/** A single-row insert; the array shape is pinned in `rls-check-multi-row-writes.test.ts`. */
168168
const insert = (engine: ObjectQL, row: Record<string, unknown>) =>
169169
engine.insert('qa_ticket', { id: 't1', title: 't', ...row } as never, { context: CALLER } as never);
170170

0 commit comments

Comments
 (0)