From a0c8aa3c0599769b7efb578cd5ec74ae28caf6f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:57:54 +0000 Subject: [PATCH 1/3] fix(driver-mongodb): refuse a { $field } cross-field reference in any comparand position translateFilter emitted the reference as a literal sub-document, so an RLS using clause such as s != t matched every row. The shape walk now refuses it with the INVALID_FILTER / 400 envelope, field and operator withheld. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../driver-mongodb/src/mongodb-filter.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/drivers/driver-mongodb/src/mongodb-filter.ts b/packages/drivers/driver-mongodb/src/mongodb-filter.ts index 5bae2d55bc6..37d7707721c 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-filter.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-filter.ts @@ -261,6 +261,27 @@ function classifyFilterKey(key: string, value: unknown, here: string): FilterVer // translates byte-identically now. if (isEmptyFieldConstraint(value)) throw emptyFieldConstraintError(key, here); + // [#19949] A `{ $field }` cross-field reference in ANY comparand position is + // refused: this driver has no lowering for a column-to-column comparison, + // and without this gate it emitted the reference as a LITERAL sub-document. + // Measured through mingo 7.2.4 (the query-semantics proxy; a live `mongod` + // is not measurable here) over the rows `{s:'a',t:'a'}` and `{s:'a',t:'b'}`: + // `{ s: { $ne: { $field: 't' } } }` selected BOTH rows and `$eq` selected + // none. `record.s != record.t` is exactly what `compileCelToFilter` lowers + // an RLS `using` clause to, and the policy is AND-composed into the read + // after the engine's comparand seams, so this translator is the only face + // between it and the server: the read restriction widened to every row. + // The negated positions widened the same way — `$nin: [ref]`, + // `$notContains: ref`, and `$eq: ref` under `$not` (lowered to `$nor`). + // + // On the WALK, for the reason the gates below give: `{ $or: [ {}, { s: + // { $ne: ref } } ] }` reduces to TRUE on its first disjunct, so an + // emitter-side gate would refuse or ignore the reference depending on its + // siblings. FIRST among the comparand gates, so every reference answers one + // refusal whichever operator carries it. ⛔ Not implemented as a `$expr` + // column comparison: the ruling on this driver is refuse, not lower. + if (carriesFieldReference(value)) throw fieldReferenceUnsupportedError(); + // [#5347] `$null`'s comparand is a boolean by declaration. Checked on THIS // walk rather than in the emitter's `$null` arm because the emitter is // skipped wholesale by a boolean identity: `{ $or: [ {}, { stage: { $null: @@ -413,6 +434,65 @@ function arrayNotEqualComparandError(): Error { ); } +/** + * [#19949] Is `value` shaped like a spec `FieldReferenceSchema` reference — + * a non-array object carrying a `$field` key? + * + * SHAPE only, spelled as `@objectstack/spec`'s schema door spells its own + * `isFieldReferenceShape` (not exported there): a `{ $field }` whose value is + * not a string is a MALFORMED reference, and it is refused with the rest + * rather than emitted as the literal document the well-formed one used to be. + */ +function isFieldReferenceShape(value: unknown): boolean { + return !!value && typeof value === 'object' && !Array.isArray(value) && '$field' in value; +} + +/** + * [#19949] Does this field constraint compare against a `{ $field }` reference + * in a position this translator would otherwise emit as a literal value? + * + * - the whole constraint — the bare `{ field: { $field: … } }` form; + * - any operator's comparand — `{ field: { $ne: { $field: … } } }`, an + * `addDays` offset included; + * - any member of a list comparand — `$in` / `$nin` members, `$between` + * endpoints, an array handed to `$ne` / `$eq`. + * + * Only `$`-prefixed keys are read as operators, exactly as the emitter reads + * them: `{ field: { nested: { $field: … } } }` has no operator and stays the + * nested-document exact match it always was. + */ +function carriesFieldReference(spec: unknown): boolean { + if (isFieldReferenceShape(spec)) return true; + if (!isFilterNode(spec)) return false; + for (const [op, comparand] of Object.entries(spec)) { + if (!op.startsWith('$')) continue; + if (isFieldReferenceShape(comparand)) return true; + if (Array.isArray(comparand) && comparand.some(isFieldReferenceShape)) return true; + } + return false; +} + +/** + * [#19949] A `{ $field }` cross-field reference reached this translator. + * + * The field, the referenced field, the operator and the position are withheld + * from the message, as {@link arrayNotEqualComparandError} withholds them and + * for its reason: 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. The message carries the refusal's identity + * — the unsupported feature, named — and the remedy. + */ +function fieldReferenceUnsupportedError(): Error { + return unsupportedFilterError( + 'The MongoDB driver does not support field-to-field comparison ({ "$field": … }): this filter ' + + 'compares a field against another field of the same record, and the driver has no lowering ' + + 'for a column-to-column comparison. It is refused rather than translated because MongoDB ' + + 'would read the reference as a literal sub-document, so "$ne" would match every row and ' + + '"$eq" none. Compare against a literal value instead. The fields and the operator 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. * From a53249d5e56bf9c435516da2e80b777630cf14d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 06:00:34 +0000 Subject: [PATCH 2/3] test(driver-mongodb): pin the { $field } refusal in every position, at both doors Also refuses a reference listed in the implicit-equality position, so a list holding one answers the same refusal as $eq with that list. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../mongodb-field-reference-refusal.test.ts | 303 ++++++++++++++++++ .../driver-mongodb/src/mongodb-filter.ts | 19 +- 2 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 packages/drivers/driver-mongodb/src/mongodb-field-reference-refusal.test.ts diff --git a/packages/drivers/driver-mongodb/src/mongodb-field-reference-refusal.test.ts b/packages/drivers/driver-mongodb/src/mongodb-field-reference-refusal.test.ts new file mode 100644 index 00000000000..bf1acdf4b4a --- /dev/null +++ b/packages/drivers/driver-mongodb/src/mongodb-field-reference-refusal.test.ts @@ -0,0 +1,303 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19949] A `{ $field }` cross-field reference is REFUSED by `translateFilter`, + * in every comparand position, with the `INVALID_FILTER` / 400 envelope every + * filter refusal on this driver speaks. + * + * ## The defect, measured + * + * `compileCelToFilter` lowers a field-to-field comparison such as + * `record.s != record.t` to `{ s: { $ne: { $field: 't' } } }` — the spec's + * `FieldReferenceSchema`. This driver had no arm for it, so `translateFilter` + * emitted the reference as a LITERAL sub-document. Over the card's rows + * `{ s: 'a', t: 'a' }` and `{ s: 'a', t: 'b' }`, read through **mingo 7.2.4, + * the named proxy** for MongoDB query semantics, `$ne` against the reference + * selected BOTH rows (the one where `s` equals `t` included) and `$eq` selected + * none. The negated positions widened the same way: `$nin: [ref]`, + * `$notContains: ref`, `$ne` against a reference carrying `addDays`, and `$eq` + * under `$not` (lowered to `$nor`). + * + * The same reading through the real read path — `ObjectQL`, the real + * `SecurityPlugin` with a `rowLevelSecurity` `using: 's != t'`, and the real + * `MongoDBDriver` over a mingo-backed collection — returned both rows from + * `find`, `2` from `count`, and the `s == t` row from `findOne`: an RLS read + * restriction widened to every row. + * + * ⚠️ **A live `mongod` is NOT MEASURED** — this fleet cannot fetch the binary — + * and neither mingo nor `@objectstack/plugin-security` is a dependency of this + * package, so those readings were taken outside this suite and are recorded + * here and in the PR. This suite pins the translator and the two doors in + * front of the server: the refusal, and that the server is never asked. + * + * ## Scope — the maintainer's ruling on this driver: refuse, do not lower + * + * The reference is refused, never implemented (no `$expr` column-to-column + * lowering). The message names the unsupported feature and withholds the + * fields, the operator and the position, as the `$ne`-array refusal beside it + * does: the filter may be an access policy the caller did not write. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +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 asyncRefusalOf = async (run: () => Promise): Promise => + run().then(() => null, (e: unknown) => e as WireBearingError); + +/** The card's two rows: `s` equals `t` on the first, not on the second. */ +const ROWS = [ + { id: 'r1', s: 'a', t: 'a' }, + { id: 'r2', s: 'a', t: 'b' }, +]; + +const REF = { $field: 't' }; + +/** What `compileCelToFilter` lowers `record.s != record.t` to. */ +const COMPILED_NOT_EQUAL = { s: { $ne: REF } }; +/** …and `record.s == record.t`. */ +const COMPILED_EQUAL = { s: { $eq: REF } }; + +describe('[#19949] the ruling\'s cases: $ne and $eq against { $field: "t" } are refused', () => { + it.each([ + ['$ne — what s != t lowers to', COMPILED_NOT_EQUAL], + ['$eq — what s == t lowers to', COMPILED_EQUAL], + ])('%s: INVALID_FILTER / 400', (_label, where) => { + const err = refusalOf(where); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + }); + + it('the message names the unsupported feature, and withholds the fields', () => { + const err = refusalOf({ secret_scope: { $ne: { $field: 'owner_scope' } } }); + expect(err.message).toContain('does not support field-to-field comparison'); + expect(err.message).toContain('"$field"'); + expect(err.message).not.toContain('secret_scope'); + expect(err.message).not.toContain('owner_scope'); + }); + + it('CONTROL — literal $ne / $eq over the same field translate exactly as before', () => { + expect(translateFilter({ s: { $ne: 'a' } })).toEqual({ s: { $ne: 'a' } }); + expect(translateFilter({ s: { $eq: 'a' } })).toEqual({ s: { $eq: 'a' } }); + expect(translateFilter({ t: { $ne: 'b' } })).toEqual({ t: { $ne: 'b' } }); + expect(translateFilter({ t: { $eq: 'b' } })).toEqual({ t: { $eq: 'b' } }); + expect(translateFilter({ t: 'b' })).toEqual({ t: 'b' }); + }); +}); + +describe('[#19949] every position a reference can reach is refused', () => { + const POSITIONS: Array<[string, Record]> = [ + ['$gt', { s: { $gt: REF } }], + ['$gte', { s: { $gte: REF } }], + ['$lt', { s: { $lt: REF } }], + ['$lte', { s: { $lte: REF } }], + ['an $in member', { s: { $in: ['x', REF] } }], + ['an $nin member', { s: { $nin: [REF] } }], + ['the $between lower endpoint', { s: { $between: [REF, 'z'] } }], + ['the $between upper endpoint', { s: { $between: ['a', REF] } }], + ['a reference carrying a literal addDays', { s: { $ne: { $field: 't', addDays: 1 } } }], + ['a reference carrying an addDays reference', { s: { $eq: { $field: 't', addDays: { $field: 'n' } } } }], + ['a malformed reference ($field not a string)', { s: { $ne: { $field: 42 } } }], + ['the bare form, with no operator', { s: REF }], + ['a list in the implicit-equality position', { s: [REF] }], + ['an array handed to $ne', { s: { $ne: [REF] } }], + ['$contains', { s: { $contains: REF } }], + ['$notContains', { s: { $notContains: REF } }], + ['$startsWith', { s: { $startsWith: REF } }], + ['$endsWith', { s: { $endsWith: REF } }], + ['$icontains', { s: { $icontains: REF } }], + ['$null', { s: { $null: REF } }], + ['$exists', { s: { $exists: REF } }], + ['beside a literal operator on the same field', { s: { $gte: 'a', $ne: REF } }], + ['inside $and', { $and: [{ id: 'r1' }, COMPILED_NOT_EQUAL] }], + ['inside $or', { $or: [COMPILED_NOT_EQUAL, { s: 'zz' }] }], + // `{}` 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: [{}, COMPILED_NOT_EQUAL] }], + ['inside $not (lowered to $nor)', { $not: COMPILED_EQUAL }], + ['nested three combinators deep', { $and: [{ $or: [{ $not: COMPILED_NOT_EQUAL }] }] }], + ]; + + for (const [label, where] of POSITIONS) { + it(`${label}: INVALID_FILTER / 400, the same refusal`, () => { + const err = refusalOf(where); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('does not support field-to-field comparison'); + }); + } +}); + +describe('[#19949] every neighbouring literal shape translates exactly as before', () => { + it('literal $in / $nin / $between', () => { + expect(translateFilter({ t: { $in: ['b'] } })).toEqual({ t: { $in: ['b'] } }); + expect(translateFilter({ t: { $nin: ['b'] } })).toEqual({ t: { $nin: ['b'] } }); + expect(translateFilter({ t: { $between: ['a', 'a'] } })).toEqual({ t: { $gte: 'a', $lte: 'a' } }); + }); + + it('literal $ne under $not, and the $ne-array refusal beside this gate', () => { + expect(translateFilter({ $not: { t: { $eq: 'b' } } })).toEqual({ $nor: [{ t: { $eq: 'b' } }] }); + const err = refusalOf({ t: { $ne: ['a', 'b'] } }); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('"$nin"'); + }); + + it('an array in the equality slot with no reference still passes through (the shared face owns it)', () => { + expect(translateFilter({ tags: ['a'] })).toEqual({ tags: ['a'] }); + expect(translateFilter({ tags: { $eq: ['a'] } })).toEqual({ tags: { $eq: ['a'] } }); + }); +}); + +// ── the doors in front of the server ───────────────────────────────────────── + +interface RecordedCall { op: string; object: string; filter: unknown } + +/** + * A `Db` stand-in that answers only what a boot and a read touch, and records + * every filter the driver hands the collection. It selects nothing: whether the + * SERVER would have matched is mingo's reading above, not this double's. + */ +function recordingDb(calls: RecordedCall[]) { + const collection = (object: string) => ({ + find: (filter: unknown) => { + calls.push({ op: 'find', object, filter }); + return { toArray: async () => [] }; + }, + findOne: async (filter: unknown) => { calls.push({ op: 'findOne', object, filter }); return null; }, + countDocuments: async (filter: unknown) => { calls.push({ op: 'count', object, filter }); return 0; }, + updateMany: async (filter: unknown) => { calls.push({ op: 'updateMany', object, filter }); return { modifiedCount: 0 }; }, + deleteMany: async (filter: unknown) => { calls.push({ op: 'deleteMany', object, filter }); return { deletedCount: 0 }; }, + aggregate: (pipeline: unknown) => { + calls.push({ op: 'aggregate', object, filter: pipeline }); + return { toArray: async () => [] }; + }, + createIndex: async () => 'ok', + }); + return { + command: async () => ({ ok: 1 }), + listCollections: () => ({ toArray: async () => [{ name: 'pair' }] }), + createCollection: async () => undefined, + collection, + }; +} + +function recordingDriver(calls: RecordedCall[]): MongoDBDriver { + const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/unused', database: 'unused' }); + (driver as unknown as { db: unknown }).db = recordingDb(calls); + // No socket: the Db above IS the connection. + driver.connect = async () => undefined; + driver.disconnect = async () => undefined; + return driver; +} + +describe('[#19949] the driver door refuses before the server is asked', () => { + it('find / findOne / count / updateMany / deleteMany / aggregate, under the engine\'s $and composition', async () => { + const calls: RecordedCall[] = []; + const driver = recordingDriver(calls); + // The shape a read reaches the driver in: the caller's where AND the policy. + const where = { $and: [{ id: 'r1' }, COMPILED_NOT_EQUAL] }; + + for (const run of [ + () => driver.find('pair', { where } as never), + () => driver.findOne('pair', { where } as never), + () => driver.count('pair', { where } as never), + () => driver.updateMany('pair', { where } as never, { s: 'x' }), + () => driver.deleteMany('pair', { where } as never), + () => driver.aggregate('pair', { where, aggregations: [{ function: 'count', alias: 'n' }] } as never), + ]) { + const err = await asyncRefusalOf(run); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.status).toBe(400); + } + expect(calls).toHaveLength(0); + }); + + it('CONTROL — a literal policy reaches the server as the document it always was', async () => { + const calls: RecordedCall[] = []; + const driver = recordingDriver(calls); + await driver.find('pair', { where: { t: { $ne: 'a' } } } as never); + await driver.count('pair', { where: { t: { $eq: 'a' } } } as never); + expect(calls.map((c) => c.filter)).toEqual([{ t: { $ne: 'a' } }, { t: { $eq: 'a' } }]); + }); +}); + +describe('[#19949] the RLS read path fails CLOSED: the engine rethrows the refusal', () => { + const pair = { + name: 'pair', + label: 'Pair', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + s: { name: 's', type: 'text' as const }, + t: { name: 't', type: 'text' as const }, + }, + }; + + const engines: ObjectQL[] = []; + afterEach(async () => { + while (engines.length) await engines.pop()?.destroy(); + }); + + /** + * An engine over the real driver, with ONE middleware that AND-composes a + * compiled `using` clause into every read — the composition the security + * middleware applies after the engine's comparand seams, so the driver is + * the only face the policy meets. (`@objectstack/plugin-security` itself is + * not a dependency of this package; its own path was measured outside this + * suite — see the file header.) + */ + async function boot(policy: Record, calls: RecordedCall[]): Promise { + const engine = new ObjectQL(); + engine.registerDriver(recordingDriver(calls) as never, true); + await engine.init(); + engine.registry.registerObject(pair as never, 'test'); + engine.registerMiddleware(async (opCtx, next) => { + const ast = (opCtx as { ast?: { where?: unknown } }).ast; + if (ast) ast.where = ast.where ? { $and: [ast.where, policy] } : policy; + await next(); + }); + // The refusal is logged once by the engine's find path at WARN; expected here. + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + engines.push(engine); + return engine; + } + + it.each([ + ['s != t', COMPILED_NOT_EQUAL], + ['s == t', COMPILED_EQUAL], + ])('using %s — find, findOne and count are refused, never answered unfiltered', async (_cel, policy) => { + const calls: RecordedCall[] = []; + const engine = await boot(policy, calls); + for (const run of [ + () => engine.find('pair', {} as never), + () => engine.findOne('pair', { where: { id: ROWS[0]!.id } } as never), + () => engine.count('pair', {} as never), + ]) { + const err = await asyncRefusalOf(run); + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.status).toBe(400); + } + expect(calls.filter((c) => c.object === 'pair')).toHaveLength(0); + }); + + it('CONTROL — a literal policy is composed and handed to the server', async () => { + const calls: RecordedCall[] = []; + const engine = await boot({ t: { $ne: 'a' } }, calls); + await engine.find('pair', {} as never); + expect(calls.filter((c) => c.object === 'pair').map((c) => c.filter)).toEqual([{ t: { $ne: 'a' } }]); + }); +}); diff --git a/packages/drivers/driver-mongodb/src/mongodb-filter.ts b/packages/drivers/driver-mongodb/src/mongodb-filter.ts index 37d7707721c..7b33578be43 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-filter.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-filter.ts @@ -451,7 +451,8 @@ function isFieldReferenceShape(value: unknown): boolean { * [#19949] Does this field constraint compare against a `{ $field }` reference * in a position this translator would otherwise emit as a literal value? * - * - the whole constraint — the bare `{ field: { $field: … } }` form; + * - the whole constraint, in the implicit-equality position — the bare + * `{ field: { $field: … } }` form, or a list holding a reference; * - any operator's comparand — `{ field: { $ne: { $field: … } } }`, an * `addDays` offset included; * - any member of a list comparand — `$in` / `$nin` members, `$between` @@ -459,19 +460,25 @@ function isFieldReferenceShape(value: unknown): boolean { * * Only `$`-prefixed keys are read as operators, exactly as the emitter reads * them: `{ field: { nested: { $field: … } } }` has no operator and stays the - * nested-document exact match it always was. + * nested-document exact match it always was. A list WITHOUT a reference is + * not this gate's business either way — the equality slot's array is ruled to + * the shared comparand-shape face, and `$ne`'s is refused just below. */ function carriesFieldReference(spec: unknown): boolean { - if (isFieldReferenceShape(spec)) return true; + if (holdsFieldReference(spec)) return true; if (!isFilterNode(spec)) return false; for (const [op, comparand] of Object.entries(spec)) { - if (!op.startsWith('$')) continue; - if (isFieldReferenceShape(comparand)) return true; - if (Array.isArray(comparand) && comparand.some(isFieldReferenceShape)) return true; + if (op.startsWith('$') && holdsFieldReference(comparand)) return true; } return false; } +/** [#19949] A `{ $field }` reference, or a list with one among its members. */ +function holdsFieldReference(comparand: unknown): boolean { + if (isFieldReferenceShape(comparand)) return true; + return Array.isArray(comparand) && comparand.some(isFieldReferenceShape); +} + /** * [#19949] A `{ $field }` cross-field reference reached this translator. * From 00612182e92d42f7efaeb99ee2b51cad01672c68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 06:02:17 +0000 Subject: [PATCH 3/3] chore(changeset): driver-mongodb refuses a { $field } reference (narrowing) Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../19949-mongodb-field-reference-refused.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .changeset/19949-mongodb-field-reference-refused.md diff --git a/.changeset/19949-mongodb-field-reference-refused.md b/.changeset/19949-mongodb-field-reference-refused.md new file mode 100644 index 00000000000..6bc86f985bb --- /dev/null +++ b/.changeset/19949-mongodb-field-reference-refused.md @@ -0,0 +1,36 @@ +--- +"@objectstack/driver-mongodb": minor +--- + +fix(driver-mongodb)!: `translateFilter` refuses a `{ $field }` cross-field reference instead of sending it to MongoDB as a literal value (#19949) + +Clause-②: no (narrowing) + +**BREAKING**: an accept-set narrowing, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. **A filter that answered before is now refused**: any filter carrying a `{ $field }` reference gets `INVALID_FILTER` / 400 from `translateFilter`, and so from every driver door that reads a `where` (`find`, `findOne`, `count`, `updateMany`, `deleteMany`, `aggregate`, `explain`). + +**Security fix for RLS reads on MongoDB.** `compileCelToFilter` lowers a field-to-field comparison such as `record.s != record.t` to `{ s: { $ne: { $field: 't' } } }`. This driver has no lowering for a column-to-column comparison, and `translateFilter` sent the reference to the server as a literal sub-document. The RLS `using` clause is composed into the read after the engine's comparand checks, so nothing stopped it on the way. Measured over the rows `{ s: 'a', t: 'a' }` and `{ s: 'a', t: 'b' }`, with mingo 7.2.4 as the proxy for MongoDB's query semantics: + +- `$ne` against the reference selected both rows, including the one where `s` equals `t`, and `$eq` selected none; +- `$nin: [ref]`, `$notContains: ref`, `$ne` against a reference carrying `addDays`, and `$eq` under `$not` also selected both rows; +- through `ObjectQL`, `SecurityPlugin` with a `rowLevelSecurity` policy `using: 's != t'`, and `MongoDBDriver` over a mingo-backed collection, `find` returned both rows, `count` returned `2`, and `findOne` returned the `s == t` row. The policy's read restriction was lost. + +A live `mongod` was not measured, because this fleet cannot fetch the binary. + +**What changes.** The driver's filter walk refuses a `{ $field }` reference in every comparand position, at any depth under `$and` / `$or` / `$not`: + +- the whole comparand of any operator: the orderings, `$eq` / `$ne`, the string operators, `$null`, `$exists`; +- a member of a list: `$in` / `$nin` members, either `$between` endpoint, an array given to `$ne` or `$eq`; +- the implicit-equality position: the bare `{ field: { $field: … } }` form, or a list holding a reference; +- a reference carrying `addDays`, and a malformed reference whose `$field` is not a string. + +The refusal uses the same envelope as the driver's other filter refusals. Its message names the unsupported feature (field-to-field comparison) and withholds the fields, the operator and the position, because the filter may be an access policy the caller did not write. Through the engine, an RLS read carrying such a policy is now refused with that 400, and the server is never asked. Before, it returned the unfiltered rows. A policy written `s == t`, which used to return no rows, is refused the same way. + +**What does not change.** + +- Every literal comparand translates to the same document as before, including literal `$in` / `$nin` / `$between` lists and `$not` around a literal. +- `$ne` with an array of literal values keeps its own refusal. An array with no reference in the equality slot still passes through `translateFilter`: the shared comparand-shape face owns that slot. +- Field-to-field comparison is not implemented on this driver. It is refused, not lowered to a MongoDB `$expr`. `driver-sql` and the in-memory evaluator are not touched. + +**What an affected author does.** On a MongoDB datasource, a row-level policy or filter can compare a field only against a literal value or a `current_user` value, not against another field of the same record. A policy that needs a field-to-field comparison cannot be enforced by this driver. Before this change it returned every row. + +