Skip to content

Commit 6d28dfe

Browse files
committed
fix(plugin-security, objectql): judge every row a predicate update stores against the row-level check
A row-level `check` (declared, or defaulted from `using`) guarantees that no stored row fails it. A predicate update (`multi: true`, no row address) was never judged: the write gate skipped it on the assumption that a `using`-scoped `where` governed it. A policy that declares only `check` scopes nothing, and a scoped `where` says nothing about the new row, so the guarantee did not hold on that path for any policy. The rows such an update changes are the ones the middleware-composed query selects, which is complete only once every middleware has run. So the security layer installs its judgement on the existing `OperationContext.postHookWriteImageCheck` seam, and the engine runs it on the predicate path once the payload is final. It hands the seam every matched row merged with that payload, read by the one matched-row read the path already makes. One failing row refuses the whole update with the existing PERMISSION_DENIED / 403 refusal. A seam the engine never runs fails closed, as it does for an insert. A falsy payload id, which the engine does not treat as a row address, is judged both ways. The by-id update and the single-row insert are unchanged. The pending release note that said bulk updates are not checked row by row is corrected in place. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1fb616e commit 6d28dfe

7 files changed

Lines changed: 367 additions & 84 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
"@objectstack/objectql": patch
4+
---
5+
6+
fix(plugin-security): 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+
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 for a by-id update. It did not enforce it for the two multi-row write shapes:
9+
10+
- **An array insert** (`insert(object, [rows])`, which the create-many data route calls). No check was installed for an array payload, so the rows were stored unjudged.
11+
- **A predicate update** (`update(object, changes, { where, multi: true })`). The new rows were never judged. 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.
12+
13+
Both shapes are now judged row by row, with the same refusal the single-row shapes give: `403 PERMISSION_DENIED`, and nothing is stored. One failing row refuses the whole write.
14+
15+
- **Array insert.** Every row is judged on the image the `beforeInsert` chain produced, as a single insert is.
16+
- **Predicate update.** Every row the write selects is judged on its new image: the matched row merged with the final payload. The engine supplies the rows (`@objectstack/objectql`): the security layer installs its judgement on `OperationContext.postHookWriteImageCheck`, the seam the insert check already uses, and the engine runs it on the predicate path over the rows its composed query selects, once the payload is final. That is the one matched-row read the path already makes for validation and per-row hooks, not a second one.
17+
18+
**Writes that are now refused.** A multi-row write is refused where the same row written alone would be:
19+
20+
- a predicate update under a policy that declares only `check`, when any matched row's new image fails that check;
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, and this is the answer the by-id update already gives;
22+
- an array insert when any row fails the check, including every configuration that already refused each single insert (a `using` or `check` that does not compile).
23+
24+
**What does not change.** A by-id update and a single-row insert are judged exactly as before. A predicate update still touches only the rows its scoped `where` selects; the check refuses a write, it never widens or narrows which rows are selected. A predicate update whose new rows all pass is admitted as before. A system-context write is not gated.
25+
26+
**Hosts that run the security plugin with their own write executor.** A predicate update that installs the judgement and returns without the engine having run it is now refused, as an insert already is: `403`, and an `error` log saying the check was not evaluated.

‎.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
});

0 commit comments

Comments
 (0)