Skip to content

Commit cab6396

Browse files
fix(plugin-security)!: a predicate-scoped update or delete matches only the rows the caller can read (#21900)
Fixes #21829 Clause-②: no (narrowing) ## What this changes A predicate-scoped (`multi: true`) update or delete now matches only the rows its caller can read. This is ruling B on #21829 (record `5994120632`), which carries ruling A on #21771 (`5985885287`) from the by-id write door to the predicate door: on the write doors, a row the caller cannot read is a row that does not exist. A row the read door would not return to the caller is not written, not counted and not refused. So a predicate that matches only such rows answers exactly what a predicate that matches nothing answers: success, zero rows. A caller who can read a matched row but may not write it keeps the answer it had. **Where.** The `plugin-security` write middleware, at the seam PR #21812 used. In step 3, before `next()`, the middleware asks the read door its question for the caller's own predicate (`options.where`, the predicate step 2.9 already guards). The question is a caller-context read through the engine (`readableRowsScopeForPredicateWrite`), so every data middleware's and every kit's read visibility applies, the parent-derived read gates included. The answer is composed onto the write's AST as an id list, beside the write scopes the step already composes. Matched set = readable ∩ writable. The engine reads its matched rows from that AST, so the narrowing binds the matched-row read, the per-row hook dispatch and the driver write alike. No engine source and no kit write hook is edited. **Binding constraints, as built:** 1. **Addressed versus nested (H2).** Only the predicate write the caller addressed is narrowed (`addressedPredicateWrite`, the twin of `addressedByIdWriteId`). A write that starts inside another engine operation reads as nested through #21812's `engineOperationScope` and keeps today's answer: a cascade, a hook's own predicate write. The referential FK clear is excluded by its server-derived marker. No second "is this addressed?" signal. 2. **By-id versus predicate** comes from the engine's own dispatch predicates (`resolveEngineUpdateDispatch`, `resolveEngineDeleteDispatch`): only their `multi` verdict is narrowed. 3. **One classifier (H3).** `absentUnderCallerRead` now delegates to `readUnlessRefused`, which the predicate read question asks too. A declared 4xx (the read grant withheld, a predicate the driver will not compile) keeps the write's previous answer. A store fault propagates as raised. No second visibility evaluator. 4. **The cap.** The id list is bounded by the platform's existing row ceiling for one predicate write, `MAX_BULK_PER_ROW_HOOK_ROWS` (10 000). Above it the write is refused with `400 INVALID_FILTER` before anything runs. That is the rule the engine's nested-relation lowering follows for the same shape. It is never a cut-off list, which would write fewer rows than the caller can reach, and never "do not narrow", which would hand the existence signal back to a padded predicate. The count is of rows the caller can read, so the refusal discloses nothing the read door does not. No error code is new. 5. **Fail closed** when the engine cannot be asked: a predicate write is not run un-narrowed. **H5, `security/explain`.** Measured: `ExplainInput` takes an object, an operation, a context and an optional record id. It has no predicate, so it models no predicate-scoped write. It is left alone. ## Client census (step 1, before any code) The question: does any shipped client, or any test in this repo, read a predicate-scoped update's or delete's `403` as "a hidden row exists", in a way the move to success-with-zero-rows would break? - `packages/client`: `data.update` and `data.delete` are by id. `data.updateMany(records)`, `data.deleteMany(ids)`, `batch` and `batchTransaction` reach REST routes that the protocol serves with per-id by-id loops. (c), unrelated. - `packages/client-react`: mutation wrappers over the same methods. (c). - REST: no route admits a caller-supplied predicate with `multi` (the delete-many ingress narrows its options to the batch-options bag). So no client door issues a predicate write. - `packages/cli`: the secret re-wrap calls the driver's `updateMany` directly, below the middleware. The migration plugin lists method names. (c). - **objectui console, at the `.objectui-sha` pin `0abd4f9f`, read-only:** the data adapter's bulk update and bulk delete send id lists to the same per-id routes, with a per-id fallback. No console, app-shell or adapter source passes `multi`. The console issues no predicate write. (c). - Server-side issuers that are not clients: the automation `update_record` and `delete_record` nodes with `multi: true`. Any error becomes a failed step with its message, and success records the written count. Nothing reads a `403` as existence. (c). - Tests: 43 files carry `multi: true`. None asserts a `403` for a predicate that matches only rows hidden from the caller through the real security middleware. The kit suites run without the security middleware. The select-only and check-only write-scope suites already assert that unreadable rows stay untouched. The bulk widener probe uses a public-read object. The unscoped-delete gate reads the caller's raw predicate. **No class (a) dependency. No class (b) pin turned.** That was confirmed empirically: every candidate suite below is green with the change. ## Premises **Premise 2, the seam, holds.** The engine runs the middleware chain around the predicate path's matched-row read, for update and delete alike (`executeWithMiddleware` wraps `driver.find(object, ast, …)` on both verbs). The AST is seeded from `options.where` before the chain runs, and step 2.9 already reads `opCtx.options.where` (H1). **Premise 1, the cost, holds.** Measured on the showcase app (`bootStack`, the real `SecurityPlugin`, the storage and audit plugins), with a caller holding `showcase_contributor`. Rows were seeded at the driver, and one predicate update matching every row was timed. "Without" is the same build with the narrowing ablated (marker proved in `dist/`, restored and rebuilt after). All numbers are shared-box wall clock: the lock excluded other locked runs only. | object | rows | without | with | rows written (without / with) | read-door id read | |---|---|---|---|---|---| | `showcase_task` (select-scoped RLS) | 1 000 | 67.7 s | 58.0 s | 1 000 / 1 000 | 7 to 11 ms | | `sys_attachment` (parent-derived read) | 1 000 | 2.51 s | 2.29 s | 1 000 / 1 000 | 12 to 13 ms | | `showcase_task` | 10 000 | 378.8 s | 472.1 s | 10 000 / 10 000 | 27 to 45 ms | | `sys_attachment` | 10 000 | 18.85 s | 19.87 s | 10 000 / 10 000 | 39 to 40 ms | The end-to-end spread is box noise: the sign flips between 1 000 and 10 000. So the narrowing's own cost was measured directly at 10 000 rows, as the median of 5 runs each: - the read-door id read: 33 ms; - the matched-row read: 125 ms plain, 144 ms with the id list; - the driver's predicate update: 18 ms plain, 47 ms with the id list. That is about 81 ms in all, against a write that pays 19 s (attachments) to 379 s (tasks) for the same rows. The write's own budget is its per-row hook dispatch. **How the narrowing meets the kits' `MULTI_WRITE_AUTH_LIMIT` (1 000).** It does not meet it. A 10 000-row predicate update on `sys_attachment` lands on both builds. The engine's per-row dispatch binds `input.id`, so the kits' row resolver takes its by-id branch. Their 1 000-row bound is reached only on the whole-operation dispatch, which refuses the unscoped shape before resolving anything. The ceiling that binds both legs is the engine's per-row hook ceiling (10 000), which is also the narrowing's cap. ## Pins New `plugin-security/src/predicate-write-unreadable-not-matched.test.ts` uses a real `ObjectQL`, a real SQL driver, the real middleware and a kit-like per-row gate, on update and delete. It pins: - a predicate matching only hidden rows equals a predicate matching nothing; - hidden and visible rows: only the visible rows change, and they alone are counted; - a reader who may not write keeps the gate's `403`; - control: a visible, writable match is written; - a read the read door refuses keeps the previous answer; - a store fault on the read question propagates, with nothing written; - a readable set over the cap is refused (`INVALID_FILTER`, 400), with nothing written; - a hook's own predicate write keeps the gate's `403`, while the same caller addressing that predicate gets zero rows; - the by-id doors keep #21812's answers. New `qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts` runs on a real stack, at the engine's predicate door, under the context the REST door resolves for the caller's own token. It covers both principal classes (outside and inside the ownership floor's `org_member` domain, each proven by arming probes), update and delete, and `sys_attachment`, `sys_comment` and a plain row-level-security object whose write-class policy reaches rows its read policy hides. 12 cells, each asserting: - hidden-only equals nothing (zero rows, nothing written); - the reader keeps its answer; - hidden and visible: count 1, only the visible row changed; - the by-id door still answers `404 RECORD_NOT_FOUND` for the hidden row. The reader cells pin the answer each class had. The gate's named `403` applies where the write scope reaches the row: outside the domain on both verbs, and on delete inside it. Zero rows applies where the ownership floor or the write-class policy already excludes the row. **Doubles.** Two plugin-security harnesses (`security-plugin.test.ts`'s middleware context and `tenant-layer0-verdict-on-operation.test.ts`'s engine) gained a `find`, because a `ql` that cannot answer the read question refuses the write. Their `findOne` now refuses what the real engine refuses (`assertEngineFindOnePredicate`). Their assertions are unchanged. ## Ablation All ablations went through `scripts/ablation-replace.mjs`, with a trap restore and the fix committed first. - **A, the un-narrowed matched set (unit).** The narrowing call was skipped. 8 of 17 unit pins went red: both verbs' hidden-only equality, both verbs' hidden-and-visible count, both verbs' store-fault propagation, the over-cap refusal, and the addressed control. Restore was proven: blob == HEAD (`38bebf13`), `git diff HEAD` empty. - **A, dist-mediated (dogfood).** A runtime-only guard was planted, and the marker was proved present in 2 built `dist/` files. 10 of 12 door cells went red, twice (once per measurement leg). The 2 cells that stay green are inside-class updates on the two kit objects, where the platform's ownership floor already kept the hidden row out of the write scope before this change. Restore was proven both times: blob == HEAD, rebuilt, `ablation-dist-preflight --absent` clean on the whole tree. - **B, narrowing applied to nested writes too (unit).** The hook's-own-write pin went red: zero rows instead of the gate's `403`. Restore was proven: blob == HEAD. ## Verification Head `23df2c8a` unless stated. The branch merges `origin/main` twice: at `e864db56` (carrying #21873) and at `67c544cc` (carrying #21881, the sibling `permission-set-projection` change). - `plugin-security`: the full suite, 168 files, 3632 passed, 45 skipped, 0 failed. `typecheck` (`tsc`, scripts, `check:test-typecheck`) is green. - `dogfood`: `typecheck` green. On this head, the new door file, the #21812 door file, #21881's write-through binding file and the owner-anchor bulk-write file: 4 files, 45 passed. On `44fa3fc1` (the first merge), a batch of 10 files, with the #21812 door and parent-derived files and every census candidate (owner-anchor bulk writes, the bulk widener probe, the unscoped attachment gate, the engine where-shape refusal, flow run-as, the attachment matrix, the authored-row write scope), is 108 passed and 1 skipped. Also green: the showcase declarative endpoints, 17 tests (its predicate delete flow runs as system). - `plugin-sharing`: 38 files, 954 passed. `service-automation`: the write-node and bulk-intent suites, 27 passed. `runtime`: the stored-metadata body boundary pin, 7 passed. - `spec`: `check:migration-registry` reports the registry current (379 semantic entries). The migration and spec-changes surface suites: 176 passed. The ADR-0087 registration gate reads `registered predicate-write-unreadable-row-not-matched` (new here). - Derived gates: `dispatch-gates --commands` lists 124 commands on this head. All 124 exit 0, each run with its exit code captured before any pipe. The `--ran` reconciliation reads: 124 derived, 124 run, 0 NOT-MEASURED (a derived zero, from the recorded exit codes), 0 UNRUN. One finding along the way was fixed: with `find` beside `findOne`, the two harnesses became engine doubles to `check:engine-double-contract`. Their `findOne` now opens with `assertEngineFindOnePredicate`, and the pinned ledger (`scripts/engine-double-contract.pinned.json`) records the grown coverage via the gate's own `--write`. - Lint, narrowed: `eslint --no-inline-config` over the 7 changed TypeScript files reports 0 errors and 0 warnings (counted from `--format json`). The population is `eslint.config.mjs`'s `**/*.{ts,…}` glob minus its never-linted list, and none of the 7 was ignored. The config enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move a verdict on an untouched file. The repo-wide run is CI's. ## Docs The sentences this made false are corrected: - the multi-delete rule in the attachments access page, and its update twin; - the performance note in the permissions matrix; - the write-widener floor paragraph in the RLS page. That paragraph also still named a `403` for a hidden by-id target, which #21812 turned into the not-found; it is corrected in the same sentence. ## ADR-0087 The changeset carries the FROM → TO a caller acts on, so `registered` is the honest disposition. One D3 semantic entry, `18.predicate-write-unreadable-row-not-matched`, sits beside `18.by-id-write-unreadable-row-not-found`. It was generated with `gen:migration-registry`, and `@objectstack/spec` is named in the changeset (`patch`). No Zod schema, contract docblock or export moves. ## Acceptance notes - **The read door's candidate window on the parent-derived objects.** The attachment and comment read middleware pre-scans at most 2 000 candidate rows per read, and fails closed beyond that: rows past the window are omitted, with a logged warning. The narrowing asks that read door, so a predicate write that reaches more than 2 000 candidate rows on those objects writes only the rows the read door returns. Measured: 3 000 attachments on 3 000 readable parents, one predicate update. Before, 3 000 were written. After, 2 000 were written, and the result says 2 000. This follows the ruling's text ("a row the read door would not return is not matched"), and the count is honest. Whether that window should be widened, or whether a narrowed write should refuse instead, is a question for the seat, not decided here. - **The cap is a narrowing too.** A predicate write whose predicate matches more than 10 000 rows the caller can read is now refused (`400 INVALID_FILTER`), even where fewer of them are writable. On a composed kernel every object measured carries per-row write hooks, so a write matching more than 10 000 writable rows was already refused by the engine's ceiling. The new refusal reaches only a predicate that matches more readable than writable rows past that ceiling. The changeset declares it. - **Inside the `org_member` domain**, the ownership floor already kept hidden rows out of predicate updates on the two kit objects. The change there is on delete, and on the plain RLS object. - **The kits' not-visible refusal** now reaches only writes the caller did not address, and nested writes. The kits' docblocks are not edited (no kit edit, per the ruling). - **Not edited:** the sharing-rules page still names a `403` for a by-id write to a row hidden on a `private` object. That has been stale since #21812, and no sentence there is about predicate writes. Carrier: none. - **Declarations:** the new dogfood file is on #6024 (`5994866670`). The step-18 entry is the conditional spec declaration on #6017 (`5994857244`). The landing needs the at-tier contract review the ruling requires. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 866683f commit cab6396

12 files changed

Lines changed: 1118 additions & 33 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(plugin-security)!: a predicate-scoped update or delete matches only the rows the caller can read
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered predicate-write-unreadable-row-not-matched -->
11+
12+
**BREAKING**: a predicate-scoped (`multi: true`) update or delete now matches only the rows the caller can read. A row the read door would not return to the caller is not written, not counted and not refused, so a predicate that reaches only such rows answers exactly what a predicate that matches nothing answers: success, zero rows. This is the by-id write doors' rule ("hidden" and "gone" are one answer to a caller who cannot read the row) carried to the predicate door. It ships as `minor` under the launch-window convention for accept-set narrowings. No export is added or removed, and no error code is new.
13+
14+
**What changed.** The rows a predicate write matched came from its write scope alone. A row the caller cannot read was matched whenever that scope reached it, for example through a write-class row-level policy wider than the read policy, or on an object whose read visibility follows a parent record. A per-row gate then refused the whole write with a `403`, or the row was written and counted. Either answer told a hidden row apart from no row. The write middleware now asks the read door which rows the caller's own predicate returns, through a read in the caller's context that every data middleware's visibility applies to, and narrows the write to those rows: readable ∩ writable. A read the read door refuses (no read grant on the object) keeps the write's previous answer. A store fault on that read propagates as raised.
15+
16+
**What is refused or narrowed now that was not.**
17+
- A predicate write no longer writes, counts or refuses rows its caller cannot read, including rows its write scope reaches.
18+
- A predicate write whose predicate matches more than 10 000 rows the caller can read is refused with `400 INVALID_FILTER`, before anything is written, rather than narrowed by a cut-off list. The limit is the platform's existing row ceiling for one predicate write.
19+
20+
**FROM → TO.**
21+
- A predicate update or delete reaching rows the caller cannot read: FROM a per-row gate's `403`, or those rows written and counted → TO those rows not matched; success with zero rows when no readable row matches.
22+
- A predicate update or delete whose readable match exceeds 10 000 rows: FROM attempted → TO `400 INVALID_FILTER`, nothing written.
23+
24+
**If you are affected.** An operator who needs a user to change rows grants that user read access to them first. A caller that read a predicate write's `403` as "a row exists here" reads the result as the count of rows it can see. A predicate whose readable match is over the ceiling is narrowed and written in batches.
25+
26+
**Unchanged.**
27+
- A caller who can read a matched row but may not write it keeps its answer.
28+
- Writes the platform issues under the caller's context keep their previous answer, because the caller never addressed them: a cascade, a hook's own write, and the referential clear of a lookup.
29+
- By-id writes keep their answers. System-context writes are not narrowed.
30+
- `security/explain` takes no predicate, so it has no predicate-write verdict to change.

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ resolve the filter fails closed (deny-all).
6666

6767
Deleting a `sys_attachment` is allowed when the caller is **the uploader** OR
6868
**can edit the parent record** (`sharing.canEdit` — public-model parents are
69-
editable by design). A multi-delete requires *every* matched row to pass.
69+
editable by design). A multi-delete (`multi: true`) matches only attachments
70+
the caller can read: an attachment whose parent is out of the caller's sight is
71+
not matched, not removed and not counted, so a predicate that reaches only such
72+
attachments answers exactly what a predicate that matches nothing answers. Every
73+
matched row must then pass.
7074

7175
| Code | Status | When |
7276
| --- | --- | --- |
@@ -76,7 +80,8 @@ editable by design). A multi-delete requires *every* matched row to pass.
7680

7781
An update of another user's attachment follows the same rule — the uploader or
7882
a parent editor — and a by-id update of an attachment the caller cannot read
79-
gets the same not-found answer.
83+
gets the same not-found answer. A multi-update matches only attachments the
84+
caller can read, as a multi-delete does.
8085

8186
The platform baseline also ships a parent-blind row-level delete floor
8287
(`owner_only_deletes`: you may delete only the rows you created, for members

‎content/docs/permissions/permissions-matrix.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ flowchart TD
360360
| 7 | **Field-Level Security** | Which fields is the user allowed to see/edit? |
361361

362362
<Callout type="tip">
363-
**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). This keeps security checks efficient even on tables with millions of rows.
363+
**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). A predicate (`multi: true`) update or delete matches only the rows the caller can read: a row the read door would not return is not written, not counted and not refused, and above 10 000 such rows the write is refused (`400 INVALID_FILTER`) rather than narrowed by a cut-off list. This keeps security checks efficient even on tables with millions of rows.
364364
</Callout>
365365

366366
## See also

‎content/docs/permissions/rls.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,11 @@ object) is not narrowed, because their readable set is already unbounded.
8888
modify what you cannot see" is a floor, not a default the write filter
8989
overrides: before a single-record `update` / `delete` runs, the target row is
9090
re-read under the caller's **own read scope** together with the write filter,
91-
and a row that read cannot see is refused with `403 PERMISSION_DENIED`. On a
91+
and a row that read cannot see answers exactly what a missing id answers
92+
(`404 RECORD_NOT_FOUND`). A predicate (`multi: true`) `update` / `delete` is
93+
narrowed the same way: it matches only the rows the caller's read returns for
94+
its predicate, so a row the widener admits but the caller cannot read is not
95+
written and not counted. On a
9296
`public_read` object that read scope is every row (minus any `select`
9397
narrowing you authored), so an `update` widener works as written. On a
9498
**`private`** object — the default OWD — the read scope is the caller's own

0 commit comments

Comments
 (0)