From 2ed80c43a7f6121cc729514502ea262f7180d6cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 02:54:41 +0000 Subject: [PATCH 1/2] fix(plugin-security): the RLS write check refuses an operator the read refuses on a declared JSON-stored column The write check now refuses, with the read's INVALID_FILTER / 400 and @objectstack/core's words, an operator in JSON_COLUMN_INCOMPATIBLE_OPERATORS (or implicit equality) aimed at a column the object declares JSON-stored, so a policy whose read is refused no longer admits a write. The gate logs the withheld diagnostic beside the policy's name. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../src/rls-check-stored-form.ts | 222 +++++++++++++++++- .../plugin-security/src/security-plugin.ts | 22 +- 2 files changed, 241 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-security/src/rls-check-stored-form.ts b/packages/plugins/plugin-security/src/rls-check-stored-form.ts index 88931797568..6da3d056d3f 100644 --- a/packages/plugins/plugin-security/src/rls-check-stored-form.ts +++ b/packages/plugins/plugin-security/src/rls-check-stored-form.ts @@ -84,7 +84,58 @@ * The comparands are left as written, because the read pairs none with the * wrap: `$contains` / `$notContains` take one MEMBER, and every scalar * comparison on such a column is refused by the read - * (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`), never compared with a list. + * (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`), never compared with a list — and, + * since [#21254], by this step too (next section). + * + * ## [#21254] An operator the read refuses on a JSON-stored column is refused here too + * + * `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS` is the set of + * operators no face answers on a column the object declares JSON-stored (a + * structured-JSON type, or a multi-valued field): the scalar comparisons, the + * orderings and the text operators other than the membership pair. The read a + * policy scopes is compiled by the driver, which refuses them with + * `INVALID_FILTER` / 400; the engine's per-aggregation `filter` and + * `driver-memory`'s filter gate refuse the same set on the same declared + * fields. The write check judged them instead, in JS, against the stored + * list. Measured through `ObjectQL.insert` + `SecurityPlugin` + two SQL driver + * families as a member resolving a permission set, `using` and `check` the + * same predicate, `tags` declared `tags`: + * + * | `check` | written | write, before | stored | read under the same predicate | + * |---|---|---|---|---| + * | `record.tags != 'x'` | `['x']` or `'x'` | admitted | `["x"]` | 400 | + * | `!(record.tags in ['x'])` | `['x']` | admitted | `["x"]` | 400 | + * | `record.tags == 'x'` | `['x']` | 403 | — | 400 | + * | `record.tags in ['x']` | `['x']` | 403 | — | 400 | + * | `record.tags > 'a'` | `['x']` | 400, the evaluator's list-under-ordering refusal | — | 400 | + * + * The first two are the exclusion family's fail-OPEN: a list never equals a + * scalar, so "not equal" held for the very row the policy names, and a policy + * whose read is refused admitted that write. So this step refuses what the + * read refuses, by one rule ({@link findJsonColumnCheckRefusal}): an operator + * in that set, or implicit equality, aimed at a column the object declares + * JSON-stored, whatever the comparand, at any depth under `$and` / `$or` / + * `$not` — the traversal objectql's per-aggregation gate takes. The set and + * the words are core's (`jsonColumnOperatorRefusalText`), imported, never + * copied; the error constructor is this face's, as each face keeps its own, + * with the read's envelope, `INVALID_FILTER` / 400. All five rows above now + * get it, so the write and the read give one answer for one policy. The three + * that refused before still admit nothing; their answer is now the read's. + * + * It reads the declaration, never the record: the verdict is reached once, + * from the parts and the declared columns, and every image the judge is handed + * gets it before any is evaluated, so a policy is refused for every row or for + * none. What still answers on such a column is unchanged: the membership pair + * `$contains` / `$notContains`, and the presence predicates `$null`, + * `$exists`, `$empty`. A column declared neither way, and an object whose + * schema cannot be loaded, are judged as before. + * + * The message names neither the field nor the operator (the policy is an + * administrator's, and the caller is usually not its author) and says the full + * diagnostic is in the server log. The write gate makes that true: the + * diagnostic travels on the error, off the wire + * ({@link jsonColumnCheckRefusalCarriedBy}), and the gate logs it beside the + * policy's name. * * ## What it does not carry * @@ -97,16 +148,25 @@ * rule declares. */ -import { multiValueStorageForm, temporalStorageForm, type TemporalComparandKind } from '@objectstack/core'; +import { + JSON_COLUMN_INCOMPATIBLE_OPERATORS, + jsonColumnOperatorRefusalText, + multiValueStorageForm, + temporalStorageForm, + type TemporalComparandKind, +} from '@objectstack/core'; import { matchesFilterCondition, type MatchesFilterOptions } from '@objectstack/formula'; +import { StandardErrorCode } from '@objectstack/spec/api'; import { CALENDAR_DATE_TYPES, CLOCK_TIME_TYPES, INSTANT_TYPES, + STRUCTURED_JSON_TYPES, filterSubtreeProvenanceOf, isMultiValueField, markFilterSubtreeProvenance, } from '@objectstack/spec/data'; +import { compiledPolicyNameOf } from './rls-compiler.js'; /** The declared temporal columns of one object, by name, each with its storage rule's kind. */ export type DeclaredTemporalColumns = ReadonlyMap; @@ -163,6 +223,156 @@ export function declaredMultiValueColumns(columns: MatchesFilterOptions | undefi return out; } +/** [#21254] The declared JSON-stored columns of one object, by name. */ +export type DeclaredJsonStoredColumns = ReadonlySet; + +/** + * [#21254] The columns `columns` declares JSON-stored: the declared + * multi-valued columns ({@link declaredMultiValueColumns}) and the columns of a + * structured-JSON type (the spec's `STRUCTURED_JSON_TYPES`). These are the two + * halves every face of core's JSON-column refusal reads, and the population + * `matchesFilterCondition` already asks `$contains` membership of, over the + * same declaration. Empty when the object hands over no declaration. + */ +export function declaredJsonStoredColumns(columns: MatchesFilterOptions | undefined): DeclaredJsonStoredColumns { + const out = new Set(); + const multiValue = declaredMultiValueColumns(columns); + for (const [name, decl] of Object.entries(columns?.fields ?? {})) { + if (multiValue.has(name) || STRUCTURED_JSON_TYPES.has(decl.type)) out.add(name); + } + return out; +} + +/** [#21254] One operator the read refuses, as {@link findJsonColumnCheckRefusal} found it. */ +export interface JsonColumnCheckRefusal { + /** The declared JSON-stored column the operator is aimed at. */ + readonly field: string; + /** The operator as written in the compiled check; `=` for implicit equality. */ + readonly operator: string; + /** Where it sits: `check[]`, then the path through the compiled filter. */ + readonly path: string; + /** The policy the offending node was compiled from, when the compiler marked one. */ + readonly policy: string | undefined; + /** What the caller is told: core's message, which names neither the field nor the operator. */ + readonly message: string; + /** + * Core's full diagnostic, the field and the operator named. SERVER-SIDE ONLY: + * the policy is an administrator's, so it goes to a log, never into an error + * message (see {@link jsonColumnCheckRefusalCarriedBy}). + */ + readonly diagnostic: string; +} + +/** + * [#21254] A column condition that is IMPLICIT equality: a comparand rather + * than an operator map (a primitive, `null`, a `Date` or an array). The split + * objectql's per-aggregation gate makes on the same shapes. + */ +function isImplicitEquality(condition: unknown): boolean { + return typeof condition !== 'object' + || condition === null + || condition instanceof Date + || Array.isArray(condition); +} + +/** + * [#21254] The first operator in `parts` that the read refuses on a declared + * JSON-stored column, or `null` when there is none: an operator in + * `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, or implicit + * equality, whatever the comparand (`null` and a `{ $field }` operand + * included), at any depth under `$and` / `$or` / `$not`. Pure: it reads the + * parts and the declaration, never a record. + * + * The traversal is objectql's per-aggregation gate's + * (`assertAggregationFilterSparesJsonStoredFields`): any other `$` key is not + * a column and is left to the evaluator, and so is a column the declaration + * does not name JSON-stored. The words are core's + * (`jsonColumnOperatorRefusalText`); the bare spelling's operator is `=`. + */ +export function findJsonColumnCheckRefusal( + parts: readonly Record[], + jsonStored: DeclaredJsonStoredColumns, +): JsonColumnCheckRefusal | null { + if (jsonStored.size === 0) return null; + const refusal = ( + field: string, + operator: string, + bare: boolean, + path: string, + policy: string | undefined, + ): JsonColumnCheckRefusal => { + const { message, diagnostic } = jsonColumnOperatorRefusalText(field, operator, bare); + return { field, operator, path, policy, message, diagnostic }; + }; + const walk = (cond: unknown, path: string, policy: string | undefined): JsonColumnCheckRefusal | null => { + if (!cond || typeof cond !== 'object') return null; + const owner = compiledPolicyNameOf(cond) ?? policy; + for (const [key, value] of Object.entries(cond)) { + const here = `${path}.${key}`; + if (key === '$and' || key === '$or') { + const branches = Array.isArray(value) ? value : [value]; + for (let i = 0; i < branches.length; i++) { + const found = walk(branches[i], `${here}[${i}]`, owner); + if (found) return found; + } + continue; + } + if (key === '$not') { + const found = walk(value, here, owner); + if (found) return found; + continue; + } + if (key.startsWith('$') || !jsonStored.has(key)) continue; + if (isImplicitEquality(value)) return refusal(key, '=', true, here, owner); + for (const op of Object.keys(value as Record)) { + if (JSON_COLUMN_INCOMPATIBLE_OPERATORS.has(op)) return refusal(key, op, false, `${here}.${op}`, owner); + } + } + return null; + }; + for (let i = 0; i < parts.length; i++) { + const found = walk(parts[i], `check[${i}]`, undefined); + if (found) return found; + } + return null; +} + +/** + * [#21254] The refusal carried on the error, under a SYMBOL key, for the + * reason `@objectstack/formula`'s comparison-class refusal carries its own + * that way: `JSON.stringify`, a spread, `Object.keys` and the structured-clone + * boundary all skip it, so no error mapper can put the field and the operator + * back on the wire. `Symbol.for` so a duplicated copy of this package resolves + * the same key. + */ +const JSON_COLUMN_CHECK_REFUSAL = Symbol.for('objectstack.plugin-security.jsonColumnCheckRefusal'); + +/** + * [#21254] The write check's refusal: core's message, with the envelope the + * read gives the same policy, `INVALID_FILTER` / 400 (and `httpStatus`, the + * same number under ADR-0112 D5's spelling, as the engine's own filter + * refusals carry it). + */ +function jsonColumnCheckRefusalError(refusal: JsonColumnCheckRefusal): Error { + const err = new Error(refusal.message) as Error & { code?: string; status?: number; httpStatus?: number }; + err.code = StandardErrorCode.enum.INVALID_FILTER; + err.status = 400; + err.httpStatus = 400; + Object.defineProperty(err, JSON_COLUMN_CHECK_REFUSAL, { value: refusal, enumerable: false }); + return err; +} + +/** + * [#21254] The JSON-column refusal an error carries, or `null` for any other + * error: the read half of the judge's refusal, for the write gate, which logs + * the diagnostic server-side beside the policy's name. + */ +export function jsonColumnCheckRefusalCarriedBy(err: unknown): JsonColumnCheckRefusal | null { + if (err === null || (typeof err !== 'object' && typeof err !== 'function')) return null; + const refusal = (err as Record)[JSON_COLUMN_CHECK_REFUSAL]; + return refusal && typeof refusal === 'object' ? (refusal as JsonColumnCheckRefusal) : null; +} + /** A plain object: a filter node, an operator map or a `{ $field }` reference — never a comparand value. */ function isPlainObject(value: unknown): value is Record { if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; @@ -301,6 +511,12 @@ export function storedFormImage( * A refusal the evaluator raises propagates unchanged. The parts the caller * attributes it to are its own: the rewritten parts are used for evaluation * only. + * + * [#21254] One refusal is this step's own: an operator the read refuses on a + * declared JSON-stored column ({@link findJsonColumnCheckRefusal}). It is + * found once, here, on the parts as compiled (they carry the policy marks), + * and thrown for every image before any is evaluated, so the verdict is the + * declaration's and never a record's. */ export function storedFormCheckJudge( parts: readonly Record[], @@ -308,10 +524,12 @@ export function storedFormCheckJudge( ): (image: Record) => boolean { const temporal = declaredTemporalColumns(columns); const multiValue = declaredMultiValueColumns(columns); + const refusal = findJsonColumnCheckRefusal(parts, declaredJsonStoredColumns(columns)); // The comparands are put into the temporal form only: on a multi-valued // column the read pairs no comparand with the wrap (see the module note). const storedParts = parts.map((part) => storedFormCheckFilter(part, temporal)); return (image) => { + if (refusal) throw jsonColumnCheckRefusalError(refusal); const stored = storedFormImage(image, temporal, multiValue); return storedParts.every((part) => matchesFilterCondition(stored, part as never, columns)); }; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 2a6806c0820..72992a01ac5 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -37,7 +37,7 @@ import { d10NarrowingStatement, } from './explain-engine.js'; import { declaredComparisonColumns } from './declared-comparison-columns.js'; -import { storedFormCheckJudge } from './rls-check-stored-form.js'; +import { jsonColumnCheckRefusalCarriedBy, storedFormCheckJudge } from './rls-check-stored-form.js'; import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -3335,6 +3335,26 @@ export class SecurityPlugin implements Plugin { }, ); } + // [#21254] An operator the read refuses on a declared JSON-stored + // column. The caller's 400 withholds the field and the operator + // and says the full diagnostic is in the server log: this line. + const jsonColumnRefusal = jsonColumnCheckRefusalCarriedBy(e); + if (jsonColumnRefusal) { + const policy = jsonColumnRefusal.policy ?? '(unattributed)'; + ctx.logger.warn( + `[Security] RLS check REFUSED on ${opCtx.operation} '${opCtx.object}' (INVALID_FILTER): ` + + `policy '${policy}' — At ${jsonColumnRefusal.path}: ${jsonColumnRefusal.diagnostic} The ` + + `read this policy scopes is refused for the same reason.`, + { + operation: opCtx.operation, + object: opCtx.object, + policies: [policy], + field: jsonColumnRefusal.field, + operator: jsonColumnRefusal.operator, + userId: opCtx.context?.userId ?? 'unknown', + }, + ); + } throw e; } }; From 9e6b96b95ba1227487d30b30c3dc1f9ed5cafa3e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:08:19 +0000 Subject: [PATCH 2/2] test(plugin-security): pin the write check's JSON-column refusal against the read, on two driver families The card's table at the engine write door beside the read the same policy scopes, the membership-pair, presence and scalar-column controls, a unit pin of the declaration-only verdict, and the stage-2e fixtures re-judged: a scalar on a declared JSON-stored column is now refused by the declaration, and the null / equality controls move to a text column where the evaluator still decides. Plus the changeset. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- ...rite-check-json-column-operator-refusal.md | 21 ++ .../src/rls-check-stored-form.test.ts | 228 +++++++++++++++++- ...-stored-list-ordering-fails-closed.test.ts | 53 ++-- 3 files changed, 280 insertions(+), 22 deletions(-) create mode 100644 .changeset/21254-rls-write-check-json-column-operator-refusal.md diff --git a/.changeset/21254-rls-write-check-json-column-operator-refusal.md b/.changeset/21254-rls-write-check-json-column-operator-refusal.md new file mode 100644 index 00000000000..c8bb61e39d5 --- /dev/null +++ b/.changeset/21254-rls-write-check-json-column-operator-refusal.md @@ -0,0 +1,21 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): a row-level `check` refuses an operator the read refuses on a field declared JSON-stored, with the read's `INVALID_FILTER` / 400, so a policy whose read is refused no longer admits writes (#21254) + +Clause-②: no + +The read a row-level policy scopes refuses a scalar comparison, an ordering or a text operator (`@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, and implicit equality) on a field the object declares JSON-stored: a structured-JSON type (`json`, `address`, …), or a multi-valued field (`tags`, `multiselect`, `checkboxes`, or a `select` / `lookup` / `user` / `file` / `image` flagged `multiple: true`). The write `check` evaluated the same operators against the stored list instead. Measured through `ObjectQL.insert` with `SecurityPlugin` on two SQLite driver families, as a member resolving a permission set, with the same predicate as `using` and `check`: + +| `check` | written | write, before | read | +|---|---|---|---| +| `record.tags != 'x'` | `['x']` or `'x'` | admitted, stored `["x"]` | 400 | +| `!(record.tags in ['x'])` | `['x']` | admitted, stored `["x"]` | 400 | +| `record.tags == 'x'` / `record.tags in ['x']` | `['x']` | 403 | 400 | +| `record.tags > 'a'` | `['x']` | 400 | 400 | +| `record.meta != 'x'` / `record.meta == 'x'` (`meta` is `json`) | a scalar | admitted, stored | 400 | + +Now the write check refuses every one of these with the read's answer: `INVALID_FILTER` / 400 and the read's words, which withhold the field and the operator. The refusal reads the object's declaration, never the record, so a policy is refused for every row or for none, on the insert, a by-id update and a predicate update. The diagnostic, which names the field, the operator and the policy, goes to the server log. Rows that already refused still store nothing; their answer is now the read's. + +Unchanged: `contains` and its negation (`$contains` / `$notContains`), and the presence checks (`== null`, `!= null`), answer on such a field as before; a field declared neither way keeps every operator; an object whose schema cannot be loaded is judged as before. To repair a refused policy, test membership with `contains` (for example `!record.tags.contains('x')`). diff --git a/packages/plugins/plugin-security/src/rls-check-stored-form.test.ts b/packages/plugins/plugin-security/src/rls-check-stored-form.test.ts index b6ff2157d0a..b244ff4a834 100644 --- a/packages/plugins/plugin-security/src/rls-check-stored-form.test.ts +++ b/packages/plugins/plugin-security/src/rls-check-stored-form.test.ts @@ -29,6 +29,23 @@ * | `!record.tags.contains('x')` | `'x'` | admitted | `["x"]` | hidden | * | `record.tags.contains('x')`, a by-id update | `'x'` | 403 | `["x"]` | shown | * + * [#21254] An operator the read refuses on a declared JSON-stored column + * (`@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, or implicit + * equality) is refused by the write check too, with the read's + * `INVALID_FILTER` / 400 and core's words. Measured on `main` before the step: + * + * | `check` | written | write, before | stored | read | + * |---|---|---|---|---| + * | `record.tags != 'x'` | `['x']` or `'x'` | admitted | `["x"]` | 400 | + * | `!(record.tags in ['x'])` | `['x']` | admitted | `["x"]` | 400 | + * | `record.tags == 'x'` | `['x']` | 403 | — | 400 | + * | `record.tags in ['x']` | `['x']` | 403 | — | 400 | + * | `record.tags > 'a'` | `['x']` | 400 | — | 400 | + * | `record.meta != 'x'` / `record.meta == 'x'` (`json`) | `'y'` / `'x'` | admitted | the scalar | 400 | + * + * The membership pair and the presence predicates answer as before, and so + * does every operator on a column declared neither way. + * * ## formula's whole-day copy is out of reach here * * `@objectstack/formula`'s matcher carries its own copy of the whole-day upper @@ -44,6 +61,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; // The mocked module (see `vi.mock` below), loaded at module top. import { matchesFilterCondition } from '@objectstack/formula'; +import { JSON_COLUMN_INCOMPATIBLE_OPERATORS, jsonColumnOperatorRefusalText } from '@objectstack/core'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; @@ -51,8 +69,11 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; import { + declaredJsonStoredColumns, declaredMultiValueColumns, declaredTemporalColumns, + findJsonColumnCheckRefusal, + jsonColumnCheckRefusalCarriedBy, storedFormCheckFilter, storedFormCheckJudge, storedFormImage, @@ -117,8 +138,11 @@ afterEach(async () => { }); let seq = 0; -/** One engine and plugin, with ONE policy whose `using` and `check` are the same predicate. */ -async function boot(makeDriver: () => Driver, predicate: string) { +/** + * One engine and plugin, with ONE policy whose `using` and `check` are the same + * predicate — or, given `using`, a policy whose `using` is that one instead. + */ +async function boot(makeDriver: () => Driver, predicate: string, using: string = predicate) { const OBJ = `qa_due_stored_${process.pid}_${++seq}`; const engine = new ObjectQL(); engine.registerDriver(makeDriver() as never, true); @@ -142,6 +166,7 @@ async function boot(makeDriver: () => Driver, predicate: string) { start_time: { name: 'start_time', type: 'time' }, tags: { name: 'tags', type: 'tags' }, owners: { name: 'owners', type: 'select', multiple: true, options: [{ label: 'X', value: 'x' }, { label: 'XY', value: 'xy' }] }, + meta: { name: 'meta', type: 'json' }, }, }, ], @@ -152,7 +177,7 @@ async function boot(makeDriver: () => Driver, predicate: string) { const set = PermissionSetSchema.parse({ name: 'qa_due_guard', objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, - rowLevelSecurity: [{ name: 'due_guard', object: OBJ, operation: 'all', using: predicate, check: predicate }], + rowLevelSecurity: [{ name: 'due_guard', object: OBJ, operation: 'all', using, check: predicate }], }); const services: Record = { manifest: { register: vi.fn() }, @@ -180,7 +205,7 @@ async function boot(makeDriver: () => Driver, predicate: string) { ((await engine.find(OBJ, { where: { id }, context: SYS_CTX } as never)) as Array>)[0]; const shownTo = async (id: string) => ((await engine.find(OBJ, { where: { id }, context: caller } as never)) as unknown[]).length > 0; - return { OBJ, engine, caller, storedRow, shownTo }; + return { OBJ, engine, caller, storedRow, shownTo, logger: ctx.logger }; } type Envelope = { code: string; status: number }; @@ -290,6 +315,201 @@ for (const [driverName, makeDriver] of DRIVERS) { }); } +// [#21254] The card's table at the engine write door, beside the read the same +// policy scopes. A refused cell names what the withheld diagnostic names: the +// column, the operator, and whether it is the bare equality spelling. +const REFUSED: Envelope = { code: 'INVALID_FILTER', status: 400 }; +type JsonColumnCell = { + predicate: string; + column: string; + value: unknown; + refused?: [field: string, op: string, bare: boolean]; + /** A cell the check still evaluates: whether it admits, the read shows the row, and what is stored. */ + admitted?: boolean; + stored?: unknown; +}; +const JSON_COLUMN_CELLS: JsonColumnCell[] = [ + // The card's rows 1–2: admitted before, while the read refused the policy. + { predicate: "record.tags != 'x'", column: 'tags', value: ['x'], refused: ['tags', '$ne', false] }, + { predicate: "record.tags != 'x'", column: 'tags', value: 'x', refused: ['tags', '$ne', false] }, + { predicate: "!(record.tags in ['x'])", column: 'tags', value: ['x'], refused: ['tags', '$in', false] }, + // Rows 3–5: refused before (403, 403, 400). Nothing is stored, as before; the answer is now the read's. + { predicate: "record.tags == 'x'", column: 'tags', value: ['x'], refused: ['tags', '=', true] }, + { predicate: "record.tags in ['x']", column: 'tags', value: ['x'], refused: ['tags', '$in', false] }, + { predicate: "record.tags > 'a'", column: 'tags', value: ['x'], refused: ['tags', '$gt', false] }, + // A `select` flagged `multiple`, and a structured-JSON column holding a scalar. + { predicate: "record.owners != 'x'", column: 'owners', value: ['x'], refused: ['owners', '$ne', false] }, + { predicate: "record.meta != 'x'", column: 'meta', value: 'y', refused: ['meta', '$ne', false] }, + { predicate: "record.meta == 'x'", column: 'meta', value: 'x', refused: ['meta', '=', true] }, + // Control: the membership pair — `contains` and its negation — answers on the stored list as before. + { predicate: "record.tags.contains('x')", column: 'tags', value: ['x'], admitted: true, stored: ['x'] }, + { predicate: "record.tags.contains('x')", column: 'tags', value: ['y'], admitted: false, stored: ['y'] }, + { predicate: "!record.tags.contains('x')", column: 'tags', value: ['x'], admitted: false, stored: ['x'] }, + { predicate: "!record.tags.contains('x')", column: 'tags', value: ['y'], admitted: true, stored: ['y'] }, + // Control: presence answers on such a column. + { predicate: 'record.tags != null', column: 'tags', value: ['x'], admitted: true, stored: ['x'] }, + // Control: a column declared neither way keeps every operator, the refused rows' among them. + { predicate: "record.title != 'x'", column: 'title', value: 'y', admitted: true, stored: 'y' }, + { predicate: "record.title != 'x'", column: 'title', value: 'x', admitted: false, stored: 'x' }, + { predicate: "record.title in ['x']", column: 'title', value: 'x', admitted: true, stored: 'x' }, +]; + +/** The write gate's server-log lines for this refusal. */ +const refusalLines = (logger: { warn: unknown }): string[] => + (logger.warn as ReturnType).mock.calls.map((c) => String(c[0])).filter((l) => l.includes('RLS check REFUSED')); + +for (const [driverName, makeDriver] of DRIVERS) { + describe(`[#21254] ${driverName}: an operator the read refuses on a declared JSON-stored column is refused by the write check, with the read's answer`, () => { + for (const cell of JSON_COLUMN_CELLS) { + const verdict = cell.refused ? 'refused 400 INVALID_FILTER, as the read is' : cell.admitted ? 'admitted and shown' : 'refused 403 and hidden'; + it(`${cell.predicate}, ${cell.column} written as ${show(cell.value)}: ${verdict}`, async () => { + const r = await boot(makeDriver, cell.predicate); + const written = await r.engine + .insert(r.OBJ, { id: 'w', [cell.column]: cell.value }, { context: r.caller } as never) + .then(() => 'admitted' as const, (e: unknown) => e); + await r.engine.insert(r.OBJ, { id: 'r', [cell.column]: cell.value }, { context: SYS_CTX } as never); + if (!cell.refused) { + expect(written === 'admitted' ? written : envelopeOf(written)).toEqual(cell.admitted ? 'admitted' : DENIED); + expect((await r.storedRow('r'))?.[cell.column]).toEqual(cell.stored); + expect(await r.shownTo('r')).toBe(cell.admitted); + expect(refusalLines(r.logger)).toEqual([]); + return; + } + const [field, op, bare] = cell.refused; + const words = jsonColumnOperatorRefusalText(field, op, bare); + // The write: the read's code and status, and core's words, which withhold the field and the operator. + expect(envelopeOf(written)).toEqual(REFUSED); + expect((written as Error).message).toBe(words.message); + expect(await r.storedRow('w')).toBeUndefined(); + // The diagnostic the message points to is in the server log, beside the policy. + const lines = refusalLines(r.logger); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("policy 'due_guard'"); + expect(lines[0]).toContain(words.diagnostic); + // The read the same policy scopes, over the same value stored by the system: the same answer. + const read = await r.engine + .find(r.OBJ, { where: { id: 'r' }, context: r.caller } as never) + .then(() => 'answered' as const, (e: unknown) => e); + expect(envelopeOf(read)).toEqual(REFUSED); + expect((read as Error).message).toBe(words.message); + }); + } + + // The `using` here is a column declared neither way: under the same + // predicate an update's pre-image read is refused first, by the driver, + // and its gate fails closed 403 before any check runs. + it("record.tags != 'x' as the check: a by-id update and a predicate update are refused 400 too, and change nothing", async () => { + const r = await boot(makeDriver, "record.tags != 'x'", "record.title == 'batch'"); + await r.engine.insert(r.OBJ, { id: 'u', title: 'batch', tags: ['y'] }, { context: SYS_CTX } as never); + expect(await outcome(r.engine.update(r.OBJ, { tags: 'x' }, { where: { id: 'u' }, context: r.caller } as never))) + .toEqual(REFUSED); + expect(await outcome(r.engine.update(r.OBJ, { tags: ['x'] }, { where: { title: 'batch' }, multi: true, context: r.caller } as never))) + .toEqual(REFUSED); + expect((await r.storedRow('u'))?.tags).toEqual(['y']); + }); + }); +} + +describe('[#21254] the JSON-column refusal reads the declaration, never the record', () => { + const DECLARED = { + fields: { + tags: { type: 'tags', multiple: false }, + labels: { type: 'multiselect', multiple: false }, + owners: { type: 'select', multiple: true }, + meta: { type: 'json', multiple: false }, + home: { type: 'address', multiple: false }, + status: { type: 'select', multiple: false }, + title: { type: 'text', multiple: false }, + due_on: { type: 'date', multiple: false }, + }, + }; + const JSON_STORED = declaredJsonStoredColumns(DECLARED); + /** The refusal a judgement raised, with everything a caller and the log read from it. */ + const refusalOf = (judge: (image: Record) => boolean, image: Record) => { + try { + judge(image); + } catch (e) { + const x = e as Error & { code?: string; status?: number; httpStatus?: number }; + return { envelope: { code: x.code, status: x.status, httpStatus: x.httpStatus }, message: x.message, carried: jsonColumnCheckRefusalCarriedBy(e), error: e }; + } + return null; + }; + const IMAGES = [{ tags: ['x'] }, { tags: 'x' }, { tags: null }, {}, { tags: ['y'], meta: 'x', owners: ['x'] }]; + + it('names exactly the multi-valued and structured-JSON columns, and none without a declaration', () => { + expect([...JSON_STORED].sort()).toEqual(['home', 'labels', 'meta', 'owners', 'tags']); + expect(declaredJsonStoredColumns(undefined).size).toBe(0); + }); + + it("refuses every operator in core's set on such a column, with the read's envelope and core's words, for every image", () => { + expect(JSON_COLUMN_INCOMPATIBLE_OPERATORS.size).toBeGreaterThan(0); + for (const op of JSON_COLUMN_INCOMPATIBLE_OPERATORS) { + const judge = storedFormCheckJudge([{ tags: { [op]: 'x' } }], DECLARED); + const words = jsonColumnOperatorRefusalText('tags', op, false); + for (const image of IMAGES) { + const got = refusalOf(judge, image); + expect(got?.envelope, op).toEqual({ code: 'INVALID_FILTER', status: 400, httpStatus: 400 }); + expect(got?.message, op).toBe(words.message); + expect(got?.carried, op).toMatchObject({ field: 'tags', operator: op, path: `check[0].tags.${op}`, diagnostic: words.diagnostic }); + } + } + }); + + it('refuses implicit equality on such a column whatever the comparand, as the bare spelling', () => { + for (const comparand of ['x', null, ['x'], new Date('2026-01-05T00:00:00Z'), 5]) { + const got = refusalOf(storedFormCheckJudge([{ meta: comparand }], DECLARED), { meta: 'x' }); + expect(got?.carried).toMatchObject({ field: 'meta', operator: '=', path: 'check[0].meta', diagnostic: jsonColumnOperatorRefusalText('meta', '=', true).diagnostic }); + } + }); + + it('finds it at any depth under $and / $or / $not and in any part, and names where', () => { + const nested = findJsonColumnCheckRefusal( + [{ $and: [{ title: 'x' }, { $or: [{ tags: { $null: true } }, { $not: { home: { $eq: 'x' } } }] }] }], + JSON_STORED, + ); + expect(nested).toMatchObject({ field: 'home', operator: '$eq', path: 'check[0].$and[1].$or[1].$not.home.$eq' }); + expect(findJsonColumnCheckRefusal([{ title: { $ne: 'x' } }, { labels: { $nin: ['a'] } }], JSON_STORED)) + .toMatchObject({ field: 'labels', operator: '$nin', path: 'check[1].labels.$nin' }); + }); + + it('leaves the membership pair, the presence predicates and every column declared neither way to the evaluator', () => { + const parts = [ + { tags: { $contains: 'x' } }, + { tags: { $notContains: 'x' } }, + { $not: { owners: { $contains: 'x' } } }, + { tags: { $null: true } }, + { meta: { $exists: true } }, + { home: { $empty: false } }, + { title: { $ne: 'x' } }, + { title: 'x' }, + { status: { $in: ['x'] } }, + { due_on: { $gt: '2026-01-05' } }, + ]; + for (const part of parts) expect(findJsonColumnCheckRefusal([part], JSON_STORED), JSON.stringify(part)).toBeNull(); + const contains = storedFormCheckJudge([{ tags: { $contains: 'x' } }], DECLARED); + const notContains = storedFormCheckJudge([{ tags: { $notContains: 'x' } }], DECLARED); + expect([contains({ tags: ['x'] }), contains({ tags: ['y'] })]).toEqual([true, false]); + expect([notContains({ tags: ['x'] }), notContains({ tags: ['y'] })]).toEqual([false, true]); + const scalar = storedFormCheckJudge([{ title: { $ne: 'x' } }], DECLARED); + expect([scalar({ title: 'y' }), scalar({ title: 'x' })]).toEqual([true, false]); + }); + + it('refuses nothing where the object hands over no declaration: judged as before', () => { + expect(findJsonColumnCheckRefusal([{ tags: { $ne: 'x' } }], declaredJsonStoredColumns(undefined))).toBeNull(); + expect(storedFormCheckJudge([{ tags: { $ne: 'y' } }], undefined)({ tags: 'x' })).toBe(true); + }); + + it('keeps the field and the operator off the wire: they travel on the error only for the server log', () => { + const got = refusalOf(storedFormCheckJudge([{ owners: { $ne: 'secret_member' } }], DECLARED), {}); + const wire = JSON.stringify({ ...(got!.error as object), message: got!.message }); + expect(wire).not.toContain('owners'); + expect(wire).not.toContain('$ne'); + expect(got?.carried?.diagnostic).toContain('"owners"'); + expect(jsonColumnCheckRefusalCarriedBy(new Error('other'))).toBeNull(); + expect(jsonColumnCheckRefusalCarriedBy(null)).toBeNull(); + }); +}); + describe('the stored-form step reads the declaration, never the values', () => { const COLUMNS = declaredTemporalColumns({ fields: { diff --git a/packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts index a892289b4c5..ce822eff5d2 100644 --- a/packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts +++ b/packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts @@ -36,6 +36,18 @@ * The controls — the same predicate over a record holding one scalar, `null`, * and a `Date`; and equality against a stored list (stage 2a) — answer exactly * as before. Ground truth is read past every scope. + * + * [#21254] On a column the object DECLARES JSON-stored (`tags` and `meta` are + * `json`, `watchers` a `multiple` lookup) the write check now refuses an + * operator the read refuses before any record is read, with the read's + * `INVALID_FILTER` / 400 (`rls-check-stored-form.ts`). So on those columns the + * ordering is refused whatever the record holds: the list as before, and now + * the scalar too (`O1`–`O9`'s scalar cells, which this stage compared). The + * write and the `using` read, which the driver refuses on those columns, now + * answer alike. This stage's value rule still decides wherever the column is + * not declared JSON-stored (`C1`, `C2`), so its `null` control and stage 2a's + * equality control sit on the `text` column `status`, where they still reach + * the evaluator. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -147,6 +159,8 @@ const envelopeOf = (e: unknown): Envelope => { }; const outcome = (p: Promise): Promise<'admitted' | Envelope> => p.then(() => 'admitted' as const, (e: unknown) => envelopeOf(e)); +const verdictWords = (v: 'admitted' | Envelope): string => + v === 'admitted' ? 'is admitted' : `is ${v.code === 'INVALID_FILTER' ? 'refused 400' : 'denied 403'}`; interface Case { id: string; @@ -155,7 +169,7 @@ interface Case { field: string; /** A post-image value that is a list or an object — refused. */ list: unknown; - /** The same field holding one value (or none) — compared as before. */ + /** The same field holding one value (or none) — compared as before, unless the column is declared JSON-stored. */ scalar: unknown; scalarVerdict: 'admitted' | Envelope; /** Whether the driver reads this column back as the list it was given (json / multiple). */ @@ -163,15 +177,18 @@ interface Case { } const CASES: Case[] = [ - { id: 'O1', predicate: "record.tags > 'a'", field: 'tags', list: ['m'], scalar: 'm', scalarVerdict: 'admitted', readsBackAsList: true }, - { id: 'O2', predicate: "record.tags < 'z'", field: 'tags', list: ['m'], scalar: 'm', scalarVerdict: 'admitted', readsBackAsList: true }, - { id: 'O3', predicate: "record.tags >= 'a'", field: 'tags', list: ['m'], scalar: 'm', scalarVerdict: 'admitted', readsBackAsList: true }, - { id: 'O4', predicate: "record.tags <= 'z'", field: 'tags', list: ['m'], scalar: 'm', scalarVerdict: 'admitted', readsBackAsList: true }, - { id: 'O5', predicate: "record.tags > 'n'", field: 'tags', list: ['a', 'z'], scalar: 'm', scalarVerdict: DENIED, readsBackAsList: true }, - { id: 'O6', predicate: "record.meta < 'a'", field: 'meta', list: { a: 1 }, scalar: 'm', scalarVerdict: DENIED, readsBackAsList: true }, - { id: 'O7', predicate: "record.meta > 'a'", field: 'meta', list: { a: 1 }, scalar: 'm', scalarVerdict: 'admitted', readsBackAsList: true }, - { id: 'O8', predicate: "record.watchers > 'a'", field: 'watchers', list: ['p1'], scalar: null, scalarVerdict: DENIED, readsBackAsList: true }, - { id: 'O9', predicate: "record.watchers < 'p2'", field: 'watchers', list: ['p1'], scalar: null, scalarVerdict: DENIED, readsBackAsList: true }, + // [#21254] Each scalar cell on a column declared JSON-stored is refused by the + // declaration, as the read is; before, it was compared (O1–O4, O7 admitted, + // O5, O6, O8, O9 denied 403). + { id: 'O1', predicate: "record.tags > 'a'", field: 'tags', list: ['m'], scalar: 'm', scalarVerdict: INVALID, readsBackAsList: true }, + { id: 'O2', predicate: "record.tags < 'z'", field: 'tags', list: ['m'], scalar: 'm', scalarVerdict: INVALID, readsBackAsList: true }, + { id: 'O3', predicate: "record.tags >= 'a'", field: 'tags', list: ['m'], scalar: 'm', scalarVerdict: INVALID, readsBackAsList: true }, + { id: 'O4', predicate: "record.tags <= 'z'", field: 'tags', list: ['m'], scalar: 'm', scalarVerdict: INVALID, readsBackAsList: true }, + { id: 'O5', predicate: "record.tags > 'n'", field: 'tags', list: ['a', 'z'], scalar: 'm', scalarVerdict: INVALID, readsBackAsList: true }, + { id: 'O6', predicate: "record.meta < 'a'", field: 'meta', list: { a: 1 }, scalar: 'm', scalarVerdict: INVALID, readsBackAsList: true }, + { id: 'O7', predicate: "record.meta > 'a'", field: 'meta', list: { a: 1 }, scalar: 'm', scalarVerdict: INVALID, readsBackAsList: true }, + { id: 'O8', predicate: "record.watchers > 'a'", field: 'watchers', list: ['p1'], scalar: null, scalarVerdict: INVALID, readsBackAsList: true }, + { id: 'O9', predicate: "record.watchers < 'p2'", field: 'watchers', list: ['p1'], scalar: null, scalarVerdict: INVALID, readsBackAsList: true }, { id: 'C1', predicate: "record.status > 'a'", field: 'status', list: ['m'], scalar: 'm', scalarVerdict: 'admitted', readsBackAsList: false }, { id: 'C2', predicate: 'record.amount > 10', field: 'amount', list: [500], scalar: 500, scalarVerdict: 'admitted', readsBackAsList: false }, ]; @@ -192,7 +209,7 @@ const READ: Record = { for (const [driverName, makeDriver] of DRIVERS) { describe(`[#19886 stage 2e] ${driverName}: an ordering check over a field holding a list or an object fails closed`, () => { for (const c of CASES) { - it(`${c.id} \`${c.predicate}\` — check insert: the list-holding write is refused 400 and nothing is stored; the scalar control ${c.scalarVerdict === 'admitted' ? 'is admitted' : 'is denied 403'}`, async () => { + it(`${c.id} \`${c.predicate}\` — check insert: the list-holding write is refused 400 and nothing is stored; the scalar control ${verdictWords(c.scalarVerdict)}`, async () => { const w1 = await boot(makeDriver, 'check', c.predicate); expect(await outcome(w1.engine.insert(OBJ, { id: 'ins_list', status: 's', [c.field]: c.list }, { context: w1.caller } as never))) .toEqual(INVALID); @@ -240,8 +257,8 @@ for (const [driverName, makeDriver] of DRIVERS) { describe(`[#19886 stage 2e] ${driverName}: the controls answer exactly as before`, () => { it('null in the ordered field is no value — compared, denied 403, not refused', async () => { - const w = await boot(makeDriver, 'check', "record.tags > 'a'"); - expect(await outcome(w.engine.insert(OBJ, { id: 'ins_null', status: 's', tags: null }, { context: w.caller } as never))) + const w = await boot(makeDriver, 'check', "record.status > 'a'"); + expect(await outcome(w.engine.insert(OBJ, { id: 'ins_null', status: null }, { context: w.caller } as never))) .toEqual(DENIED); expect(await w.stored()).toEqual([]); }); @@ -256,13 +273,13 @@ for (const [driverName, makeDriver] of DRIVERS) { expect(await w2.stored()).toEqual([]); }); - it("C3 `record.tags == 'm'` — equality against a stored list is untouched (stage 2a): the list is denied 403, the scalar admitted", async () => { - const w1 = await boot(makeDriver, 'check', "record.tags == 'm'"); - expect(await outcome(w1.engine.insert(OBJ, { id: 'ins_list', status: 's', tags: ['m'] }, { context: w1.caller } as never))) + it("C3 `record.status == 'm'` — equality against a stored list is untouched (stage 2a): the list is denied 403, the scalar admitted", async () => { + const w1 = await boot(makeDriver, 'check', "record.status == 'm'"); + expect(await outcome(w1.engine.insert(OBJ, { id: 'ins_list', status: ['m'] }, { context: w1.caller } as never))) .toEqual(DENIED); expect(await w1.stored()).toEqual([]); - const w2 = await boot(makeDriver, 'check', "record.tags == 'm'"); - expect(await outcome(w2.engine.insert(OBJ, { id: 'ins_scalar', status: 's', tags: 'm' }, { context: w2.caller } as never))) + const w2 = await boot(makeDriver, 'check', "record.status == 'm'"); + expect(await outcome(w2.engine.insert(OBJ, { id: 'ins_scalar', status: 'm' }, { context: w2.caller } as never))) .toBe('admitted'); }); });