Skip to content

Commit f0d7647

Browse files
os-warrenclaude
andauthored
fix(approvals): stop pushing a free-text predicate onto a masked payload snapshot (#11103)
* fix(approvals): stop pushing a free-text predicate onto a masked payload snapshot The approval payload snapshot is redacted at serve time, per reader (#10749), but `listRequests` / `countRequests` pushed a free-text disjunct onto the stored column, which the driver evaluates unmasked. The field-level read controls enforced on the way out therefore did not hold on the way in — declared, not enforced. This extends the settled `maskingRule` posture (#8993) to the snapshot column: a caller whose view of the snapshot is masked keeps free-text matching on the four columns of `sys_approval_request` they read whole, and loses only the snapshot disjunct. The masked/unmasked verdict comes from the same authority and the same per-caller call the serve path uses, asked as the caller — never a second, independently derived notion of "redacted", since that drift is the defect one layer down. Where serve declines to narrow (no authority wired, or a wired authority answering "unresolvable"), the disjunct is kept: consistency with serve, not blanket fail-closed. Dropping a disjunct is strictly narrowing — no query is refused, nothing is widened — and four disjuncts always remain, so the `$or` is never empty. Part of #11040 * test(approvals): index the pushed-down predicate without `Array.prototype.at` `lib` is ES2021 for this package, where `at` does not exist. The package's own `typecheck` excludes `**/*.test.ts`, so the error surfaced only in the `check:type-check-coverage --re-measure` ratchet, as TEST_DEBT 348 -> 349. Fixed in the test rather than by growing the ledger or widening `lib`: the ratchet is shrink-only, and the file needs no ES2022 library surface. Part of #11040 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 147f9cd commit f0d7647

3 files changed

Lines changed: 494 additions & 9 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
**Deliberate search-semantics change.** `ApprovalService.listRequests` /
6+
`countRequests` no longer push a free-text predicate onto the payload snapshot
7+
column for a caller whose view of that snapshot is masked (#11040).
8+
9+
Since #10749 the approval snapshot (`sys_approval_request.payload_json`, the
10+
submitted record's row) is redacted **at serve time, per reader**: each row is
11+
cut down to the fields that caller may read on that row's subject object. The
12+
full row deliberately stays at rest, so the approval record remains audit
13+
evidence of what was actually submitted.
14+
15+
The free-text filter, however, is evaluated by the driver against the stored
16+
column — before anything is served, and therefore against the unmasked bytes. A
17+
predicate over that column is a question about its contents whose answer is row
18+
membership, so the field-level read controls #10749 enforces on the way out did
19+
not hold on the way in. That is `declared ≠ enforced`, and the platform has
20+
already settled the governing principle for it: under `maskingRule` (#8993) a
21+
field a caller sees masked is non-filterable, refused loudly, because otherwise
22+
equality probes reconstruct the hidden span. This extends that settled posture
23+
to the snapshot column, which is reached through a different door.
24+
25+
**What changes.** For a caller whose view of the snapshot is masked, free-text
26+
search matches on `process_name`, `object_name`, `record_id` and `submitter_id`
27+
— the columns of `sys_approval_request` itself, which anyone who can see the row
28+
reads whole — and no longer on snapshot contents. Such a caller can still find a
29+
request by process, object, record id or submitter; they can no longer find one
30+
by a value they may not read. Rows returned, their order and pagination are
31+
otherwise untouched, and no query is refused: the change only ever removes one
32+
disjunct, never denies.
33+
34+
**What does not change.** A caller the serve path hands the whole snapshot to
35+
keeps today's behaviour exactly — same rows, same order. That includes every
36+
deployment that has not wired a field-visibility authority (the seam is
37+
late-bound, and absent it snapshots are served unredacted), and the case where a
38+
wired authority declines to narrow. Consistency with the serve path is the rule
39+
here rather than blanket fail-closed: where serve hands over the whole snapshot,
40+
keeping the predicate discloses nothing serve does not already disclose.
41+
42+
The masked/unmasked verdict is read from **the same authority and the same
43+
per-caller call the serve path uses**, asked as the caller. It is deliberately
44+
not a second, independently derived notion of "redacted" — two derivations drift,
45+
and the drift between a serve rule and a filter rule is exactly what this fixes.
46+
47+
Because redaction is decided per row while a filter is built before any row
48+
exists, the predicate-time scope matters: with an `object` filter the subject
49+
object is known and the seam is asked about it directly; without one the query
50+
spans every object, nothing sound can be asked, and the disjunct is dropped.
51+
52+
Held by `approval-free-text-scope.test.ts`.

0 commit comments

Comments
 (0)