From e366da3cc13f13f2c77a74fb629e592cb5b4f491 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:02:08 +0000 Subject: [PATCH 1/4] fix(lint)!: refuse an RLS field-to-field comparison against a json or multiple field at authoring time WIP: the rule arm; tests and changeset follow. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- .../validate-rls-predicate-enforceability.ts | 142 +++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index efc78efa1fb..d3784234a44 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -199,6 +199,32 @@ * The finding keeps the id {@link RLS_PREDICATE_UNENFORCEABLE} and quotes the * engine's message verbatim, with its code and status: at authoring time the * text is the author's own, so nothing is withheld. + * + * ## A field compared with a field that holds a list or an object (#19886) + * + * `record.status != record.tags`, with `tags` a `json` field or a `multiple` + * lookup, lowers to `{ status: { $ne: { $field: 'tags' } } }` — a legal shape, + * because the lowering knows the predicate's text and not the object's field + * types. The engine's admission does not judge a `{ $field }` reference against + * the referenced column's type either. Measured before this arm: every cell of + * `==` / `!=` / `!(==)` / `>` / `<=`, against a `json`, `address`, + * `multiselect`, `multiple` lookup and `multiple` user field, in both operand + * orders, on every clause and operation, was clean at `os validate` and at the + * save door. The runtime refuses every one of them: the write check refuses the + * comparison per record when the compared column holds a list or an object + * (`INVALID_FILTER` / 400, nothing stored), and driver-sql refuses a + * cross-field comparison against such a column by its DECLARED type, so a read + * the `using` scopes answers 400 and a by-id update or delete it scopes 403. + * + * This rule holds what neither of those holds: the declared field map + * ({@link ObjectGraph}). So it judges by declared type, as driver-sql does, and + * reads the spec's own value-shape classes rather than a list of its own — + * {@link STRUCTURED_JSON_TYPES} (a structured JSON payload) and + * {@link isMultiValueField} (an inherently-multi option type, or a + * multi-capable type flagged `multiple: true`), the two driver-sql builds its + * JSON-column set and its multi-valued test from. It runs on every clause, and + * before the engine's pass, so the engine never judges a clause this arm + * already refused: one defect, one finding. */ import type { EngineFilterJudgement, IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -211,7 +237,12 @@ import { } from '@objectstack/formula'; import type { CelBoundsOverrun } from '@objectstack/formula'; import { RESERVED_RLS_MEMBERSHIP_KEYS } from '@objectstack/spec/contracts'; -import { assertListComparandShapes, normalizeFilterComparandTypes } from '@objectstack/spec/data'; +import { + STRUCTURED_JSON_TYPES, + assertListComparandShapes, + isMultiValueField, + normalizeFilterComparandTypes, +} from '@objectstack/spec/data'; import { ExecutionContextSchema } from '@objectstack/spec/kernel'; import { describeFieldPathVerdict, @@ -221,6 +252,7 @@ import { recordsOf, resolveFieldPath, suggestName, + type GraphField, type ObjectGraph, } from './object-graph.js'; @@ -946,6 +978,89 @@ const DROPPED_FACE_REFUSED = 'logged; the only signal is a per-request "DENY (fail closed)" WARN, emitted only when nothing else ' + 'applicable compiles. '; +/** + * The lowered field-to-field operators, back to the CEL operator an author + * writes. They are the six `cel-to-filter.ts` emits with a `{ $field }` + * comparand, and the six the write check and driver-sql refuse against a + * list-holding column. + */ +const FIELD_COMPARISON_SYMBOL: ReadonlyMap = new Map([ + ['$eq', '=='], + ['$ne', '!='], + ...ORDERING_SYMBOL, +]); + +/** + * What a declared field holds when it holds a list or an object, or `null` + * when it holds one value (see this file's header for the two spec classes). + */ +function listHoldingDeclaration(meta: GraphField | undefined): string | null { + const type = meta?.type; + if (!type) return null; + if (STRUCTURED_JSON_TYPES.has(type)) return `a \`${type}\` field`; + if (!isMultiValueField({ type, multiple: meta.multiple === true })) return null; + return meta.multiple === true ? `a \`multiple\` \`${type}\` field` : `a \`${type}\` field`; +} + +/** One lowered comparison between two columns, at least one of which holds a list or an object. */ +interface ListHoldingComparison { + /** The comparison as the author wrote it, back in CEL. */ + written: string; + /** Each list-holding column, with what it is declared as. */ + columns: string[]; +} + +/** + * Every lowered `{ $field }` comparison in which either column is declared to + * hold a list or an object, read off the COMPILER'S OUTPUT and resolved against + * the object graph. A column the graph cannot answer for (an object outside the + * stack, no field map, a name it does not declare) is not judged here; the + * reference pass above owns an unknown name. + */ +function listHoldingComparisons( + graph: ObjectGraph, + object: string, + filter: Record, +): ListHoldingComparison[] { + const found = new Map(); + const sites = loweredSites(filter, (op, operand) => + FIELD_COMPARISON_SYMBOL.has(op) && + !!operand && typeof operand === 'object' && !Array.isArray(operand) && + typeof (operand as Record).$field === 'string'); + for (const site of sites) { + const referenced = (site.operand as { $field: string }).$field; + const columns: string[] = []; + for (const name of new Set([site.field, referenced])) { + const verdict = resolveFieldPath(graph, object, name); + const held = verdict?.kind === 'ok' ? listHoldingDeclaration(verdict.meta) : null; + if (held) columns.push(`\`${name}\` is ${held}`); + } + if (columns.length === 0) continue; + const written = `record.${site.field} ${FIELD_COMPARISON_SYMBOL.get(site.op)} record.${referenced}`; + found.set(written, { written, columns }); + } + return [...found.values()]; +} + +/** + * What a comparison against a list-holding column does at request time, per + * clause. Measured through the real plugin-security on driver-sql, every + * clause and operation: see this file's header. + */ +function listHoldingConsequence(clause: 'using' | 'check'): string { + const write = + 'every single-record insert and by-id update whose record holds a list or an object in that column is ' + + 'refused (`INVALID_FILTER` / 400) and stores nothing, because the write check compares one value with ' + + 'one value and will not guess what a list means'; + return clause === 'using' + ? 'every read this policy scopes is refused on the SQL drivers (`INVALID_FILTER` / 400: driver-sql ' + + 'refuses a cross-field comparison against such a column by its declared type), and every by-id ' + + 'update or delete it scopes fails closed (`PERMISSION_DENIED` / 403). On an `insert`, `update` or ' + + '`all` policy the same `using` is also the write check whenever no applicable policy for that ' + + `operation declares a \`check\` (ADR-0058 D4): then ${write}.` + : `${write}. The policy reads as a write rule and behaves as a refusal of every write it was meant to judge.`; +} + /** * What a reference miss costs at request time, per clause. Measured, not inferred. * @@ -1237,6 +1352,31 @@ function referenceFindings( }); } + // [#19886] A field compared with a field that holds a list or an object, + // judged by DECLARED type (this file's header). Every clause, and ahead of + // the engine's pass below, which then does not judge this clause at all. + const listComparisons = filter ? listHoldingComparisons(graph, object, filter) : []; + if (listComparisons.length > 0) { + findings.push({ + severity: 'error', + rule: RLS_PREDICATE_UNENFORCEABLE, + where, + path, + message: + `RLS ${clause} \`${quote(source)}\` lowers, but compares a field with a field that holds a list or an ` + + `object: ${listComparisons.map((c) => `\`${c.written}\`, where ${c.columns.join(' and ')}`).join('; ')}. ` + + 'A column that holds a list or an object is not one comparable value, on either side of a ' + + 'field-to-field comparison, so the platform refuses the comparison instead of evaluating it: ' + + listHoldingConsequence(clause), + hint: + 'A field compared with a `json` or `multiple` field has no row-filter form: a row filter compares ' + + 'one value with one value, and cannot test membership in a list another column holds. Compare ' + + 'with a single-valued column, or with a literal or a `current_user` value — "one of these ' + + "values\" is `record.status in ['open', 'pending']` or `record.owner in current_user.org_user_ids` " + + '— or move the condition into a validation rule or a hook.', + }); + } + // [#20158] The engine's own admission of the lowered read scope — run only // on a clause every pass above left clean, so one defect earns one finding. // The verdict and its sentence are the engine's; this rule adds the clause, From cfb95d4806cb6fe22c188024c4411ff057c6a1fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:11:50 +0000 Subject: [PATCH 2/4] test(lint, cli): pin the json / multiple field-to-field arm, table-driven and at both doors Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- .../rls-policy-authoring-admission.test.ts | 70 ++++- ...-enforceability.list-holding-field.test.ts | 269 ++++++++++++++++++ .../validate-rls-predicate-enforceability.ts | 11 +- 3 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 packages/lint/src/validate-rls-predicate-enforceability.list-holding-field.test.ts diff --git a/packages/cli/test/rls-policy-authoring-admission.test.ts b/packages/cli/test/rls-policy-authoring-admission.test.ts index 84d76c284a6..10bd1331a55 100644 --- a/packages/cli/test/rls-policy-authoring-admission.test.ts +++ b/packages/cli/test/rls-policy-authoring-admission.test.ts @@ -61,25 +61,27 @@ const deal = { returnType: 'boolean', }, account: { type: 'lookup', label: 'Account', reference: 'account' }, + tags: { type: 'json', label: 'Tags' }, + watchers: { type: 'lookup', label: 'Watchers', reference: 'account', multiple: true }, }, }; const account = { name: 'account', label: 'Account', fields: { region: { type: 'text', label: 'Region' } } }; -const permissionSet = (using: string) => ({ +const permissionSet = (using: string, policy: Record = { operation: 'select', using }) => ({ name: 'sales', label: 'Sales', objects: { deal: { allowRead: true } }, - rowLevelSecurity: [{ name: 'p', label: 'P', object: 'deal', operation: 'select' as const, using }], + rowLevelSecurity: [{ name: 'p', label: 'P', object: 'deal', ...policy }], }); const RLS = (f: { rule: string }) => f.rule.startsWith('rls-predicate-'); /** `os validate` step 3, in process — see the file header. */ -function cliDoor(using: string): AuthoringFinding[] { +function cliDoor(using: string, set = permissionSet(using)): AuthoringFinding[] { const config = { manifest: { id: 'com.example.rls', namespace: 'rls', version: '1.0.0', name: 'RLS', type: 'app' }, objects: [deal, account], - permissions: [permissionSet(using)], + permissions: [set], }; const normalized = normalizeStackInput(config as Record); const lowering = lowerCallables(normalized as Record); @@ -126,10 +128,10 @@ interface SaveOutcome { issues: Array<{ rule: string; path: string; message: string }>; } -async function runtimeDoor(using: string): Promise { +async function runtimeDoor(using: string, set = permissionSet(using)): Promise { const { protocol } = await runtimeHost(); try { - await protocol.saveMetaItem({ type: 'permission', name: 'sales', item: permissionSet(using) }); + await protocol.saveMetaItem({ type: 'permission', name: 'sales', item: set }); return { accepted: true, issues: [] }; } catch (err) { const e = err as { code?: string; status?: number; issues?: SaveOutcome['issues'] }; @@ -235,3 +237,59 @@ describe('the judge pass binds an app-staged membership key to [] (#20158)', () expect(saved.issues.map((i) => i.message)).toEqual([cli[0].message]); }); }); + +/** + * [#19886] A field compared with a field that holds a list or an object — a + * `json` field or a `multiple` lookup — is refused when it is AUTHORED, at both + * doors, on every clause. The lowering sees the predicate's text and the + * engine's admission does not judge a `{ $field }` reference against the + * referenced column's type, so before this arm every row below was ACCEPTED at + * both doors (measured) while the runtime refused it: the write check per + * record (400), driver-sql on the read by declared type (400), and the by-id + * update or delete a `using` scopes fails closed (403). The rule judges by the + * DECLARED type its object graph carries; the full operator × clause × class × + * order table is pinned beside the rule in `@objectstack/lint`. + */ +describe('a field compared with a json / multiple field is refused at both doors, on every clause (#19886)', () => { + const ROWS: ReadonlyArray<{ label: string; clause: 'using' | 'check'; operation: string; predicate: string }> = [ + { label: 'using on select, != a json field', clause: 'using', operation: 'select', predicate: 'record.region != record.tags' }, + { label: 'using on all, the json field first', clause: 'using', operation: 'all', predicate: 'record.tags != record.region' }, + { label: 'using on update, a negated == a multiple lookup', clause: 'using', operation: 'update', predicate: '!(record.owner == record.watchers)' }, + { label: 'using on delete, == a multiple lookup', clause: 'using', operation: 'delete', predicate: 'record.owner == record.watchers' }, + { label: 'check on insert, != a json field', clause: 'check', operation: 'insert', predicate: 'record.region != record.tags' }, + { label: 'check on update, > a multiple lookup', clause: 'check', operation: 'update', predicate: 'record.watchers > record.owner' }, + ]; + const CONTROLS: ReadonlyArray<{ label: string; clause: 'using' | 'check'; operation: string; predicate: string }> = [ + { label: 'using on select, text != text', clause: 'using', operation: 'select', predicate: 'record.region != record.owner' }, + { label: 'check on insert, number > number', clause: 'check', operation: 'insert', predicate: 'record.amount > record.amount' }, + { label: 'using on all, a json field null test', clause: 'using', operation: 'all', predicate: 'record.tags != null' }, + ]; + const setFor = (row: { clause: string; operation: string; predicate: string }) => + permissionSet('', { operation: row.operation, [row.clause]: row.predicate }); + + for (const row of ROWS) { + it(`REFUSED at both doors with one sentence — ${row.label}: \`${row.predicate}\``, async () => { + const cli = cliDoor('', setFor(row)); + const saved = await runtimeDoor('', setFor(row)); + + expect(cli.map((f) => ({ severity: f.severity, rule: f.rule, path: f.path }))).toEqual([ + { severity: 'error', rule: UNENFORCEABLE, path: `permissions[0].rowLevelSecurity[0].${row.clause}` }, + ]); + expect(cli[0].message).toContain('lowers, but compares a field with a field that holds a list or an object'); + + expect(saved.accepted).toBe(false); + expect({ code: saved.code, status: saved.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(saved.issues.map((i) => ({ rule: i.rule, path: i.path }))).toEqual([ + { rule: UNENFORCEABLE, path: `permissions.sales.rowLevelSecurity[0].${row.clause}` }, + ]); + expect(saved.issues[0].message).toBe(cli[0].message); + }); + } + + for (const row of CONTROLS) { + it(`ACCEPTED at both doors — ${row.label}: \`${row.predicate}\``, async () => { + expect(cliDoor('', setFor(row))).toEqual([]); + expect(await runtimeDoor('', setFor(row))).toEqual({ accepted: true, issues: [] }); + }); + } +}); diff --git a/packages/lint/src/validate-rls-predicate-enforceability.list-holding-field.test.ts b/packages/lint/src/validate-rls-predicate-enforceability.list-holding-field.test.ts new file mode 100644 index 00000000000..1a0cdb43746 --- /dev/null +++ b/packages/lint/src/validate-rls-predicate-enforceability.list-holding-field.test.ts @@ -0,0 +1,269 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19886] A field compared with a field that holds a LIST or an OBJECT is + * refused at authoring time, judged by the DECLARED type the rule's object + * graph carries. + * + * `record.status != record.tags` (`tags` a `json` field or a `multiple` lookup) + * lowers to a legal `{ status: { $ne: { $field: 'tags' } } }`, and before this + * arm nothing refused it when it was written: measured at `os validate` and at + * the metadata save door, every cell of the table below was clean. The runtime + * refuses every one of them — the write check per record (`INVALID_FILTER` / + * 400), driver-sql on the read by declared type (400), and the by-id update or + * delete a `using` scopes fails closed (403) — so each is a policy that can + * never do what it says. + * + * One table: operator × clause × column class × operand order, then the scalar + * field-to-field controls, which stay clean. Both doors are pinned with a real + * engine in `packages/cli/test/rls-policy-authoring-admission.test.ts`. + */ + +import { describe, expect, it } from 'vitest'; +import type { EngineFilterJudgement, EngineFilterJudgementOptions } from '@objectstack/spec/contracts'; + +import { + validateRlsPredicateEnforceability, + RLS_PREDICATE_UNENFORCEABLE, + RLS_PREDICATE_UNKNOWN_FIELD, +} from './validate-rls-predicate-enforceability.js'; +import { runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; + +const deal = { + name: 'deal', + label: 'Deal', + fields: { + status: { type: 'text', label: 'Status' }, + owner: { type: 'text', label: 'Owner' }, + amount: { type: 'number', label: 'Amount' }, + budget: { type: 'number', label: 'Budget' }, + close_date: { type: 'date', label: 'Close date' }, + signed_date: { type: 'date', label: 'Signed date' }, + account: { type: 'lookup', label: 'Account', reference: 'account' }, + stage: { type: 'select', label: 'Stage', options: [{ label: 'Open', value: 'open' }] }, + tags: { type: 'json', label: 'Tags' }, + reviewers: { type: 'lookup', label: 'Reviewers', reference: 'account', multiple: true }, + }, +}; +const account = { name: 'account', label: 'Account', fields: { region: { type: 'text', label: 'Region' } } }; + +/** One permission set, one policy on `deal`. */ +const stackWith = (policy: Record, objects: unknown[] = [deal, account]) => ({ + objects, + permissions: [{ name: 'sales', label: 'Sales', rowLevelSecurity: [{ name: 'p', object: 'deal', ...policy }] }], +}); + +/** Every clause, on every operation it can be authored on. */ +const CLAUSES = [ + { clause: 'using', operation: 'select' }, + { clause: 'using', operation: 'all' }, + { clause: 'using', operation: 'update' }, + { clause: 'using', operation: 'delete' }, + { clause: 'using', operation: 'insert' }, + { clause: 'check', operation: 'insert' }, + { clause: 'check', operation: 'update' }, + { clause: 'check', operation: 'all' }, +] as const; + +/** Each operator an author can write between two fields, and what the finding quotes back. */ +const OPERATORS: ReadonlyArray<{ op: string; spell: (a: string, b: string) => string; quoted: string }> = [ + { op: '!=', spell: (a, b) => `record.${a} != record.${b}`, quoted: '!=' }, + { op: '!(==)', spell: (a, b) => `!(record.${a} == record.${b})`, quoted: '==' }, + { op: '==', spell: (a, b) => `record.${a} == record.${b}`, quoted: '==' }, + { op: '>', spell: (a, b) => `record.${a} > record.${b}`, quoted: '>' }, + { op: '>=', spell: (a, b) => `record.${a} >= record.${b}`, quoted: '>=' }, + { op: '<', spell: (a, b) => `record.${a} < record.${b}`, quoted: '<' }, + { op: '<=', spell: (a, b) => `record.${a} <= record.${b}`, quoted: '<=' }, +]; + +const COLUMNS = [ + { column: 'a json field', field: 'tags', declared: "`tags` is declared `type: 'json'`" }, + { column: 'a multiple lookup', field: 'reviewers', declared: "`reviewers` is declared `type: 'lookup'`, `multiple: true`" }, +] as const; + +const CLASS_SENTENCE = + 'A column that holds a list or an object is not one comparable value, on either side of a field-to-field ' + + 'comparison, so the platform refuses the comparison instead of evaluating it'; + +describe('validateRlsPredicateEnforceability — a field compared with a json / multiple field is REFUSED (#19886)', () => { + for (const { clause, operation } of CLAUSES) { + for (const { op, spell, quoted } of OPERATORS) { + for (const { column, field, declared } of COLUMNS) { + for (const order of ['scalar first', 'list first'] as const) { + const [left, right] = order === 'scalar first' ? ['status', field] : [field, 'status']; + const predicate = spell(left, right); + it(`${clause} on ${operation} · ${op} · ${column} · ${order}: \`${predicate}\``, () => { + const findings = validateRlsPredicateEnforceability(stackWith({ operation, [clause]: predicate })); + expect(findings.map((f) => ({ severity: f.severity, rule: f.rule, path: f.path }))).toEqual([ + { severity: 'error', rule: RLS_PREDICATE_UNENFORCEABLE, path: `permissions[0].rowLevelSecurity[0].${clause}` }, + ]); + const [finding] = findings; + expect(finding.where).toBe('permission set "sales" policy "p" on object "deal"'); + expect(finding.message).toContain( + `RLS ${clause} \`${predicate}\` lowers, but compares a field with a field that holds a list or an ` + + `object: \`record.${left} ${quoted} record.${right}\`, where ${declared}. ${CLASS_SENTENCE}: `, + ); + expect(finding.hint).toMatch(/^A field compared with a `json` or `multiple` field has no row-filter form/); + }); + } + } + } + } + + it('the table covers every clause-operation, every field-to-field operator, both classes, both orders', () => { + expect(CLAUSES.length * OPERATORS.length * COLUMNS.length * 2).toBe(224); + }); + + it('states the consequence of its own clause — the read and the fail-closed write for `using`, the write for `check`', () => { + const using = validateRlsPredicateEnforceability(stackWith({ operation: 'select', using: 'record.status != record.tags' }))[0]; + expect(using.message).toContain( + 'every read this policy scopes is refused on the SQL drivers (`INVALID_FILTER` / 400: driver-sql refuses a ' + + 'cross-field comparison against such a column by its declared type), and every by-id update or delete it ' + + 'scopes fails closed (`PERMISSION_DENIED` / 403).', + ); + expect(using.message).toContain('the same `using` is also the write check whenever no applicable policy'); + + const check = validateRlsPredicateEnforceability(stackWith({ operation: 'insert', check: 'record.status != record.tags' }))[0]; + expect(check.message).toContain( + 'every single-record insert and by-id update whose record holds a list or an object in that column is ' + + 'refused (`INVALID_FILTER` / 400) and stores nothing', + ); + expect(check.message).not.toContain('every read this policy scopes'); + }); +}); + +describe('validateRlsPredicateEnforceability — every declared list-or-object class, read from the spec (#19886)', () => { + // The spec's two value-shape classes: `STRUCTURED_JSON_TYPES` and + // `isMultiValueField` (an inherently-multi option type, or a multi-capable + // type flagged `multiple: true`). + const LIST_HOLDING: ReadonlyArray<[string, Record, string]> = [ + ['json', { type: 'json' }, "`type: 'json'`"], + ['composite', { type: 'composite' }, "`type: 'composite'`"], + ['repeater', { type: 'repeater' }, "`type: 'repeater'`"], + ['record', { type: 'record' }, "`type: 'record'`"], + ['location', { type: 'location' }, "`type: 'location'`"], + ['address', { type: 'address' }, "`type: 'address'`"], + ['vector', { type: 'vector' }, "`type: 'vector'`"], + ['multiselect', { type: 'multiselect' }, "`type: 'multiselect'`"], + ['checkboxes', { type: 'checkboxes' }, "`type: 'checkboxes'`"], + ['tags', { type: 'tags' }, "`type: 'tags'`"], + ['multiple select', { type: 'select', multiple: true }, "`type: 'select'`, `multiple: true`"], + ['multiple radio', { type: 'radio', multiple: true }, "`type: 'radio'`, `multiple: true`"], + ['multiple lookup', { type: 'lookup', reference: 'account', multiple: true }, "`type: 'lookup'`, `multiple: true`"], + ['multiple user', { type: 'user', multiple: true }, "`type: 'user'`, `multiple: true`"], + ['multiple file', { type: 'file', multiple: true }, "`type: 'file'`, `multiple: true`"], + ['multiple image', { type: 'image', multiple: true }, "`type: 'image'`, `multiple: true`"], + ]; + for (const [label, def, declared] of LIST_HOLDING) { + it(`${label}: refused, naming the declaration`, () => { + const objects = [{ ...deal, fields: { ...deal.fields, subject: { label: 'Subject', ...def } } }, account]; + const findings = validateRlsPredicateEnforceability( + stackWith({ operation: 'select', using: 'record.status != record.subject' }, objects), + ); + expect(findings.map((f) => f.rule)).toEqual([RLS_PREDICATE_UNENFORCEABLE]); + expect(findings[0].message).toContain(`\`record.status != record.subject\`, where \`subject\` is declared ${declared}.`); + }); + } +}); + +describe('validateRlsPredicateEnforceability — the one-value spellings stay CLEAN (#19886 controls)', () => { + const CONTROLS: ReadonlyArray<[string, string]> = [ + ['text != text', 'record.status != record.owner'], + ['text == text', 'record.status == record.owner'], + ['!(text == text)', '!(record.status == record.owner)'], + ['number > number', 'record.amount > record.budget'], + ['date <= date', 'record.close_date <= record.signed_date'], + ['a single lookup == text', 'record.account == record.owner'], + ['a single select != text', 'record.stage != record.status'], + ['a json field against a literal (not a field-to-field comparison)', "record.tags == 'a'"], + ['a json field null test', 'record.tags != null'], + ['a flat literal list', "record.status in ['open', 'pending']"], + ['a membership set', 'record.owner in current_user.org_user_ids'], + ]; + for (const { clause, operation } of CLAUSES) { + for (const [label, predicate] of CONTROLS) { + it(`${clause} on ${operation} · ${label}: \`${predicate}\``, () => { + expect(validateRlsPredicateEnforceability(stackWith({ operation, [clause]: predicate }))).toEqual([]); + }); + } + } + + it('a single-valued field of a multi-capable type is one value: `select`, `lookup`, `user`, `file`', () => { + for (const def of [{ type: 'select' }, { type: 'lookup', reference: 'account' }, { type: 'user' }, { type: 'file' }]) { + const objects = [{ ...deal, fields: { ...deal.fields, subject: { label: 'Subject', ...def } } }, account]; + expect( + validateRlsPredicateEnforceability(stackWith({ operation: 'select', using: 'record.status != record.subject' }, objects)), + def.type, + ).toEqual([]); + } + }); +}); + +describe('validateRlsPredicateEnforceability — the arm is the graph\'s, and reports once (#19886)', () => { + it('names every offending comparison of one clause in ONE finding', () => { + const findings = validateRlsPredicateEnforceability( + stackWith({ operation: 'select', using: 'record.status != record.tags || record.reviewers == record.tags' }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain( + "object: `record.status != record.tags`, where `tags` is declared `type: 'json'`; " + + "`record.reviewers == record.tags`, where `reviewers` is declared `type: 'lookup'`, `multiple: true` and " + + "`tags` is declared `type: 'json'`. ", + ); + }); + + it('judges nothing the graph cannot answer: an object outside the stack, a field map it cannot read', () => { + expect(validateRlsPredicateEnforceability(stackWith({ operation: 'select', using: 'record.status != record.tags' }, [account]))).toEqual([]); + expect( + validateRlsPredicateEnforceability(stackWith({ operation: 'select', using: 'record.status != record.tags' }, [{ name: 'deal' }])), + ).toEqual([]); + }); + + it('an undeclared column is the unknown-field finding alone — this arm never doubles it', () => { + const findings = validateRlsPredicateEnforceability(stackWith({ operation: 'select', using: 'record.status != record.nope' })); + expect(findings.map((f) => f.rule)).toEqual([RLS_PREDICATE_UNKNOWN_FIELD]); + }); + + it('one defect, one finding: a read scope this arm refused is not handed to the engine judge', () => { + const calls: unknown[] = []; + const judgeFilter = (object: string, where: unknown, options?: EngineFilterJudgementOptions): EngineFilterJudgement => { + calls.push({ object, where, options }); + return { ok: true }; + }; + const refused = validateRlsPredicateEnforceability(stackWith({ operation: 'select', using: 'record.status != record.tags' }), { + judgeFilter, + }); + expect(refused.map((f) => f.rule)).toEqual([RLS_PREDICATE_UNENFORCEABLE]); + expect(calls).toEqual([]); + + // CONTROL: the scalar spelling reaches the judge, as every clean read scope does. + expect( + validateRlsPredicateEnforceability(stackWith({ operation: 'select', using: 'record.status != record.owner' }), { judgeFilter }), + ).toEqual([]); + expect(calls).toEqual([ + { object: 'deal', where: { status: { $ne: { $field: 'owner' } } }, options: { operation: 'find' } }, + ]); + }); + + it('reaches the author through `os validate`\'s rule table and the runtime publish gate alike', () => { + const stack = stackWith({ operation: 'insert', check: 'record.tags != record.status' }); + const cli = runAuthoringRules('validate', { normalized: stack, parsed: stack }).filter((f) => + f.rule.startsWith('rls-predicate-'), + ); + expect(cli.map((f) => ({ rule: f.rule, path: f.path }))).toEqual([ + { rule: RLS_PREDICATE_UNENFORCEABLE, path: 'permissions[0].rowLevelSecurity[0].check' }, + ]); + + const saved = runRuntimeAuthoringRules({ + type: 'permission', + item: stack.permissions[0], + context: { objects: [deal, account], permissions: [] }, + }); + const refused = saved.errors.filter((f) => f.rule.startsWith('rls-predicate-')); + expect(refused.map((f) => ({ rule: f.rule, path: f.path }))).toEqual([ + { rule: RLS_PREDICATE_UNENFORCEABLE, path: 'permissions.sales.rowLevelSecurity[0].check' }, + ]); + expect(refused[0].message).toBe(cli[0].message); + }); +}); diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index d3784234a44..d83e514c87c 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -991,15 +991,16 @@ const FIELD_COMPARISON_SYMBOL: ReadonlyMap = new Map([ ]); /** - * What a declared field holds when it holds a list or an object, or `null` - * when it holds one value (see this file's header for the two spec classes). + * The declaration that makes a field hold a list or an object, spelled as the + * author wrote it, or `null` when the field holds one value (see this file's + * header for the two spec classes). */ function listHoldingDeclaration(meta: GraphField | undefined): string | null { const type = meta?.type; if (!type) return null; - if (STRUCTURED_JSON_TYPES.has(type)) return `a \`${type}\` field`; + if (STRUCTURED_JSON_TYPES.has(type)) return `\`type: '${type}'\``; if (!isMultiValueField({ type, multiple: meta.multiple === true })) return null; - return meta.multiple === true ? `a \`multiple\` \`${type}\` field` : `a \`${type}\` field`; + return meta.multiple === true ? `\`type: '${type}'\`, \`multiple: true\`` : `\`type: '${type}'\``; } /** One lowered comparison between two columns, at least one of which holds a list or an object. */ @@ -1033,7 +1034,7 @@ function listHoldingComparisons( for (const name of new Set([site.field, referenced])) { const verdict = resolveFieldPath(graph, object, name); const held = verdict?.kind === 'ok' ? listHoldingDeclaration(verdict.meta) : null; - if (held) columns.push(`\`${name}\` is ${held}`); + if (held) columns.push(`\`${name}\` is declared ${held}`); } if (columns.length === 0) continue; const written = `record.${site.field} ${FIELD_COMPARISON_SYMBOL.get(site.op)} record.${referenced}`; From fdda49e59b56896ae306aac1d616e19cffde867b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:22:48 +0000 Subject: [PATCH 3/4] chore(changeset): the json / multiple field-to-field arm, and its check-clause wording Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- ...list-holding-field-comparison-authoring.md | 24 +++++++++++++++++++ .../validate-rls-predicate-enforceability.ts | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .changeset/19886-rls-list-holding-field-comparison-authoring.md diff --git a/.changeset/19886-rls-list-holding-field-comparison-authoring.md b/.changeset/19886-rls-list-holding-field-comparison-authoring.md new file mode 100644 index 00000000000..437813a10c9 --- /dev/null +++ b/.changeset/19886-rls-list-holding-field-comparison-authoring.md @@ -0,0 +1,24 @@ +--- +"@objectstack/lint": minor +--- + +A row-level-security predicate that compares a field with a `json` or `multiple` field is refused when it is authored, at `os validate` / `os build` / `os lint` and at the metadata save door, instead of only when it runs (#19886). + +**BREAKING** — an accept-set narrowing, shipped by `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is already registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`, which names this class. + +Clause-②: no (narrowing) + +`record.status != record.tags`, with `tags` a `json` field or a `multiple` lookup, lowers to a legal filter shape, because the CEL lowering sees the predicate's text and not the object's field types, and the engine's filter admission does not judge a `{ $field }` reference against the referenced column's type either. Measured before this change: 400 cells (`==`, `!=`, `!(==)`, `>`, `<=`; a `json`, `address`, `multiselect`, `multiple` lookup and `multiple` user field; both operand orders; `using` on `select` / `all` / `update` / `delete` / `insert` and `check` on `insert` / `update` / `all`) were all accepted by the real `os validate` and by the save door. The runtime refused every one of them, measured through the real plugin-security on driver-sql: a read the `using` scopes answered `INVALID_FILTER` / 400, a by-id update or delete it scopes `PERMISSION_DENIED` / 403, and every insert or by-id update judged by the `check` (or by a `using` standing in as the check) `INVALID_FILTER` / 400, with nothing stored. + +What changes: + +- `@objectstack/lint`: `validateRlsPredicateEnforceability` reports `rls-predicate-unenforceable` for every lowered field-to-field comparison (`==`, `!=`, `>`, `>=`, `<`, `<=`, on either side, under `!` too) in which either column is DECLARED to hold a list or an object. The declaration is read from the stack's own objects through the spec's value-shape classes, the same two driver-sql refuses such a comparison by: a structured JSON type (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), or a multi-valued field (`multiselect`, `checkboxes`, `tags`, or `select` / `radio` / `lookup` / `user` / `file` / `image` with `multiple: true`). It judges `using` and `check` on every operation. The finding names each comparison and the declaration behind it, and states the clause's run-time consequence. A clause it refuses is not also handed to the engine's filter judge, so one defect earns one finding. +- Both doors run this rule already, so both refuse: `os validate` / `os build` / `os lint` fail, and a publish through the metadata save door answers `422 INVALID_METADATA` with the same sentence in `issues[]`. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns the save-door refusal into a logged warning. + +Not changed: a field compared with a single-valued field (`record.status != record.owner`, `record.amount > record.budget`), a `json` or `multiple` field compared with a literal or tested against `null`, and any column the stack does not declare (an object from another package, an external object with no field map), which the rule does not judge. The runtime refusals of stages 2d and 2e stay as the backstop. The stage 2d changeset's sentence that a field compared with a `json` or `multiple` field "is not reported at authoring time" no longer holds: it is now reported at both doors. + +No shipped predicate moves: 0 of the 187 `using` / `check` / `condition` strings in this repository's packages and examples, and 0 of the 3 in the cloud repository, compare a field with a `json` or `multiple` field, and `os validate` over `app-crm`, `app-multi-package` and `app-todo` reports no new finding. + +**What to change.** A field compared with a `json` or `multiple` field has no row-filter form: compare with a single-valued column, or with a literal or a `current_user` value ("one of these values" is `record.status in ['open', 'pending']`, or `record.owner in current_user.org_user_ids`), or move the condition into a validation rule or a hook. + + diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index d83e514c87c..2c4be42828c 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -1059,7 +1059,8 @@ function listHoldingConsequence(clause: 'using' | 'check'): string { 'update or delete it scopes fails closed (`PERMISSION_DENIED` / 403). On an `insert`, `update` or ' + '`all` policy the same `using` is also the write check whenever no applicable policy for that ' + `operation declares a \`check\` (ADR-0058 D4): then ${write}.` - : `${write}. The policy reads as a write rule and behaves as a refusal of every write it was meant to judge.`; + : `${write}. The policy reads as a comparison and behaves as a refusal of every write that leaves a list ` + + 'or an object in that column.'; } /** From 509728de8fc9f3c47c25421daa82dbe35e2ad8a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:54:34 +0000 Subject: [PATCH 4/4] chore(changeset): name every example the arm was measured over Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN --- .changeset/19886-rls-list-holding-field-comparison-authoring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/19886-rls-list-holding-field-comparison-authoring.md b/.changeset/19886-rls-list-holding-field-comparison-authoring.md index 437813a10c9..37995425236 100644 --- a/.changeset/19886-rls-list-holding-field-comparison-authoring.md +++ b/.changeset/19886-rls-list-holding-field-comparison-authoring.md @@ -17,7 +17,7 @@ What changes: Not changed: a field compared with a single-valued field (`record.status != record.owner`, `record.amount > record.budget`), a `json` or `multiple` field compared with a literal or tested against `null`, and any column the stack does not declare (an object from another package, an external object with no field map), which the rule does not judge. The runtime refusals of stages 2d and 2e stay as the backstop. The stage 2d changeset's sentence that a field compared with a `json` or `multiple` field "is not reported at authoring time" no longer holds: it is now reported at both doors. -No shipped predicate moves: 0 of the 187 `using` / `check` / `condition` strings in this repository's packages and examples, and 0 of the 3 in the cloud repository, compare a field with a `json` or `multiple` field, and `os validate` over `app-crm`, `app-multi-package` and `app-todo` reports no new finding. +No shipped predicate moves: 0 of the 187 `using` / `check` / `condition` strings in this repository's packages and examples, and 0 of the 3 in the cloud repository, compare a field with a `json` or `multiple` field, and the real `os validate` over `app-crm`, `app-multi-package`, `app-showcase` and `app-todo` reports no `rls-predicate-*` finding. **What to change.** A field compared with a `json` or `multiple` field has no row-filter form: compare with a single-valued column, or with a literal or a `current_user` value ("one of these values" is `record.status in ['open', 'pending']`, or `record.owner in current_user.org_user_ids`), or move the condition into a validation rule or a hook.