Skip to content

Commit ff3127e

Browse files
committed
test(plugin-security): a contains policy over a multi-valued field answers membership on read and on the write check
Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
1 parent a525d25 commit ff3127e

1 file changed

Lines changed: 162 additions & 0 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A row-level policy written with `contains` over a multi-valued field gets ONE
5+
* answer on both sides of a write, through the real plugin, the real engine
6+
* and the SQL drivers: the read it scopes (`using`) and the write its `check`
7+
* judges both ask MEMBERSHIP, the question `FILTER_OPERATORS`' `$contains`
8+
* docblock (`@objectstack/spec`) gives a JSON-stored column.
9+
*
10+
* `record.tags.contains('x')` lowers to `{ tags: { $contains: 'x' } }`. The
11+
* read is compiled by `driver-sql` as a membership test over the JSON column,
12+
* so it shows the row holding `['x']` and hides the row holding `['xy']`. The
13+
* write check evaluates the same filter in-process against the post-image
14+
* (`@objectstack/formula`'s `matchesFilterCondition`, handed the object's
15+
* declared columns), which answered the substring test alone: a stored array is
16+
* not a string, so it refused EVERY write, the one the read shows included.
17+
* It now asks membership too:
18+
*
19+
* | post-image `tags` | read under `using` | `check` insert / by-id update, before | after |
20+
* |---|---|---|---|
21+
* | `['x']` | shown | 403 | **admitted** |
22+
* | `['xy']` | hidden | 403 | 403 |
23+
* | `['a', 'x']` | shown | 403 | **admitted** |
24+
*
25+
* The change only widens the check's admit set, and only onto rows the read
26+
* under the same predicate shows; a member the read hides stays refused.
27+
*/
28+
29+
import { describe, it, expect, afterEach, vi } from 'vitest';
30+
import { ObjectQL } from '@objectstack/objectql';
31+
import { SqlDriver } from '@objectstack/driver-sql';
32+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
33+
import { PermissionSetSchema } from '@objectstack/spec/security';
34+
import { SecurityPlugin } from './security-plugin.js';
35+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
36+
37+
const SYS_CTX = { isSystem: true, userId: 'usr_system' };
38+
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
39+
const PREDICATE = "record.tags.contains('x')";
40+
41+
type Driver = { disconnect?: () => Promise<void> };
42+
const DRIVERS: Array<[name: string, make: () => Driver]> = [
43+
['driver-sql (better-sqlite3)', () =>
44+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true })],
45+
['driver-sqlite-wasm', () => new SqliteWasmDriver({ filename: ':memory:' })],
46+
];
47+
48+
const booted: ObjectQL[] = [];
49+
afterEach(async () => {
50+
while (booted.length) {
51+
try { await booted.pop()!.destroy(); } catch { /* noop */ }
52+
}
53+
});
54+
55+
let seq = 0;
56+
async function boot(makeDriver: () => Driver, clause: 'using' | 'check') {
57+
const OBJ = `qa_ticket_contains_${process.pid}_${++seq}`;
58+
const engine = new ObjectQL();
59+
engine.registerDriver(makeDriver() as never, true);
60+
await engine.init();
61+
engine.registerApp({
62+
id: `com.objectstack.qa.rls-contains-membership-${seq}`,
63+
name: 'RLS contains membership',
64+
version: '1.0.0',
65+
type: 'plugin',
66+
scope: 'system',
67+
objects: [
68+
{
69+
name: OBJ,
70+
label: 'Ticket',
71+
sharingModel: 'public_read_write',
72+
fields: {
73+
id: { name: 'id', type: 'text', primaryKey: true },
74+
title: { name: 'title', type: 'text' },
75+
tags: { name: 'tags', type: 'tags' },
76+
},
77+
},
78+
],
79+
} as never);
80+
await engine.syncSchemas();
81+
booted.push(engine);
82+
83+
const set = PermissionSetSchema.parse({
84+
name: 'qa_ticket_guard',
85+
objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
86+
rowLevelSecurity: [{ name: 'ticket_guard', object: OBJ, operation: 'all', [clause]: PREDICATE }],
87+
});
88+
const services: Record<string, unknown> = {
89+
manifest: { register: vi.fn() },
90+
objectql: engine,
91+
metadata: {
92+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
93+
list: async () => [MEMBER_DEFAULT, set],
94+
},
95+
};
96+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
97+
const ctx = {
98+
logger,
99+
registerService: vi.fn(),
100+
getService: (name: string) => {
101+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
102+
return services[name];
103+
},
104+
};
105+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
106+
await plugin.init(ctx as never);
107+
await plugin.start(ctx as never);
108+
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
109+
110+
const caller = { userId: 'usr_member', positions: ['qa_pos'], permissions: [set.name], posture: 'MEMBER' };
111+
const storedIds = async () =>
112+
((await engine.find(OBJ, { context: SYS_CTX } as never)) as Array<Record<string, unknown>>)
113+
.map((r) => String(r.id))
114+
.sort();
115+
return { OBJ, engine, caller, storedIds };
116+
}
117+
118+
type Envelope = { code: string; status: number };
119+
const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 };
120+
const envelopeOf = (e: unknown): Envelope => {
121+
const x = e as { code?: string; status?: number; statusCode?: number };
122+
return { code: String(x?.code), status: Number(x?.statusCode ?? x?.status) };
123+
};
124+
const outcome = (p: Promise<unknown>): Promise<'admitted' | Envelope> =>
125+
p.then(() => 'admitted' as const, (e: unknown) => envelopeOf(e));
126+
127+
const ROWS = [
128+
{ id: 'r1', title: 'member', tags: ['x'] },
129+
{ id: 'r2', title: 'substring only', tags: ['xy'] },
130+
{ id: 'r3', title: 'member among others', tags: ['a', 'x'] },
131+
];
132+
133+
for (const [driverName, makeDriver] of DRIVERS) {
134+
describe(`${driverName}: a contains policy over a multi-valued field answers membership on read and on write`, () => {
135+
it("using: the read shows the rows holding the member 'x', and hides ['xy']", async () => {
136+
const r = await boot(makeDriver, 'using');
137+
await r.engine.insert(r.OBJ, ROWS, { context: SYS_CTX } as never);
138+
const shown = (await r.engine.find(r.OBJ, { context: r.caller } as never)) as Array<{ id: string }>;
139+
expect(shown.map((x) => x.id).sort()).toEqual(['r1', 'r3']);
140+
});
141+
142+
it("check: an insert of the rows the read shows is admitted and stored; ['xy'] is refused 403", async () => {
143+
const w = await boot(makeDriver, 'check');
144+
for (const row of ROWS) {
145+
const expected = row.id === 'r2' ? DENIED : 'admitted';
146+
expect(await outcome(w.engine.insert(w.OBJ, row, { context: w.caller } as never)), row.id).toEqual(expected);
147+
}
148+
expect(await w.storedIds()).toEqual(['r1', 'r3']);
149+
});
150+
151+
it("check: a by-id update into ['xy'] is refused 403 and changes nothing; back into ['x', 'b'] is admitted", async () => {
152+
const w = await boot(makeDriver, 'check');
153+
await w.engine.insert(w.OBJ, [ROWS[0]], { context: SYS_CTX } as never);
154+
expect(await outcome(w.engine.update(w.OBJ, { tags: ['xy'] }, { where: { id: 'r1' }, context: w.caller } as never)))
155+
.toEqual(DENIED);
156+
const after = (await w.engine.find(w.OBJ, { where: { id: 'r1' }, context: SYS_CTX } as never)) as Array<{ tags: unknown }>;
157+
expect(after[0]?.tags).toEqual(['x']);
158+
expect(await outcome(w.engine.update(w.OBJ, { tags: ['x', 'b'] }, { where: { id: 'r1' }, context: w.caller } as never)))
159+
.toBe('admitted');
160+
});
161+
});
162+
}

0 commit comments

Comments
 (0)