Skip to content

Commit 6023d46

Browse files
committed
test(plugin-security): pin explain's answer to an operator the read refuses on a JSON-stored column
Both SQLite families: the card's three rows plus a select and a lookup flagged multiple answer the read's INVALID_FILTER / 400 from explain for every operation, the object-level report and an absent record id, beside the real find / insert / update / delete. The membership pair, presence and a column declared neither way keep their row verdict on both sides. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent a84c35f commit 6023d46

1 file changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21319] `security.explain` answers what enforcement answers for a row-level
5+
* policy that aims an operator the read refuses at a column the object
6+
* declares JSON-stored: the request is REFUSED, with the find's own envelope
7+
* (`INVALID_FILTER` / 400), and no verdict is reported.
8+
*
9+
* A multi-valued field (`tags`, a `select` or `lookup` flagged `multiple`) and
10+
* a structured-JSON type (`json`) are stored as JSON. driver-sql refuses
11+
* `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, and implicit
12+
* equality, on such a column, so the caller's find answers `INVALID_FILTER` /
13+
* 400 whatever the rows; a by-id update or delete fails closed at its
14+
* row-level gate (403), whose pre-image re-read is that refused read; and the
15+
* RLS write check refuses an insert with the read's 400 (#21254). The explain
16+
* engine's record matcher evaluated the operators in JS instead. Measured on
17+
* this stack before the fix, on both SQLite families:
18+
*
19+
* | `using` | stored | find | by-id update / delete | explain, every operation |
20+
* |---|---|---|---|---|
21+
* | `record.tags != 'x'` | `['y']` | 400 | 403 | `visible: true`, `decidedBy: 'rls'` |
22+
* | `record.meta == 'x'` | `'x'` | 400 | 403 | `visible: true`, `decidedBy: 'rls'` |
23+
* | `!(record.tags in ['x'])` | `['y']` | 400 | 403 | `visible: true`, `decidedBy: 'rls'` |
24+
* | `record.owners != 'x'` (`select`, `multiple`) | `['y']` | 400 | 403 | `visible: true`, `decidedBy: 'rls'` |
25+
* | `record.refs != 'x'` (`lookup`, `multiple`) | `['y']` | 400 | 403 | `visible: true`, `decidedBy: 'rls'` |
26+
*
27+
* The object-level report (no `recordId`) said `allowed: true` with `rls`
28+
* `narrows`, and a record id no row carries was reported `visible: false`.
29+
* `POST /api/v1/security/explain` relayed the same 200.
30+
*
31+
* The explain engine now judges each row filter by the rule the write check
32+
* applies (`findJsonColumnCheckRefusal`, imported, so the operator set is
33+
* core's and is copied nowhere), before the matcher reads a record, and
34+
* answers with the refusal: the shape it gives a cross-class comparison
35+
* (`explain-cross-class-refusal.test.ts`), a thrown envelope with no decision.
36+
* Its `cause` is the read's refusal: core's message, which the find answers
37+
* byte for byte. Its message is core's diagnostic, naming the field and the
38+
* operator, then the policy that carries them.
39+
*
40+
* Every refused cell asserts both halves: explain's answer, and the real
41+
* request through the real `SecurityPlugin`, `ObjectQL` and SQL driver. The
42+
* controls are the operators that still answer on such a column (the
43+
* membership pair and presence) and a column declared neither way, which keep
44+
* their row verdict on both sides.
45+
*
46+
* PostgreSQL runs when `OS_TEST_POSTGRES_URL` names a server (CI does not run
47+
* this package against a live server, so there it is skipped).
48+
*/
49+
50+
import { describe, it, expect, afterEach, vi } from 'vitest';
51+
import { ObjectQL } from '@objectstack/objectql';
52+
import { SqlDriver } from '@objectstack/driver-sql';
53+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
54+
import { jsonColumnOperatorRefusalText } from '@objectstack/core';
55+
import { truncateClientMessage } from '@objectstack/types';
56+
import { PermissionSetSchema } from '@objectstack/spec/security';
57+
import type { ExplainDecision } from '@objectstack/spec/security';
58+
import { SecurityPlugin } from './security-plugin.js';
59+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
60+
61+
const SYS_CTX = { isSystem: true, userId: 'usr_system' };
62+
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
63+
const PG_URL = process.env.OS_TEST_POSTGRES_URL;
64+
const POLICY = 'tag_guard';
65+
66+
type Driver = { disconnect?: () => Promise<void> };
67+
const DRIVERS: Array<[name: string, make: () => Driver, available: boolean]> = [
68+
['driver-sql (better-sqlite3)', () =>
69+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true],
70+
['driver-sqlite-wasm', () => new SqliteWasmDriver({ filename: ':memory:' }), true],
71+
['driver-sql (PostgreSQL)', () => new SqlDriver({ client: 'pg', connection: PG_URL! }), !!PG_URL],
72+
];
73+
74+
const booted: Array<{ engine: ObjectQL; driver: Driver; table: string }> = [];
75+
afterEach(async () => {
76+
while (booted.length) {
77+
const { engine, driver, table } = booted.pop()!;
78+
// A live server outlives the run: drop what this file created.
79+
const knex = (driver as { knex?: { schema: { dropTableIfExists: (t: string) => Promise<unknown> } } }).knex;
80+
if (PG_URL && knex && driver instanceof SqlDriver) {
81+
try { await knex.schema.dropTableIfExists(table); } catch { /* noop */ }
82+
}
83+
try { await engine.destroy(); } catch { /* noop */ }
84+
}
85+
});
86+
87+
type ExplainOp = 'read' | 'create' | 'update' | 'delete';
88+
89+
let seq = 0;
90+
/** One engine and plugin, with the policies `predicates` names, each scoping every operation. */
91+
async function boot(makeDriver: () => Driver, predicates: Record<string, string>) {
92+
const OBJ = `qa_deal_21319_${process.pid}_${++seq}`;
93+
const driver = makeDriver();
94+
const engine = new ObjectQL();
95+
engine.registerDriver(driver as never, true);
96+
await engine.init();
97+
engine.registerApp({
98+
id: `com.objectstack.qa.explain-json-column-21319-${seq}`,
99+
name: 'Explain: RLS operator on a JSON-stored column',
100+
version: '1.0.0',
101+
type: 'plugin',
102+
scope: 'system',
103+
objects: [
104+
{
105+
name: OBJ,
106+
label: 'Deal',
107+
sharingModel: 'public_read_write',
108+
fields: {
109+
id: { name: 'id', type: 'text', primaryKey: true },
110+
title: { name: 'title', type: 'text' },
111+
tags: { name: 'tags', type: 'tags' },
112+
owners: {
113+
name: 'owners',
114+
type: 'select',
115+
multiple: true,
116+
options: [{ label: 'X', value: 'x' }, { label: 'Y', value: 'y' }],
117+
},
118+
refs: { name: 'refs', type: 'lookup', reference: OBJ, multiple: true },
119+
meta: { name: 'meta', type: 'json' },
120+
},
121+
},
122+
],
123+
} as never);
124+
await engine.syncSchemas();
125+
booted.push({ engine, driver, table: OBJ });
126+
127+
const set = PermissionSetSchema.parse({
128+
name: 'qa_tag_guard',
129+
objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
130+
rowLevelSecurity: Object.entries(predicates).map(([name, using]) => ({ name, object: OBJ, operation: 'all', using })),
131+
});
132+
const services: Record<string, unknown> = {
133+
manifest: { register: vi.fn() },
134+
objectql: engine,
135+
metadata: {
136+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
137+
list: async () => [MEMBER_DEFAULT, set],
138+
},
139+
};
140+
const registerService = vi.fn();
141+
const ctx = {
142+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
143+
registerService,
144+
getService: (name: string) => {
145+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
146+
return services[name];
147+
},
148+
};
149+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
150+
await plugin.init(ctx as never);
151+
await plugin.start(ctx as never);
152+
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
153+
154+
/** The registered `security` service: what `POST /api/v1/security/explain` dispatches to. */
155+
const security = registerService.mock.calls.find((c) => c[0] === 'security')?.[1] as {
156+
explain: (request: Record<string, unknown>, callerContext: unknown) => Promise<ExplainDecision>;
157+
};
158+
expect(security?.explain, 'the security service is registered with explain').toBeTypeOf('function');
159+
160+
const caller = { userId: 'usr_member', positions: ['qa_pos'], permissions: [set.name], posture: 'MEMBER' };
161+
/** The caller's own access, for one record or (no id) for the object. */
162+
const explain = (operation: ExplainOp, recordId?: string): Promise<ExplainDecision> =>
163+
security.explain({ object: OBJ, operation, ...(recordId ? { recordId } : {}) }, caller);
164+
/** The caller's own request for the one record, through the real middleware and driver. */
165+
const request = (operation: ExplainOp, recordId: string): Promise<unknown> =>
166+
operation === 'read'
167+
? engine.find(OBJ, { where: { id: recordId }, context: caller } as never)
168+
: operation === 'create'
169+
? engine.insert(OBJ, { id: recordId, title: 'n' }, { context: caller } as never)
170+
: operation === 'update'
171+
? engine.update(OBJ, { title: 'z' }, { where: { id: recordId }, context: caller } as never)
172+
: engine.delete(OBJ, { where: { id: recordId }, context: caller } as never);
173+
const visibleIds = async (): Promise<string[]> =>
174+
((await engine.find(OBJ, { context: caller } as never)) as Array<{ id: string }>).map((r) => r.id).sort();
175+
const stored = async () =>
176+
((await engine.find(OBJ, { context: SYS_CTX } as never)) as Array<Record<string, unknown>>)
177+
.map((r) => ({ id: r.id, title: r.title }))
178+
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
179+
return { OBJ, engine, explain, request, visibleIds, stored };
180+
}
181+
182+
type Envelope = { code: string; status: number };
183+
const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 };
184+
const INVALID: Envelope = { code: 'INVALID_FILTER', status: 400 };
185+
186+
const envelopeOf = (e: unknown): Envelope => {
187+
const x = e as { code?: string; status?: number; statusCode?: number };
188+
return { code: String(x?.code), status: Number(x?.statusCode ?? x?.status) };
189+
};
190+
const outcome = (p: Promise<unknown>): Promise<'admitted' | Envelope> =>
191+
p.then(() => 'admitted' as const, (e: unknown) => envelopeOf(e));
192+
const thrown = (p: Promise<unknown>): Promise<unknown> =>
193+
p.then(() => 'answered' as const, (e: unknown) => e);
194+
195+
/** What enforcement answers the caller's own request with, per operation. */
196+
const ENFORCED: Record<ExplainOp, Envelope> = { read: INVALID, create: INVALID, update: DENIED, delete: DENIED };
197+
198+
type Refused = [field: string, op: string, bare: boolean];
199+
200+
/**
201+
* Explain's answer is the read's refusal: the find's envelope, no decision and
202+
* so no verdict; core's diagnostic first, which names the field and the
203+
* operator and holds the remedy, then the policy; and, as its `cause`, the
204+
* read's own refusal, whose message is the find's byte for byte.
205+
*/
206+
async function expectExplainRefuses(p: Promise<unknown>, refused: Refused, findMessage: string, policies = [POLICY]) {
207+
const e = await thrown(p);
208+
expect(e, 'explain answered a decision').not.toBe('answered');
209+
expect(envelopeOf(e)).toEqual(INVALID);
210+
const words = jsonColumnOperatorRefusalText(...refused);
211+
const message = String((e as Error).message);
212+
expect(message.startsWith(`${words.diagnostic} `), message).toBe(true);
213+
for (const policy of policies) expect(message).toContain(`'${policy}'`);
214+
// The REST door keeps a 4xx message's head: the diagnostic, remedy included, survives its bound.
215+
expect(truncateClientMessage(message).startsWith(words.diagnostic)).toBe(true);
216+
const cause = (e as { cause?: unknown }).cause;
217+
expect(envelopeOf(cause)).toEqual(INVALID);
218+
expect((cause as Error).message).toBe(words.message);
219+
expect((cause as Error).message).toBe(findMessage);
220+
}
221+
222+
interface Case {
223+
id: string;
224+
predicate: string;
225+
/** The stored row: a list on a multi-valued column, a scalar on the `json` one. */
226+
row: Record<string, unknown>;
227+
refused: Refused;
228+
}
229+
230+
/** The card's three rows (J1–J3), then a `select` and a `lookup` flagged `multiple`. */
231+
const REFUSED: Case[] = [
232+
{ id: 'J1 multi-valued !=', predicate: "record.tags != 'x'", row: { tags: ['y'] }, refused: ['tags', '$ne', false] },
233+
{ id: 'J2 json ==', predicate: "record.meta == 'x'", row: { meta: 'x' }, refused: ['meta', '=', true] },
234+
{ id: 'J3 negated in', predicate: "!(record.tags in ['x'])", row: { tags: ['y'] }, refused: ['tags', '$in', false] },
235+
{ id: 'J4 select multiple !=', predicate: "record.owners != 'x'", row: { owners: ['y'] }, refused: ['owners', '$ne', false] },
236+
{ id: 'J5 lookup multiple !=', predicate: "record.refs != 'x'", row: { refs: ['y'] }, refused: ['refs', '$ne', false] },
237+
];
238+
239+
interface Control {
240+
id: string;
241+
predicate: string;
242+
rows: Array<Record<string, unknown>>;
243+
/** The rows the caller's find returns under the policy. */
244+
visible: string[];
245+
}
246+
247+
const CONTROLS: Control[] = [
248+
{
249+
id: 'membership `contains`',
250+
predicate: "record.tags.contains('x')",
251+
rows: [{ id: 'r1', tags: ['x'] }, { id: 'r2', tags: ['y'] }],
252+
visible: ['r1'],
253+
},
254+
{
255+
id: 'membership `!contains`',
256+
predicate: "!record.tags.contains('x')",
257+
rows: [{ id: 'r1', tags: ['x'] }, { id: 'r2', tags: ['y'] }],
258+
visible: ['r2'],
259+
},
260+
{
261+
id: 'presence `!= null`',
262+
predicate: 'record.tags != null',
263+
rows: [{ id: 'r1', tags: ['x'] }, { id: 'r2', tags: null }],
264+
visible: ['r1'],
265+
},
266+
{
267+
id: 'a column declared neither way, `!=`',
268+
predicate: "record.title != 'x'",
269+
rows: [{ id: 'r1', title: 'y' }, { id: 'r2', title: 'x' }],
270+
visible: ['r1'],
271+
},
272+
];
273+
274+
for (const [driverName, makeDriver, available] of DRIVERS) {
275+
describe.skipIf(!available)(`[#21319] ${driverName}: explain reports the refusal enforcement gives an operator the read refuses on a JSON-stored column`, () => {
276+
for (const c of REFUSED) {
277+
it(`${c.id} \`${c.predicate}\` — every operation: enforcement refuses, explain answers the read's INVALID_FILTER / 400 and no verdict`, async () => {
278+
const w = await boot(makeDriver, { [POLICY]: c.predicate });
279+
await w.engine.insert(w.OBJ, [{ id: 'r1', title: 't', ...c.row }], { context: SYS_CTX } as never);
280+
const before = await w.stored();
281+
const findError = await thrown(w.request('read', 'r1'));
282+
const findMessage = String((findError as Error)?.message);
283+
284+
for (const op of ['read', 'create', 'update', 'delete'] as const) {
285+
const id = op === 'create' ? 'r_new' : 'r1';
286+
expect(await outcome(w.request(op, id)), `${op}: enforcement`).toEqual(ENFORCED[op]);
287+
await expectExplainRefuses(w.explain(op, id), c.refused, findMessage);
288+
}
289+
// The object-level report, and a record id no row carries: the find refuses both, whatever the rows.
290+
await expectExplainRefuses(w.explain('read'), c.refused, findMessage);
291+
expect(await outcome(w.request('read', 'r_absent'))).toEqual(INVALID);
292+
await expectExplainRefuses(w.explain('read', 'r_absent'), c.refused, findMessage);
293+
expect(await w.stored()).toEqual(before);
294+
});
295+
}
296+
297+
it('two policies, one refusing: explain names only the one that carries the refused operator, and the find refuses both', async () => {
298+
const w = await boot(makeDriver, { [POLICY]: "record.tags != 'x'", title_guard: "record.title == 't'" });
299+
await w.engine.insert(w.OBJ, [{ id: 'r1', title: 't', tags: ['y'] }], { context: SYS_CTX } as never);
300+
const findError = await thrown(w.request('read', 'r1'));
301+
expect(envelopeOf(findError)).toEqual(INVALID);
302+
for (const p of [w.explain('read', 'r1'), w.explain('read')]) {
303+
const e = await thrown(p);
304+
await expectExplainRefuses(Promise.reject(e), ['tags', '$ne', false], String((findError as Error).message));
305+
expect(String((e as Error).message)).not.toContain("'title_guard'");
306+
}
307+
});
308+
309+
for (const c of CONTROLS) {
310+
it(`control — ${c.id} \`${c.predicate}\` keeps its row verdict on both sides`, async () => {
311+
const w = await boot(makeDriver, { [POLICY]: c.predicate });
312+
await w.engine.insert(w.OBJ, c.rows.map((r) => ({ title: 't', ...r })), { context: SYS_CTX } as never);
313+
expect(await w.visibleIds()).toEqual(c.visible);
314+
315+
const object = await w.explain('read');
316+
expect(object.allowed).toBe(true);
317+
expect(object.readFilter).toBeTruthy();
318+
for (const { id } of c.rows as Array<{ id: string }>) {
319+
const visible = c.visible.includes(id);
320+
expect((await w.explain('read', id)).record, `read ${id}`).toEqual({ recordId: id, visible, decidedBy: 'rls' });
321+
expect((await w.explain('update', id)).record, `update ${id}`).toEqual({ recordId: id, visible, decidedBy: 'rls' });
322+
}
323+
});
324+
}
325+
});
326+
}

0 commit comments

Comments
 (0)