Skip to content

Commit 4463966

Browse files
fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores (#20012)
Fixes #19989 Clause-②: no (narrowing) ## What this fixes A row-level security `check` (declared on a policy, or defaulted from its `using`) is a guarantee about the row that is stored (`RowLevelSecurityPolicySchema.check`; ADR-0058 D4). An insert and a predicate update are judged on the row the driver stores, through the engine-run `OperationContext.postHookWriteImageCheck` seam. A by-id update was judged only inside the security middleware, on the caller's pre-image merged with the change set as sent, before `next()` runs the `beforeUpdate` chain. A value a hook wrote into a checked field after that point was never judged, so the row could be stored outside the policy, including in an organization the caller does not hold. A by-id update is now also judged on the row it stores, with the one existing refusal (`PERMISSION_DENIED` / 403, nothing stored). The middleware's existing judgement of the change set as sent stays, so this change only ever refuses more. ## Landing: two packages - `packages/objectql/src/engine.ts` (`domain:engine`, declared cross-seat in the claim). The by-id branch of `update()` runs the installed seam right after `assertNoStrictDrops()`, the same point the predicate path uses. By then the `beforeUpdate` chain, the hand-back of withheld read-only values, both readonly strips and the strict-drop refusal have run, and nothing below changes a value before `driver.update`. The image is the prior row (already read under the not-found gate) merged with the final payload, coerced as the predicate path's images are. The seam's doc comments now name the by-id path. - `packages/plugins/plugin-security/src/security-plugin.ts`, step 3.6. The seam is installed for every update, not only a predicate update. The by-id change-set judgement is unchanged. The post-`next()` fail-closed guard therefore covers a by-id update too. **Why the engine is the producer (measured).** The middleware's only image of a by-id update is taken before the hooks run. The engine is the one place that holds the final payload and the prior row together, and it already runs this seam for the other two write shapes. **One adjacent edge, closed so that the change never admits more.** The middleware reads a falsy scalar payload `id` as a row address; the engine does not, and binds a truthy scalar `where.id` instead. In that shape the middleware's by-id gates judged a different row than the one written. Before this change the write reached the driver and was refused only afterwards, by the fail-closed guard, because the seam had been installed for a predicate path the engine never took (measured: 403 with the row already stored). With the engine now running the seam on the by-id path, that guard would stop firing and the 403 would become a 200. So step 3.6 asks the engine's own dispatch predicate (`resolveEngineUpdateDispatch`, from `@objectstack/metadata-core`, already a runtime dependency) and refuses that shape before `next()`, with nothing stored. It applies only where a `check` applies, which is the set the old guard covered. ## Mechanism hypotheses (dispatch Section 2), measured Base: `origin/main` `e8f163fc3a` at worktree creation. It is one unrelated commit (service-analytics) after the dispatch's `009da14713`. 1. **Held.** Step 3.6's by-id branch, verbatim on the base: `// BY-ID UPDATE — unchanged. Build the post-image: the caller's pre-image merged with the change set`, then `let postImage = { ...(opCtx.data) }`, `if (pre) postImage = { ...pre, ...(opCtx.data) }`, `if (!satisfiesCheck(postImage)) denyCheck();`. All of it runs before `next()`. 2. **Reproduced, both drivers (driver-sql on better-sqlite3, driver-sqlite-wasm), through the real `SecurityPlugin` and `ObjectQL`.** - The organization shape: admitted, and the stored row carried an organization the caller does not hold. - The simpler shape: admitted, and the stored row carried a value the check refuses. - Step 3.7 does not catch the organization shape independently: it judges only an `organization_id` supplied in the change set as sent. On the tenant-wall harness, re-pointing to a parent in another tenant is refused earlier, by the reference check (`VALIDATION_FAILED`), not by step 3.7. A related Layer 0 reading is handed to the seat as an out-of-scope finding; it is not fixed here. 3. **Found.** The by-id branch of `update()` has the equivalent point: after `assertNoStrictDrops()` and before `evaluateValidationRules`. The predicate path's seam call sits in the same position. ## Tests (code head `7efc9eb010`; round 2 head `0474ea38f3`) New: `packages/plugins/plugin-security/src/rls-check-by-id-update-post-hook.test.ts`, 24 cells (12 per driver). It uses the real `SecurityPlugin` and `ObjectQL` on both SQL drivers. Every refusal asserts `code` `PERMISSION_DENIED` and `status` 403 plus the gate's developer half, then reads the stored rows straight off the driver's table. - The negative cells: the organization shape; the simpler shape; a hook-closed row failing on a field the update never carried (the stored row, not the change set); fail-closed on a host that strips the installed seam; a falsy payload `id` beside a truthy `where.id` (for both `''` and `0`). - The controls: an in-scope by-id update is admitted and stored; a hook that touches no checked field is unchanged; the change set as sent is still refused; a change set the check refuses stays refused when a hook would replace it with an admitted value; the predicate twin gives the same answer; a falsy payload `id` with no `where.id` is still a judged predicate update. - **Failing first.** The file was committed first (`36f1dff9d6`). Then both source files were set back to the base blobs and restored from `HEAD` (blobs equal HEAD, `git diff HEAD` empty). The run gave 12 red (the six negative cells × two drivers) and 12 green controls. Ablations were run on the final head, and the counts were identical on the pre-merge head `414909be35`. Each is one mutation through `scripts/ablation-replace.mjs`: the anchor hit 1 → 0 and the blob changed, then the file was restored with blob == HEAD and an empty `git diff HEAD`. On resolution, 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 a mutation and the run. | # | mutation | red | |---|---|---| | A1 | the engine never runs the seam on the by-id path | 10: the three by-id negative cells (organization, plain, unchanged field) are refused only by the fail-closed guard, after the row is stored, and the two admitted controls are refused, × 2 | | A2 | the plugin installs the seam only for a predicate update (the old condition) | 8: org, plain, unchanged-field, fail-closed, × 2 | | A3 | the by-id change-set judgement dropped | 2: the only-refuses-more cell, × 2 | | A4 | the falsy-id refusal dropped | 4: both falsy-id cells, × 2 | | A5 | the engine's image is the payload alone, not merged with the prior row | 2: the unchanged-field cell, × 2 | | A6 | the post-`next()` fail-closed guard dropped | 2: the fail-closed cell, × 2 | | A7 | the engine never runs the seam on the predicate path | 4: the predicate twin and the falsy-id predicate control, × 2 | Suites on `7efc9eb010`, after merging `origin/main`, with the closure rebuilt: - plugin-security: 128 files, 2501 tests passed. - objectql local project: two halves, 156 files / 2454 tests and 155 files / 2784 tests, all passed. - objectql repo project: 1 file / 5 tests. - Round 2, on `0474ea38f3`: plugin-auth 114 files / 2439 tests passed (CI Test Core 6/6 had 8 red in `sys-user-self-service-route.test.ts` on `7efc9eb010`; see the surface section); runtime 277 files / 3911 passed, 1 skipped; plugin-dev 8 files / 80 passed; plugin-auth `typecheck` exit 0 (test-layer ledger held at 10 files / 94 errors). - Round 2, real engine: a member updating their OWN `sys_user` row (real `ObjectQL` on driver-sql, the real `SysUser` schema, the shipped permission sets, the real identity write guard) is admitted and stored for `locale` and `name`, with the seam installed and honoured. The peer row is refused by the by-id pre-image gate, and `role` by the identity guard. So the plugin-auth red was the harness, not the product path. - `typecheck` green for both packages. The plugin-security test layer is at 0 files / 0 errors; objectql holds its ledger unchanged at 40 files / 234 errors. ## Gates - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` derived 69 families from this diff on `0474ea38f3` (68 on `7efc9eb010`, plus `check-dev-prereqs --self-test` for the plugin-auth file). All 69 ran and `--ran` reconciled 69 derived / 69 run / 0 NOT-MEASURED. 68 exit 0; `check-empty-changeset` exits 1 by design (see Deliberate correction). That list is a superset of the dispatch-time list; the additions include `check:engine-double-contract`, `check:adr-0087-registration`, `check:empty-changeset`, `check:durability-log-level` and the type-check ratchets. - `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt` first answered PREREQUISITE NOT MET (exit 3) and were re-run green after a full package-closure build. - The board-probing `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs` passed: 15 citations resolve. The dead tracker the in-tree note cited is removed, and is referred to in words. - Narrowed lint: `eslint --no-inline-config --format json` over the 13 touched `.ts` files gave 13 files, 0 errors and 0 warnings. Three facts make it a full measurement for these files. The population is the `**/*.{ts,…}` and `packages/**` blocks of `eslint.config.mjs`. The count, 13, comes from the JSON output. The config never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move the verdict for any untouched file. The repo-wide `pnpm lint` is left to CI. ## Surface beyond the claim, with reasons - Eight existing plugin-security test files. Their engine doubles now run the stored-row check on a by-id update the way the real engine does; otherwise the fail-closed guard refuses them, which is the intended behaviour. The files are `authored-row-write-verdict`, `authz-matrix-gate`, `check-only-write-scope`, `controlled-by-parent-detail-write-authority`, `controlled-by-parent-master-widener`, `rls-check-membership-staging` (its double had judged the payload alone), `security-denial-user-copy` and `select-only-write-visibility`. `security-plugin.test.ts` changes a comment only. - `insert-check-post-image.test.ts`: the in-tree note that recorded this gap as open now points at the new pins. - Round 2: `packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts`. Its terminal `next()` stood in for the engine without running the seam, so the middleware's fail-closed guard refused every own-row write the file pins as admitted. The failing run's developer message was the guard's own ("the update on 'sys_user' was executed without the row-level CHECK being evaluated"), not the check refusal. The terminal now runs the seam on the fixture row merged with the payload, through the producer's dispatch predicate, without adding a read to the recorded pre-image wheres. The census of `SecurityPlugin` hosts outside its own package found one other hand-made engine double, `runtime/src/domains/share-links-enforcement-context.test.ts`. It passes unchanged (runtime suite green), so it is untouched. - Round 2: the two pending changesets named under Deliberate correction. - A merge of `origin/main` brought PR #19728, which touched both files. It left one conflict in `engine.ts`, at this exact point: the seam and #19728's related-record binding for validation both follow `assertNoStrictDrops()`. The resolution keeps both, with the seam first, as on the predicate path. ## Behaviour that changes (all in the refusing direction) - A by-id update whose `beforeUpdate` chain leaves a checked field at a value the check refuses is refused, and nothing is stored. - A by-id update on a host that installs the judgement and never runs it is refused (403, `error` log), as inserts and predicate updates already are. - An update whose payload `id` addresses no row while `where.id` addresses one, under a policy with a `check`, is refused before anything runs. It used to be written and then refused. ## Deliberate correction This PR rewrites one sentence in each of two pending release notes it did not add. Both sentences read as release-level claims, and this PR makes them false in a release that ships it. Every other byte of both files is unchanged (reversing each replacement reproduces the base blob exactly). - `.changeset/19950-rls-check-multi-row-writes.md`, under "What does not change". - Old: "A by-id update and a single-row insert are judged exactly as before." - New: "A single-row insert is judged exactly as before. A by-id update is not changed by this entry; its judgement on the row it stores is its own entry (#19989)." - Why: this PR changes how a by-id update is judged, so "judged exactly as before" would be false in the release. - `.changeset/insert-check-post-image.md`, under "What changed, mechanically". - Old clause: "`update` is unchanged." - New clause: "this entry leaves `update` unchanged, and the predicate and by-id updates move onto the same seam in their own entries (#19950, #19989)." - Why: the by-id update now uses the same seam, so a bare "`update` is unchanged" would be false in the release. `check-empty-changeset` goes red on this PR, and that is expected. It refuses any change to a changeset present on the merge base, and names both files with the DELIBERATE CORRECTION class. Its remedy for that class is to keep the correction and have it confirmed on the PR, not to restore the old text. Local run on `0474ea38f3`: exit 1, "This PR changes a changeset it did not add", listing both files as "present on the merge base and CHANGED by this PR". ## Acceptance notes - The change-set judgement is kept, so that this change only refuses more. As a consequence, a change set the check refuses as sent stays refused even when a hook would overwrite the refused value with one the check admits (pinned). Dropping it would match the insert's ruled "judge the stored row only" semantics, but it would widen, so it is a separate decision. - The seam's position is pinned relative to the hooks: the refusal cells can only pass on post-hook values. Its position relative to the readonly strips is not separately pinned on this path. - Ablation A3 reading: the "change set as sent still refuses" cell stays green without the middleware judgement, because the seam refuses the same value after the hooks. It is a behaviour control, not a pin of the middleware judgement; the only-refuses-more cell is that pin. - The doubles in the two controlled-by-parent harnesses and in `authz-matrix-gate` model only the by-id path, through the producer's dispatch predicate. The `security-plugin.test.ts` double holds no table and judges no rows on an update; this is documented there. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a0920b4 commit 4463966

17 files changed

Lines changed: 823 additions & 71 deletions

‎.changeset/19950-rls-check-multi-row-writes.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Both shapes are now judged row by row. An array insert judges each row on the im
2626

2727
**What does not change.**
2828

29-
- A by-id update and a single-row insert are judged exactly as before.
29+
- A single-row insert is judged exactly as before. A by-id update is not changed by this entry; its judgement on the row it stores is its own entry (#19989).
3030
- A predicate update still touches only the rows its scoped `where` selects. The check refuses a write; it never changes which rows are selected.
3131
- A predicate update or array insert whose rows all pass is admitted as before.
3232
- A system-context write is not gated.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
fix(plugin-security, objectql)!: a by-id update's row-level `check` now holds for the row it stores, after the `beforeUpdate` chain (#19989)
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 change the hook, the policy's `check`, or the data. -->
11+
12+
**BREAKING**: this narrows the set of writes the write gate accepts. A by-id update that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, as the multi-row check did (#19950).
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. An insert and a predicate update are judged on the row the driver stores. A by-id update was judged only on the change set as the caller sent it, merged with the stored row, before the `beforeUpdate` chain ran. A value a hook wrote into a checked field after that point was never judged, so the row it produced could be stored outside the policy.
15+
16+
A by-id update is now also judged on the row it stores: the prior row merged with the final payload, after the `beforeUpdate` chain and both readonly strips, before the statement. The engine (`@objectstack/objectql`) runs that judgement through the seam the insert and predicate update already use (`OperationContext.postHookWriteImageCheck`). The existing judgement of the change set as sent stays, so this change only ever refuses more.
17+
18+
**Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. There is no transition switch.
19+
20+
- **A by-id update whose `beforeUpdate` chain writes a checked field to a value the check refuses**, including a value derived from a field the caller changed.
21+
- **A by-id 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 and a predicate update already are, with an `error` log saying the check was not evaluated.
22+
- **An update whose payload `id` addresses no row while `where.id` addresses one**, under a policy with a `check`. The engine writes the `where.id` row while the gate had judged the payload id. It used to be written and then refused; it is now refused before anything runs.
23+
24+
**Remedy.** A hook that must store a value the caller's `check` refuses does so in a separate write under a system context, or the policy declares a `check` that admits it. Otherwise fix the data the write carries. For the last case, address the row with one id: `update(object, { id, ...fields })` or `update(object, fields, { where: { id } })`.
25+
26+
**What does not change.**
27+
28+
- A by-id update whose hooks leave the checked fields inside the check is admitted as before.
29+
- A change set the check refuses as sent is refused as before, even when a hook would have replaced the refused value.
30+
- Inserts and predicate updates are judged exactly as before.
31+
- A system-context write is not gated.

‎.changeset/insert-check-post-image.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ A denormalised scoping field is exactly what an RLS predicate compares (ADR-0055
1818

1919
Ruled 2026-09-07 (maintainer, verbatim 「同意」, director seat, summon #17, decision batch #3). The refused alternative — keep the order and write the contract that a checked field must arrive from the caller, plus an `os validate` rule to police it — institutionalises the contradiction and needs a permanent lint to hold it in place.
2020

21-
**What changed, mechanically.** `OperationContext` gains `postHookWriteImageCheck` (`@objectstack/objectql`), an optional judgement an enforcement layer installs and `ObjectQL.insert` runs once the `beforeInsert` chain has produced the row — after the post-hook declared-field door, after the two value-changing strips (`stripRuntimeOwnedFields` and the static-`readonly` strip with its `defaultValue` re-default, both moved ahead of it), and before every producer with a side effect (the secret channel, the autonumber, validation, the statement), so a refusal still costs nothing. `@objectstack/plugin-security` installs its compiled `check` filter there for `insert` instead of matching it against `opCtx.data`; `update` is unchanged. The compiled filter is still built in the middleware, where the caller's permission sets, the ADR-0090 D10 delegator's, the staged membership and the request context are all resolved — only the IMAGE is deferred. A middleware that installed the judgement and finds the seam was never run refuses the write and logs at ERROR: an unjudged write is not an allowed one.
21+
**What changed, mechanically.** `OperationContext` gains `postHookWriteImageCheck` (`@objectstack/objectql`), an optional judgement an enforcement layer installs and `ObjectQL.insert` runs once the `beforeInsert` chain has produced the row — after the post-hook declared-field door, after the two value-changing strips (`stripRuntimeOwnedFields` and the static-`readonly` strip with its `defaultValue` re-default, both moved ahead of it), and before every producer with a side effect (the secret channel, the autonumber, validation, the statement), so a refusal still costs nothing. `@objectstack/plugin-security` installs its compiled `check` filter there for `insert` instead of matching it against `opCtx.data`; this entry leaves `update` unchanged, and the predicate and by-id updates move onto the same seam in their own entries (#19950, #19989). The compiled filter is still built in the middleware, where the caller's permission sets, the ADR-0090 D10 delegator's, the staged membership and the request context are all resolved — only the IMAGE is deferred. A middleware that installed the judgement and finds the seam was never run refuses the write and logs at ERROR: an unjudged write is not an allowed one.
2222

2323
**Who is affected.** Only objects governed by a permission set that EXPLICITLY declares `check`, on single-row inserts by a non-system caller — the gate's existing scope, unchanged. Two behaviour changes to expect, and they are the two halves of the same correction: an insert that left a hook-stamped field off the payload now succeeds where it used to be refused, and an insert whose hook-stamped field lands outside the caller's scope is now refused where it used to be admitted. Callers that were duplicating the stamp to get past the gate keep working and may stop.
2424

‎packages/objectql/src/engine.ts‎

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2235,8 +2235,12 @@ export interface OperationContext {
22352235
* are the rows the COMPOSED AST selects, and that AST is complete only after
22362236
* every middleware has run. So {@link ObjectQL.update} calls it on that path,
22372237
* once the payload is final, with every matched row merged with the payload.
2238-
* A by-id `update` is never handed to the seam: the middleware judges that
2239-
* one row itself, by merging its pre-image with the change set.
2238+
*
2239+
* [#19989] A by-id `update` uses it too. A middleware can read that one row,
2240+
* but only BEFORE the `beforeUpdate` chain runs, so its image is the change
2241+
* set as sent and a hook that rewrites a judged field is never judged. So
2242+
* {@link ObjectQL.update} calls it on the by-id path as well, once the
2243+
* payload is final, with the prior row merged with the payload.
22402244
*
22412245
* ABSENT is the ordinary state — no enforcement layer is mounted, or the
22422246
* write is one it does not gate. The engine never invents one.
@@ -2253,7 +2257,8 @@ export interface OperationContext {
22532257
* chain left them, only the live ones (a row the declared-field door culled
22542258
* from a partial batch is never judged: it will not be written). On a
22552259
* predicate `update` they are the matched rows, each merged with the final
2256-
* payload. It is called at most once per operation.
2260+
* payload; on a by-id `update` ([#19989]) the one prior row merged with the
2261+
* final payload. It is called at most once per operation.
22572262
*
22582263
* `honoured` is set by the engine immediately before `evaluate` runs. It exists
22592264
* so the installer can fail CLOSED on a seam that was never called: an
@@ -13420,6 +13425,41 @@ export class ObjectQL implements IObjectQLEngine {
1342013425
// "you sent a read-only field" should not depend on whether some
1342113426
// other field also failed a business rule.
1342213427
assertNoStrictDrops();
13428+
// ── [#19989] The post-image seam on the BY-ID path ─────────────
13429+
//
13430+
// The by-id twin of the predicate-path call below, placed at the
13431+
// same point and for the same reason: the payload is FINAL here.
13432+
// The `beforeUpdate` chain, the hand-back, both readonly strips
13433+
// and the strict-drop refusal have all run, and nothing below
13434+
// changes a value before the statement.
13435+
//
13436+
// An enforcement layer used to judge this row only in its own
13437+
// middleware, on its read of the row merged with the change set
13438+
// AS SENT. That image is taken before `next()` runs the hooks, so
13439+
// a `beforeUpdate` that rewrote a checked field (a scoping column
13440+
// derived from a re-pointed parent, a status derived from another
13441+
// field) was never judged, and the row it produced was stored
13442+
// unjudged. The layer keeps that earlier judgement and installs
13443+
// this seam as well, so the row the driver stores is judged too.
13444+
//
13445+
// The image is the prior row (read once, above, under the
13446+
// not-found gate, so it is present) merged with the final
13447+
// payload: the row `driver.update` is about to produce, and the
13448+
// shape the predicate path hands over per matched row. The
13449+
// credential channel runs above on this branch too, so a `check`
13450+
// naming a secret field judges the stored reference, as on the
13451+
// predicate path.
13452+
//
13453+
// `honoured` is set BEFORE `evaluate`: it answers "did the seam
13454+
// run", never "did the write pass".
13455+
const byIdImageCheck = opCtx.postHookWriteImageCheck;
13456+
if (byIdImageCheck) {
13457+
byIdImageCheck.honoured = true;
13458+
const payload = hookContext.input.data as Record<string, unknown>;
13459+
await byIdImageCheck.evaluate([
13460+
coerceBooleanFields(updateSchema as any, { ...priorRecord, ...payload } as any) as Record<string, unknown>,
13461+
]);
13462+
}
1342313463
// [#18682] The reference FK a predicate traverses may come from
1342413464
// the PATCH or from the stored row, so the id is read off the
1342513465
// POST-strip merged view `evaluateValidationRules` evaluates.
@@ -13629,9 +13669,11 @@ export class ObjectQL implements IObjectQLEngine {
1362913669
//
1363013670
// An enforcement layer's write `check` must hold for EVERY row a
1363113671
// write stores (ADR-0058 D4: "and on the AST-injected bulk
13632-
// path"). For a by-id update the enforcement middleware can judge
13633-
// the new row itself: it knows the one row and reads it. For a
13634-
// predicate update it cannot: the rows are the ones the
13672+
// path"). For a by-id update the enforcement middleware knows the
13673+
// one row, but can read it only before the hooks run, so that
13674+
// path hands the final row to the same seam ([#19989], the by-id
13675+
// branch above). For a predicate update the middleware cannot
13676+
// even name the rows: they are the ones the
1363513677
// middleware-COMPOSED AST selects, and that AST is complete only
1363613678
// once every middleware has run (the enforcement layer's own
1363713679
// scope, a sharing layer's editable-rows filter, the tenant

‎packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
import { describe, it, expect, vi } from 'vitest';
6262
import { SysUser } from '@objectstack/platform-objects/identity';
6363
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
64+
import { assertEngineUpdateDispatch } from '@objectstack/objectql';
6465
import {
6566
registerIdentityWriteGuard,
6667
registerManagedUpdateWhitelist,
@@ -204,9 +205,31 @@ async function route(
204205
context,
205206
};
206207

208+
// [#19989] The engine's half of the write gate, which the terminal `next()`
209+
// stands in for. `ObjectQL.update` runs the installed
210+
// `postHookWriteImageCheck` on the row a by-id update writes (the stored row
211+
// merged with the payload) before the statement; a terminal that skipped it
212+
// would be refused fail-closed by the middleware, and would read here as a
213+
// `row-scope` refusal. The row comes straight from the fixture, NOT through
214+
// `findOne`, so `preImageWheres` still records only the middleware's reads.
215+
// The guard below runs after this, so the image carries the pre-guard
216+
// payload; the check this file composes (`sys_user_self`, whose `using` is
217+
// the check: `id == current_user.id`) reads only `id`, which the guard never
218+
// touches. Only the by-id path is modelled, through the producer's own
219+
// dispatch predicate.
220+
const runByIdWriteImageCheck = async () => {
221+
const seam = opCtx.postHookWriteImageCheck;
222+
if (!seam || operation !== 'update') return;
223+
const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options);
224+
if (dispatch.kind !== 'by-id') return;
225+
seam.honoured = true;
226+
const row = ROWS[String(dispatch.id)];
227+
await seam.evaluate(row ? [{ ...row, ...opCtx.data }] : []);
228+
};
229+
207230
const snapshotWrites: Array<{ key: string; value: any }> = [];
208231
try {
209-
await middleware(opCtx, async () => {});
232+
await middleware(opCtx, runByIdWriteImageCheck);
210233
} catch (error: any) {
211234
return {
212235
// The pre-image re-read is the first engine call past the CRUD gate, so

‎packages/plugins/plugin-security/src/authored-row-write-verdict.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,23 @@ function makeEngine(opts: { findOneThrows?: boolean } = {}) {
231231
},
232232
// Both write verbs open with the PRODUCER's own dispatch predicate
233233
// (#4550 / #5480 / #6277), never a hand-mirrored guard.
234-
async update(object: string, data: any, options?: any) {
234+
//
235+
// [#19989] `opCtx` is the operation the middleware chain ran on. The engine
236+
// runs an installed write-image check on every update before it writes
237+
// (the rows it will store, each merged with the payload), so this double
238+
// does too: one that skipped it would be refused, fail-closed, by the
239+
// security middleware.
240+
async update(object: string, data: any, options?: any, opCtx?: any) {
235241
const dispatch = assertEngineUpdateDispatch(data, options);
236242
const rows = (tables[object] ??= []);
237243
const targets = dispatch.kind === 'by-id'
238244
? rows.filter((r) => r.id === dispatch.id)
239245
: rows.filter((r) => matches(r, options?.where));
246+
const seam = opCtx?.postHookWriteImageCheck;
247+
if (seam) {
248+
seam.honoured = true;
249+
await seam.evaluate(targets.map((r) => ({ ...r, ...data })));
250+
}
240251
for (const r of targets) Object.assign(r, data);
241252
return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
242253
},
@@ -303,7 +314,7 @@ async function makeStack(engineOpts: { findOneThrows?: boolean } = {}): Promise<
303314
let reached = false;
304315
try {
305316
await securityMw(opCtx, async () => {
306-
await engine.update(opCtx.object, opCtx.data, opCtx.options);
317+
await engine.update(opCtx.object, opCtx.data, opCtx.options, opCtx);
307318
reached = true;
308319
});
309320
} catch (e: any) {

‎packages/plugins/plugin-security/src/authz-matrix-gate.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646

4747
import { describe, it, expect, vi } from 'vitest';
4848
import { derivePosture, POSTURE_RANK } from '@objectstack/core';
49+
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
4950
import { SecurityPlugin, hasPlatformAdminCapability } from './security-plugin.js';
5051
import { PermissionEvaluator } from './permission-evaluator.js';
5152
import { defaultPermissionSets, BETTER_AUTH_MANAGED_OBJECTS } from './objects/default-permission-sets.js';
@@ -154,7 +155,26 @@ function makeHarness(opts: {
154155
return services[name];
155156
},
156157
};
157-
return { ctx, findOne, run: async (opCtx: any) => { await middleware(opCtx, async () => {}); return opCtx; } };
158+
// [#19989] The engine's stored-row check on a by-id UPDATE, which the empty
159+
// terminal stands in for: `ObjectQL.update` runs the installed
160+
// `postHookWriteImageCheck` on the row it writes (here the stubbed pre-image,
161+
// merged with the payload) before the statement, and a terminal that skipped
162+
// it would be refused fail-closed by the middleware. Only the by-id path is
163+
// modelled, through the producer's own dispatch predicate.
164+
const runByIdWriteImageCheck = async (opCtx: any) => {
165+
const seam = opCtx?.postHookWriteImageCheck;
166+
if (!seam || opCtx.operation !== 'update') return;
167+
const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options);
168+
if (dispatch.kind !== 'by-id') return;
169+
seam.honoured = true;
170+
const prior = opts.findOneImpl ? opts.findOneImpl({ where: { id: dispatch.id } }) : null;
171+
await seam.evaluate(prior ? [{ ...prior, ...opCtx.data }] : []);
172+
};
173+
return {
174+
ctx,
175+
findOne,
176+
run: async (opCtx: any) => { await middleware(opCtx, () => runByIdWriteImageCheck(opCtx)); return opCtx; },
177+
};
158178
}
159179

160180
/** Effective READ filter the engine would AND onto a `find` (the visible-row set). */

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,15 +293,16 @@ function makeEngine() {
293293
// [#19950] `opCtx` is the operation the middleware chain ran on, as the
294294
// real engine holds it. On the PREDICATE path the engine hands an
295295
// 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.
296+
// before it writes, and [#19989] on the BY-ID path the one row it writes,
297+
// merged with the payload. This double does the same on both: a double
298+
// that skipped it would be refused, fail-closed, by the security middleware.
298299
async update(object: string, data: any, options?: any, opCtx?: any) {
299300
const dispatch = assertEngineUpdateDispatch(data, options);
300301
const rows = (tables[object] ??= []);
301302
const targets = dispatch.kind === 'by-id'
302303
? rows.filter((r) => r.id === dispatch.id)
303304
: rows.filter((r) => matches(r, options?.where));
304-
const seam = dispatch.kind === 'by-id' ? undefined : opCtx?.postHookWriteImageCheck;
305+
const seam = opCtx?.postHookWriteImageCheck;
305306
if (seam) {
306307
seam.honoured = true;
307308
await seam.evaluate(targets.map((r) => ({ ...r, ...data })));

0 commit comments

Comments
 (0)