Skip to content

Commit 2ed80c4

Browse files
committed
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 <noreply@anthropic.com>
1 parent 5a9292e commit 2ed80c4

2 files changed

Lines changed: 241 additions & 3 deletions

File tree

‎packages/plugins/plugin-security/src/rls-check-stored-form.ts‎

Lines changed: 220 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,58 @@
8484
* The comparands are left as written, because the read pairs none with the
8585
* wrap: `$contains` / `$notContains` take one MEMBER, and every scalar
8686
* comparison on such a column is refused by the read
87-
* (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`), never compared with a list.
87+
* (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`), never compared with a list — and,
88+
* since [#21254], by this step too (next section).
89+
*
90+
* ## [#21254] An operator the read refuses on a JSON-stored column is refused here too
91+
*
92+
* `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS` is the set of
93+
* operators no face answers on a column the object declares JSON-stored (a
94+
* structured-JSON type, or a multi-valued field): the scalar comparisons, the
95+
* orderings and the text operators other than the membership pair. The read a
96+
* policy scopes is compiled by the driver, which refuses them with
97+
* `INVALID_FILTER` / 400; the engine's per-aggregation `filter` and
98+
* `driver-memory`'s filter gate refuse the same set on the same declared
99+
* fields. The write check judged them instead, in JS, against the stored
100+
* list. Measured through `ObjectQL.insert` + `SecurityPlugin` + two SQL driver
101+
* families as a member resolving a permission set, `using` and `check` the
102+
* same predicate, `tags` declared `tags`:
103+
*
104+
* | `check` | written | write, before | stored | read under the same predicate |
105+
* |---|---|---|---|---|
106+
* | `record.tags != 'x'` | `['x']` or `'x'` | admitted | `["x"]` | 400 |
107+
* | `!(record.tags in ['x'])` | `['x']` | admitted | `["x"]` | 400 |
108+
* | `record.tags == 'x'` | `['x']` | 403 | — | 400 |
109+
* | `record.tags in ['x']` | `['x']` | 403 | — | 400 |
110+
* | `record.tags > 'a'` | `['x']` | 400, the evaluator's list-under-ordering refusal | — | 400 |
111+
*
112+
* The first two are the exclusion family's fail-OPEN: a list never equals a
113+
* scalar, so "not equal" held for the very row the policy names, and a policy
114+
* whose read is refused admitted that write. So this step refuses what the
115+
* read refuses, by one rule ({@link findJsonColumnCheckRefusal}): an operator
116+
* in that set, or implicit equality, aimed at a column the object declares
117+
* JSON-stored, whatever the comparand, at any depth under `$and` / `$or` /
118+
* `$not` — the traversal objectql's per-aggregation gate takes. The set and
119+
* the words are core's (`jsonColumnOperatorRefusalText`), imported, never
120+
* copied; the error constructor is this face's, as each face keeps its own,
121+
* with the read's envelope, `INVALID_FILTER` / 400. All five rows above now
122+
* get it, so the write and the read give one answer for one policy. The three
123+
* that refused before still admit nothing; their answer is now the read's.
124+
*
125+
* It reads the declaration, never the record: the verdict is reached once,
126+
* from the parts and the declared columns, and every image the judge is handed
127+
* gets it before any is evaluated, so a policy is refused for every row or for
128+
* none. What still answers on such a column is unchanged: the membership pair
129+
* `$contains` / `$notContains`, and the presence predicates `$null`,
130+
* `$exists`, `$empty`. A column declared neither way, and an object whose
131+
* schema cannot be loaded, are judged as before.
132+
*
133+
* The message names neither the field nor the operator (the policy is an
134+
* administrator's, and the caller is usually not its author) and says the full
135+
* diagnostic is in the server log. The write gate makes that true: the
136+
* diagnostic travels on the error, off the wire
137+
* ({@link jsonColumnCheckRefusalCarriedBy}), and the gate logs it beside the
138+
* policy's name.
88139
*
89140
* ## What it does not carry
90141
*
@@ -97,16 +148,25 @@
97148
* rule declares.
98149
*/
99150

100-
import { multiValueStorageForm, temporalStorageForm, type TemporalComparandKind } from '@objectstack/core';
151+
import {
152+
JSON_COLUMN_INCOMPATIBLE_OPERATORS,
153+
jsonColumnOperatorRefusalText,
154+
multiValueStorageForm,
155+
temporalStorageForm,
156+
type TemporalComparandKind,
157+
} from '@objectstack/core';
101158
import { matchesFilterCondition, type MatchesFilterOptions } from '@objectstack/formula';
159+
import { StandardErrorCode } from '@objectstack/spec/api';
102160
import {
103161
CALENDAR_DATE_TYPES,
104162
CLOCK_TIME_TYPES,
105163
INSTANT_TYPES,
164+
STRUCTURED_JSON_TYPES,
106165
filterSubtreeProvenanceOf,
107166
isMultiValueField,
108167
markFilterSubtreeProvenance,
109168
} from '@objectstack/spec/data';
169+
import { compiledPolicyNameOf } from './rls-compiler.js';
110170

111171
/** The declared temporal columns of one object, by name, each with its storage rule's kind. */
112172
export type DeclaredTemporalColumns = ReadonlyMap<string, TemporalComparandKind>;
@@ -163,6 +223,156 @@ export function declaredMultiValueColumns(columns: MatchesFilterOptions | undefi
163223
return out;
164224
}
165225

226+
/** [#21254] The declared JSON-stored columns of one object, by name. */
227+
export type DeclaredJsonStoredColumns = ReadonlySet<string>;
228+
229+
/**
230+
* [#21254] The columns `columns` declares JSON-stored: the declared
231+
* multi-valued columns ({@link declaredMultiValueColumns}) and the columns of a
232+
* structured-JSON type (the spec's `STRUCTURED_JSON_TYPES`). These are the two
233+
* halves every face of core's JSON-column refusal reads, and the population
234+
* `matchesFilterCondition` already asks `$contains` membership of, over the
235+
* same declaration. Empty when the object hands over no declaration.
236+
*/
237+
export function declaredJsonStoredColumns(columns: MatchesFilterOptions | undefined): DeclaredJsonStoredColumns {
238+
const out = new Set<string>();
239+
const multiValue = declaredMultiValueColumns(columns);
240+
for (const [name, decl] of Object.entries(columns?.fields ?? {})) {
241+
if (multiValue.has(name) || STRUCTURED_JSON_TYPES.has(decl.type)) out.add(name);
242+
}
243+
return out;
244+
}
245+
246+
/** [#21254] One operator the read refuses, as {@link findJsonColumnCheckRefusal} found it. */
247+
export interface JsonColumnCheckRefusal {
248+
/** The declared JSON-stored column the operator is aimed at. */
249+
readonly field: string;
250+
/** The operator as written in the compiled check; `=` for implicit equality. */
251+
readonly operator: string;
252+
/** Where it sits: `check[<part>]`, then the path through the compiled filter. */
253+
readonly path: string;
254+
/** The policy the offending node was compiled from, when the compiler marked one. */
255+
readonly policy: string | undefined;
256+
/** What the caller is told: core's message, which names neither the field nor the operator. */
257+
readonly message: string;
258+
/**
259+
* Core's full diagnostic, the field and the operator named. SERVER-SIDE ONLY:
260+
* the policy is an administrator's, so it goes to a log, never into an error
261+
* message (see {@link jsonColumnCheckRefusalCarriedBy}).
262+
*/
263+
readonly diagnostic: string;
264+
}
265+
266+
/**
267+
* [#21254] A column condition that is IMPLICIT equality: a comparand rather
268+
* than an operator map (a primitive, `null`, a `Date` or an array). The split
269+
* objectql's per-aggregation gate makes on the same shapes.
270+
*/
271+
function isImplicitEquality(condition: unknown): boolean {
272+
return typeof condition !== 'object'
273+
|| condition === null
274+
|| condition instanceof Date
275+
|| Array.isArray(condition);
276+
}
277+
278+
/**
279+
* [#21254] The first operator in `parts` that the read refuses on a declared
280+
* JSON-stored column, or `null` when there is none: an operator in
281+
* `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, or implicit
282+
* equality, whatever the comparand (`null` and a `{ $field }` operand
283+
* included), at any depth under `$and` / `$or` / `$not`. Pure: it reads the
284+
* parts and the declaration, never a record.
285+
*
286+
* The traversal is objectql's per-aggregation gate's
287+
* (`assertAggregationFilterSparesJsonStoredFields`): any other `$` key is not
288+
* a column and is left to the evaluator, and so is a column the declaration
289+
* does not name JSON-stored. The words are core's
290+
* (`jsonColumnOperatorRefusalText`); the bare spelling's operator is `=`.
291+
*/
292+
export function findJsonColumnCheckRefusal(
293+
parts: readonly Record<string, unknown>[],
294+
jsonStored: DeclaredJsonStoredColumns,
295+
): JsonColumnCheckRefusal | null {
296+
if (jsonStored.size === 0) return null;
297+
const refusal = (
298+
field: string,
299+
operator: string,
300+
bare: boolean,
301+
path: string,
302+
policy: string | undefined,
303+
): JsonColumnCheckRefusal => {
304+
const { message, diagnostic } = jsonColumnOperatorRefusalText(field, operator, bare);
305+
return { field, operator, path, policy, message, diagnostic };
306+
};
307+
const walk = (cond: unknown, path: string, policy: string | undefined): JsonColumnCheckRefusal | null => {
308+
if (!cond || typeof cond !== 'object') return null;
309+
const owner = compiledPolicyNameOf(cond) ?? policy;
310+
for (const [key, value] of Object.entries(cond)) {
311+
const here = `${path}.${key}`;
312+
if (key === '$and' || key === '$or') {
313+
const branches = Array.isArray(value) ? value : [value];
314+
for (let i = 0; i < branches.length; i++) {
315+
const found = walk(branches[i], `${here}[${i}]`, owner);
316+
if (found) return found;
317+
}
318+
continue;
319+
}
320+
if (key === '$not') {
321+
const found = walk(value, here, owner);
322+
if (found) return found;
323+
continue;
324+
}
325+
if (key.startsWith('$') || !jsonStored.has(key)) continue;
326+
if (isImplicitEquality(value)) return refusal(key, '=', true, here, owner);
327+
for (const op of Object.keys(value as Record<string, unknown>)) {
328+
if (JSON_COLUMN_INCOMPATIBLE_OPERATORS.has(op)) return refusal(key, op, false, `${here}.${op}`, owner);
329+
}
330+
}
331+
return null;
332+
};
333+
for (let i = 0; i < parts.length; i++) {
334+
const found = walk(parts[i], `check[${i}]`, undefined);
335+
if (found) return found;
336+
}
337+
return null;
338+
}
339+
340+
/**
341+
* [#21254] The refusal carried on the error, under a SYMBOL key, for the
342+
* reason `@objectstack/formula`'s comparison-class refusal carries its own
343+
* that way: `JSON.stringify`, a spread, `Object.keys` and the structured-clone
344+
* boundary all skip it, so no error mapper can put the field and the operator
345+
* back on the wire. `Symbol.for` so a duplicated copy of this package resolves
346+
* the same key.
347+
*/
348+
const JSON_COLUMN_CHECK_REFUSAL = Symbol.for('objectstack.plugin-security.jsonColumnCheckRefusal');
349+
350+
/**
351+
* [#21254] The write check's refusal: core's message, with the envelope the
352+
* read gives the same policy, `INVALID_FILTER` / 400 (and `httpStatus`, the
353+
* same number under ADR-0112 D5's spelling, as the engine's own filter
354+
* refusals carry it).
355+
*/
356+
function jsonColumnCheckRefusalError(refusal: JsonColumnCheckRefusal): Error {
357+
const err = new Error(refusal.message) as Error & { code?: string; status?: number; httpStatus?: number };
358+
err.code = StandardErrorCode.enum.INVALID_FILTER;
359+
err.status = 400;
360+
err.httpStatus = 400;
361+
Object.defineProperty(err, JSON_COLUMN_CHECK_REFUSAL, { value: refusal, enumerable: false });
362+
return err;
363+
}
364+
365+
/**
366+
* [#21254] The JSON-column refusal an error carries, or `null` for any other
367+
* error: the read half of the judge's refusal, for the write gate, which logs
368+
* the diagnostic server-side beside the policy's name.
369+
*/
370+
export function jsonColumnCheckRefusalCarriedBy(err: unknown): JsonColumnCheckRefusal | null {
371+
if (err === null || (typeof err !== 'object' && typeof err !== 'function')) return null;
372+
const refusal = (err as Record<symbol, unknown>)[JSON_COLUMN_CHECK_REFUSAL];
373+
return refusal && typeof refusal === 'object' ? (refusal as JsonColumnCheckRefusal) : null;
374+
}
375+
166376
/** A plain object: a filter node, an operator map or a `{ $field }` reference — never a comparand value. */
167377
function isPlainObject(value: unknown): value is Record<string, unknown> {
168378
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
@@ -301,17 +511,25 @@ export function storedFormImage(
301511
* A refusal the evaluator raises propagates unchanged. The parts the caller
302512
* attributes it to are its own: the rewritten parts are used for evaluation
303513
* only.
514+
*
515+
* [#21254] One refusal is this step's own: an operator the read refuses on a
516+
* declared JSON-stored column ({@link findJsonColumnCheckRefusal}). It is
517+
* found once, here, on the parts as compiled (they carry the policy marks),
518+
* and thrown for every image before any is evaluated, so the verdict is the
519+
* declaration's and never a record's.
304520
*/
305521
export function storedFormCheckJudge(
306522
parts: readonly Record<string, unknown>[],
307523
columns: MatchesFilterOptions | undefined,
308524
): (image: Record<string, unknown>) => boolean {
309525
const temporal = declaredTemporalColumns(columns);
310526
const multiValue = declaredMultiValueColumns(columns);
527+
const refusal = findJsonColumnCheckRefusal(parts, declaredJsonStoredColumns(columns));
311528
// The comparands are put into the temporal form only: on a multi-valued
312529
// column the read pairs no comparand with the wrap (see the module note).
313530
const storedParts = parts.map((part) => storedFormCheckFilter(part, temporal));
314531
return (image) => {
532+
if (refusal) throw jsonColumnCheckRefusalError(refusal);
315533
const stored = storedFormImage(image, temporal, multiValue);
316534
return storedParts.every((part) => matchesFilterCondition(stored, part as never, columns));
317535
};

‎packages/plugins/plugin-security/src/security-plugin.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
d10NarrowingStatement,
3838
} from './explain-engine.js';
3939
import { declaredComparisonColumns } from './declared-comparison-columns.js';
40-
import { storedFormCheckJudge } from './rls-check-stored-form.js';
40+
import { jsonColumnCheckRefusalCarriedBy, storedFormCheckJudge } from './rls-check-stored-form.js';
4141
import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security';
4242
import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts';
4343

@@ -3335,6 +3335,26 @@ export class SecurityPlugin implements Plugin {
33353335
},
33363336
);
33373337
}
3338+
// [#21254] An operator the read refuses on a declared JSON-stored
3339+
// column. The caller's 400 withholds the field and the operator
3340+
// and says the full diagnostic is in the server log: this line.
3341+
const jsonColumnRefusal = jsonColumnCheckRefusalCarriedBy(e);
3342+
if (jsonColumnRefusal) {
3343+
const policy = jsonColumnRefusal.policy ?? '(unattributed)';
3344+
ctx.logger.warn(
3345+
`[Security] RLS check REFUSED on ${opCtx.operation} '${opCtx.object}' (INVALID_FILTER): ` +
3346+
`policy '${policy}' — At ${jsonColumnRefusal.path}: ${jsonColumnRefusal.diagnostic} The ` +
3347+
`read this policy scopes is refused for the same reason.`,
3348+
{
3349+
operation: opCtx.operation,
3350+
object: opCtx.object,
3351+
policies: [policy],
3352+
field: jsonColumnRefusal.field,
3353+
operator: jsonColumnRefusal.operator,
3354+
userId: opCtx.context?.userId ?? 'unknown',
3355+
},
3356+
);
3357+
}
33383358
throw e;
33393359
}
33403360
};

0 commit comments

Comments
 (0)