Skip to content

Commit 3bf405b

Browse files
committed
test(plugin-security), docs(formula): re-judge the evaluator-face check pin for the lowering refusal
A CEL check comparing against a list is now dropped at compile, so the forbidden insert is refused with the row-level CHECK envelope (PERMISSION_DENIED / 403) before the evaluator's INVALID_FILTER / 400 is reached. The pin keeps its property (the forbidden insert is refused and nothing is stored) and asserts the envelope it gets at head. Docblock spans false at head are cut, in the pin and in matches-filter.ts: the claims that these CEL spellings lower to the refused shapes, that the refusal propagates out of the check seam, and that the empty-field constraint is the evaluator's one throw. No code or error-message change in formula. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0af6ee5 commit 3bf405b

2 files changed

Lines changed: 16 additions & 27 deletions

File tree

‎packages/formula/src/matches-filter.ts‎

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,13 @@
6969
* (`{ field: [...] }`, `{ field: { $eq: [...] } }`). It is the one position
7070
* where this face's answer was not merely silent but the WRONG way round for a
7171
* write gate: a strict comparison never equals an array, so `$ne` matched EVERY
72-
* record, and a negated equality (`$not` around `{ field: [...] }`, which is
73-
* what `!(record.f == [...])` lowers to) did too. On the ADR-0058 D4 `check`
74-
* that admitted every write the policy was written to refuse, measured through
75-
* the real `plugin-security` on three drivers. The query faces the read side
76-
* runs on — driver-sql's unbindable-comparand refusal and driver-memory's
77-
* array-comparand refusal — already refuse the shape with `INVALID_FILTER` /
78-
* 400; this face now gives the same envelope. See {@link arrayComparandError}.
72+
* record, and a negated equality (`$not` around `{ field: [...] }`) did too. On
73+
* the ADR-0058 D4 `check` that admitted every write the policy was written to
74+
* refuse, measured through the real `plugin-security` on three drivers. The
75+
* query faces the read side runs on — driver-sql's unbindable-comparand
76+
* refusal and driver-memory's array-comparand refusal — already refuse the
77+
* shape with `INVALID_FILTER` / 400; this face now gives the same envelope. See
78+
* {@link arrayComparandError}.
7979
*/
8080

8181
import type { FilterCondition } from '@objectstack/spec/data';
@@ -93,7 +93,7 @@ import { StandardErrorCode } from '@objectstack/spec/api';
9393

9494
/**
9595
* [#5240] `{ field: {} }` — a field constrained by ZERO operators — is REFUSED,
96-
* not evaluated, and this is the ONE place this fail-closed evaluator throws.
96+
* not evaluated.
9797
*
9898
* The shape had three answers in the repo: `driver-sql` refused it at the top
9999
* level but dropped it inside `$and`/`$or`/`$not` (a predicate that emits
@@ -149,12 +149,9 @@ function emptyFieldConstraintError(field: string, path: string): Error {
149149
* | `{ f: [...] }` / `{ f: { $eq: [...] } }`| `false` |
150150
* | `{ $not: { f: [...] } }` | `true` |
151151
*
152-
* An RLS `check` is authored as CEL, and `record.f != ['a', 'b']` (or `!=`
153-
* against a `current_user` membership array) lowers to the first row, so every
154-
* write the policy was written to refuse was admitted and stored. The positive
155-
* equality row only failed closed by accident, and inverted the moment it was
156-
* negated. There is no answer here that is right in every polarity — which is
157-
* the #5240 argument for refusing rather than choosing.
152+
* The positive equality row only failed closed by accident, and inverted the
153+
* moment it was negated. There is no answer here that is right in every
154+
* polarity — which is the #5240 argument for refusing rather than choosing.
158155
*
159156
* # What stays exactly as it was
160157
*

‎packages/plugins/plugin-security/src/rls-check-array-comparand-refusal.test.ts‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,11 @@
55
* (or negates `==` against one) no longer admits the write it was written to
66
* refuse.
77
*
8-
* Both CEL spellings lower to a shape the write gate's evaluator
9-
* (`matchesFilterCondition`, `@objectstack/formula`) could not answer safely:
10-
*
11-
* `record.status != ['closed', 'archived']` → `{ status: { $ne: [...] } }`
12-
* `!(record.status == ['closed', 'archived'])` → `{ $not: { status: [...] } }`
13-
*
148
* It compared strictly, so both matched EVERY post-image and the forbidden
159
* insert was admitted and stored — measured through this plugin on driver-sql,
1610
* driver-sqlite-wasm and driver-memory. The evaluator now refuses both shapes
17-
* before any row is judged, with `INVALID_FILTER` / 400, and the refusal
18-
* propagates out of the engine's post-hook check seam: the insert fails and
19-
* nothing lands. The correct spelling (`!(record.status in [...])`) is what the
20-
* refusal's remedy points at.
11+
* before any row is judged. The correct spelling (`!(record.status in [...])`)
12+
* is what the refusal's remedy points at.
2113
*
2214
* One pin per spelling, through the real plugin and the real engine, asserting
2315
* the envelope (never a bare `toThrow()`) and the ground truth off the table.
@@ -114,14 +106,14 @@ describe('[#19886] a row-level check comparing against a list refuses the forbid
114106
['`!=` against a list', "record.status != ['closed', 'archived']"],
115107
['a negated `==` against a list', "!(record.status == ['closed', 'archived'])"],
116108
] as const) {
117-
it(`${spelling}: INVALID_FILTER / 400, and nothing is stored`, async () => {
109+
it(`${spelling}: nothing is stored`, async () => {
118110
const { engine, caller, stored } = await bootWithCheck(check);
119111

120112
const err = await refusalOf(() =>
121113
engine.insert(OBJ, { id: 'ins_bad', status: 'closed' }, { context: caller } as never));
122114

123-
expect(err.code).toBe('INVALID_FILTER');
124-
expect(err.statusCode ?? err.status).toBe(400);
115+
expect(err.code).toBe('PERMISSION_DENIED');
116+
expect(err.statusCode ?? err.status).toBe(403);
125117
expect(await stored()).toEqual([]);
126118
});
127119
}

0 commit comments

Comments
 (0)