Skip to content

Commit 9e6b96b

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

3 files changed

Lines changed: 280 additions & 22 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
9+
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`:
10+
11+
| `check` | written | write, before | read |
12+
|---|---|---|---|
13+
| `record.tags != 'x'` | `['x']` or `'x'` | admitted, stored `["x"]` | 400 |
14+
| `!(record.tags in ['x'])` | `['x']` | admitted, stored `["x"]` | 400 |
15+
| `record.tags == 'x'` / `record.tags in ['x']` | `['x']` | 403 | 400 |
16+
| `record.tags > 'a'` | `['x']` | 400 | 400 |
17+
| `record.meta != 'x'` / `record.meta == 'x'` (`meta` is `json`) | a scalar | admitted, stored | 400 |
18+
19+
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.
20+
21+
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')`).

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

Lines changed: 224 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@
2929
* | `!record.tags.contains('x')` | `'x'` | admitted | `["x"]` | hidden |
3030
* | `record.tags.contains('x')`, a by-id update | `'x'` | 403 | `["x"]` | shown |
3131
*
32+
* [#21254] An operator the read refuses on a declared JSON-stored column
33+
* (`@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, or implicit
34+
* equality) is refused by the write check too, with the read's
35+
* `INVALID_FILTER` / 400 and core's words. Measured on `main` before the step:
36+
*
37+
* | `check` | written | write, before | stored | read |
38+
* |---|---|---|---|---|
39+
* | `record.tags != 'x'` | `['x']` or `'x'` | admitted | `["x"]` | 400 |
40+
* | `!(record.tags in ['x'])` | `['x']` | admitted | `["x"]` | 400 |
41+
* | `record.tags == 'x'` | `['x']` | 403 | — | 400 |
42+
* | `record.tags in ['x']` | `['x']` | 403 | — | 400 |
43+
* | `record.tags > 'a'` | `['x']` | 400 | — | 400 |
44+
* | `record.meta != 'x'` / `record.meta == 'x'` (`json`) | `'y'` / `'x'` | admitted | the scalar | 400 |
45+
*
46+
* The membership pair and the presence predicates answer as before, and so
47+
* does every operator on a column declared neither way.
48+
*
3249
* ## formula's whole-day copy is out of reach here
3350
*
3451
* `@objectstack/formula`'s matcher carries its own copy of the whole-day upper
@@ -44,15 +61,19 @@
4461
import { describe, it, expect, afterEach, vi } from 'vitest';
4562
// The mocked module (see `vi.mock` below), loaded at module top.
4663
import { matchesFilterCondition } from '@objectstack/formula';
64+
import { JSON_COLUMN_INCOMPATIBLE_OPERATORS, jsonColumnOperatorRefusalText } from '@objectstack/core';
4765
import { ObjectQL } from '@objectstack/objectql';
4866
import { SqlDriver } from '@objectstack/driver-sql';
4967
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
5068
import { PermissionSetSchema } from '@objectstack/spec/security';
5169
import { SecurityPlugin } from './security-plugin.js';
5270
import { defaultPermissionSets } from './objects/default-permission-sets.js';
5371
import {
72+
declaredJsonStoredColumns,
5473
declaredMultiValueColumns,
5574
declaredTemporalColumns,
75+
findJsonColumnCheckRefusal,
76+
jsonColumnCheckRefusalCarriedBy,
5677
storedFormCheckFilter,
5778
storedFormCheckJudge,
5879
storedFormImage,
@@ -117,8 +138,11 @@ afterEach(async () => {
117138
});
118139

119140
let seq = 0;
120-
/** One engine and plugin, with ONE policy whose `using` and `check` are the same predicate. */
121-
async function boot(makeDriver: () => Driver, predicate: string) {
141+
/**
142+
* One engine and plugin, with ONE policy whose `using` and `check` are the same
143+
* predicate — or, given `using`, a policy whose `using` is that one instead.
144+
*/
145+
async function boot(makeDriver: () => Driver, predicate: string, using: string = predicate) {
122146
const OBJ = `qa_due_stored_${process.pid}_${++seq}`;
123147
const engine = new ObjectQL();
124148
engine.registerDriver(makeDriver() as never, true);
@@ -142,6 +166,7 @@ async function boot(makeDriver: () => Driver, predicate: string) {
142166
start_time: { name: 'start_time', type: 'time' },
143167
tags: { name: 'tags', type: 'tags' },
144168
owners: { name: 'owners', type: 'select', multiple: true, options: [{ label: 'X', value: 'x' }, { label: 'XY', value: 'xy' }] },
169+
meta: { name: 'meta', type: 'json' },
145170
},
146171
},
147172
],
@@ -152,7 +177,7 @@ async function boot(makeDriver: () => Driver, predicate: string) {
152177
const set = PermissionSetSchema.parse({
153178
name: 'qa_due_guard',
154179
objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
155-
rowLevelSecurity: [{ name: 'due_guard', object: OBJ, operation: 'all', using: predicate, check: predicate }],
180+
rowLevelSecurity: [{ name: 'due_guard', object: OBJ, operation: 'all', using, check: predicate }],
156181
});
157182
const services: Record<string, unknown> = {
158183
manifest: { register: vi.fn() },
@@ -180,7 +205,7 @@ async function boot(makeDriver: () => Driver, predicate: string) {
180205
((await engine.find(OBJ, { where: { id }, context: SYS_CTX } as never)) as Array<Record<string, unknown>>)[0];
181206
const shownTo = async (id: string) =>
182207
((await engine.find(OBJ, { where: { id }, context: caller } as never)) as unknown[]).length > 0;
183-
return { OBJ, engine, caller, storedRow, shownTo };
208+
return { OBJ, engine, caller, storedRow, shownTo, logger: ctx.logger };
184209
}
185210

186211
type Envelope = { code: string; status: number };
@@ -290,6 +315,201 @@ for (const [driverName, makeDriver] of DRIVERS) {
290315
});
291316
}
292317

318+
// [#21254] The card's table at the engine write door, beside the read the same
319+
// policy scopes. A refused cell names what the withheld diagnostic names: the
320+
// column, the operator, and whether it is the bare equality spelling.
321+
const REFUSED: Envelope = { code: 'INVALID_FILTER', status: 400 };
322+
type JsonColumnCell = {
323+
predicate: string;
324+
column: string;
325+
value: unknown;
326+
refused?: [field: string, op: string, bare: boolean];
327+
/** A cell the check still evaluates: whether it admits, the read shows the row, and what is stored. */
328+
admitted?: boolean;
329+
stored?: unknown;
330+
};
331+
const JSON_COLUMN_CELLS: JsonColumnCell[] = [
332+
// The card's rows 1–2: admitted before, while the read refused the policy.
333+
{ predicate: "record.tags != 'x'", column: 'tags', value: ['x'], refused: ['tags', '$ne', false] },
334+
{ predicate: "record.tags != 'x'", column: 'tags', value: 'x', refused: ['tags', '$ne', false] },
335+
{ predicate: "!(record.tags in ['x'])", column: 'tags', value: ['x'], refused: ['tags', '$in', false] },
336+
// Rows 3–5: refused before (403, 403, 400). Nothing is stored, as before; the answer is now the read's.
337+
{ predicate: "record.tags == 'x'", column: 'tags', value: ['x'], refused: ['tags', '=', true] },
338+
{ predicate: "record.tags in ['x']", column: 'tags', value: ['x'], refused: ['tags', '$in', false] },
339+
{ predicate: "record.tags > 'a'", column: 'tags', value: ['x'], refused: ['tags', '$gt', false] },
340+
// A `select` flagged `multiple`, and a structured-JSON column holding a scalar.
341+
{ predicate: "record.owners != 'x'", column: 'owners', value: ['x'], refused: ['owners', '$ne', false] },
342+
{ predicate: "record.meta != 'x'", column: 'meta', value: 'y', refused: ['meta', '$ne', false] },
343+
{ predicate: "record.meta == 'x'", column: 'meta', value: 'x', refused: ['meta', '=', true] },
344+
// Control: the membership pair — `contains` and its negation — answers on the stored list as before.
345+
{ predicate: "record.tags.contains('x')", column: 'tags', value: ['x'], admitted: true, stored: ['x'] },
346+
{ predicate: "record.tags.contains('x')", column: 'tags', value: ['y'], admitted: false, stored: ['y'] },
347+
{ predicate: "!record.tags.contains('x')", column: 'tags', value: ['x'], admitted: false, stored: ['x'] },
348+
{ predicate: "!record.tags.contains('x')", column: 'tags', value: ['y'], admitted: true, stored: ['y'] },
349+
// Control: presence answers on such a column.
350+
{ predicate: 'record.tags != null', column: 'tags', value: ['x'], admitted: true, stored: ['x'] },
351+
// Control: a column declared neither way keeps every operator, the refused rows' among them.
352+
{ predicate: "record.title != 'x'", column: 'title', value: 'y', admitted: true, stored: 'y' },
353+
{ predicate: "record.title != 'x'", column: 'title', value: 'x', admitted: false, stored: 'x' },
354+
{ predicate: "record.title in ['x']", column: 'title', value: 'x', admitted: true, stored: 'x' },
355+
];
356+
357+
/** The write gate's server-log lines for this refusal. */
358+
const refusalLines = (logger: { warn: unknown }): string[] =>
359+
(logger.warn as ReturnType<typeof vi.fn>).mock.calls.map((c) => String(c[0])).filter((l) => l.includes('RLS check REFUSED'));
360+
361+
for (const [driverName, makeDriver] of DRIVERS) {
362+
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`, () => {
363+
for (const cell of JSON_COLUMN_CELLS) {
364+
const verdict = cell.refused ? 'refused 400 INVALID_FILTER, as the read is' : cell.admitted ? 'admitted and shown' : 'refused 403 and hidden';
365+
it(`${cell.predicate}, ${cell.column} written as ${show(cell.value)}: ${verdict}`, async () => {
366+
const r = await boot(makeDriver, cell.predicate);
367+
const written = await r.engine
368+
.insert(r.OBJ, { id: 'w', [cell.column]: cell.value }, { context: r.caller } as never)
369+
.then(() => 'admitted' as const, (e: unknown) => e);
370+
await r.engine.insert(r.OBJ, { id: 'r', [cell.column]: cell.value }, { context: SYS_CTX } as never);
371+
if (!cell.refused) {
372+
expect(written === 'admitted' ? written : envelopeOf(written)).toEqual(cell.admitted ? 'admitted' : DENIED);
373+
expect((await r.storedRow('r'))?.[cell.column]).toEqual(cell.stored);
374+
expect(await r.shownTo('r')).toBe(cell.admitted);
375+
expect(refusalLines(r.logger)).toEqual([]);
376+
return;
377+
}
378+
const [field, op, bare] = cell.refused;
379+
const words = jsonColumnOperatorRefusalText(field, op, bare);
380+
// The write: the read's code and status, and core's words, which withhold the field and the operator.
381+
expect(envelopeOf(written)).toEqual(REFUSED);
382+
expect((written as Error).message).toBe(words.message);
383+
expect(await r.storedRow('w')).toBeUndefined();
384+
// The diagnostic the message points to is in the server log, beside the policy.
385+
const lines = refusalLines(r.logger);
386+
expect(lines).toHaveLength(1);
387+
expect(lines[0]).toContain("policy 'due_guard'");
388+
expect(lines[0]).toContain(words.diagnostic);
389+
// The read the same policy scopes, over the same value stored by the system: the same answer.
390+
const read = await r.engine
391+
.find(r.OBJ, { where: { id: 'r' }, context: r.caller } as never)
392+
.then(() => 'answered' as const, (e: unknown) => e);
393+
expect(envelopeOf(read)).toEqual(REFUSED);
394+
expect((read as Error).message).toBe(words.message);
395+
});
396+
}
397+
398+
// The `using` here is a column declared neither way: under the same
399+
// predicate an update's pre-image read is refused first, by the driver,
400+
// and its gate fails closed 403 before any check runs.
401+
it("record.tags != 'x' as the check: a by-id update and a predicate update are refused 400 too, and change nothing", async () => {
402+
const r = await boot(makeDriver, "record.tags != 'x'", "record.title == 'batch'");
403+
await r.engine.insert(r.OBJ, { id: 'u', title: 'batch', tags: ['y'] }, { context: SYS_CTX } as never);
404+
expect(await outcome(r.engine.update(r.OBJ, { tags: 'x' }, { where: { id: 'u' }, context: r.caller } as never)))
405+
.toEqual(REFUSED);
406+
expect(await outcome(r.engine.update(r.OBJ, { tags: ['x'] }, { where: { title: 'batch' }, multi: true, context: r.caller } as never)))
407+
.toEqual(REFUSED);
408+
expect((await r.storedRow('u'))?.tags).toEqual(['y']);
409+
});
410+
});
411+
}
412+
413+
describe('[#21254] the JSON-column refusal reads the declaration, never the record', () => {
414+
const DECLARED = {
415+
fields: {
416+
tags: { type: 'tags', multiple: false },
417+
labels: { type: 'multiselect', multiple: false },
418+
owners: { type: 'select', multiple: true },
419+
meta: { type: 'json', multiple: false },
420+
home: { type: 'address', multiple: false },
421+
status: { type: 'select', multiple: false },
422+
title: { type: 'text', multiple: false },
423+
due_on: { type: 'date', multiple: false },
424+
},
425+
};
426+
const JSON_STORED = declaredJsonStoredColumns(DECLARED);
427+
/** The refusal a judgement raised, with everything a caller and the log read from it. */
428+
const refusalOf = (judge: (image: Record<string, unknown>) => boolean, image: Record<string, unknown>) => {
429+
try {
430+
judge(image);
431+
} catch (e) {
432+
const x = e as Error & { code?: string; status?: number; httpStatus?: number };
433+
return { envelope: { code: x.code, status: x.status, httpStatus: x.httpStatus }, message: x.message, carried: jsonColumnCheckRefusalCarriedBy(e), error: e };
434+
}
435+
return null;
436+
};
437+
const IMAGES = [{ tags: ['x'] }, { tags: 'x' }, { tags: null }, {}, { tags: ['y'], meta: 'x', owners: ['x'] }];
438+
439+
it('names exactly the multi-valued and structured-JSON columns, and none without a declaration', () => {
440+
expect([...JSON_STORED].sort()).toEqual(['home', 'labels', 'meta', 'owners', 'tags']);
441+
expect(declaredJsonStoredColumns(undefined).size).toBe(0);
442+
});
443+
444+
it("refuses every operator in core's set on such a column, with the read's envelope and core's words, for every image", () => {
445+
expect(JSON_COLUMN_INCOMPATIBLE_OPERATORS.size).toBeGreaterThan(0);
446+
for (const op of JSON_COLUMN_INCOMPATIBLE_OPERATORS) {
447+
const judge = storedFormCheckJudge([{ tags: { [op]: 'x' } }], DECLARED);
448+
const words = jsonColumnOperatorRefusalText('tags', op, false);
449+
for (const image of IMAGES) {
450+
const got = refusalOf(judge, image);
451+
expect(got?.envelope, op).toEqual({ code: 'INVALID_FILTER', status: 400, httpStatus: 400 });
452+
expect(got?.message, op).toBe(words.message);
453+
expect(got?.carried, op).toMatchObject({ field: 'tags', operator: op, path: `check[0].tags.${op}`, diagnostic: words.diagnostic });
454+
}
455+
}
456+
});
457+
458+
it('refuses implicit equality on such a column whatever the comparand, as the bare spelling', () => {
459+
for (const comparand of ['x', null, ['x'], new Date('2026-01-05T00:00:00Z'), 5]) {
460+
const got = refusalOf(storedFormCheckJudge([{ meta: comparand }], DECLARED), { meta: 'x' });
461+
expect(got?.carried).toMatchObject({ field: 'meta', operator: '=', path: 'check[0].meta', diagnostic: jsonColumnOperatorRefusalText('meta', '=', true).diagnostic });
462+
}
463+
});
464+
465+
it('finds it at any depth under $and / $or / $not and in any part, and names where', () => {
466+
const nested = findJsonColumnCheckRefusal(
467+
[{ $and: [{ title: 'x' }, { $or: [{ tags: { $null: true } }, { $not: { home: { $eq: 'x' } } }] }] }],
468+
JSON_STORED,
469+
);
470+
expect(nested).toMatchObject({ field: 'home', operator: '$eq', path: 'check[0].$and[1].$or[1].$not.home.$eq' });
471+
expect(findJsonColumnCheckRefusal([{ title: { $ne: 'x' } }, { labels: { $nin: ['a'] } }], JSON_STORED))
472+
.toMatchObject({ field: 'labels', operator: '$nin', path: 'check[1].labels.$nin' });
473+
});
474+
475+
it('leaves the membership pair, the presence predicates and every column declared neither way to the evaluator', () => {
476+
const parts = [
477+
{ tags: { $contains: 'x' } },
478+
{ tags: { $notContains: 'x' } },
479+
{ $not: { owners: { $contains: 'x' } } },
480+
{ tags: { $null: true } },
481+
{ meta: { $exists: true } },
482+
{ home: { $empty: false } },
483+
{ title: { $ne: 'x' } },
484+
{ title: 'x' },
485+
{ status: { $in: ['x'] } },
486+
{ due_on: { $gt: '2026-01-05' } },
487+
];
488+
for (const part of parts) expect(findJsonColumnCheckRefusal([part], JSON_STORED), JSON.stringify(part)).toBeNull();
489+
const contains = storedFormCheckJudge([{ tags: { $contains: 'x' } }], DECLARED);
490+
const notContains = storedFormCheckJudge([{ tags: { $notContains: 'x' } }], DECLARED);
491+
expect([contains({ tags: ['x'] }), contains({ tags: ['y'] })]).toEqual([true, false]);
492+
expect([notContains({ tags: ['x'] }), notContains({ tags: ['y'] })]).toEqual([false, true]);
493+
const scalar = storedFormCheckJudge([{ title: { $ne: 'x' } }], DECLARED);
494+
expect([scalar({ title: 'y' }), scalar({ title: 'x' })]).toEqual([true, false]);
495+
});
496+
497+
it('refuses nothing where the object hands over no declaration: judged as before', () => {
498+
expect(findJsonColumnCheckRefusal([{ tags: { $ne: 'x' } }], declaredJsonStoredColumns(undefined))).toBeNull();
499+
expect(storedFormCheckJudge([{ tags: { $ne: 'y' } }], undefined)({ tags: 'x' })).toBe(true);
500+
});
501+
502+
it('keeps the field and the operator off the wire: they travel on the error only for the server log', () => {
503+
const got = refusalOf(storedFormCheckJudge([{ owners: { $ne: 'secret_member' } }], DECLARED), {});
504+
const wire = JSON.stringify({ ...(got!.error as object), message: got!.message });
505+
expect(wire).not.toContain('owners');
506+
expect(wire).not.toContain('$ne');
507+
expect(got?.carried?.diagnostic).toContain('"owners"');
508+
expect(jsonColumnCheckRefusalCarriedBy(new Error('other'))).toBeNull();
509+
expect(jsonColumnCheckRefusalCarriedBy(null)).toBeNull();
510+
});
511+
});
512+
293513
describe('the stored-form step reads the declaration, never the values', () => {
294514
const COLUMNS = declaredTemporalColumns({
295515
fields: {

0 commit comments

Comments
 (0)