Skip to content

Commit 0af6ee5

Browse files
committed
fix(spec, changeset): keep the evaluator-face entry true once the CEL lowering refuses first
With the lowering refusal, a CEL-authored check carrying a list comparand is dropped at compile (RLS_DENY_FILTER, 403) and never reaches the evaluator's INVALID_FILTER / 400. The rls-predicate-array-comparand-refused entry now claims no envelope for the authored predicate: the explain parenthetical and the write-envelope sentence are cut, and the lowered shapes are past tense. registry.ts regenerated by gen:migration-registry. This PR's changeset says what a CEL-authored check gets relative to the 400 that matchesFilterCondition keeps for a filter passed to it directly. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent a71e096 commit 0af6ee5

4 files changed

Lines changed: 16 additions & 23 deletions

File tree

‎.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Clause-②: no (narrowing)
1616
**Security fix for RLS reads on MongoDB and RLS write checks.** A policy written `record.status != ['closed', 'archived']` (or `!(record.status == [...])`, or `!=` against a `current_user` membership set) lowered to `{ status: { $ne: [...] } }` (or `$not` around a bare-array equality). The RLS `using` clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server, where it selects every scalar row: the read returned the rows the policy was written to hide. A `check` written `!=` against a membership set admitted every write.
1717

1818
- `@objectstack/formula`: `compileCelToFilter` refuses `==` / `!=` whose comparand is a list (`unsupported`): a list literal, or a `current_user` variable that resolves to an array. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports the literal; a resolved array is refused per request.
19-
- `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403).
19+
- `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403). A CEL-authored `check` gets this 403; the `INVALID_FILTER` / 400 of `matchesFilterCondition` remains for a filter passed to it directly.
2020
- `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded.
2121
- `@objectstack/lint`: the list-literal form is reported (`rls-predicate-unenforceable`, `sharing-rule-unlowerable-condition`). The RLS reference pass probes each kernel-resolved `current_user` key with its runtime type.
2222
- `@objectstack/driver-mongodb`: `translateFilter` refuses `$ne` with an array comparand at any depth, with `INVALID_FILTER` / 400, as driver-sql and driver-memory already do.

‎packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@
1414
* to exclude, which is what the lowered `$ne: [...]` (and the `$nor` of a
1515
* negated equality) returned on driver-mongodb before the refusal;
1616
* - a `check` write is refused with the row-level CHECK envelope
17-
* (`PERMISSION_DENIED` / 403) and nothing is stored — before the refusal
18-
* the resolved-array spellings ADMITTED the forbidden row here.
17+
* (`PERMISSION_DENIED` / 403) and nothing is stored.
1918
*
2019
* The controls are the spellings the refusal points at, `!(record.f in …)`,
2120
* which keep their exact meaning. The ground truth is read past every scope.

‎packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ export const entry: SemanticMigration = {
1111
// No backticks in `surface` — build-upgrade-guide renders it inside a code
1212
// span already, and a nested backtick would close it.
1313
surface:
14-
'security.PermissionSet rowLevelSecurity[].check (and .using, where the explain engine '
15-
+ 'attributes a record) — a CEL predicate comparing a field with != or == against a list, '
14+
'security.PermissionSet rowLevelSecurity[].check — a CEL predicate comparing a field with != '
15+
+ 'or == against a list, '
1616
+ 'a list literal or a current_user membership array, and the negation of such an ==. They '
17-
+ 'lower to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which '
17+
+ 'lowered to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which '
1818
+ 'the @objectstack/formula evaluator matchesFilterCondition now refuses, together with '
1919
+ '{ field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included',
2020
replacement:
@@ -31,12 +31,9 @@ export const entry: SemanticMigration = {
3131
+ 'membership array, matched EVERY post-image, and a check written '
3232
+ '!(record.status == ["closed", "archived"]) did the same: every write such a policy was '
3333
+ 'written to refuse was admitted and stored. The positive record.status == ["open", '
34-
+ '"pending"] refused every write (403). All of these shapes now fail the write with '
35-
+ 'INVALID_FILTER / 400 before any record is judged, the envelope driver-sql and '
36-
+ 'driver-memory already give the same shape on the read side, and the explain engine\'s '
37-
+ 'record attribution refuses too. The message withholds the field, the operator and the '
38-
+ 'value, because the filter is usually an access policy the caller did not write and the '
39-
+ 'comparand may be a resolved membership set. Metadata AT REST is not rewritten and this '
34+
+ '"pending"] refused every write (403). The evaluator now refuses all of these shapes '
35+
+ 'before any record is judged. The message withholds the field, the operator and the '
36+
+ 'value. Metadata AT REST is not rewritten and this '
4037
+ 'entry adds no D2 conversion: the platform cannot tell which list operator a list '
4138
+ 'comparison was standing in for, and a policy rewritten on the author\'s behalf would '
4239
+ 'change which writes it admits (the negated forms would start refusing writes they '
@@ -46,7 +43,7 @@ export const entry: SemanticMigration = {
4643
'Grep the rowLevelSecurity check and using predicates of your permission sets for != or == '
4744
+ 'whose right-hand side is a list literal or a current_user membership array, and for the '
4845
+ 'negation of such an ==, then rewrite each with in or !(... in ...). A check that still '
49-
+ 'carries the shape refuses every write it governs with INVALID_FILTER / 400, allowed '
46+
+ 'carries the shape refuses every write it governs, allowed '
5047
+ 'values included, so one allowed write under each policy finds every such check left. '
5148
+ 'Then re-check what each policy is supposed to refuse rather than assuming the writes it '
5249
+ 'admitted before were right: before this change a != or a negated == against a list '

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11509,10 +11509,10 @@ const step18: MigrationStep = {
1150911509
// No backticks in `surface` — build-upgrade-guide renders it inside a code
1151011510
// span already, and a nested backtick would close it.
1151111511
surface:
11512-
'security.PermissionSet rowLevelSecurity[].check (and .using, where the explain engine '
11513-
+ 'attributes a record) — a CEL predicate comparing a field with != or == against a list, '
11512+
'security.PermissionSet rowLevelSecurity[].check — a CEL predicate comparing a field with != '
11513+
+ 'or == against a list, '
1151411514
+ 'a list literal or a current_user membership array, and the negation of such an ==. They '
11515-
+ 'lower to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which '
11515+
+ 'lowered to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which '
1151611516
+ 'the @objectstack/formula evaluator matchesFilterCondition now refuses, together with '
1151711517
+ '{ field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included',
1151811518
replacement:
@@ -11529,12 +11529,9 @@ const step18: MigrationStep = {
1152911529
+ 'membership array, matched EVERY post-image, and a check written '
1153011530
+ '!(record.status == ["closed", "archived"]) did the same: every write such a policy was '
1153111531
+ 'written to refuse was admitted and stored. The positive record.status == ["open", '
11532-
+ '"pending"] refused every write (403). All of these shapes now fail the write with '
11533-
+ 'INVALID_FILTER / 400 before any record is judged, the envelope driver-sql and '
11534-
+ 'driver-memory already give the same shape on the read side, and the explain engine\'s '
11535-
+ 'record attribution refuses too. The message withholds the field, the operator and the '
11536-
+ 'value, because the filter is usually an access policy the caller did not write and the '
11537-
+ 'comparand may be a resolved membership set. Metadata AT REST is not rewritten and this '
11532+
+ '"pending"] refused every write (403). The evaluator now refuses all of these shapes '
11533+
+ 'before any record is judged. The message withholds the field, the operator and the '
11534+
+ 'value. Metadata AT REST is not rewritten and this '
1153811535
+ 'entry adds no D2 conversion: the platform cannot tell which list operator a list '
1153911536
+ 'comparison was standing in for, and a policy rewritten on the author\'s behalf would '
1154011537
+ 'change which writes it admits (the negated forms would start refusing writes they '
@@ -11544,7 +11541,7 @@ const step18: MigrationStep = {
1154411541
'Grep the rowLevelSecurity check and using predicates of your permission sets for != or == '
1154511542
+ 'whose right-hand side is a list literal or a current_user membership array, and for the '
1154611543
+ 'negation of such an ==, then rewrite each with in or !(... in ...). A check that still '
11547-
+ 'carries the shape refuses every write it governs with INVALID_FILTER / 400, allowed '
11544+
+ 'carries the shape refuses every write it governs, allowed '
1154811545
+ 'values included, so one allowed write under each policy finds every such check left. '
1154911546
+ 'Then re-check what each policy is supposed to refuse rather than assuming the writes it '
1155011547
+ 'admitted before were right: before this change a != or a negated == against a list '

0 commit comments

Comments
 (0)