From 4f717dadc059892df19388527ba5752e691f3e96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 02:40:36 +0000 Subject: [PATCH 01/11] fix(formula, driver-mongodb): refuse a list comparand on == / != at the CEL lowering and $ne array at the mongodb face compileCelToFilter lowered `record.f != [...]`, `record.f == [...]` and the same comparisons against a current_user membership array to `$ne: [...]` / a bare-array field spec. The RLS `using` clause is composed after the engine's comparand-shape seam, so on driver-mongodb nothing refused it and the read widened to every scalar row. The lowering now refuses both operators with an array comparand (`unsupported`), so the RLS compiler drops the policy and the sharing seeder skips the rule. driver-mongodb's translateFilter refuses `$ne` with an array on its shape walk with INVALID_FILTER / 400, the envelope driver-sql and driver-memory give. The equality position is left to the shared face, whose ruling pins this translator passing it through. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../driver-mongodb/src/mongodb-filter.ts | 43 +++++++++++++++++++ packages/formula/src/cel-to-filter.ts | 38 ++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/packages/drivers/driver-mongodb/src/mongodb-filter.ts b/packages/drivers/driver-mongodb/src/mongodb-filter.ts index b69a5521cbc..5bae2d55bc6 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-filter.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-filter.ts @@ -318,6 +318,29 @@ function classifyFilterKey(key: string, value: unknown, here: string): FilterVer throw malformedBetweenError(key, value.$between, `${here}.$between`); } + // [#19886] `$ne`'s comparand is ONE value, gated on the WALK for the reason + // the three gates above give: `{ $or: [ {}, { s: { $ne: [...] } } ] }` + // reduces to TRUE on its first disjunct, so an emitter-side gate would refuse + // or ignore it depending on its siblings. Before this gate the shape was + // emitted unchanged, and MongoDB reads `$ne` against an array operand as + // "not equal to that array and not holding it as an element" — every scalar + // row. On an RLS read that is a widening: the `using` clause is AND-composed + // AFTER the engine's comparand-shape seam, so this driver was the only face + // left between it and the server. `driver-sql` (unbindable comparand) and + // `driver-memory` (array comparand) already refuse it with this envelope. + // + // ⚠️ Deliberately `$ne` ONLY. The equality position (`{ f: [...] }`, + // `$eq: [...]`) is ruled to the shared face, and that ruling's own pin keeps + // this translator passing it through unchanged — a driver-local copy there + // would be a second implementation of the shared rule. + if ( + isFilterNode(value) && + Object.prototype.hasOwnProperty.call(value, '$ne') && + Array.isArray(value.$ne) + ) { + throw arrayNotEqualComparandError(); + } + // A field key always contributes a predicate — `'clause'`, exactly as #5239 // classified it. The refusals above do not change that verdict for any shape // that survives them. @@ -370,6 +393,26 @@ function unsupportedFilterError(message: string): Error { return err; } +/** + * [#19886] `$ne` whose comparand is an array. + * + * The field, the value and the position are withheld from the message: + * this translator receives the RLS `using` predicate AND-composed into the + * caller's `where`, and the caller who reads the 400 is not the author of that + * predicate, whose comparand may be a resolved membership set (other users' + * ids). That is the posture `driver-sql`'s withheld refusals took for the + * same reason. The message carries the refusal's identity and the remedy. + */ +function arrayNotEqualComparandError(): Error { + return unsupportedFilterError( + 'A "$ne" comparison in this filter received an array as its comparand. "$ne" compares one ' + + 'value; for "none of these values" use "$nin". It is refused rather than translated because ' + + 'MongoDB reads "$ne" against an array operand as "not equal to that array and not holding it ' + + 'as an element", which every scalar value satisfies. The field and the value are withheld ' + + 'from this message because the filter may be an access policy the caller did not write.', + ); +} + /** * [#5347] `$null` whose comparand is not a boolean. * diff --git a/packages/formula/src/cel-to-filter.ts b/packages/formula/src/cel-to-filter.ts index b87311f1d25..f782a887382 100644 --- a/packages/formula/src/cel-to-filter.ts +++ b/packages/formula/src/cel-to-filter.ts @@ -417,8 +417,46 @@ function lowerStringMethod(args: [string, ASTNode, ASTNode[]], ctx: Ctx): Filter return { [(recv as { path: string }).path]: { [mapped]: arg } } as FilterCondition; } +/** + * [#19886] `==` / `!=` compare ONE value, and a list is not one value. + * + * Until this refusal `record.status != ['closed', 'archived']` lowered to + * `{ status: { $ne: [...] } }` and `record.status == [...]` to the bare-array + * `{ status: [...] }`, and so did the same comparison against a `current_user` + * membership ARRAY (`record.reviewer_id != current_user.org_user_ids`). The + * backends that received them disagreed, and two of the answers widened: + * + * | lowered shape | driver-sql / memory / turso | formula `matchesFilterCondition` (RLS `check`), before its own refusal | driver-mongodb (mingo proxy) | + * |:-------------------------------|:----------------------------|:----------------------|:-----------------------------| + * | `{ f: { $ne: [...] } }` | 400 | every row | every scalar row | + * | `{ $not: { f: [...] } }` | 400 | every row | every row (`$nor`) | + * + * The RLS `using` clause is AND-composed into the query AFTER the engine's + * comparand-shape seam, so no shared face stood between that lowering and a + * driver that answered it. Refusing HERE closes it at the one point every + * consumer compiles through — the RLS `using` / `check` compiler, the + * sharing-rule bootstrap, and the authoring lint — and it fails closed the way + * every other `unsupported` shape does: the RLS compiler drops the policy + * (`RLS_DENY_FILTER` when nothing else applies), and the sharing seeder skips + * the rule. + * + * A literal list is caught by the shape check too (`isPushdownableCel`), so the + * authoring gate sees it; a variable that RESOLVES to an array can only be + * caught when it resolves, at request time. "One of these values" is `in`; + * "none of these values" is `!(… in …)`. + */ +function arrayComparandRefusal(op: string): CompileError { + return new CompileError( + 'unsupported', + `\`${op}\` compares one value, but its comparand is a list (a list literal, or a ` + + '`current_user` membership set) — spell "one of these" as `record.f in [...]` and ' + + '"none of these" as `!(record.f in [...])`', + ); +} + /** Build `{ field: value }`. `isRef` true → value is a `{ $field }` reference. */ function emit(field: string, op: string, value: unknown, isRef: boolean): FilterCondition { + if ((op === '==' || op === '!=') && !isRef && Array.isArray(value)) throw arrayComparandRefusal(op); if (op === '==') { if (!isRef && value === null) return { [field]: { $null: true } } as FilterCondition; if (isRef) return { [field]: { $eq: value } } as FilterCondition; From 708100115353d0e362fa22624efbe93b9edc6599 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 02:47:25 +0000 Subject: [PATCH 02/11] fix(formula): scope the == / != list refusal to list literals Refusing a current_user variable that resolves to an array silenced the authoring lint's reference pass: it compiles every predicate with each kernel-resolved key, `id` included, bound to an array probe, and two of its pins went red. The lowering now refuses a list LITERAL only; the resolved-array half waits on a lint change and is named in the docblock, with where its lowered shapes are refused today. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/formula/src/cel-to-filter.ts | 35 ++++++++++++++++++++------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/formula/src/cel-to-filter.ts b/packages/formula/src/cel-to-filter.ts index f782a887382..4652d562614 100644 --- a/packages/formula/src/cel-to-filter.ts +++ b/packages/formula/src/cel-to-filter.ts @@ -345,6 +345,13 @@ function lowerComparison(op: string, lNode: ASTNode, rNode: ASTNode, ctx: Ctx): // field-to-field comparison → `{ $field: otherPath }` reference. return emit((L as { path: string }).path, op, { $field: (R as { path: string }).path }, true); } + // [#19886] A LIST LITERAL compared with `==` / `!=` is refused before it is + // emitted — see {@link arrayComparandRefusal} for the scope, and why a + // variable that resolves to an array is not (yet) refused here. + if ((op === '==' || op === '!=') && (lField || rField)) { + const other = lField ? R : L; + if (other.kind === 'literal' && Array.isArray(other.value)) throw arrayComparandRefusal(op); + } if (lField) return emit((L as { path: string }).path, op, resolveValue(R, ctx), false); if (rField) return emit((R as { path: string }).path, FLIP[op] ?? op, resolveValue(L, ctx), false); @@ -422,9 +429,8 @@ function lowerStringMethod(args: [string, ASTNode, ASTNode[]], ctx: Ctx): Filter * * Until this refusal `record.status != ['closed', 'archived']` lowered to * `{ status: { $ne: [...] } }` and `record.status == [...]` to the bare-array - * `{ status: [...] }`, and so did the same comparison against a `current_user` - * membership ARRAY (`record.reviewer_id != current_user.org_user_ids`). The - * backends that received them disagreed, and two of the answers widened: + * `{ status: [...] }`. The backends that received them disagreed, and two of + * the answers widened: * * | lowered shape | driver-sql / memory / turso | formula `matchesFilterCondition` (RLS `check`), before its own refusal | driver-mongodb (mingo proxy) | * |:-------------------------------|:----------------------------|:----------------------|:-----------------------------| @@ -441,22 +447,33 @@ function lowerStringMethod(args: [string, ASTNode, ASTNode[]], ctx: Ctx): Filter * the rule. * * A literal list is caught by the shape check too (`isPushdownableCel`), so the - * authoring gate sees it; a variable that RESOLVES to an array can only be - * caught when it resolves, at request time. "One of these values" is `in`; - * "none of these values" is `!(… in …)`. + * authoring gate sees it. "One of these values" is `in`; "none of these + * values" is `!(… in …)`. + * + * ⚠️ SCOPE — list LITERALS only. A `current_user` variable that RESOLVES to an + * array (`record.r != current_user.org_user_ids`) still lowers as before. The + * authoring lint's reference pass compiles every predicate with each + * kernel-resolved `current_user` key — `id` and `email` included — bound to an + * ARRAY probe, and relies on `==` / `!=` accepting it to reach the field and + * variable checks; refusing a resolved array here would silence those checks + * for every `field == current_user.id` policy. That half waits on a lint change + * this refusal does not carry. What a resolved array still lowers to meets the + * driver faces: `$ne: [...]` is refused by driver-sql, driver-memory and + * driver-mongodb; the equality spelling (`{ r: [...] }`, and its `$not`) by + * driver-sql and driver-memory, and on driver-mongodb it is left to the shared + * comparand-shape face's ruling. */ function arrayComparandRefusal(op: string): CompileError { return new CompileError( 'unsupported', - `\`${op}\` compares one value, but its comparand is a list (a list literal, or a ` + - '`current_user` membership set) — spell "one of these" as `record.f in [...]` and ' + + `\`${op}\` compares one value, but its comparand is a list literal — spell "one of these" ` + + 'as `record.f in [...]` and ' + '"none of these" as `!(record.f in [...])`', ); } /** Build `{ field: value }`. `isRef` true → value is a `{ $field }` reference. */ function emit(field: string, op: string, value: unknown, isRef: boolean): FilterCondition { - if ((op === '==' || op === '!=') && !isRef && Array.isArray(value)) throw arrayComparandRefusal(op); if (op === '==') { if (!isRef && value === null) return { [field]: { $null: true } } as FilterCondition; if (isRef) return { [field]: { $eq: value } } as FilterCondition; From 05351f1601f581dd650d449db6e452a692e8eb7b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 02:51:43 +0000 Subject: [PATCH 03/11] test(formula, driver-mongodb, plugin-security): pin the list-comparand refusals and their fail-closed consequence Formula pins the lowering refusal for `==` / `!=` against a list literal in every position, at request time and in the authoring shape check, with the neighbours unchanged. driver-mongodb pins `$ne` with an array refused at every depth and at the driver door before the server is asked. plugin-security pins the consequence through the real plugin: a `using` read under such a policy returns zero rows and a `check` write is refused 403 with nothing stored. Plus the changeset. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...-comparand-and-mongodb-ne-array-refused.md | 15 ++ ...mongodb-ne-array-comparand-refusal.test.ts | 120 +++++++++++++++ .../src/cel-to-filter-list-comparand.test.ts | 86 +++++++++++ ...ist-literal-comparand-fails-closed.test.ts | 139 ++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 .changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md create mode 100644 packages/drivers/driver-mongodb/src/mongodb-ne-array-comparand-refusal.test.ts create mode 100644 packages/formula/src/cel-to-filter-list-comparand.test.ts create mode 100644 packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts diff --git a/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md b/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md new file mode 100644 index 00000000000..32c94fe5ae3 --- /dev/null +++ b/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md @@ -0,0 +1,15 @@ +--- +"@objectstack/formula": patch +"@objectstack/driver-mongodb": patch +--- + +A row-level or sharing-rule predicate comparing a field against a list with `!=` / `==` no longer lowers to a filter that widens on driver-mongodb, and driver-mongodb refuses `$ne` with an array comparand (#19886). + +Clause-②: no + +**Security fix for RLS reads on MongoDB.** A policy written `record.status != ['closed', 'archived']` (or `!(record.status == [...])`) 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. + +- `@objectstack/formula`: `compileCelToFilter` now refuses `==` / `!=` whose comparand is a list literal (`unsupported`). The RLS compiler drops such a policy and fails closed (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403). A declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports it. +- `@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. + +**What to change.** "One of these values" is `record.status in ['open', 'pending']`; "none of these values" is `!(record.status in ['closed', 'archived'])`. In a raw filter, use `$in` / `$nin`. `in`, scalar `==` / `!=`, `null` and field-to-field comparisons are unchanged. diff --git a/packages/drivers/driver-mongodb/src/mongodb-ne-array-comparand-refusal.test.ts b/packages/drivers/driver-mongodb/src/mongodb-ne-array-comparand-refusal.test.ts new file mode 100644 index 00000000000..8a5d7f6b0a6 --- /dev/null +++ b/packages/drivers/driver-mongodb/src/mongodb-ne-array-comparand-refusal.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19886] `$ne` with an ARRAY comparand is refused by `translateFilter`, with + * the `INVALID_FILTER` / 400 envelope `driver-sql` and `driver-memory` give the + * same shape. + * + * ## Why this driver needed its own gate + * + * The RLS `using` clause is AND-composed into the query AFTER the engine's + * comparand-shape seam, so a policy lowered to `{ f: { $ne: [...] } }` reached + * this translator with no shared face in front of it, and the translator + * emitted it unchanged. MongoDB reads `$ne` against an array operand as "not + * equal to that array and not holding it as an element", which every scalar + * value satisfies: measured through the real `MongoDBDriver` on mingo 7.2.4, + * the named proxy for MongoDB query semantics, an RLS read under + * `record.status != current_user.` returned EVERY row. A live + * `mongod` is NOT MEASURED — this fleet cannot fetch the binary, and mingo is + * not a dependency of this package, so the proxy reading was taken outside this + * suite. This suite pins the translator and the driver door: the refusal, and + * that the server is never asked. + * + * ## Scope + * + * `$ne` only. The equality position (`{ f: [...] }`, `$eq: [...]`) is ruled to + * the shared comparand-shape face, which keeps this translator passing it + * through; it is not pinned either way here. + */ + +import { describe, it, expect } from 'vitest'; +import { translateFilter } from './mongodb-filter.js'; +import { MongoDBDriver } from './mongodb-driver.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +const refusalOf = (where: unknown): WireBearingError => { + try { + translateFilter(where); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected the translator to refuse this filter, but it translated'); +}; + +const LIST = ['closed', 'archived']; + +describe('[#19886] translateFilter refuses $ne with an array comparand', () => { + const POSITIONS: Array<[string, Record]> = [ + ['top level', { status: { $ne: LIST } }], + ['beside another operator on the field', { status: { $ne: LIST, $exists: true } }], + ['inside $and', { $and: [{ owner: 'u1' }, { status: { $ne: LIST } }] }], + // `{}` reduces the `$or` to TRUE on its first disjunct, so the emitter never + // reaches the second: only a gate on the WALK refuses this one. + ['inside $or, beside a TRUE identity', { $or: [{}, { status: { $ne: LIST } }] }], + ['inside $not (lowered to $nor)', { $not: { status: { $ne: LIST } } }], + ['nested two combinators deep', { $and: [{ $or: [{ $not: { status: { $ne: LIST } } }] }] }], + ['an EMPTY array', { status: { $ne: [] } }], + ]; + + for (const [label, where] of POSITIONS) { + it(`${label}: INVALID_FILTER / 400`, () => { + const err = refusalOf(where); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + }); + } + + it('the message names "$nin" and withholds the field and the comparand', () => { + const err = refusalOf({ secret_scope: { $ne: ['usr_member_one', 'usr_member_two'] } }); + expect(err.message).toContain('"$nin"'); + expect(err.message).not.toContain('secret_scope'); + expect(err.message).not.toContain('usr_member_one'); + }); + + it('the driver door refuses before the server is asked', async () => { + const calls: unknown[] = []; + const collection = { + find: (filter: unknown) => { calls.push(filter); return { toArray: async () => [] }; }, + findOne: async (filter: unknown) => { calls.push(filter); return null; }, + countDocuments: async (filter: unknown) => { calls.push(filter); return 0; }, + }; + const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/unused', database: 'unused' }); + (driver as unknown as { db: unknown }).db = { collection: () => collection }; + + for (const run of [ + () => driver.find('deal', { where: { status: { $ne: LIST } } } as never), + () => driver.findOne('deal', { where: { status: { $ne: LIST } } } as never), + () => driver.count('deal', { where: { status: { $ne: LIST } } } as never), + ]) { + const err = await run().then(() => null, (e: WireBearingError) => e); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.status).toBe(400); + } + expect(calls).toHaveLength(0); + }); +}); + +describe('[#19886] every neighbouring shape translates exactly as before', () => { + it('$nin / $in with a list', () => { + expect(translateFilter({ status: { $nin: LIST } })).toEqual({ status: { $nin: LIST } }); + expect(translateFilter({ status: { $in: LIST } })).toEqual({ status: { $in: LIST } }); + }); + + it('scalar $ne, and $ne: null', () => { + expect(translateFilter({ status: { $ne: 'closed' } })).toEqual({ status: { $ne: 'closed' } }); + expect(translateFilter({ status: { $ne: null } })).toEqual({ status: { $ne: null } }); + }); + + it('a scalar equality on an array-valued field — MongoDB\'s element match — is untouched', () => { + expect(translateFilter({ tags: 'a' })).toEqual({ tags: 'a' }); + expect(translateFilter({ tags: { $ne: 'a' } })).toEqual({ tags: { $ne: 'a' } }); + }); + + it('$not around a scalar $ne still lowers to $nor', () => { + expect(translateFilter({ $not: { status: { $ne: 'closed' } } })).toEqual({ $nor: [{ status: { $ne: 'closed' } }] }); + }); +}); diff --git a/packages/formula/src/cel-to-filter-list-comparand.test.ts b/packages/formula/src/cel-to-filter-list-comparand.test.ts new file mode 100644 index 00000000000..0b695aa1b4b --- /dev/null +++ b/packages/formula/src/cel-to-filter-list-comparand.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19886] `==` / `!=` against a LIST LITERAL is a compile error, not a lowering. + * + * It used to lower to `{ f: { $ne: [...] } }` and to the bare-array + * `{ f: [...] }` (with `$not` around it for `!(… == [...])`). Those shapes + * reached the backends through the RLS `using` clause, which is composed AFTER + * the engine's comparand-shape seam, and two backends widened on them + * (`$ne: [...]` matched every row on the write-check evaluator and every scalar + * row on driver-mongodb). Refused here, every consumer of this compiler fails + * closed on its own existing `unsupported` path: the RLS compiler drops the + * policy, the sharing seeder skips the rule, and the authoring gate + * (`isPushdownableCel` / `isSupportedRlsExpression`) reports it. + * + * Scope, pinned by what is NOT here as much as by what is: a `current_user` + * variable that resolves to an array is not refused by this change. + */ + +import { describe, expect, it } from 'vitest'; + +import { compileCelToFilter, isPushdownableCel } from './cel-to-filter'; +import { isSupportedRlsExpression } from './rls-predicate'; + +const VARS = { current_user: { id: 'u_me', org_user_ids: ['u_me', 'u_peer'] } }; + +const REFUSED = [ + "record.status != ['closed', 'archived']", + "record.status == ['open', 'pending']", + "!(record.status == ['closed', 'archived'])", + "!(record.status != ['closed', 'archived'])", + "['closed', 'archived'] != record.status", + "['open'] == record.status", + 'record.status != []', + "record.owner_id == current_user.id && record.status != ['closed']", + "record.owner_id == current_user.id || !(record.status == ['closed'])", +]; + +describe('[#19886] == / != against a list literal is refused at the lowering', () => { + for (const source of REFUSED) { + it(`${source} — unsupported, at request time and at authoring time`, () => { + const compiled = compileCelToFilter(source, { variables: VARS }); + expect(compiled.ok).toBe(false); + expect(compiled.ok ? undefined : compiled.reason).toBe('unsupported'); + // The authoring gate asks the same compiler in shape mode, so the refusal + // is visible before any request: the lint and the RLS compiler's + // "uncompilable predicate" branch both read this. + expect(isPushdownableCel(source).ok).toBe(false); + expect(isSupportedRlsExpression(source)).toBe(false); + }); + } +}); + +describe('[#19886] every neighbouring comparison lowers exactly as before', () => { + const ok = (source: string) => { + const r = compileCelToFilter(source, { variables: VARS }); + if (!r.ok) throw new Error(`expected "${source}" to lower, got ${r.reason}: ${r.detail}`); + return r.filter; + }; + + it('`in` / `not in` against a list literal — the spelling the refusal points at', () => { + expect(ok("record.status in ['open', 'pending']")).toEqual({ status: { $in: ['open', 'pending'] } }); + expect(ok("!(record.status in ['closed', 'archived'])")).toEqual({ $not: { status: { $in: ['closed', 'archived'] } } }); + }); + + it('`in` against a resolved membership array', () => { + expect(ok('record.owner_id in current_user.org_user_ids')).toEqual({ owner_id: { $in: ['u_me', 'u_peer'] } }); + }); + + it('scalar == / != against a literal and against a resolved scalar', () => { + expect(ok("record.status != 'closed'")).toEqual({ status: { $ne: 'closed' } }); + expect(ok("record.status == 'open'")).toEqual({ status: 'open' }); + expect(ok('record.owner_id == current_user.id')).toEqual({ owner_id: 'u_me' }); + expect(ok('record.owner_id != current_user.id')).toEqual({ owner_id: { $ne: 'u_me' } }); + }); + + it('== null / != null', () => { + expect(ok('record.closed_at == null')).toEqual({ closed_at: { $null: true } }); + expect(ok('record.closed_at != null')).toEqual({ closed_at: { $null: false } }); + }); + + it('field-to-field { $field } references', () => { + expect(ok('record.owner == record.manager')).toEqual({ owner: { $eq: { $field: 'manager' } } }); + expect(ok('record.owner != record.manager')).toEqual({ owner: { $ne: { $field: 'manager' } } }); + }); +}); diff --git a/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts new file mode 100644 index 00000000000..2ae7f2fdc4e --- /dev/null +++ b/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19886] A row-level policy that compares a field against a LIST LITERAL with + * `!=` or `==` fails closed on both clauses, through the real plugin and engine. + * + * `compileCelToFilter` refuses the comparison (`unsupported`), so + * `RLSCompiler.compileFilter` drops the policy on its existing + * "uncompilable predicate" branch and, with nothing else applicable, answers + * `RLS_DENY_FILTER`: + * + * - a `using` read returns ZERO rows — never the rows the policy was written + * to exclude, which is what the lowered `$ne: [...]` returned on + * driver-mongodb before the refusal; + * - a `check` write is refused with the row-level CHECK envelope + * (`PERMISSION_DENIED` / 403) and nothing is stored. + * + * The control is the spelling the refusal points at, `!(record.status in [...])`, + * which keeps its exact meaning. The ground truth is read past every scope. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const OBJ = 'qa_ticket'; +const SYS_CTX = { isSystem: true, userId: 'usr_system' }; +const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(clause: 'using' | 'check', predicate: string) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.rls-list-literal-19886', + name: 'RLS list literal', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [{ + name: OBJ, + label: 'Ticket', + sharingModel: 'public_read_write', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + status: { name: 'status', type: 'text' }, + }, + }], + } as never); + await engine.syncSchemas(); + engines.push(engine); + + const set = PermissionSetSchema.parse({ + name: 'qa_ticket_guard', + objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true } }, + rowLevelSecurity: [{ name: 'not_closed', object: OBJ, operation: 'all', [clause]: predicate }], + }); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER_DEFAULT, set], + }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + const caller = { userId: 'usr_member', positions: ['qa_pos'], permissions: [set.name], posture: 'MEMBER' }; + const stored = async () => + ((await engine.find(OBJ, { context: SYS_CTX } as never)) as Array<{ id: string }>).map((r) => r.id).sort(); + return { engine, caller, stored }; +} + +const EXCLUDING = [ + ['`!=` against a list literal', "record.status != ['closed', 'archived']"], + ['a negated `==` against a list literal', "!(record.status == ['closed', 'archived'])"], +] as const; + +describe('[#19886] a `using` clause comparing against a list literal reads ZERO rows', () => { + for (const [spelling, predicate] of EXCLUDING) { + it(spelling, async () => { + const { engine, caller, stored } = await boot('using', predicate); + await engine.insert(OBJ, [{ id: 'r_open', status: 'open' }, { id: 'r_closed', status: 'closed' }], { context: SYS_CTX } as never); + + const rows = (await engine.find(OBJ, { context: caller } as never)) as Array<{ id: string }>; + + expect(rows).toEqual([]); + expect(await stored()).toEqual(['r_closed', 'r_open']); + }); + } + + it('CONTROL — `!(record.status in [...])` reads exactly the rows it admits', async () => { + const { engine, caller } = await boot('using', "!(record.status in ['closed', 'archived'])"); + await engine.insert(OBJ, [{ id: 'r_open', status: 'open' }, { id: 'r_closed', status: 'closed' }], { context: SYS_CTX } as never); + + const rows = (await engine.find(OBJ, { context: caller } as never)) as Array<{ id: string }>; + + expect(rows.map((r) => r.id)).toEqual(['r_open']); + }); +}); + +describe('[#19886] a `check` clause comparing against a list literal refuses the write', () => { + for (const [spelling, predicate] of EXCLUDING) { + it(`${spelling}: PERMISSION_DENIED / 403, and nothing is stored`, async () => { + const { engine, caller, stored } = await boot('check', predicate); + + const err = await engine.insert(OBJ, { id: 'ins_bad', status: 'closed' }, { context: caller } as never) + .then(() => null, (e: { code?: string; status?: number; statusCode?: number }) => e); + + expect(err?.code).toBe('PERMISSION_DENIED'); + expect(err?.statusCode ?? err?.status).toBe(403); + expect(await stored()).toEqual([]); + }); + } +}); From 6477cacd1b2df1f187d05656c3666cabeec62418 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 03:36:38 +0000 Subject: [PATCH 04/11] fix(formula, lint): refuse a resolved list comparand on == / != and probe kernel keys by runtime type The CEL lowering now refuses `==` / `!=` whose comparand is a list in both forms: a list literal, as before, and a `current_user` variable that resolves to an array (a membership set). The reason is the same `unsupported`; the shape check still sees literals only, because a variable's value exists only per request. The authoring lint's reference pass bound every kernel-resolved key to an array probe, which relied on `==` accepting an array. It now binds each key to a probe of the type its ExecutionContext field declares: a scalar for id / email / organization_id, an array for the membership sets. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/formula/src/cel-to-filter.ts | 78 ++++++++++++------- .../validate-rls-predicate-enforceability.ts | 48 ++++++++++-- 2 files changed, 91 insertions(+), 35 deletions(-) diff --git a/packages/formula/src/cel-to-filter.ts b/packages/formula/src/cel-to-filter.ts index 4652d562614..5f37b5eb859 100644 --- a/packages/formula/src/cel-to-filter.ts +++ b/packages/formula/src/cel-to-filter.ts @@ -345,15 +345,11 @@ function lowerComparison(op: string, lNode: ASTNode, rNode: ASTNode, ctx: Ctx): // field-to-field comparison → `{ $field: otherPath }` reference. return emit((L as { path: string }).path, op, { $field: (R as { path: string }).path }, true); } - // [#19886] A LIST LITERAL compared with `==` / `!=` is refused before it is - // emitted — see {@link arrayComparandRefusal} for the scope, and why a - // variable that resolves to an array is not (yet) refused here. - if ((op === '==' || op === '!=') && (lField || rField)) { - const other = lField ? R : L; - if (other.kind === 'literal' && Array.isArray(other.value)) throw arrayComparandRefusal(op); - } - if (lField) return emit((L as { path: string }).path, op, resolveValue(R, ctx), false); - if (rField) return emit((R as { path: string }).path, FLIP[op] ?? op, resolveValue(L, ctx), false); + // [#19886] `==` / `!=` against a comparand that IS a list — a list literal, or + // a `current_user` variable that resolves to an array — is refused before it + // is emitted. See {@link arrayComparandRefusal}. + if (lField) return emit((L as { path: string }).path, op, comparandOf(op, R, ctx), false); + if (rField) return emit((R as { path: string }).path, FLIP[op] ?? op, comparandOf(op, L, ctx), false); // Neither side is a field: a constant comparison. Fold the always-true case // (`1 == 1`, the RLS allow-all) to "no restriction"; refuse the rest (a @@ -446,32 +442,58 @@ function lowerStringMethod(args: [string, ASTNode, ASTNode[]], ctx: Ctx): Filter * (`RLS_DENY_FILTER` when nothing else applies), and the sharing seeder skips * the rule. * - * A literal list is caught by the shape check too (`isPushdownableCel`), so the - * authoring gate sees it. "One of these values" is `in`; "none of these - * values" is `!(… in …)`. + * ## Both kinds of list, one refusal + * + * A LIST LITERAL (`record.status != ['closed', 'archived']`) and a + * `current_user` variable that RESOLVES to an array + * (`record.reviewer_id != current_user.org_user_ids`) lower to the same shapes + * and are refused alike, with the same `unsupported` reason. They differ only + * in WHEN the refusal is visible: + * + * - A literal is refused by the shape check too (`isPushdownableCel`, so + * `isSupportedRlsExpression`): the authoring gate reports it before any + * request, and the RLS compiler drops it as an uncompilable predicate. + * - A variable's value exists only per request, so the shape check cannot see + * it and passes the source. The refusal lands at request time instead, as + * this compile's `unsupported` result: the RLS compiler collects the policy + * as a denial and returns `RLS_DENY_FILTER` when nothing else applies — + * zero rows on a read, `PermissionDeniedError` on a `check` write. The + * sharing-rule bootstrap compiles with no variables at all, so a variable + * never reaches this check there (it is `unresolved-variable` first). + * + * The authoring lint's reference pass compiles each predicate with every + * kernel-resolved `current_user` key bound to a probe of that key's RUNTIME + * type — a scalar for `id` / `email` / `organization_id`, an array for the + * membership sets — so `field == current_user.id` still lowers there and keeps + * its field and variable checks. * - * ⚠️ SCOPE — list LITERALS only. A `current_user` variable that RESOLVES to an - * array (`record.r != current_user.org_user_ids`) still lowers as before. The - * authoring lint's reference pass compiles every predicate with each - * kernel-resolved `current_user` key — `id` and `email` included — bound to an - * ARRAY probe, and relies on `==` / `!=` accepting it to reach the field and - * variable checks; refusing a resolved array here would silence those checks - * for every `field == current_user.id` policy. That half waits on a lint change - * this refusal does not carry. What a resolved array still lowers to meets the - * driver faces: `$ne: [...]` is refused by driver-sql, driver-memory and - * driver-mongodb; the equality spelling (`{ r: [...] }`, and its `$not`) by - * driver-sql and driver-memory, and on driver-mongodb it is left to the shared - * comparand-shape face's ruling. + * "One of these values" is `in`; "none of these values" is `!(… in …)`. The + * refusal message names the variable PATH the author wrote and never a value: + * a resolved array is a membership set. */ -function arrayComparandRefusal(op: string): CompileError { +function arrayComparandRefusal(op: string, leaf: Leaf): CompileError { + const list = leaf.kind === 'var' ? leaf.path.join('.') : '[...]'; + const comparand = + leaf.kind === 'var' ? `its comparand \`${list}\` resolves to a list` : 'its comparand is a list literal'; return new CompileError( 'unsupported', - `\`${op}\` compares one value, but its comparand is a list literal — spell "one of these" ` + - 'as `record.f in [...]` and ' + - '"none of these" as `!(record.f in [...])`', + `\`${op}\` compares one value, but ${comparand} — spell "one of these" ` + + `as \`record.f in ${list}\` and ` + + `"none of these" as \`!(record.f in ${list})\``, ); } +/** + * Resolve the non-field side of a comparison, refusing a list under `==` / `!=` + * ({@link arrayComparandRefusal}). In shape mode a variable resolves to the + * placeholder, so only a literal list is refused there. + */ +function comparandOf(op: string, leaf: Leaf, ctx: Ctx): unknown { + const value = resolveValue(leaf, ctx); + if ((op === '==' || op === '!=') && Array.isArray(value)) throw arrayComparandRefusal(op, leaf); + return value; +} + /** Build `{ field: value }`. `isRef` true → value is a `{ $field }` reference. */ function emit(field: string, op: string, value: unknown, isRef: boolean): FilterCondition { if (op === '==') { diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index 03439c52c9f..792940de873 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -160,6 +160,7 @@ import { } from '@objectstack/formula'; import type { CelBoundsOverrun } from '@objectstack/formula'; import { RESERVED_RLS_MEMBERSHIP_KEYS } from '@objectstack/spec/contracts'; +import { ExecutionContextSchema } from '@objectstack/spec/kernel'; import { describeFieldPathVerdict, indexObjectGraph, @@ -329,15 +330,46 @@ const PRERESOLVED_USER_KEYS: ReadonlySet = new Set(RESERVED_RLS_MEMBERSH /** * Probe values bound in place of the real request context. * - * An ARRAY is the value that lowers in EVERY position the pushdown subset has: - * `lowerMembership` requires `Array.isArray` on the right of `in`, and - * `lowerComparison` accepts any value at all — so binding every known key to one - * array lets a well-formed predicate compile without a request. The SCALAR is + * Each kernel-resolved key is bound to a value of the type it HOLDS at runtime + * (see {@link kernelKeyProbe}): a scalar for a scalar key, an array for a + * membership set. The compiler's verdict on a position then matches the + * runtime's — `==` / `!=` refuse an array (#19886) and `in` requires one — so a + * well-formed predicate compiles without a request, and a predicate the runtime + * drops for a type mismatch does not compile here either. The SCALAR is also * the discriminator described on {@link userVariableIsScalarPositioned}. */ const PROBE_ARRAY: readonly string[] = ['__objectstack_lint_probe__']; const PROBE_SCALAR = '__objectstack_lint_probe__'; +/** + * The kernel keys `RLSCompiler.compileFilter` copies from an `ExecutionContext` + * field of a DIFFERENT name (`id: executionContext.userId`, + * `organization_id: executionContext.tenantId`). Every other kernel key is + * copied from the field of its own name. + */ +const KERNEL_KEY_RENAMED_FROM: Readonly> = { + id: 'userId', + organization_id: 'tenantId', +}; + +/** + * The probe for one kernel-resolved key: whatever its `ExecutionContext` field + * accepts. The type is asked of the declaring schema (`ExecutionContextSchema`, + * `@objectstack/spec/kernel`) rather than transcribed here — if that field takes + * an array the key is a membership set, otherwise it is a scalar. + * + * A key with no declared field keeps the array probe this pass used for every + * key before #19886; the test that pins the key-binding table goes red first. + */ +function kernelKeyProbe(key: string): unknown { + const field = KERNEL_KEY_RENAMED_FROM[key] ?? key; + const declared = (ExecutionContextSchema.shape as Record)[ + field + ]; + if (!declared) return PROBE_ARRAY; + return declared.safeParse(PROBE_ARRAY).success ? PROBE_ARRAY : PROBE_SCALAR; +} + /** `variable "current_user.nope" is undefined` → `current_user.nope`. */ function unresolvedVariablePath(detail: string): string | null { const m = /variable "([^"]+)"/.exec(detail); @@ -348,7 +380,7 @@ type UserProbe = Record; function baseUserProbe(): UserProbe { const probe: UserProbe = {}; - for (const key of PRERESOLVED_USER_KEYS) probe[key] = PROBE_ARRAY; + for (const key of PRERESOLVED_USER_KEYS) probe[key] = kernelKeyProbe(key); return probe; } @@ -435,8 +467,10 @@ function resolveReferences( if (res.ok) return { filter: res.filter as Record, unresolvedScalars }; if (res.reason !== 'unresolved-variable') { // The predicate passed `isSupportedRlsExpression`, so a shape refusal here - // can only be a probe value the position cannot take (an array handed to - // `startsWith`). Nothing further is decidable; report what was found. + // can only be a value the position cannot take — a membership set under + // `==` / `!=` or handed to `startsWith`, a scalar key on the right of `in`. + // The runtime refuses the same value in the same position, so nothing + // further is decidable; report what was found. return { filter: null, unresolvedScalars }; } const path = unresolvedVariablePath(res.detail); From 9c6741699643ef482ed2683a44a3b88e616d133e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 03:45:34 +0000 Subject: [PATCH 05/11] test(formula, lint, plugin-security): pin the resolved-list refusal and the runtime-typed kernel probes formula: == / != against a current_user variable that resolves to an array is refused (unsupported), naming the variable and withholding its members, while the shape check still passes the source; in / not in against the same set, and scalar keys, lower as before. lint: each kernel-resolved key is probed with its runtime type; a scalar key still reaches the field check under ==, a membership key under in. plugin-security: a using read under != / negated == against a resolved membership set reads zero rows, and a check insert is refused 403 with nothing stored; not in against the same set keeps its meaning. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/cel-to-filter-list-comparand.test.ts | 57 ++++++++++++++-- ...idate-rls-predicate-enforceability.test.ts | 48 ++++++++++++++ ...ist-literal-comparand-fails-closed.test.ts | 65 +++++++++++++------ 3 files changed, 143 insertions(+), 27 deletions(-) diff --git a/packages/formula/src/cel-to-filter-list-comparand.test.ts b/packages/formula/src/cel-to-filter-list-comparand.test.ts index 0b695aa1b4b..456fb42f3b7 100644 --- a/packages/formula/src/cel-to-filter-list-comparand.test.ts +++ b/packages/formula/src/cel-to-filter-list-comparand.test.ts @@ -1,7 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#19886] `==` / `!=` against a LIST LITERAL is a compile error, not a lowering. + * [#19886] `==` / `!=` against a LIST is a compile error, not a lowering — a + * list literal, and a `current_user` variable that resolves to an array. * * It used to lower to `{ f: { $ne: [...] } }` and to the bare-array * `{ f: [...] }` (with `$not` around it for `!(… == [...])`). Those shapes @@ -11,10 +12,11 @@ * row on driver-mongodb). Refused here, every consumer of this compiler fails * closed on its own existing `unsupported` path: the RLS compiler drops the * policy, the sharing seeder skips the rule, and the authoring gate - * (`isPushdownableCel` / `isSupportedRlsExpression`) reports it. + * (`isPushdownableCel` / `isSupportedRlsExpression`) reports a literal. * - * Scope, pinned by what is NOT here as much as by what is: a `current_user` - * variable that resolves to an array is not refused by this change. + * A variable's value exists only per request, so the shape check cannot see a + * resolved array: that refusal is pinned at request time, with the shape check + * still passing the source. */ import { describe, expect, it } from 'vitest'; @@ -22,7 +24,16 @@ import { describe, expect, it } from 'vitest'; import { compileCelToFilter, isPushdownableCel } from './cel-to-filter'; import { isSupportedRlsExpression } from './rls-predicate'; -const VARS = { current_user: { id: 'u_me', org_user_ids: ['u_me', 'u_peer'] } }; +const VARS = { + current_user: { + id: 'u_me', + email: 'me@example.test', + org_user_ids: ['u_me', 'u_peer'], + // A §7.3.1 membership set staged by an app resolver, and an emptied one. + blocked_accounts: ['acc_secret'], + empty_set: [] as string[], + }, +}; const REFUSED = [ "record.status != ['closed', 'archived']", @@ -51,6 +62,36 @@ describe('[#19886] == / != against a list literal is refused at the lowering', ( } }); +const REFUSED_RESOLVED = [ + 'record.reviewer_id != current_user.org_user_ids', + 'record.reviewer_id == current_user.org_user_ids', + '!(record.reviewer_id == current_user.org_user_ids)', + '!(record.reviewer_id != current_user.org_user_ids)', + 'current_user.org_user_ids != record.reviewer_id', + 'current_user.org_user_ids == record.reviewer_id', + 'record.account != current_user.blocked_accounts', + 'record.account != current_user.empty_set', + "record.status == 'open' && record.reviewer_id != current_user.org_user_ids", + "record.status == 'open' || !(record.reviewer_id == current_user.org_user_ids)", +]; + +describe('[#19886] == / != against a variable that RESOLVES to a list is refused at request time', () => { + for (const source of REFUSED_RESOLVED) { + it(`${source} — unsupported, naming the variable and withholding its value`, () => { + const compiled = compileCelToFilter(source, { variables: VARS }); + expect(compiled.ok).toBe(false); + expect(compiled.ok ? undefined : compiled.reason).toBe('unsupported'); + const detail = compiled.ok ? '' : compiled.detail; + expect(detail).toMatch(/current_user\.(org_user_ids|blocked_accounts|empty_set)/); + for (const member of ['u_me', 'u_peer', 'acc_secret']) expect(detail).not.toContain(member); + // The value is per request, so the shape check passes the source: the + // refusal lands on the RLS compiler's per-request denial path instead. + expect(isPushdownableCel(source).ok).toBe(true); + expect(isSupportedRlsExpression(source)).toBe(true); + }); + } +}); + describe('[#19886] every neighbouring comparison lowers exactly as before', () => { const ok = (source: string) => { const r = compileCelToFilter(source, { variables: VARS }); @@ -63,8 +104,11 @@ describe('[#19886] every neighbouring comparison lowers exactly as before', () = expect(ok("!(record.status in ['closed', 'archived'])")).toEqual({ $not: { status: { $in: ['closed', 'archived'] } } }); }); - it('`in` against a resolved membership array', () => { + it('`in` / `not in` against a resolved membership array', () => { expect(ok('record.owner_id in current_user.org_user_ids')).toEqual({ owner_id: { $in: ['u_me', 'u_peer'] } }); + expect(ok('!(record.owner_id in current_user.org_user_ids)')).toEqual({ + $not: { owner_id: { $in: ['u_me', 'u_peer'] } }, + }); }); it('scalar == / != against a literal and against a resolved scalar', () => { @@ -72,6 +116,7 @@ describe('[#19886] every neighbouring comparison lowers exactly as before', () = expect(ok("record.status == 'open'")).toEqual({ status: 'open' }); expect(ok('record.owner_id == current_user.id')).toEqual({ owner_id: 'u_me' }); expect(ok('record.owner_id != current_user.id')).toEqual({ owner_id: { $ne: 'u_me' } }); + expect(ok('current_user.email == record.owner')).toEqual({ owner: 'me@example.test' }); }); it('== null / != null', () => { diff --git a/packages/lint/src/validate-rls-predicate-enforceability.test.ts b/packages/lint/src/validate-rls-predicate-enforceability.test.ts index 322837d6891..1f5cc14bb8d 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.test.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.test.ts @@ -736,6 +736,54 @@ describe('validateRlsPredicateEnforceability — the `current_user` set is DERIV }); }); +describe('validateRlsPredicateEnforceability — each kernel key is probed with its RUNTIME type (#19886)', () => { + /** + * The CEL lowering refuses `==` / `!=` against a list (#19886), so a probe of + * the wrong type silences the reference half: an array bound to `id` made + * every `field == current_user.id` policy compile to nothing here, and its + * field and variable checks never ran. Each key is therefore probed with the + * type `RLSCompiler.compileFilter` hands the compiler at runtime. + * + * Read off the resolver, not off any list: `compileFilter` builds its + * `current_user` context in `plugin-security/src/rls-compiler.ts` from + * `ExecutionContext` (`id` ← `userId`, `organization_id` ← `tenantId`, the + * rest by name), and `ExecutionContextSchema` declares `userId`, `tenantId` + * and `email` as strings and `positions`, `org_user_ids` and + * `accessible_org_ids` as string arrays; `resolveAuthzContext` produces the + * same types. + */ + const RUNTIME_TYPE: Record = { + id: 'scalar', + organization_id: 'scalar', + email: 'scalar', + positions: 'array', + org_user_ids: 'array', + accessible_org_ids: 'array', + }; + + it('covers exactly the kernel-resolved keys the contract names', () => { + expect(Object.keys(RUNTIME_TYPE).sort()).toEqual([...RESERVED_RLS_MEMBERSHIP_KEYS].sort()); + }); + + it.each(Object.entries(RUNTIME_TYPE).filter(([, type]) => type === 'scalar'))( + 'a SCALAR key (%s) lowers under `==`, so the field check still runs', + (key) => { + expect(ids(siteWith('using', `owner_id_nope == current_user.${key}`))).toEqual([RLS_PREDICATE_UNKNOWN_FIELD]); + expect(ids(siteWith('check', `owner_id_nope != current_user.${key}`))).toEqual([RLS_PREDICATE_UNKNOWN_FIELD]); + }, + ); + + it.each(Object.entries(RUNTIME_TYPE).filter(([, type]) => type === 'array'))( + 'a MEMBERSHIP key (%s) lowers under `in`, so the field check still runs', + (key) => { + expect(ids(siteWith('using', `owner_id_nope in current_user.${key}`))).toEqual([RLS_PREDICATE_UNKNOWN_FIELD]); + expect(ids(siteWith('check', `!(owner_id_nope in current_user.${key})`))).toEqual([ + RLS_PREDICATE_UNKNOWN_FIELD, + ]); + }, + ); +}); + describe('validateRlsPredicateEnforceability — §7.3.1 membership keys stay UNKNOWABLE', () => { /** * The false-positive this rule exists on the edge of. An app stages arbitrary diff --git a/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts index 2ae7f2fdc4e..9355701004d 100644 --- a/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts +++ b/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts @@ -1,22 +1,24 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#19886] A row-level policy that compares a field against a LIST LITERAL with - * `!=` or `==` fails closed on both clauses, through the real plugin and engine. + * [#19886] A row-level policy that compares a field with `!=` or `==` against a + * LIST — a list literal, or a `current_user` membership set the kernel resolves + * to an array — fails closed on both clauses, through the real plugin and engine. * * `compileCelToFilter` refuses the comparison (`unsupported`), so - * `RLSCompiler.compileFilter` drops the policy on its existing - * "uncompilable predicate" branch and, with nothing else applicable, answers - * `RLS_DENY_FILTER`: + * `RLSCompiler.compileFilter` drops the policy — a literal on its + * "uncompilable predicate" branch, a resolved array on its per-request denial + * branch — and, with nothing else applicable, answers `RLS_DENY_FILTER`: * * - a `using` read returns ZERO rows — never the rows the policy was written - * to exclude, which is what the lowered `$ne: [...]` returned on - * driver-mongodb before the refusal; + * to exclude, which is what the lowered `$ne: [...]` (and the `$nor` of a + * negated equality) returned on driver-mongodb before the refusal; * - a `check` write is refused with the row-level CHECK envelope - * (`PERMISSION_DENIED` / 403) and nothing is stored. + * (`PERMISSION_DENIED` / 403) and nothing is stored — before the refusal + * the resolved-array spellings ADMITTED the forbidden row here. * - * The control is the spelling the refusal points at, `!(record.status in [...])`, - * which keeps its exact meaning. The ground truth is read past every scope. + * The controls are the spellings the refusal points at, `!(record.f in …)`, + * which keep their exact meaning. The ground truth is read past every scope. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -57,6 +59,7 @@ async function boot(clause: 'using' | 'check', predicate: string) { fields: { id: { name: 'id', type: 'text', primaryKey: true }, status: { name: 'status', type: 'text' }, + reviewer_id: { name: 'reviewer_id', type: 'text' }, }, }], } as never); @@ -89,7 +92,13 @@ async function boot(clause: 'using' | 'check', predicate: string) { await plugin.start(ctx as never); vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); - const caller = { userId: 'usr_member', positions: ['qa_pos'], permissions: [set.name], posture: 'MEMBER' }; + const caller = { + userId: 'usr_member', + positions: ['qa_pos'], + permissions: [set.name], + posture: 'MEMBER', + org_user_ids: ['usr_member', 'usr_peer'], + }; const stored = async () => ((await engine.find(OBJ, { context: SYS_CTX } as never)) as Array<{ id: string }>).map((r) => r.id).sort(); return { engine, caller, stored }; @@ -98,13 +107,22 @@ async function boot(clause: 'using' | 'check', predicate: string) { const EXCLUDING = [ ['`!=` against a list literal', "record.status != ['closed', 'archived']"], ['a negated `==` against a list literal', "!(record.status == ['closed', 'archived'])"], + ['`!=` against a resolved membership set', 'record.reviewer_id != current_user.org_user_ids'], + ['a negated `==` against a resolved membership set', '!(record.reviewer_id == current_user.org_user_ids)'], ] as const; -describe('[#19886] a `using` clause comparing against a list literal reads ZERO rows', () => { +/** `r_closed` is excluded by every spelling above: closed, and reviewed inside the caller's org. */ +const ROWS = [ + { id: 'r_open', status: 'open', reviewer_id: 'usr_external' }, + { id: 'r_closed', status: 'closed', reviewer_id: 'usr_member' }, +]; +const FORBIDDEN = { id: 'ins_bad', status: 'closed', reviewer_id: 'usr_member' }; + +describe('[#19886] a `using` clause comparing against a list reads ZERO rows', () => { for (const [spelling, predicate] of EXCLUDING) { it(spelling, async () => { const { engine, caller, stored } = await boot('using', predicate); - await engine.insert(OBJ, [{ id: 'r_open', status: 'open' }, { id: 'r_closed', status: 'closed' }], { context: SYS_CTX } as never); + await engine.insert(OBJ, ROWS, { context: SYS_CTX } as never); const rows = (await engine.find(OBJ, { context: caller } as never)) as Array<{ id: string }>; @@ -113,22 +131,27 @@ describe('[#19886] a `using` clause comparing against a list literal reads ZERO }); } - it('CONTROL — `!(record.status in [...])` reads exactly the rows it admits', async () => { - const { engine, caller } = await boot('using', "!(record.status in ['closed', 'archived'])"); - await engine.insert(OBJ, [{ id: 'r_open', status: 'open' }, { id: 'r_closed', status: 'closed' }], { context: SYS_CTX } as never); + for (const predicate of [ + "!(record.status in ['closed', 'archived'])", + '!(record.reviewer_id in current_user.org_user_ids)', + ]) { + it(`CONTROL — \`${predicate}\` reads exactly the rows it admits`, async () => { + const { engine, caller } = await boot('using', predicate); + await engine.insert(OBJ, ROWS, { context: SYS_CTX } as never); - const rows = (await engine.find(OBJ, { context: caller } as never)) as Array<{ id: string }>; + const rows = (await engine.find(OBJ, { context: caller } as never)) as Array<{ id: string }>; - expect(rows.map((r) => r.id)).toEqual(['r_open']); - }); + expect(rows.map((r) => r.id)).toEqual(['r_open']); + }); + } }); -describe('[#19886] a `check` clause comparing against a list literal refuses the write', () => { +describe('[#19886] a `check` clause comparing against a list refuses the write', () => { for (const [spelling, predicate] of EXCLUDING) { it(`${spelling}: PERMISSION_DENIED / 403, and nothing is stored`, async () => { const { engine, caller, stored } = await boot('check', predicate); - const err = await engine.insert(OBJ, { id: 'ins_bad', status: 'closed' }, { context: caller } as never) + const err = await engine.insert(OBJ, FORBIDDEN, { context: caller } as never) .then(() => null, (e: { code?: string; status?: number; statusCode?: number }) => e); expect(err?.code).toBe('PERMISSION_DENIED'); From 9a38132f4f1485c414fb98a43ee4319e00b91124 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 03:51:18 +0000 Subject: [PATCH 06/11] chore(changeset, spec): declare the list-comparand refusal a narrowing and register its prescription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-② no (narrowing) with the BREAKING banner; formula, driver-mongodb, plugin-security, plugin-sharing and lint graded minor under the launch-window convention for accept-set narrowings, spec patch for the ledger entry. The hand-migration prescription is registered under protocol major 18 as cel-predicate-list-comparand-refused (registry.ts regenerated by gen:migration-registry). Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...-comparand-and-mongodb-ne-array-refused.md | 24 ++++++--- ...18.cel-predicate-list-comparand-refused.ts | 52 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 48 +++++++++++++++++ 3 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts diff --git a/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md b/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md index 32c94fe5ae3..335fcafa7e6 100644 --- a/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md +++ b/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md @@ -1,15 +1,27 @@ --- -"@objectstack/formula": patch -"@objectstack/driver-mongodb": patch +"@objectstack/formula": minor +"@objectstack/driver-mongodb": minor +"@objectstack/plugin-security": minor +"@objectstack/plugin-sharing": minor +"@objectstack/lint": minor +"@objectstack/spec": patch --- -A row-level or sharing-rule predicate comparing a field against a list with `!=` / `==` no longer lowers to a filter that widens on driver-mongodb, and driver-mongodb refuses `$ne` with an array comparand (#19886). +A row-level or sharing-rule predicate comparing a field against a list with `!=` / `==` is refused at the CEL lowering instead of lowering to a filter that widens on driver-mongodb, and driver-mongodb refuses `$ne` with an array comparand (#19886). -Clause-②: no +**BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/driver-mongodb`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing` and `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-list-comparand-refused`. -**Security fix for RLS reads on MongoDB.** A policy written `record.status != ['closed', 'archived']` (or `!(record.status == [...])`) 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. +Clause-②: no (narrowing) -- `@objectstack/formula`: `compileCelToFilter` now refuses `==` / `!=` whose comparand is a list literal (`unsupported`). The RLS compiler drops such a policy and fails closed (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403). A declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports it. +**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. + +- `@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. +- `@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). +- `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. +- `@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. - `@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. +- `@objectstack/spec`: the migration registry carries the entry. **What to change.** "One of these values" is `record.status in ['open', 'pending']`; "none of these values" is `!(record.status in ['closed', 'archived'])`. In a raw filter, use `$in` / `$nin`. `in`, scalar `==` / `!=`, `null` and field-to-field comparisons are unchanged. + + diff --git a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts new file mode 100644 index 00000000000..a58c2abbbb8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The CEL-lowering face of the list-comparand refusal: the pushdown compiler +// every row-level policy and declared sharing rule compiles through, plus the +// driver-mongodb face that answered the lowered shape. Recorded as its own entry +// because the surface an author rewrites is a CEL predicate string, and on +// MongoDB a stored query filter. +export const entry: SemanticMigration = { + id: 'cel-predicate-list-comparand-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a ' + + 'CEL predicate comparing a field with != or == against a list, either a list literal or a ' + + 'current_user membership set the runtime resolves to an array (org_user_ids, positions, ' + + 'accessible_org_ids, or a key staged into rlsMembership), and the negation of such a ' + + 'comparison. On driver-mongodb, also a query filter carrying $ne with an array comparand, at ' + + 'any depth under $and / $or / $not', + replacement: + 'the list operator the comparison was standing in for. "One of these values" is in: ' + + 'record.status in ["open", "pending"], or record.reviewer_id in current_user.org_user_ids. ' + + '"None of these values" is the negated in: !(record.status in ["closed", "archived"]). In a ' + + 'query filter, $in and $nin. Scalar != and ==, null, in, and field-to-field comparisons lower ' + + 'exactly as before', + reason: + 'The @objectstack/formula pushdown compiler lowered such a comparison to a $ne carrying the ' + + 'array, to a bare-array equality, or to a $not around one. A row-level using clause is ' + + 'composed into the query after the engine\'s comparand-shape check, and driver-mongodb passed ' + + 'the shape to the server: measured through mingo, the named proxy for MongoDB query ' + + 'semantics, $ne against an array and the $nor that a negated equality becomes selected every ' + + 'row storing a scalar, so the read returned the rows the policy was written to hide. A check ' + + 'written != against a membership set admitted and stored every write, on driver-sql as on ' + + 'driver-mongodb. The compiler now refuses the comparison with reason unsupported, so the RLS ' + + 'compiler drops the policy and fails closed: reads under it return no rows and check writes ' + + 'are refused 403. A declared sharing rule with such a condition is skipped at bootstrap and ' + + 'never seeded. The authoring lint reports a list literal as rls-predicate-unenforceable; a ' + + 'membership set holds its value only per request, so that form is refused at request time. ' + + 'driver-mongodb refuses $ne with an array comparand with INVALID_FILTER / 400, as driver-sql ' + + 'and driver-memory already do. Metadata AT REST is not rewritten and this entry adds no D2 ' + + 'conversion: the platform cannot tell which list operator a list comparison was standing in ' + + 'for, and a policy rewritten on the author\'s behalf would change which rows it admits, which ' + + 'is the policy author\'s decision. ADR-0058 D4 / ADR-0087.', + acceptanceCriteria: + 'Grep the rowLevelSecurity using and check predicates of your permission sets, and the ' + + 'condition of your sharing rules, for != or == whose other side is a list literal or a ' + + 'current_user membership set, and for the negation of such an ==, then rewrite each with in ' + + 'or its negation. A policy that still carries the shape reads no rows and refuses every write ' + + 'it governs, so one read under each policy finds every such predicate left. On driver-mongodb, ' + + 'grep stored query filters for $ne with an array value and rewrite each with $nin.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2bcf3efcca6..bfdba2b7cb1 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6141,6 +6141,54 @@ const step18: MigrationStep = { + 'reference under sharingModel: controlled_by_parent`. Stored metadata keeps loading ' + 'byte-identically (`safeParse` green, `required` unrewritten).', }, + // The CEL-lowering face of the list-comparand refusal: the pushdown compiler + // every row-level policy and declared sharing rule compiles through, plus the + // driver-mongodb face that answered the lowered shape. Recorded as its own entry + // because the surface an author rewrites is a CEL predicate string, and on + // MongoDB a stored query filter. + { + id: 'cel-predicate-list-comparand-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a ' + + 'CEL predicate comparing a field with != or == against a list, either a list literal or a ' + + 'current_user membership set the runtime resolves to an array (org_user_ids, positions, ' + + 'accessible_org_ids, or a key staged into rlsMembership), and the negation of such a ' + + 'comparison. On driver-mongodb, also a query filter carrying $ne with an array comparand, at ' + + 'any depth under $and / $or / $not', + replacement: + 'the list operator the comparison was standing in for. "One of these values" is in: ' + + 'record.status in ["open", "pending"], or record.reviewer_id in current_user.org_user_ids. ' + + '"None of these values" is the negated in: !(record.status in ["closed", "archived"]). In a ' + + 'query filter, $in and $nin. Scalar != and ==, null, in, and field-to-field comparisons lower ' + + 'exactly as before', + reason: + 'The @objectstack/formula pushdown compiler lowered such a comparison to a $ne carrying the ' + + 'array, to a bare-array equality, or to a $not around one. A row-level using clause is ' + + 'composed into the query after the engine\'s comparand-shape check, and driver-mongodb passed ' + + 'the shape to the server: measured through mingo, the named proxy for MongoDB query ' + + 'semantics, $ne against an array and the $nor that a negated equality becomes selected every ' + + 'row storing a scalar, so the read returned the rows the policy was written to hide. A check ' + + 'written != against a membership set admitted and stored every write, on driver-sql as on ' + + 'driver-mongodb. The compiler now refuses the comparison with reason unsupported, so the RLS ' + + 'compiler drops the policy and fails closed: reads under it return no rows and check writes ' + + 'are refused 403. A declared sharing rule with such a condition is skipped at bootstrap and ' + + 'never seeded. The authoring lint reports a list literal as rls-predicate-unenforceable; a ' + + 'membership set holds its value only per request, so that form is refused at request time. ' + + 'driver-mongodb refuses $ne with an array comparand with INVALID_FILTER / 400, as driver-sql ' + + 'and driver-memory already do. Metadata AT REST is not rewritten and this entry adds no D2 ' + + 'conversion: the platform cannot tell which list operator a list comparison was standing in ' + + 'for, and a policy rewritten on the author\'s behalf would change which rows it admits, which ' + + 'is the policy author\'s decision. ADR-0058 D4 / ADR-0087.', + acceptanceCriteria: + 'Grep the rowLevelSecurity using and check predicates of your permission sets, and the ' + + 'condition of your sharing rules, for != or == whose other side is a list literal or a ' + + 'current_user membership set, and for the negation of such an ==, then rewrite each with in ' + + 'or its negation. A policy that still carries the shape reads no rows and refuses every write ' + + 'it governs, so one read under each policy finds every such predicate left. On driver-mongodb, ' + + 'grep stored query filters for $ne with an array value and rewrite each with $nin.', + }, { id: 'change-management-duration-keys-retired', surface: From 0af6ee59736d9ff4ba5649c0af4bf923646cb3be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 04:44:26 +0000 Subject: [PATCH 07/11] 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 --- ...st-comparand-and-mongodb-ne-array-refused.md | 2 +- ...-list-literal-comparand-fails-closed.test.ts | 3 +-- .../18.rls-predicate-array-comparand-refused.ts | 17 +++++++---------- packages/spec/src/migrations/registry.ts | 17 +++++++---------- 4 files changed, 16 insertions(+), 23 deletions(-) diff --git a/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md b/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md index 335fcafa7e6..0b21b31f2af 100644 --- a/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md +++ b/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md @@ -16,7 +16,7 @@ Clause-②: no (narrowing) **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. - `@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. -- `@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). +- `@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. - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. - `@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. - `@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. diff --git a/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts index 9355701004d..82bd2c6716e 100644 --- a/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts +++ b/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts @@ -14,8 +14,7 @@ * to exclude, which is what the lowered `$ne: [...]` (and the `$nor` of a * negated equality) returned on driver-mongodb before the refusal; * - a `check` write is refused with the row-level CHECK envelope - * (`PERMISSION_DENIED` / 403) and nothing is stored — before the refusal - * the resolved-array spellings ADMITTED the forbidden row here. + * (`PERMISSION_DENIED` / 403) and nothing is stored. * * The controls are the spellings the refusal points at, `!(record.f in …)`, * which keep their exact meaning. The ground truth is read past every scope. diff --git a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts index eca68292edd..7c42e831769 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts @@ -11,10 +11,10 @@ export const entry: SemanticMigration = { // No backticks in `surface` — build-upgrade-guide renders it inside a code // span already, and a nested backtick would close it. surface: - 'security.PermissionSet rowLevelSecurity[].check (and .using, where the explain engine ' - + 'attributes a record) — a CEL predicate comparing a field with != or == against a list, ' + 'security.PermissionSet rowLevelSecurity[].check — a CEL predicate comparing a field with != ' + + 'or == against a list, ' + 'a list literal or a current_user membership array, and the negation of such an ==. They ' - + 'lower to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which ' + + 'lowered to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which ' + 'the @objectstack/formula evaluator matchesFilterCondition now refuses, together with ' + '{ field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included', replacement: @@ -31,12 +31,9 @@ export const entry: SemanticMigration = { + 'membership array, matched EVERY post-image, and a check written ' + '!(record.status == ["closed", "archived"]) did the same: every write such a policy was ' + 'written to refuse was admitted and stored. The positive record.status == ["open", ' - + '"pending"] refused every write (403). All of these shapes now fail the write with ' - + 'INVALID_FILTER / 400 before any record is judged, the envelope driver-sql and ' - + 'driver-memory already give the same shape on the read side, and the explain engine\'s ' - + 'record attribution refuses too. The message withholds the field, the operator and the ' - + 'value, because the filter is usually an access policy the caller did not write and the ' - + 'comparand may be a resolved membership set. Metadata AT REST is not rewritten and this ' + + '"pending"] refused every write (403). The evaluator now refuses all of these shapes ' + + 'before any record is judged. The message withholds the field, the operator and the ' + + 'value. Metadata AT REST is not rewritten and this ' + 'entry adds no D2 conversion: the platform cannot tell which list operator a list ' + 'comparison was standing in for, and a policy rewritten on the author\'s behalf would ' + 'change which writes it admits (the negated forms would start refusing writes they ' @@ -46,7 +43,7 @@ export const entry: SemanticMigration = { 'Grep the rowLevelSecurity check and using predicates of your permission sets for != or == ' + 'whose right-hand side is a list literal or a current_user membership array, and for the ' + 'negation of such an ==, then rewrite each with in or !(... in ...). A check that still ' - + 'carries the shape refuses every write it governs with INVALID_FILTER / 400, allowed ' + + 'carries the shape refuses every write it governs, allowed ' + 'values included, so one allowed write under each policy finds every such check left. ' + 'Then re-check what each policy is supposed to refuse rather than assuming the writes it ' + 'admitted before were right: before this change a != or a negated == against a list ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 8aa60b9bd4f..9d0379c445e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -11509,10 +11509,10 @@ const step18: MigrationStep = { // No backticks in `surface` — build-upgrade-guide renders it inside a code // span already, and a nested backtick would close it. surface: - 'security.PermissionSet rowLevelSecurity[].check (and .using, where the explain engine ' - + 'attributes a record) — a CEL predicate comparing a field with != or == against a list, ' + 'security.PermissionSet rowLevelSecurity[].check — a CEL predicate comparing a field with != ' + + 'or == against a list, ' + 'a list literal or a current_user membership array, and the negation of such an ==. They ' - + 'lower to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which ' + + 'lowered to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which ' + 'the @objectstack/formula evaluator matchesFilterCondition now refuses, together with ' + '{ field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included', replacement: @@ -11529,12 +11529,9 @@ const step18: MigrationStep = { + 'membership array, matched EVERY post-image, and a check written ' + '!(record.status == ["closed", "archived"]) did the same: every write such a policy was ' + 'written to refuse was admitted and stored. The positive record.status == ["open", ' - + '"pending"] refused every write (403). All of these shapes now fail the write with ' - + 'INVALID_FILTER / 400 before any record is judged, the envelope driver-sql and ' - + 'driver-memory already give the same shape on the read side, and the explain engine\'s ' - + 'record attribution refuses too. The message withholds the field, the operator and the ' - + 'value, because the filter is usually an access policy the caller did not write and the ' - + 'comparand may be a resolved membership set. Metadata AT REST is not rewritten and this ' + + '"pending"] refused every write (403). The evaluator now refuses all of these shapes ' + + 'before any record is judged. The message withholds the field, the operator and the ' + + 'value. Metadata AT REST is not rewritten and this ' + 'entry adds no D2 conversion: the platform cannot tell which list operator a list ' + 'comparison was standing in for, and a policy rewritten on the author\'s behalf would ' + 'change which writes it admits (the negated forms would start refusing writes they ' @@ -11544,7 +11541,7 @@ const step18: MigrationStep = { 'Grep the rowLevelSecurity check and using predicates of your permission sets for != or == ' + 'whose right-hand side is a list literal or a current_user membership array, and for the ' + 'negation of such an ==, then rewrite each with in or !(... in ...). A check that still ' - + 'carries the shape refuses every write it governs with INVALID_FILTER / 400, allowed ' + + 'carries the shape refuses every write it governs, allowed ' + 'values included, so one allowed write under each policy finds every such check left. ' + 'Then re-check what each policy is supposed to refuse rather than assuming the writes it ' + 'admitted before were right: before this change a != or a negated == against a list ' From 3bf405b501230416af0c83318a2bda3ca00ea2f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 05:14:36 +0000 Subject: [PATCH 08/11] 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 --- packages/formula/src/matches-filter.ts | 25 ++++++++----------- .../rls-check-array-comparand-refusal.test.ts | 18 ++++--------- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/packages/formula/src/matches-filter.ts b/packages/formula/src/matches-filter.ts index d6efb91bac0..c7416c8f9b6 100644 --- a/packages/formula/src/matches-filter.ts +++ b/packages/formula/src/matches-filter.ts @@ -69,13 +69,13 @@ * (`{ field: [...] }`, `{ field: { $eq: [...] } }`). It is the one position * where this face's answer was not merely silent but the WRONG way round for a * write gate: a strict comparison never equals an array, so `$ne` matched EVERY - * record, and a negated equality (`$not` around `{ field: [...] }`, which is - * what `!(record.f == [...])` lowers to) did too. On the ADR-0058 D4 `check` - * that admitted every write the policy was written to refuse, measured through - * the real `plugin-security` on three drivers. The query faces the read side - * runs on — driver-sql's unbindable-comparand refusal and driver-memory's - * array-comparand refusal — already refuse the shape with `INVALID_FILTER` / - * 400; this face now gives the same envelope. See {@link arrayComparandError}. + * record, and a negated equality (`$not` around `{ field: [...] }`) did too. On + * the ADR-0058 D4 `check` that admitted every write the policy was written to + * refuse, measured through the real `plugin-security` on three drivers. The + * query faces the read side runs on — driver-sql's unbindable-comparand + * refusal and driver-memory's array-comparand refusal — already refuse the + * shape with `INVALID_FILTER` / 400; this face now gives the same envelope. See + * {@link arrayComparandError}. */ import type { FilterCondition } from '@objectstack/spec/data'; @@ -93,7 +93,7 @@ import { StandardErrorCode } from '@objectstack/spec/api'; /** * [#5240] `{ field: {} }` — a field constrained by ZERO operators — is REFUSED, - * not evaluated, and this is the ONE place this fail-closed evaluator throws. + * not evaluated. * * The shape had three answers in the repo: `driver-sql` refused it at the top * level but dropped it inside `$and`/`$or`/`$not` (a predicate that emits @@ -149,12 +149,9 @@ function emptyFieldConstraintError(field: string, path: string): Error { * | `{ f: [...] }` / `{ f: { $eq: [...] } }`| `false` | * | `{ $not: { f: [...] } }` | `true` | * - * An RLS `check` is authored as CEL, and `record.f != ['a', 'b']` (or `!=` - * against a `current_user` membership array) lowers to the first row, so every - * write the policy was written to refuse was admitted and stored. The positive - * equality row only failed closed by accident, and inverted the moment it was - * negated. There is no answer here that is right in every polarity — which is - * the #5240 argument for refusing rather than choosing. + * The positive equality row only failed closed by accident, and inverted the + * moment it was negated. There is no answer here that is right in every + * polarity — which is the #5240 argument for refusing rather than choosing. * * # What stays exactly as it was * diff --git a/packages/plugins/plugin-security/src/rls-check-array-comparand-refusal.test.ts b/packages/plugins/plugin-security/src/rls-check-array-comparand-refusal.test.ts index be8b5685754..0f39074ee27 100644 --- a/packages/plugins/plugin-security/src/rls-check-array-comparand-refusal.test.ts +++ b/packages/plugins/plugin-security/src/rls-check-array-comparand-refusal.test.ts @@ -5,19 +5,11 @@ * (or negates `==` against one) no longer admits the write it was written to * refuse. * - * Both CEL spellings lower to a shape the write gate's evaluator - * (`matchesFilterCondition`, `@objectstack/formula`) could not answer safely: - * - * `record.status != ['closed', 'archived']` → `{ status: { $ne: [...] } }` - * `!(record.status == ['closed', 'archived'])` → `{ $not: { status: [...] } }` - * * It compared strictly, so both matched EVERY post-image and the forbidden * insert was admitted and stored — measured through this plugin on driver-sql, * driver-sqlite-wasm and driver-memory. The evaluator now refuses both shapes - * before any row is judged, with `INVALID_FILTER` / 400, and the refusal - * propagates out of the engine's post-hook check seam: the insert fails and - * nothing lands. The correct spelling (`!(record.status in [...])`) is what the - * refusal's remedy points at. + * before any row is judged. The correct spelling (`!(record.status in [...])`) + * is what the refusal's remedy points at. * * One pin per spelling, through the real plugin and the real engine, asserting * 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 ['`!=` against a list', "record.status != ['closed', 'archived']"], ['a negated `==` against a list', "!(record.status == ['closed', 'archived'])"], ] as const) { - it(`${spelling}: INVALID_FILTER / 400, and nothing is stored`, async () => { + it(`${spelling}: nothing is stored`, async () => { const { engine, caller, stored } = await bootWithCheck(check); const err = await refusalOf(() => engine.insert(OBJ, { id: 'ins_bad', status: 'closed' }, { context: caller } as never)); - expect(err.code).toBe('INVALID_FILTER'); - expect(err.statusCode ?? err.status).toBe(400); + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.statusCode ?? err.status).toBe(403); expect(await stored()).toEqual([]); }); } From 5bd2df581bbf496f1d9c3e4be01cbf67e3f84fc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 06:10:44 +0000 Subject: [PATCH 09/11] fix(changeset, spec): cut the envelope spans the lowering refusal makes false In the pending 2a changeset, three spans are false once a CEL check with a list comparand is dropped at compile: the write envelope (INVALID_FILTER / 400; the check now gets 403), the explain sentence (explain reports a denial, it does not refuse), and the positive form's move to 400 (it is still 403). Each is cut; no replacement prose. A deliberate correction of an unreleased changeset under the #17712 gate. In cel-predicate-list-comparand-refused, the acceptanceCriteria universal (a policy carrying the shape reads no rows and refuses every write, so one read finds it) is false beside an OR-sibling policy and for a bulk update; it is cut. registry.ts regenerated by gen:migration-registry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/19886-formula-array-comparand-refused.md | 2 +- .../semantic/18.cel-predicate-list-comparand-refused.ts | 3 +-- packages/spec/src/migrations/registry.ts | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.changeset/19886-formula-array-comparand-refused.md b/.changeset/19886-formula-array-comparand-refused.md index c784dc472a8..f7409ee4445 100644 --- a/.changeset/19886-formula-array-comparand-refused.md +++ b/.changeset/19886-formula-array-comparand-refused.md @@ -15,7 +15,7 @@ Clause-②: no (narrowing) - a `check` written `record.status != ['closed', 'archived']`, or `!=` against a `current_user` membership array, lowered to `{ status: { $ne: [...] } }` and matched **every** post-image; - a `check` written `!(record.status == ['closed', 'archived'])` lowered to `{ $not: { status: [...] } }` and did the same. -Every write such a policy was written to refuse was admitted and stored. Both shapes now fail the write with `INVALID_FILTER` / 400, the envelope driver-sql and driver-memory already give the same shape on the read side. The explain engine's record attribution reads the same evaluator, so it refuses too, instead of reporting a row as admitted that the enforcing read refuses. The positive `record.status == ['open', 'pending']` already refused every write (403); it now refuses with the 400 instead. +Every write such a policy was written to refuse was admitted and stored. Both shapes now fail the write. The positive `record.status == ['open', 'pending']` already refused every write (403). The message withholds the field, the operator and the value, because the filter is usually an access policy the caller did not write, and the comparand may be a resolved membership set. diff --git a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts index a58c2abbbb8..73e32bcff4e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts @@ -46,7 +46,6 @@ export const entry: SemanticMigration = { 'Grep the rowLevelSecurity using and check predicates of your permission sets, and the ' + 'condition of your sharing rules, for != or == whose other side is a list literal or a ' + 'current_user membership set, and for the negation of such an ==, then rewrite each with in ' - + 'or its negation. A policy that still carries the shape reads no rows and refuses every write ' - + 'it governs, so one read under each policy finds every such predicate left. On driver-mongodb, ' + + 'or its negation. On driver-mongodb, ' + 'grep stored query filters for $ne with an array value and rewrite each with $nin.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2a97164333f..048eb3b4fae 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6249,8 +6249,7 @@ const step18: MigrationStep = { 'Grep the rowLevelSecurity using and check predicates of your permission sets, and the ' + 'condition of your sharing rules, for != or == whose other side is a list literal or a ' + 'current_user membership set, and for the negation of such an ==, then rewrite each with in ' - + 'or its negation. A policy that still carries the shape reads no rows and refuses every write ' - + 'it governs, so one read under each policy finds every such predicate left. On driver-mongodb, ' + + 'or its negation. On driver-mongodb, ' + 'grep stored query filters for $ne with an array value and rewrite each with $nin.', }, { From a54aa9e29fe2a86925e88e1608ae5e4955e5430e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 06:22:06 +0000 Subject: [PATCH 10/11] fix(changeset, spec): cut the write universals an OR-sibling policy falsifies Measured at the merged head on driver-sql: beside a sibling policy whose check admits, a policy carrying a list comparand is dropped and the sibling alone decides, so the forbidden insert is admitted. Two sentences claimed otherwise and are cut, with no replacement prose: the rest of the 2a changeset's span-1 sentence ("Both shapes now fail the write"), and the 2a entry's acceptanceCriteria universal, the same sentence class this PR's own entry lost in the previous commit. registry.ts regenerated. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/19886-formula-array-comparand-refused.md | 2 +- .../semantic/18.rls-predicate-array-comparand-refused.ts | 4 +--- packages/spec/src/migrations/registry.ts | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.changeset/19886-formula-array-comparand-refused.md b/.changeset/19886-formula-array-comparand-refused.md index f7409ee4445..b59b508b3e4 100644 --- a/.changeset/19886-formula-array-comparand-refused.md +++ b/.changeset/19886-formula-array-comparand-refused.md @@ -15,7 +15,7 @@ Clause-②: no (narrowing) - a `check` written `record.status != ['closed', 'archived']`, or `!=` against a `current_user` membership array, lowered to `{ status: { $ne: [...] } }` and matched **every** post-image; - a `check` written `!(record.status == ['closed', 'archived'])` lowered to `{ $not: { status: [...] } }` and did the same. -Every write such a policy was written to refuse was admitted and stored. Both shapes now fail the write. The positive `record.status == ['open', 'pending']` already refused every write (403). +Every write such a policy was written to refuse was admitted and stored. The positive `record.status == ['open', 'pending']` already refused every write (403). The message withholds the field, the operator and the value, because the filter is usually an access policy the caller did not write, and the comparand may be a resolved membership set. diff --git a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts index 7c42e831769..8023aa9edc5 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts @@ -42,9 +42,7 @@ export const entry: SemanticMigration = { acceptanceCriteria: 'Grep the rowLevelSecurity check and using predicates of your permission sets for != or == ' + 'whose right-hand side is a list literal or a current_user membership array, and for the ' - + 'negation of such an ==, then rewrite each with in or !(... in ...). A check that still ' - + 'carries the shape refuses every write it governs, allowed ' - + 'values included, so one allowed write under each policy finds every such check left. ' + + 'negation of such an ==, then rewrite each with in or !(... in ...). ' + 'Then re-check what each policy is supposed to refuse rather than assuming the writes it ' + 'admitted before were right: before this change a != or a negated == against a list ' + 'admitted every write.', diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 048eb3b4fae..d7ee0bdaa8f 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -11603,9 +11603,7 @@ const step18: MigrationStep = { acceptanceCriteria: 'Grep the rowLevelSecurity check and using predicates of your permission sets for != or == ' + 'whose right-hand side is a list literal or a current_user membership array, and for the ' - + 'negation of such an ==, then rewrite each with in or !(... in ...). A check that still ' - + 'carries the shape refuses every write it governs, allowed ' - + 'values included, so one allowed write under each policy finds every such check left. ' + + 'negation of such an ==, then rewrite each with in or !(... in ...). ' + 'Then re-check what each policy is supposed to refuse rather than assuming the writes it ' + 'admitted before were right: before this change a != or a negated == against a list ' + 'admitted every write.', From f49e075189b193c3ac16d69e7eaaae9afafe8bd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 07:02:25 +0000 Subject: [PATCH 11/11] fix(changeset, spec): scope the fail-closed sentence to the sole-policy case The RLS compiler drops a policy carrying a list comparand, and the clause fails closed (RLS_DENY_FILTER) only when no other policy applies: beside an OR-sibling, the sibling decides. The qualifier "when no other policy applies" is added after "fails closed" in this PR's changeset and in the cel-predicate-list-comparand-refused entry's reason, as ruled. registry.ts regenerated by gen:migration-registry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../19886-cel-list-comparand-and-mongodb-ne-array-refused.md | 2 +- .../semantic/18.cel-predicate-list-comparand-refused.ts | 3 ++- packages/spec/src/migrations/registry.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md b/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md index 0b21b31f2af..52caa957aaa 100644 --- a/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md +++ b/.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md @@ -16,7 +16,7 @@ Clause-②: no (narrowing) **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. - `@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. -- `@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. +- `@objectstack/plugin-security`: the RLS compiler drops such a policy and fails closed when no other policy applies (`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. - `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded. - `@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. - `@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. diff --git a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts index 73e32bcff4e..3b0d0880b0f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts @@ -33,7 +33,8 @@ export const entry: SemanticMigration = { + 'row storing a scalar, so the read returned the rows the policy was written to hide. A check ' + 'written != against a membership set admitted and stored every write, on driver-sql as on ' + 'driver-mongodb. The compiler now refuses the comparison with reason unsupported, so the RLS ' - + 'compiler drops the policy and fails closed: reads under it return no rows and check writes ' + + 'compiler drops the policy and fails closed when no other policy applies: reads under it ' + + 'return no rows and check writes ' + 'are refused 403. A declared sharing rule with such a condition is skipped at bootstrap and ' + 'never seeded. The authoring lint reports a list literal as rls-predicate-unenforceable; a ' + 'membership set holds its value only per request, so that form is refused at request time. ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d7ee0bdaa8f..0e507d2aa14 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6236,7 +6236,8 @@ const step18: MigrationStep = { + 'row storing a scalar, so the read returned the rows the policy was written to hide. A check ' + 'written != against a membership set admitted and stored every write, on driver-sql as on ' + 'driver-mongodb. The compiler now refuses the comparison with reason unsupported, so the RLS ' - + 'compiler drops the policy and fails closed: reads under it return no rows and check writes ' + + 'compiler drops the policy and fails closed when no other policy applies: reads under it ' + + 'return no rows and check writes ' + 'are refused 403. A declared sharing rule with such a condition is skipped at bootstrap and ' + 'never seeded. The authoring lint reports a list literal as rls-predicate-unenforceable; a ' + 'membership set holds its value only per request, so that form is refused at request time. '