|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#19886 stage 2d] Every comparison the pushdown compiler lowers compares ONE |
| 5 | + * value, and an `in` list holds one value per member. Stage 2c refused a list |
| 6 | + * under `==` / `!=` opposite a field; the same fault stayed open one position |
| 7 | + * over, each measured admitting and storing the writes a row-level `check` was |
| 8 | + * written to refuse (real `SecurityPlugin` + ObjectQL, driver-sql and |
| 9 | + * driver-memory): |
| 10 | + * |
| 11 | + * | predicate | lowered to (before) | write-check evaluator (before) | |
| 12 | + * |---------------------------------------------|----------------------------------------------|--------------------------------| |
| 13 | + * | `!(record.status in [['closed', 'archived']])` | `$not { status: { $in: [[…]] } }` | every write admitted | |
| 14 | + * | `record.status > ['m']` | `{ status: { $gt: ['m'] } }` | compared as the string `'m'` | |
| 15 | + * | `current_user.org_user_ids != 'x'` | `{}` — "no restriction" | every write admitted | |
| 16 | + * | `current_user.org_user_ids > 'a'` | `{}` — folded on the coerced string | every write admitted | |
| 17 | + * | `record.reviewer_id > current_user` | `{ reviewer_id: { $gt: <context object> } }` | compared as `[object Object]` | |
| 18 | + * |
| 19 | + * Each is now `unsupported`, so every consumer fails closed on the path it |
| 20 | + * already has: the RLS compiler drops the policy (`RLS_DENY_FILTER` when |
| 21 | + * nothing else applies), the sharing seeder skips the rule, and the authoring |
| 22 | + * gate reports what the SOURCE shows (a list literal, the variable root). A |
| 23 | + * variable's value exists per request, so a resolved list is refused at |
| 24 | + * request time with the shape check still passing the source. |
| 25 | + * |
| 26 | + * NOT here, because the lowering cannot see it: a field compared with another |
| 27 | + * field whose column holds a list (`record.status != record.tags`, `tags` a |
| 28 | + * json or multiple field). The compiler knows the predicate's text, not the |
| 29 | + * object's field types; that one is refused by the write-check evaluator |
| 30 | + * (`matches-filter-array-comparand.test.ts`). |
| 31 | + */ |
| 32 | + |
| 33 | +import { describe, expect, it } from 'vitest'; |
| 34 | + |
| 35 | +import { compileCelToFilter, isPushdownableCel } from './cel-to-filter'; |
| 36 | +import { isSupportedRlsExpression } from './rls-predicate'; |
| 37 | + |
| 38 | +const VARS = { |
| 39 | + current_user: { |
| 40 | + id: 'u_me', |
| 41 | + email: 'me@example.test', |
| 42 | + org_user_ids: ['u_me', 'u_peer'], |
| 43 | + // A membership set a host staged with a list member — the ExecutionContext |
| 44 | + // contract declares string members, so only a host violating it supplies one. |
| 45 | + nested_set: [['acc_secret_a', 'acc_secret_b']], |
| 46 | + profile: { tier: 'gold' }, |
| 47 | + }, |
| 48 | +}; |
| 49 | + |
| 50 | +const refusedEverywhere = (source: string) => { |
| 51 | + const compiled = compileCelToFilter(source, { variables: VARS }); |
| 52 | + expect(compiled.ok).toBe(false); |
| 53 | + expect(compiled.ok ? undefined : compiled.reason).toBe('unsupported'); |
| 54 | + return compiled.ok ? '' : compiled.detail; |
| 55 | +}; |
| 56 | + |
| 57 | +/** Refused per request AND by the authoring shape check: the source shows the fault. */ |
| 58 | +const REFUSED_BY_SHAPE: Array<[string, string]> = [ |
| 59 | + // (b) a nested list under `in` |
| 60 | + ['(b) not-in with a nested list', "!(record.status in [['closed', 'archived']])"], |
| 61 | + ['(b) in with a nested list', "record.status in [['closed', 'archived']]"], |
| 62 | + ['(b) a nested list beside a scalar member', "record.status in ['open', ['closed']]"], |
| 63 | + ['(b) an empty nested list', 'record.status in [[]]'], |
| 64 | + ['(b) under && / ||', "record.owner_id == current_user.id || !(record.status in [['closed']])"], |
| 65 | + // (c) an ordering operator against a list literal, either side, negated, constant |
| 66 | + ['(c) > a list literal', "record.status > ['m']"], |
| 67 | + ['(c) >= an empty list', 'record.amount >= []'], |
| 68 | + ['(c) <= a list literal', 'record.amount <= [10, 20]'], |
| 69 | + ['(c) negated <', "!(record.status < ['m'])"], |
| 70 | + ['(c) the list on the left', "['m'] < record.status"], |
| 71 | + ['(c) a constant ordering against a list literal', "['a'] > 'b'"], |
| 72 | + // (e) a list literal on the constant branch |
| 73 | + ['(e) a constant != against a list literal', "['a'] != 'x'"], |
| 74 | + // (d) the variable root under an ordering operator |
| 75 | + ['(d) > the variable root', 'record.reviewer_id > current_user'], |
| 76 | + ['(d) the root on the left of <=', 'current_user <= record.reviewer_id'], |
| 77 | + ['(d) a constant ordering of the root', "current_user > 'a'"], |
| 78 | +]; |
| 79 | + |
| 80 | +describe('[#19886 stage 2d] a comparand that is not one value is refused — visible to the authoring gate', () => { |
| 81 | + for (const [name, source] of REFUSED_BY_SHAPE) { |
| 82 | + it(`${name}: ${source}`, () => { |
| 83 | + refusedEverywhere(source); |
| 84 | + expect(isPushdownableCel(source).ok).toBe(false); |
| 85 | + expect(isSupportedRlsExpression(source)).toBe(false); |
| 86 | + }); |
| 87 | + } |
| 88 | +}); |
| 89 | + |
| 90 | +/** Refused per request; the source passes the shape check because the value is per request. */ |
| 91 | +const REFUSED_PER_REQUEST: Array<[string, string, RegExp]> = [ |
| 92 | + ['(b) a membership set with a list member', '!(record.account in current_user.nested_set)', /current_user\.nested_set/], |
| 93 | + ['(c) > a membership set', 'record.reviewer_id > current_user.org_user_ids', /current_user\.org_user_ids/], |
| 94 | + ['(c) a membership set on the left of <=', 'current_user.org_user_ids <= record.reviewer_id', /current_user\.org_user_ids/], |
| 95 | + ['(c) a constant ordering of a membership set', "current_user.org_user_ids > 'a'", /current_user\.org_user_ids/], |
| 96 | + ['(e) a constant != of a membership set', "current_user.org_user_ids != 'x'", /current_user\.org_user_ids/], |
| 97 | + ['(e) a constant == of a membership set', "current_user.org_user_ids == 'u_me'", /current_user\.org_user_ids/], |
| 98 | + ['(d) > a key that resolves to an object', 'record.tier > current_user.profile', /current_user\.profile/], |
| 99 | +]; |
| 100 | + |
| 101 | +describe('[#19886 stage 2d] a resolved comparand that is not one value is refused at request time', () => { |
| 102 | + for (const [name, source, names] of REFUSED_PER_REQUEST) { |
| 103 | + it(`${name}: ${source} — naming the variable, withholding its value`, () => { |
| 104 | + const detail = refusedEverywhere(source); |
| 105 | + expect(detail).toMatch(names); |
| 106 | + for (const secret of ['u_me', 'u_peer', 'acc_secret_a', 'acc_secret_b', 'gold']) { |
| 107 | + expect(detail).not.toContain(secret); |
| 108 | + } |
| 109 | + expect(isPushdownableCel(source).ok).toBe(true); |
| 110 | + expect(isSupportedRlsExpression(source)).toBe(true); |
| 111 | + }); |
| 112 | + } |
| 113 | +}); |
| 114 | + |
| 115 | +describe('[#19886 stage 2d] each refusal carries its own remedy', () => { |
| 116 | + it('an ordering operator is told to take one bound, a range, or `in`', () => { |
| 117 | + const detail = refusedEverywhere("record.status > ['m']"); |
| 118 | + expect(detail).toContain('`>` orders against one value'); |
| 119 | + expect(detail).toContain("record.f > 'm'"); |
| 120 | + expect(detail).toContain('&&'); |
| 121 | + }); |
| 122 | + |
| 123 | + it('a nested list is told to flatten, naming the member', () => { |
| 124 | + const detail = refusedEverywhere("!(record.status in ['open', ['closed']])"); |
| 125 | + expect(detail).toContain('member 1 of the list literal is itself a list'); |
| 126 | + expect(detail).toContain("record.f in ['a', 'b']"); |
| 127 | + }); |
| 128 | + |
| 129 | + it('`==` / `!=` keep the stage 2c remedy', () => { |
| 130 | + const detail = refusedEverywhere("current_user.org_user_ids != 'x'"); |
| 131 | + expect(detail).toContain('`!=` compares one value'); |
| 132 | + expect(detail).toContain('!(record.f in current_user.org_user_ids)'); |
| 133 | + }); |
| 134 | +}); |
| 135 | + |
| 136 | +describe('[#19886 stage 2d] every neighbouring comparison lowers exactly as before', () => { |
| 137 | + const ok = (source: string) => { |
| 138 | + const r = compileCelToFilter(source, { variables: VARS }); |
| 139 | + if (!r.ok) throw new Error(`expected "${source}" to lower, got ${r.reason}: ${r.detail}`); |
| 140 | + return r.filter; |
| 141 | + }; |
| 142 | + |
| 143 | + it('an ordering operator against one literal, one scalar key, and another field', () => { |
| 144 | + expect(ok("record.status > 'm'")).toEqual({ status: { $gt: 'm' } }); |
| 145 | + expect(ok('record.amount <= 10')).toEqual({ amount: { $lte: 10 } }); |
| 146 | + expect(ok('record.owner_id >= current_user.id')).toEqual({ owner_id: { $gte: 'u_me' } }); |
| 147 | + expect(ok('record.a > record.b')).toEqual({ a: { $gt: { $field: 'b' } } }); |
| 148 | + }); |
| 149 | + |
| 150 | + it('`in` / `not in` against a flat list and a membership set', () => { |
| 151 | + expect(ok("record.status in ['open', 'pending']")).toEqual({ status: { $in: ['open', 'pending'] } }); |
| 152 | + expect(ok("!(record.status in ['closed'])")).toEqual({ $not: { status: { $in: ['closed'] } } }); |
| 153 | + expect(ok('record.owner_id in current_user.org_user_ids')).toEqual({ owner_id: { $in: ['u_me', 'u_peer'] } }); |
| 154 | + expect(ok('record.status in []')).toEqual({ status: { $in: [] } }); |
| 155 | + }); |
| 156 | + |
| 157 | + it('constant comparisons over scalars still fold', () => { |
| 158 | + expect(ok('1 == 1')).toEqual({}); |
| 159 | + expect(ok("current_user.id != 'guest'")).toEqual({}); |
| 160 | + expect(ok("current_user.id > 'a'")).toEqual({}); |
| 161 | + }); |
| 162 | + |
| 163 | + it('field-to-field `==` / `!=` still lower — the column TYPE is not the lowering\'s to judge', () => { |
| 164 | + expect(ok('record.status != record.tags')).toEqual({ status: { $ne: { $field: 'tags' } } }); |
| 165 | + expect(ok('!(record.status == record.tags)')).toEqual({ $not: { status: { $eq: { $field: 'tags' } } } }); |
| 166 | + }); |
| 167 | +}); |
0 commit comments