Skip to content

Commit ed3546f

Browse files
fix(plugin-security)!: the Layer 0 tenant write wall holds for the row a write stores (#20013) (#20043)
Fixes #20013 Clause-②: no (narrowing) ## What this fixes Step 3.7 of the security middleware is the Layer 0 tenant write wall (ADR-0095 D1, ADR-0105 D5). Its contract, `packages/plugins/plugin-security/src/security-plugin.ts` lines 3239-3248 on `origin/main` `246314dffe`: > Both close identically here: a SUPPLIED (non-empty) `organization_id` in the write payload must satisfy the SAME Layer 0 filter the read side uses (isolation active, tenant object, platform-admin posture exemption, fail-closed on a missing active org). For UPDATE this makes `organization_id` effectively immutable in non-platform user contexts: the only value that passes is the caller's active org (which — since the pre-image already scoped the target to that org — equals the row's current org), so a re-point to any OTHER tenant is denied. A bulk update carrying a cross-tenant `organization_id` change-set is caught too (the check inspects the change-set value, not a per-row post-image). The wall judged `opCtx.data`, the payload as the caller sent it, before `next()` runs the engine's `beforeInsert` / `beforeUpdate` chain. A value a hook wrote into `organization_id` was never judged by the wall, so the row was stored in whatever organization the hook named. The engine records the insert twin of the same gap at `packages/objectql/src/engine.ts` lines 11846-11847 on `246314dffe`: 「The Layer 0 tenant wall still judges the PRE-hook image — filed separately, and the fix's host is this seam.」 The wall now also judges the row the engine is about to store, through the seam the row-level `check` already uses (`OperationContext.postHookWriteImageCheck`). The refusal is the wall's existing one: `PERMISSION_DENIED` / 403, nothing stored. The judgement of the payload as sent stays, so this change only ever refuses more. No engine change. ## Mechanism hypotheses (dispatch Section 2), measured Base: `origin/main` `26550c6603`. Real `SecurityPlugin` and `ObjectQL`, `isolated` posture, driver-sql (better-sqlite3) and driver-sqlite-wasm. The fixture is a tenant object whose `beforeInsert` and `beforeUpdate` hook copies another payload field into `organization_id`, and a caller whose active organization is `org_a`. 1. **Held.** Step 3.7 judged only rows whose `organization_id` was present in `opCtx.data` (the `suppliedRows` filter), before `next()`. 2. **Reproduced on both drivers, all three legs** (identical on both): | leg | outcome on the base | stored `organization_id` | |:--|:--|:--| | control: by-id update supplies `org_b` | refused, `PERMISSION_DENIED` / 403 ("the update would place 'qa_account' in another tenant") | `org_a`, unchanged | | control: insert supplies `org_b` | refused, `PERMISSION_DENIED` / 403 | no row | | by-id update, hook writes `org_b` | **admitted** | **`org_b`** | | insert, hook writes `org_b` | **admitted** | **`org_b`** | | array insert, hook writes `org_b` on the second row | **admitted** | first row `org_a`, second **`org_b`** | | predicate update (`multi: true`), hook writes `org_b` | **admitted** | **`org_b`** | | control: by-id update, hook writes `org_a` | admitted | `org_a` | 3. **The seam is installed only where a business `check` applies**, measured: with no row-level policy on the object, an inner middleware saw `postHookWriteImageCheck` absent on every leg above. So the wall gets its own installation condition: a walled posture, a tenant object, a caller Layer 0 does not exempt (that is, `computeWriteTenantCheckFilter` returns a filter). When step 3.6 also installed its judgement, the two are composed into the one handle the engine runs, in the order the middleware judges in (the `check`, then the wall). **A second finding shaped the fail-closed guard.** The post-`next()` guard now covers the new installation. The ADR-0094 permission-set data door executes an insert or update of `sys_permission_set` itself, through the metadata protocol, and never calls `next()`. Measured on the base with a platform administrator (active organization `org_a`) under `isolated`: Layer 0 walls the object (`organization_id = org_a`), the insert is admitted, and the engine's write never runs. A guard-covered wall seam alone would turn that into a 403. No engine write runs there, so no hook chain runs, and the payload judgement has already cleared the only `organization_id` such a write can carry. The guard therefore stands down for a seam that carries the wall alone on a write the door executed itself. The fact is observed by a wrapper around the door's registration, which records a write the door never passed to `next()` in a plugin-private `WeakSet`. No operation field is involved that another middleware could set. A seam carrying a row-level `check` is not stood down: the data door keeps failing closed under one, exactly as before. **ADR-0095 D1 "Not touched"** records that D1 added no tenant post-image check to `computeWriteCheckFilter`. Its reason is that such a check "would risk denying legitimate inserts before the auto-stamp runs". This change does not touch `computeWriteCheckFilter`, and it judges an image only when the image names an organization. An absent value (the auto-stamp's to fill) is never judged, and a control pins that. ADR-0105 D5 affirms the direction: an explicit value is validated against the membership set or equality. ## What changed - `packages/plugins/plugin-security/src/security-plugin.ts` - Step 3.7 computes the wall when a payload names an organization (as before) or the posture walls. A `single` posture pays for nothing new. - One refusal (`denyTenantPlacement`) serves both halves. The step installs the stored-row judgement whenever the wall applies (insert, or a non-array update, as step 3.6 scopes it), composed after step 3.6's judgement when one is installed. - The post-`next()` guard covers the new installation with the data-door stand-down above. Its developer message names what was not evaluated: the business-check sentence is unchanged byte for byte, and a wall-only seam gets its own sentence. - The data door is registered through the observing wrapper. - `packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts` (new): 34 cells, 17 per driver. - 11 existing plugin-security test files: engine doubles (see "Surface beyond the claim"). - `.changeset/20013-tenant-wall-post-hook.md`: `@objectstack/plugin-security` minor, BREAKING, remedy, `not-required (no-migration-prescription)`. ## Tests New file, real `SecurityPlugin` and `ObjectQL` on both SQL drivers, `isolated` posture unless a cell says otherwise. Every refusal asserts `code` `PERMISSION_DENIED`, `status` 403 and the wall's message ("the insert/update would place 'OBJECT' in another tenant"), then reads the table straight off the driver. - **Negative cells:** - a hook-written out-of-scope organization, refused on four paths: a by-id update, an insert, an array insert (the whole write refused) and a predicate update; - the same with a business `check` installed and passing (one composed seam); - the composed seam still runs the `check` (an in-scope insert the check refuses is refused); - `group` posture: outside the membership set refused, inside admitted; - fail-closed: a host that strips the installed wall-only seam is refused, with the guard's wall sentence. - **Controls:** - an in-scope hook write is admitted on all three paths; - a supplied out-of-scope value is refused before the hook chain, as before; - only refuses more: a supplied out-of-scope value stays refused when a hook would replace it with an in-scope one; - an update that does not touch the column is admitted on both update paths; - an insert that leaves the column absent is admitted and lands in `org_a`; - a platform administrator on a `private` object is exempt; - a system-context write is ungated; - the `single` posture is unchanged; - the data door under `isolated`: admitted, and the engine's write never ran. **Failing first.** The file was committed before the fix (`8afaec51a1`). On the base it gave 14 red (the 7 negative cells that existed then, × 2) and 18 green. Every red read "expected a refusal, got a completed write". **Ablations**, each through `scripts/ablation-replace.mjs` in WRAP mode, with the anchor hit 1 → 0 and a blob change reported by the tool. Each ran under an outer `trap` restoring from `HEAD`, and was then proven restored (blob == HEAD `278b25f19e`, empty `git diff HEAD`). They ran on `e43cda19b4`, whose blobs for `security-plugin.ts` and the pin file equal the final head's. The subject is imported relatively by the test file and `@objectstack/objectql` is aliased to `src/` in this package's `vitest.config.ts`, so no `dist/` sits between a mutation and the run. | # | mutation | red (of 34) | |---|---|---| | A1 | the stored-row wall judgement never refuses | 12: by-id, insert, array insert, predicate, composed-with-check, `group`, × 2 | | A2 | the wall's seam installed only when a business `check` is (the old condition) | 12: by-id, insert, array insert, predicate, `group`, fail-closed, × 2 | | A3 | the guard stands down for every wall-only seam | 2: fail-closed, × 2 | | A4 | no data-door stand-down | 2: the data door, × 2 | | A5 | the payload judgement dropped | 4: supplied-value control and only-refuses-more, × 2 | | A6 | an absent `organization_id` judged too | 2: the absent-value insert control, × 2 | | A7 | the composed seam drops the business `check` | 2: the composed-check cell, × 2 | A5 has an extra reading. Without the payload judgement, a *supplied* out-of-scope value is admitted but lands nowhere. The engine's static `readonly` strip drops a caller-sent `organization_id`, which the registry injects as `readonly: true`, while a hook-written value is exempt from that strip. That exemption is why the hook path reached the store, and why the payload refusal is the loud half of the supplied case. **Suites** (final head `cce969cf4d`, after merging `origin/main` `7766b62282`, closure rebuilt): - plugin-security: 133 files, 2648 tests passed; - plugin-auth: 114 files, 2439 tests passed; - runtime: 278 files, 3962 passed, 1 skipped; - plugin-security `typecheck`: exit 0; the test layer compiles all 131 test files (`--listFiles`), and its ledger holds 0 files / 0 errors. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` on `cce969cf4d` derived 62 families: the dispatch-time 49 plus 13 more. The 13 are `check-adr-0087-registration` and `check-empty-changeset` (each with its self-test), `release-rehearsal-clone --self-test`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. All 62 ran, and `--ran` reconciled 62 derived / 62 run / 0 NOT-MEASURED, every line carrying its exit code, all 0. `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 workspace build. The board-probing `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs` passed: 9 citations resolve. Narrowed lint: `eslint --no-inline-config --format json` over the 13 touched `.ts` files reported 13 files, 0 errors, 0 warnings. It is a full measurement for these files for three reasons. 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` and the Dogfood Regression Gate are left to CI. ## Surface beyond the claim, with reasons The new installation runs on every walled insert and update. So every plugin-security test harness that stands in for the engine with a terminal that skips the seam, under a walled posture, was refused by the fail-closed guard (48 red on the first full run). Each double now runs the seam the way the engine does: an insert's rows as sent (no hooks in a double), and on an update the by-id row or the matched rows merged with the payload, through the producer's dispatch predicate where the double dispatches updates. - The 10 files that went red on the first run: `authz-matrix-gate`, `can-write-object-admission`, `check-only-write-scope`, `controlled-by-parent-detail-write-authority`, `controlled-by-parent-master-widener`, `explain-write-verdict-inputs`, `no-active-organization-write-refusal`, `row-write-widener-composition`, `select-only-write-visibility` and `tenant-layer0-verdict-on-operation`. - `explain-dependency-fault`, which arrived with the merge of `origin/main` (PR #20030) and went red on the merged tree for the same reason. - `check:engine-double-contract` is green, with no ledger change. - Census outside the package: `grep -rln postHookWriteImageCheck packages --include=*.test.ts` names only plugin-auth's `sys-user-self-service-route.test.ts` (PR #20012's). The hand-made SecurityPlugin hosts under a walled posture are runtime's `share-links-enforcement-context` and `standalone-stack-seeder-declaration-copy`. All pass unchanged (plugin-auth and runtime suites green), so none is touched. ## Behaviour that changes (all in the refusing direction) - Under `isolated` or `group`, an insert, by-id update or predicate update whose hook chain leaves `organization_id` outside the caller's organization scope (or the delegator's, ADR-0090 D10) is refused, and nothing is stored. - A walled write on a host that installs the wall's judgement and never runs it is refused after the write, with an `error` log. The ADR-0094 data door is the stated exception, for a wall-only seam. ## Pending changesets This change makes no sentence in a pending changeset false. None says the tenant wall is unchanged. The "admitted as before" and "judged exactly as before" sentences in `19950-rls-check-multi-row-writes.md` and `19989-by-id-update-post-hook-check.md` are scoped to the row-level `check`, whose judgement this change does not alter. So no deliberate correction was made, and `check-empty-changeset` is green. ## Acceptance notes - `packages/objectql/src/engine.ts` lines 11846-11847 still read "The Layer 0 tenant wall still judges the PRE-hook image — filed separately". After this change that sentence is false: the wall judges the stored row through that same seam. The file was held by the engine lane at dispatch, and this PR does not touch it. Suggested replacement for the engine lane: "(The Layer 0 tenant wall judges this row too, through the same seam; #20013.)". - Cost: - Under a walled posture, every non-system insert and update now computes the Layer 0 filter. - A walled predicate update now always pays the engine's memoized matched-row read, because the seam receives matched rows merged with the payload. On an object with an update hook or a row-reading rule that read already happened. On one with neither it is new. There it is one `driver.find` over the composed `where`, with no row ceiling on that path. The row-level `check` seam (#19950) already imposes the same cost where a `check` applies. NOT MEASURED: bulk-update latency or memory. - An absent or emptied `organization_id` is not judged by either half. That mirrors step 3.7's "supplied (non-empty)" scope and ADR-0095 D1's reason. A hook that clears the column on an update therefore lands a row with no organization. It is not measured here, and no declared contract covers it. - An array UPDATE payload gets no stored-row wall judgement, as step 3.6 gets none (the engine takes one payload per update). The payload judgement still covers it. - A by-id update is now judged by the wall twice: the payload as sent, before `next()`, and the stored row, in the engine. The first is what keeps this change refusing only more (A5). - The merge commit `fe1579223a` carries no `Claude-Session` trailer; the other commits carry the model-free pair. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 655e8c0 commit ed3546f

14 files changed

Lines changed: 870 additions & 71 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
fix(plugin-security)!: the Layer 0 tenant write wall now holds for the row a write stores, after the `beforeInsert` / `beforeUpdate` chain (#20013)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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 or the data it derives the organization from. -->
10+
11+
**BREAKING**: this narrows the set of writes the write gate accepts. Under a walled tenancy posture (`isolated` or `group`), a write whose `beforeInsert` or `beforeUpdate` hook sets `organization_id` to an organization outside the caller's organization scope is now refused. It is admitted today, and the row is stored in that organization. It ships as `minor` under the launch-window convention, as the row-level `check` changes did (#19950, #19989).
12+
13+
The Layer 0 tenant wall (ADR-0095 D1, ADR-0105 D5) holds a write's `organization_id` to the same filter the read side uses, so a non-platform user can only place a row in an organization they hold. The wall judged the payload as the caller sent it, before the engine ran the hook chain. A value a hook wrote into `organization_id` after that point was never judged, whether the hook derived it from another field, a parent record or a lookup.
14+
15+
The wall now also judges the row the engine is about to store, through the seam the row-level `check` already uses (`OperationContext.postHookWriteImageCheck`): an insert's rows once the `beforeInsert` chain has run (every row of an array insert), the one row of a by-id update, and every matched row of a predicate update, each merged with the final payload. It is installed whenever the wall applies to the write, with or without a business `check`. The existing judgement of the payload as sent stays, so this change only ever refuses more.
16+
17+
**Writes that are now refused.** Each refusal is the wall's existing denial, `403 PERMISSION_DENIED` ("the insert/update would place '…' in another tenant"), and nothing is stored. There is no transition switch.
18+
19+
- **An insert, a by-id update or a predicate update whose hook chain leaves `organization_id` outside the caller's organization scope**: another organization under `isolated`, one outside the membership set under `group`. For an on-behalf-of write the delegator's scope applies too (ADR-0090 D10).
20+
- **A walled write 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 after the write with an `error` log saying the tenant wall was not evaluated on the stored row, as a write with an uncalled row-level `check` already is. The platform's own permission-set data door (ADR-0094), which executes its writes without the engine and so runs no hook chain, is not affected.
21+
22+
**Remedy.** A hook that must place a row in another organization does so in a separate write under a system context, which the wall does not gate. Otherwise fix the hook, or the data it derives the organization from, so the stored row stays in the caller's organization scope.
23+
24+
**What does not change.**
25+
26+
- A write whose hooks leave `organization_id` in the caller's scope, or leave it alone, is admitted as before. An update that does not touch the column keeps the row's current organization.
27+
- An insert that leaves `organization_id` absent is not judged on it: the platform fills it with the caller's active organization, as before.
28+
- A supplied out-of-scope `organization_id` is refused before anything runs, as before, even when a hook would replace it with an in-scope one.
29+
- A system-context write, a platform administrator on an object whose posture lets them cross the wall, and every write under the `single` posture are not gated by the wall, as before.

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,18 @@ function makeHarness(opts: {
161161
// merged with the payload) before the statement, and a terminal that skipped
162162
// it would be refused fail-closed by the middleware. Only the by-id path is
163163
// modelled, through the producer's own dispatch predicate.
164+
//
165+
// [#20013] …and the INSERT path, because the Layer 0 tenant wall now
166+
// installs the same seam on a walled insert: `ObjectQL.insert` runs it on
167+
// the rows the `beforeInsert` chain produced, which here (no hooks) are the
168+
// rows as sent.
164169
const runByIdWriteImageCheck = async (opCtx: any) => {
165170
const seam = opCtx?.postHookWriteImageCheck;
171+
if (seam && opCtx.operation === 'insert') {
172+
seam.honoured = true;
173+
await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]);
174+
return;
175+
}
166176
if (!seam || opCtx.operation !== 'update') return;
167177
const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options);
168178
if (dispatch.kind !== 'by-id') return;

‎packages/plugins/plugin-security/src/can-write-object-admission.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,19 @@ async function middlewareAdmits(
388388
data,
389389
};
390390
try {
391-
await middleware(opCtx, async () => {});
391+
// [#20013] The terminal stands in for the engine, and the engine runs the
392+
// installed `postHookWriteImageCheck` on an INSERT's rows once its
393+
// `beforeInsert` chain has run — here (no hooks) the rows as sent. Under a
394+
// walled posture the Layer 0 tenant wall installs that seam on every
395+
// insert it walls, and a terminal that skipped it would be refused
396+
// fail-closed. Only the insert path is modelled: every walled case in this
397+
// file that reaches the terminal is an insert.
398+
await middleware(opCtx, async () => {
399+
const seam = opCtx.postHookWriteImageCheck;
400+
if (!seam || opCtx.operation !== 'insert') return;
401+
seam.honoured = true;
402+
await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]);
403+
});
392404
return true;
393405
} catch {
394406
return false;

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,16 @@ function makeEngine() {
283283
const rows = await this.find(object, { ...options, limit: 1 });
284284
return rows[0] ?? null;
285285
},
286-
async insert(object: string, data: any) {
286+
// [#20013] The INSERT twin of the seam run below: the engine hands an
287+
// installed write-image check the rows its `beforeInsert` chain produced —
288+
// here (no hooks) the rows as sent. The Layer 0 tenant wall installs that
289+
// seam on every walled insert, with or without a `check`.
290+
async insert(object: string, data: any, opCtx?: any) {
291+
const seam = opCtx?.postHookWriteImageCheck;
292+
if (seam) {
293+
seam.honoured = true;
294+
await seam.evaluate(Array.isArray(data) ? data : [data]);
295+
}
287296
(tables[object] ??= []).push({ ...data });
288297
return data;
289298
},
@@ -390,7 +399,7 @@ async function makeStack(opts: { orgScoping?: boolean } = {}): Promise<Stack> {
390399
await securityMw(opCtx, async () => {
391400
await sharingMw(opCtx, async () => {
392401
if (opCtx.operation === 'delete') await engine.delete(opCtx.object, opCtx.options);
393-
else if (opCtx.operation === 'insert') await engine.insert(opCtx.object, opCtx.data);
402+
else if (opCtx.operation === 'insert') await engine.insert(opCtx.object, opCtx.data, opCtx);
394403
else await engine.update(opCtx.object, opCtx.data, opCtx.options, opCtx);
395404
reached = true;
396405
});

‎packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,9 +387,18 @@ function makeStore(rows: Record<string, Row[]>) {
387387
* the payload, before the statement; a terminal that skipped it would be
388388
* refused fail-closed by the security middleware. Only the by-id path is
389389
* modelled, through the producer's own dispatch predicate.
390+
*
391+
* [#20013] …and the INSERT path: the Layer 0 tenant wall installs the same
392+
* seam on every walled insert, and `ObjectQL.insert` runs it on the rows
393+
* its `beforeInsert` chain produced — here (no hooks) the rows as sent.
390394
*/
391395
async runByIdWriteImageCheck(opCtx: any) {
392396
const seam = opCtx?.postHookWriteImageCheck;
397+
if (seam && opCtx.operation === 'insert') {
398+
seam.honoured = true;
399+
await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]);
400+
return;
401+
}
393402
if (!seam || opCtx.operation !== 'update') return;
394403
const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options);
395404
if (dispatch.kind !== 'by-id') return;

‎packages/plugins/plugin-security/src/controlled-by-parent-master-widener.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,18 @@ function makeStore(rows: Record<string, Row[]>) {
283283
* the payload, before the statement; a terminal that skipped it would be
284284
* refused fail-closed by the security middleware. Only the by-id path is
285285
* modelled, through the producer's own dispatch predicate.
286+
*
287+
* [#20013] …and the INSERT path: the Layer 0 tenant wall installs the same
288+
* seam on every walled insert, and `ObjectQL.insert` runs it on the rows
289+
* its `beforeInsert` chain produced — here (no hooks) the rows as sent.
286290
*/
287291
async runByIdWriteImageCheck(opCtx: any) {
288292
const seam = opCtx?.postHookWriteImageCheck;
293+
if (seam && opCtx.operation === 'insert') {
294+
seam.honoured = true;
295+
await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]);
296+
return;
297+
}
289298
if (!seam || opCtx.operation !== 'update') return;
290299
const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options);
291300
if (dispatch.kind !== 'by-id') return;

‎packages/plugins/plugin-security/src/explain-dependency-fault.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
// controls pin that a dependency that ANSWERS — including one that answers
2828
// "no restriction" — is reported exactly as before.
2929
import { describe, it, expect, vi } from 'vitest';
30-
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
30+
import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
3131
import { SharingService, buildSharingMiddleware } from '@objectstack/plugin-sharing';
3232
import { PermissionSetSchema } from '@objectstack/spec/security';
3333
import type { PermissionSet } from '@objectstack/spec/security';
@@ -244,6 +244,19 @@ async function makeStack(
244244
// A write stops at the engine's door: reaching it is the admission
245245
// under test, and no write verb is exercised on this double.
246246
if (operation === 'read') rows = await engine.find(OBJECT, { where: opCtx.ast.where });
247+
// [#20013] …except the one step the engine takes at that door
248+
// before its statement: it runs the installed stored-row seam
249+
// (`postHookWriteImageCheck`) on the row it writes, the prior row
250+
// merged with the payload. The Layer 0 tenant wall installs that
251+
// seam on every walled update (this stack is walled), and a door
252+
// that skipped it would be refused fail-closed after `next()`.
253+
const seam = opCtx.postHookWriteImageCheck;
254+
if (operation === 'update' && seam) {
255+
const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options);
256+
const prior = dispatch.kind === 'by-id' ? await engine.find(OBJECT, { where: { id: dispatch.id } }) : [];
257+
seam.honoured = true;
258+
await seam.evaluate(prior.map((r: Record<string, unknown>) => ({ ...r, ...opCtx.data })));
259+
}
247260
});
248261
});
249262
} catch (error) {

‎packages/plugins/plugin-security/src/explain-write-verdict-inputs.test.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,24 @@ function makeEngine(schema: Record<string, unknown>, rowOverrides: Partial<Recor
172172
// Both write verbs open with the PRODUCER's dispatch predicate (#6277), so
173173
// a call shape `ObjectQL` would refuse fails loudly instead of collecting
174174
// a green from gates that never ran.
175-
async update(object: string, data: any, options?: any) {
175+
//
176+
// [#20013] `opCtx` is the operation the middleware chain ran on. The
177+
// engine hands an installed write-image check the rows it is about to
178+
// store — the by-id row, or every matched row, merged with the payload —
179+
// before it writes. The Layer 0 tenant wall installs that seam on every
180+
// walled update (this stack is walled), and a double that skipped it would
181+
// be refused, fail-closed, by the security middleware.
182+
async update(object: string, data: any, options?: any, opCtx?: any) {
176183
const dispatch = assertEngineUpdateDispatch(data, options);
177184
const rows = (tables[object] ??= []);
178185
const targets = dispatch.kind === 'by-id'
179186
? rows.filter((r) => r.id === dispatch.id)
180187
: rows.filter((r) => matches(r, options?.where));
188+
const seam = opCtx?.postHookWriteImageCheck;
189+
if (seam) {
190+
seam.honoured = true;
191+
await seam.evaluate(targets.map((r) => ({ ...r, ...data })));
192+
}
181193
for (const r of targets) Object.assign(r, data);
182194
return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
183195
},
@@ -265,7 +277,7 @@ async function makeStack(
265277
await securityMw(opCtx, async () => {
266278
await sharingMw(opCtx, async () => {
267279
if (operation === 'delete') await engine.delete(opCtx.object, opCtx.options);
268-
else await engine.update(opCtx.object, opCtx.data, opCtx.options);
280+
else await engine.update(opCtx.object, opCtx.data, opCtx.options, opCtx);
269281
reached = true;
270282
});
271283
});

‎packages/plugins/plugin-security/src/no-active-organization-write-refusal.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,22 @@ async function boot(opts: BootOpts = {}) {
124124
warn,
125125
// eslint-disable-next-line @typescript-eslint/no-explicit-any
126126
readFilter: (object: string, context: any) => (plugin as any).getReadFilter(object, context),
127+
// [#20013] The terminal stands in for the engine, which runs the installed
128+
// `postHookWriteImageCheck` on an INSERT's rows once its `beforeInsert`
129+
// chain has run — here (no hooks) the rows as sent. The Layer 0 tenant wall
130+
// installs that seam on every insert it walls, and a terminal that skipped
131+
// it would be refused fail-closed. Only the insert path is modelled: every
132+
// walled write in this file that reaches the terminal is an insert.
127133
// eslint-disable-next-line @typescript-eslint/no-explicit-any
128-
run: async (opCtx: any) => { await middleware!(opCtx, async () => {}); return opCtx; },
134+
run: async (opCtx: any) => {
135+
await middleware!(opCtx, async () => {
136+
const seam = opCtx.postHookWriteImageCheck;
137+
if (!seam || opCtx.operation !== 'insert') return;
138+
seam.honoured = true;
139+
await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]);
140+
});
141+
return opCtx;
142+
},
129143
};
130144
}
131145

‎packages/plugins/plugin-security/src/row-write-widener-composition.test.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,12 +256,24 @@ function makeEngine() {
256256
// (#4550 / #5480 / #6277), never a hand-mirrored guard: a fixture that
257257
// drifts to a call shape `ObjectQL` would refuse fails loudly here instead
258258
// of collecting a green from gates that never ran.
259-
async update(object: string, data: any, options?: any) {
259+
//
260+
// [#20013] `opCtx` is the operation the middleware chain ran on. The
261+
// engine hands an installed write-image check the rows it is about to
262+
// store — the by-id row, or every matched row, merged with the payload —
263+
// before it writes. The Layer 0 tenant wall installs that seam on every
264+
// walled update (this stack is walled), and a double that skipped it would
265+
// be refused, fail-closed, by the security middleware.
266+
async update(object: string, data: any, options?: any, opCtx?: any) {
260267
const dispatch = assertEngineUpdateDispatch(data, options);
261268
const rows = (tables[object] ??= []);
262269
const targets = dispatch.kind === 'by-id'
263270
? rows.filter((r) => r.id === dispatch.id)
264271
: rows.filter((r) => matches(r, options?.where));
272+
const seam = opCtx?.postHookWriteImageCheck;
273+
if (seam) {
274+
seam.honoured = true;
275+
await seam.evaluate(targets.map((r) => ({ ...r, ...data })));
276+
}
265277
for (const r of targets) Object.assign(r, data);
266278
return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
267279
},
@@ -354,7 +366,7 @@ async function makeStack(): Promise<Stack> {
354366
await securityMw(opCtx, async () => {
355367
await sharingMw(opCtx, async () => {
356368
if (operation === 'delete') await engine.delete(opCtx.object, opCtx.options);
357-
else await engine.update(opCtx.object, opCtx.data, opCtx.options);
369+
else await engine.update(opCtx.object, opCtx.data, opCtx.options, opCtx);
358370
reached = true;
359371
});
360372
});

0 commit comments

Comments
 (0)