Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .changeset/20355-rls-write-check-cross-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
'@objectstack/plugin-security': minor
'@objectstack/formula': minor
'@objectstack/driver-sql': patch
'@objectstack/lint': patch
'@objectstack/spec': patch
---

fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355)

Clause-②: yes (narrowing)

<!-- adr-0087: registered rls-predicate-cross-class-field-comparison-refused -->

**BREAKING** — an accept-set narrowing on the row-level write check, shipped as `minor`
under the launch-window convention (`check-changeset-no-major` refuses `major` until GA;
breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the
level). The hand-migration prescription is registered under protocol major 18 as
`rls-predicate-cross-class-field-comparison-refused`, one ADR-0087 D3 entry for the whole
family: the authoring arm `os validate` gained in #20347 and this write-check arm.

**What changed.** A row-level policy that compares two fields of no shared comparison
class — `record.status != record.amount` (text and a number), `record.status !=
record.photo` (text and a file field), `record.status != record.is_open` (text and a
formula field), `record.status != record.meta` (text and a json field) — already had
every read it scopes refused with `INVALID_FILTER` / 400 on the SQL drivers, because
driver-sql compiles a column-to-column comparison only within one class. The write
check did not know the rule: it compared the two raw values in-process, so an insert
or update the policy's `check` judges (or its `using`, standing in as the check) was
admitted and stored whenever that comparison happened to hold. Measured through
plugin-security and ObjectQL on SQLite, sqlite-wasm and PostgreSQL. The write check
now refuses the comparison too, with the read's envelope, `INVALID_FILTER` / 400, for
every insert (single or array), by-id update and predicate update it judges, and
nothing is stored. The same-class comparisons it always compared are compared as
before. The 400 names no column of the policy; the server log names the policy and
both columns. A comparison against a json or `multiple` field is refused by its
declared type now, where it used to be judged by the value each record held.

**`@objectstack/formula`.** `matchesFilterCondition(record, filter, options?)` takes an
optional third argument: `options.fields`, the object's declared columns (`type` and
`multiple` per field name). Given it, every `{ $field }` comparison between two
declared columns is judged by `crossFieldComparisonVerdict` from
`@objectstack/spec/data` before any record is read, and one the platform defines no
answer for throws `INVALID_FILTER` / 400. Without it the evaluator behaves exactly as
before. Two new exports go with it: `findCrossFieldClassRefusal(filter, fields)`, the
pure judgement, and `crossFieldClassRefusalCarriedBy(error)`, which reads the refused
comparison off the error for a server-side log.

**`@objectstack/driver-sql`.** `crossFieldComparisonClass` reads the same export
(`crossFieldColumnVerdict`) instead of keeping its own copy of the classification, and
layers above it only its internal type aliases. Every read answers as before.

**`@objectstack/lint`.** The `rls-predicate-unenforceable` finding for such a
comparison now states the write answer the runtime gives: the in-process write check
refuses it by the same classification and stores nothing.

**If a policy of yours is refused.** The platform defines no comparison between those
two columns on any path, so the policy never protected a read either. Compare a field
only with a field of the same class — a number with a number, text with text, a
boolean with a boolean, a date with a date, a datetime with a datetime, a time of day
with a time of day — or, if the two columns do hold comparable values, correct the
declaration of the one declared with the wrong type. `os validate` names every such
comparison.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20355] This driver's cross-field comparison class READS the spec's
* classification (`crossFieldColumnVerdict`, `@objectstack/spec/data`) for
* every declared `FieldType`, and layers above it only what the spec leaves to
* a driver: its internal aliases, which are not `FieldType` members and which
* the spec answers `undefined` for.
*
* The `FieldType` half needs no pin of its own any more — it IS the export,
* which `filter-cross-field-comparison-class.test.ts` pins in the spec. The
* #20347 parity test that held this driver's private copy equal to the export
* over every declared pair retired with the copy. What this file pins is the
* alias layer the rewire kept, read off the driver's own column sets:
*
* | declared `type` | class | why |
* |---|---|---|
* | `integer` / `int` / `float` | numeric | `NUMERIC_SCALAR_TYPES`' driver-internal aliases |
* | `object` / `array` | none — refused | `JSON_COLUMN_TYPES`' driver-internal aliases |
*
* (The absent-type `string` default is not pinned: `createColumn` refuses a
* field that declares no `type`, so no managed table carries one to compare.)
*
* Each cell is read from what the driver DOES — the comparison compiles and
* runs, or it is refused in the `INVALID_FILTER` / 400 envelope — so an alias
* the rewire reclassified shows up as an admitted refusal or a refused
* admission, never as prose.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqlDriver, withheldFilterDiagnosticOf } from './index.js';
import type { FilterCondition } from '@objectstack/spec/data';

const OBJ = 'cfc_alias_probe';

const FIELDS: Record<string, Record<string, unknown>> = {
id: { name: 'id', type: 'text' },
f_number: { name: 'f_number', type: 'number' },
f_integer: { name: 'f_integer', type: 'integer' },
f_int: { name: 'f_int', type: 'int' },
f_float: { name: 'f_float', type: 'float' },
f_text: { name: 'f_text', type: 'text' },
f_object: { name: 'f_object', type: 'object' },
f_array: { name: 'f_array', type: 'array' },
f_json: { name: 'f_json', type: 'json' },
};

type Observed = 'admitted' | 'refused';

const CELLS: ReadonlyArray<[target: string, ref: string, expected: Observed]> = [
['f_integer', 'f_number', 'admitted'],
['f_int', 'f_float', 'admitted'],
['f_float', 'f_number', 'admitted'],
['f_integer', 'f_text', 'refused'],
['f_object', 'f_text', 'refused'],
['f_text', 'f_array', 'refused'],
['f_object', 'f_object', 'refused'],
['f_object', 'f_json', 'refused'],
];

describe('[#20355] driver-sql cross-field class — the driver-internal aliases above the spec classification', () => {
let driver: SqlDriver;

beforeAll(async () => {
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await driver.initObjects([{ name: OBJ, fields: FIELDS } as never]);
});

afterAll(async () => {
await driver.disconnect();
});

async function observe(target: string, ref: string): Promise<Observed> {
const where = { [target]: { $eq: { $field: ref } } } as FilterCondition;
try {
await driver.find(OBJ, { fields: ['id'], where });
return 'admitted';
} catch (e) {
const err = e as { code?: unknown; status?: unknown };
expect({ code: err.code, status: err.status }, `${target} vs ${ref}: ${String(e)}`)
.toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(withheldFilterDiagnosticOf(e), `${target} vs ${ref}: not the cross-field boundary's refusal`)
.toMatch(/stored as|no scalar stored column/);
return 'refused';
}
}

for (const [target, ref, expected] of CELLS) {
it(`${target} vs ${ref}: ${expected}, in both orders`, async () => {
expect(await observe(target, ref)).toBe(expected);
expect(await observe(ref, target)).toBe(expected);
});
}
});
41 changes: 30 additions & 11 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERI
// `os generate migration` reads the SAME table, in both of its formats — that
// shared table IS the repair, so ⛔ never restate one of its numbers here.
import { numericColumnFor } from '@objectstack/spec/data';
// [#20355] The cross-field comparison class, defined once in the spec (#20347,
// lifted case for case from this driver's #5222 boundary). This driver READS it
// — `crossFieldComparisonClass` below delegates — so the read it compiles and
// the write check `@objectstack/formula` evaluates judge one comparison by one
// rule.
import { crossFieldColumnVerdict, type CrossFieldComparisonClass } from '@objectstack/spec/data';
// [#5659] The Filter Protocol's boolean identity reduction — `$and: []` is TRUE,
// `$or: []` is FALSE, `{}` is a TRUE disjunct, `$not: {}` is FALSE. One
// implementation for all four consumers, proven against the same
Expand Down Expand Up @@ -2729,6 +2735,16 @@ const CROSS_FIELD_COMPARISON_OPERATORS: ReadonlySet<string> = new Set([
* [#5222] The comparison class a declared field's stored column belongs to, or
* `null` for a field no column-to-column comparison can be compiled against.
*
* [#20355] The classification is the SPEC'S now — `crossFieldColumnVerdict`
* (`@objectstack/spec/data`), lifted case for case from this function by
* #20347 — and this function is its reader for the one thing the spec leaves
* to a driver: its internal aliases. The write check (`@objectstack/formula`'s
* `matchesFilterCondition`, handed the object's declared columns by the RLS
* write gate) and the authoring door (`@objectstack/lint`) read the same
* export, so a policy's comparison has one answer on the read, on the write
* and at `os validate`. The reasoning below is the classification's, kept
* here because this driver is where it was measured.
*
* Cross-field comparison is only emitted between two columns of the SAME
* class. One class = one storage shape on both sides of one row, which is what
* makes the SQL answer provably the memory evaluator's answer (the cross-path
Expand Down Expand Up @@ -2771,19 +2787,22 @@ const CROSS_FIELD_COMPARISON_OPERATORS: ReadonlySet<string> = new Set([
*/
function crossFieldComparisonClass(
decl: Record<string, unknown>,
): 'numeric' | 'text' | 'boolean' | 'date' | 'datetime' | 'time' | null {
): CrossFieldComparisonClass | null {
const type = String((decl as { type?: unknown }).type || 'string');
if (isMultiValuedColumn(type, decl)) return null;
if (type === 'formula') return null;
if (JSON_COLUMN_TYPES.has(type) || FILE_REFERENCE_TYPES.has(type)) return null;
// [#20355] A declared `FieldType` is the spec's to classify — the one
// classification the write check and the authoring door read too. Its
// `multiple` reading is `isMultiValueField`'s, the same predicate
// `isMultiValuedColumn` asks.
const verdict = crossFieldColumnVerdict({ type, multiple: (decl as { multiple?: unknown }).multiple === true });
if (verdict !== undefined) return verdict.kind === 'class' ? verdict.class : null;
// A type outside `FieldType` is a driver-internal alias the spec does not
// judge (its module header: "a driver layers its own aliases above this
// table"). This driver's are read off its own column sets, never restated:
// `object` / `array` are JSON columns ({@link JSON_COLUMN_TYPES}), `integer` /
// `int` / `float` numeric ones ({@link NUMERIC_SCALAR_TYPES}), and everything
// else — the absent-type default `string` included — is stored as TEXT.
if (JSON_COLUMN_TYPES.has(type)) return null;
if (NUMERIC_SCALAR_TYPES.has(type)) return 'numeric';
if (type === 'boolean' || type === 'toggle') return 'boolean';
if (type === 'date') return 'date';
if (type === 'datetime') return 'datetime';
if (type === 'time') return 'time';
// Everything else `createColumn` stores as TEXT: string/text/textarea/html/
// markdown/email/url/phone/password, select, lookup/user (row ids),
// autonumber, and the unknown-type default.
return 'text';
}

Expand Down
5 changes: 5 additions & 0 deletions packages/formula/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ export { __resetPushdownLimitWarnings } from './cel-to-filter';
// conditions ARE the red/green line.
export { isSupportedRlsExpression, sqlPredicateToCel } from './rls-predicate';
export { matchesFilterCondition } from './matches-filter';
// #20355 — the write check's comparison-class rule: the spec's cross-field
// classification, judged over a filter against the object's declared columns,
// and the reader a caller uses to log the refused comparison server-side.
export { crossFieldClassRefusalCarriedBy, findCrossFieldClassRefusal } from './matches-filter';
export type { CrossFieldClassRefusal, MatchesFilterOptions } from './matches-filter';
// #13594 — the function-EXISTENCE verdict, isolated from the rest of what
// cel-js's `check()` has an opinion about. Published for the same reason as
// `firstUndeclaredReference` above and under the same discipline: the answer to
Expand Down
Loading
Loading