Skip to content

Commit 89470f7

Browse files
os-zhuangclaude
andauthored
test(dogfood): probe app-authored RLS wideners on the bulk write path (#6736) (#7274)
End-to-end probe, not a fix. #6736's acceptance section names an end-to-end measurement as the first thing needed ("the mechanism above is a code read"), and the maintainer's 2026-08-08 ruling on #5493 (Q2 = A1) deferred the fix for this path with a stated reason: no measured pull. This supplies the missing measurement and pins today's behaviour so it cannot change unobserved in either direction. Measured on the real stack (bootStack + real SecurityPlugin + real SharingServicePlugin + real ObjectQL engine), app-authored RLS wideners on update and delete: update({multi}): declaration admits 3 rows, statement touches 1, no error delete({multi}): declaration admits 3 rows, statement removes 1, no error Both narrowed by the same predicate, `{ owner_id: <caller> }`, which buildWriteFilter contributes and the bulk branch ANDs into the AST. Zero WARN or ERROR lines in the whole run: the affected-row count is the only signal and it names no authority. Two discrimination controls keep the headline numbers meaningful: the same principal / row / widener SUCCEEDS on the by-id path (so the declaration is live, post-#5493), and a row the widener does not admit is still refused with the ADR-0112 envelope (so the widener has a real boundary). Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5d12675 commit 89470f7

1 file changed

Lines changed: 336 additions & 0 deletions

File tree

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#6736] PROBE — app-authored RLS wideners on the BULK write path.
4+
//
5+
// ⚠️ This file PINS TODAY'S BEHAVIOUR, and today's behaviour is
6+
// defective-by-declaration. It is NOT a fix and must never be read as one.
7+
// The maintainer's 2026-08-08 ruling on #5493 (Q2 = A1) deliberately deferred
8+
// the only shape that could cover this path — a filter-shaped authored-only
9+
// write `FilterCondition` OR-composed into `buildWriteFilter` — with a stated
10+
// reason: no measured pull. #6736's own acceptance section names the missing
11+
// first step: "An end-to-end probe first (the mechanism above is a code read)."
12+
// This file IS that probe. Its job is to produce the numbers a future pricing
13+
// decision can rest on, and to fail loudly the day the behaviour changes in
14+
// either direction.
15+
//
16+
// ── The claim under test ──────────────────────────────────────────────────
17+
// The bulk write path (`update({multi})` / `delete({multi})`) ANDs
18+
// `SharingService.buildWriteFilter` into the query AST
19+
// (`plugin-sharing/src/sharing-plugin.ts`, the `// Bulk (multi) write` branch).
20+
// An app-authored RLS write-widener lives one layer away — `plugin-security`
21+
// ANDs `computeRlsFilter`'s answer into the SAME `ast.where`
22+
// (`plugin-security/src/security-plugin.ts`, step 3 "RLS filter injection").
23+
// Applicable RLS policies OR-combine, so the widener widens WITHIN the RLS
24+
// layer and is then INTERSECTED with sharing's owner-match. It therefore
25+
// cannot widen the row set at all on this path — and, unlike the by-id half
26+
// (#5493, which refuses loudly with FORBIDDEN), nothing says so: no error, no
27+
// 403, no envelope field, no log on that branch. The statement silently
28+
// touches FEWER rows than the declaration admits.
29+
//
30+
// ── Why the fixture is shaped this way ────────────────────────────────────
31+
// The object is `public_read` (OWD `read`) rather than `private`, deliberately:
32+
// • `buildReadFilter` returns null for a non-`private` model, so READS are
33+
// open and cannot confound the measurement. A `private` object would hide
34+
// the cross-owner rows from the caller entirely, and a bulk write that
35+
// touched nothing would be explained by read scoping rather than by the
36+
// write composition — the probe would measure the wrong thing.
37+
// • `buildWriteFilter` returns the owner-match for BOTH `private` and `read`
38+
// (only a fully `public` object is write-open — see its doc comment). So on
39+
// this object the sharing WRITE filter is the ONLY narrowing agent, which
40+
// is exactly the composition under test, isolated.
41+
//
42+
// ── The discrimination controls ───────────────────────────────────────────
43+
// A bulk count of "1 out of 3" proves nothing on its own: an inert widener
44+
// (never parsed, never applicable) and a fixture that accidentally grants
45+
// ownership would each produce a confident-looking number. Two controls split
46+
// those apart, and both must hold for the headline count to mean anything:
47+
// C1 the SAME principal, SAME row, SAME widener via the BY-ID path SUCCEEDS
48+
// (post-#5493 / PR #6909). An inert widener would 403 here.
49+
// C2 a row the widener does NOT admit is still REFUSED by-id, with the
50+
// ADR-0112 envelope. A fixture that leaked ownership or disabled the gate
51+
// would let this through.
52+
// Together: the declaration is live, it is genuinely a widener, its boundary
53+
// is real — and the bulk path still ignores it.
54+
55+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
56+
import { defineStack, definePermissionSet } from '@objectstack/spec';
57+
import { ObjectSchema, Field } from '@objectstack/spec/data';
58+
import { bootStack, type VerifyStack } from '@objectstack/verify';
59+
import { resolveAuthzContext } from '@objectstack/core';
60+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
61+
62+
// ── the app under probe ────────────────────────────────────────────────────
63+
64+
const OBJECT = 'probe_note';
65+
66+
/**
67+
* Read-open, write-owned (`public_read` ⇒ effective OWD `read`), with the
68+
* `owner_id` anchor record sharing enforces on. See the header for why this
69+
* posture, and not `private`, is what isolates the composition under test.
70+
*/
71+
const ProbeNote = ObjectSchema.create({
72+
name: OBJECT,
73+
label: 'Probe Note',
74+
pluralLabel: 'Probe Notes',
75+
sharingModel: 'public_read',
76+
fields: {
77+
title: Field.text({ label: 'Title', required: true, maxLength: 160 }),
78+
body: Field.text({ label: 'Body', maxLength: 2000 }),
79+
stage: Field.text({ label: 'Stage', maxLength: 40 }),
80+
owner_id: Field.lookup('sys_user', { label: 'Owner' }),
81+
},
82+
});
83+
84+
/**
85+
* The app's declaration, in the author's own words: "any holder of this set may
86+
* UPDATE a note in stage `open`, and DELETE a note in stage `stale`" — said
87+
* about the ROW, never about its owner. Both policies are APP-AUTHORED (neither
88+
* is the platform ownership floor `owner_only_writes` / `owner_only_deletes`),
89+
* which is the provenance `checkAuthoredRowWrite` filters on.
90+
*
91+
* Not `isDefault` — it carries `allowDelete`, an anchor-forbidden bit
92+
* (ADR-0090 D5) — so it is bound directly to the probe members below, exactly
93+
* as `owner-anchor-and-bulk-writes.dogfood.test.ts` binds its delete grant.
94+
*/
95+
const ProbeWidenerSet = definePermissionSet({
96+
name: 'probe_widener',
97+
label: 'Probe — app-authored write wideners',
98+
objects: {
99+
[OBJECT]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
100+
},
101+
rowLevelSecurity: [
102+
{
103+
name: 'probe_open_stage_updates',
104+
object: OBJECT,
105+
operation: 'update',
106+
using: "stage == 'open'",
107+
},
108+
{
109+
name: 'probe_stale_stage_deletes',
110+
object: OBJECT,
111+
operation: 'delete',
112+
using: "stage == 'stale'",
113+
},
114+
],
115+
});
116+
117+
const probeApp = defineStack({
118+
manifest: {
119+
id: 'com.example.bulkwidenerprobe',
120+
namespace: 'probe',
121+
version: '0.0.1',
122+
type: 'app',
123+
name: 'Bulk Widener Probe',
124+
engines: { protocol: '^17' },
125+
},
126+
objects: [ProbeNote],
127+
permissions: [ProbeWidenerSet],
128+
});
129+
130+
const SYS = { isSystem: true } as const;
131+
132+
interface Row { id: string; title: string; stage: string; owner_id: string; body?: string }
133+
134+
describe('[#6736 PROBE] app-authored RLS wideners on the bulk write path', () => {
135+
let stack: VerifyStack;
136+
let ql: any;
137+
let security: any;
138+
let sharing: any;
139+
let bobToken: string;
140+
let bobId: string;
141+
let aliceId: string;
142+
let bobCtx: any;
143+
144+
/** The SAME authz context the REST entry point builds — never a hand-rolled principal. */
145+
const authzFor = async (token: string) => {
146+
const authService: any = await stack.kernel.getServiceAsync('auth');
147+
let api: any = authService?.api;
148+
if (!api && typeof authService?.getApi === 'function') api = await authService.getApi();
149+
const headers = new Headers({ authorization: `Bearer ${token}` });
150+
return resolveAuthzContext({
151+
ql,
152+
headers,
153+
getSession: async (h: any) => api?.getSession?.({ headers: h }),
154+
});
155+
};
156+
157+
const seed = async (row: Row) =>
158+
ql.insert(OBJECT, { ...row }, { context: { ...SYS } });
159+
160+
const rowsBySystem = async (where: Record<string, unknown> = {}): Promise<Row[]> =>
161+
(await ql.find(OBJECT, { where, context: { ...SYS } })) as Row[];
162+
163+
const rowById = async (id: string): Promise<Row | null> =>
164+
(await ql.findOne(OBJECT, { where: { id }, context: { ...SYS } })) as Row | null;
165+
166+
beforeAll(async () => {
167+
stack = await bootStack(probeApp, {
168+
// The app's own set must be resolvable alongside the platform seeds; the
169+
// fallback stays the platform `member_default` so nothing about this
170+
// fixture's baseline differs from an ordinary deployment's.
171+
security: new SecurityPlugin({
172+
defaultPermissionSets: [...securityDefaultPermissionSets, ProbeWidenerSet as any],
173+
fallbackPermissionSet: 'member_default',
174+
}),
175+
});
176+
await stack.signIn(); // seed dev admin (platform admin)
177+
bobToken = await stack.signUp('probe-bob@verify.test'); // plain member
178+
await stack.signUp('probe-alice@verify.test'); // plain member (row owner only)
179+
180+
ql = await stack.kernel.getServiceAsync('objectql');
181+
security = await stack.kernel.getServiceAsync('security');
182+
sharing = await stack.kernel.getServiceAsync('sharing');
183+
184+
const uid = async (email: string) =>
185+
(await ql.findOne('sys_user', { where: { email }, context: { ...SYS } }))?.id;
186+
bobId = await uid('probe-bob@verify.test');
187+
aliceId = await uid('probe-alice@verify.test');
188+
expect(bobId).toBeTruthy();
189+
expect(aliceId).toBeTruthy();
190+
191+
const setRow = await ql.findOne('sys_permission_set', {
192+
where: { name: 'probe_widener' }, context: { ...SYS },
193+
});
194+
expect(setRow?.id, 'the app-declared widener set is seeded').toBeTruthy();
195+
await ql.insert('sys_user_permission_set',
196+
{ user_id: bobId, permission_set_id: setRow.id }, { context: { ...SYS } });
197+
198+
// UPDATE fixture: three rows the declaration admits (`stage: 'open'`), one
199+
// of them Bob's; one row it does not admit, to bound the widener.
200+
await seed({ id: 'n_bob_open', title: 'bob open', stage: 'open', owner_id: bobId, body: 'seed' });
201+
await seed({ id: 'n_alice_open_1', title: 'alice open 1', stage: 'open', owner_id: aliceId, body: 'seed' });
202+
await seed({ id: 'n_alice_open_2', title: 'alice open 2', stage: 'open', owner_id: aliceId, body: 'seed' });
203+
await seed({ id: 'n_alice_closed', title: 'alice closed', stage: 'closed', owner_id: aliceId, body: 'seed' });
204+
205+
// DELETE fixture, same shape on the delete widener's own stage.
206+
await seed({ id: 'd_bob_stale', title: 'bob stale', stage: 'stale', owner_id: bobId });
207+
await seed({ id: 'd_alice_stale_1', title: 'alice stale 1', stage: 'stale', owner_id: aliceId });
208+
await seed({ id: 'd_alice_stale_2', title: 'alice stale 2', stage: 'stale', owner_id: aliceId });
209+
210+
bobCtx = await authzFor(bobToken);
211+
}, 180_000);
212+
213+
afterAll(async () => { await stack?.stop(); });
214+
215+
// ── probe integrity ──────────────────────────────────────────────────────
216+
//
217+
// Everything below is worthless if the fixture quietly handed Bob ownership
218+
// or the widener never reached the resolver. Assert both BEFORE measuring.
219+
220+
it('[integrity] Bob holds the app-authored set and owns exactly ONE row of each fixture', async () => {
221+
expect(bobCtx?.userId, 'the resolved principal is Bob').toBe(bobId);
222+
expect(bobCtx?.permissions, 'the app-authored set resolved onto the context')
223+
.toContain('probe_widener');
224+
expect(bobCtx?.isSystem, 'the probe never runs as system').toBeFalsy();
225+
226+
const open = await rowsBySystem({ stage: 'open' });
227+
const stale = await rowsBySystem({ stage: 'stale' });
228+
expect(open.map((r) => r.id).sort()).toEqual(['n_alice_open_1', 'n_alice_open_2', 'n_bob_open']);
229+
expect(stale.map((r) => r.id).sort()).toEqual(['d_alice_stale_1', 'd_alice_stale_2', 'd_bob_stale']);
230+
expect(open.filter((r) => r.owner_id === bobId).map((r) => r.id)).toEqual(['n_bob_open']);
231+
expect(stale.filter((r) => r.owner_id === bobId).map((r) => r.id)).toEqual(['d_bob_stale']);
232+
});
233+
234+
// ── C1 / C2 — the discrimination controls ────────────────────────────────
235+
236+
it('[C1] the widener is LIVE: by-id, Bob updates a row he does not own but the declaration admits', async () => {
237+
// Sharing refuses on its own terms; the middleware consults the authored
238+
// verdict and defers (#5493 step 2 / PR #6909). If the widener were inert
239+
// — unparsed, inapplicable, wrong object — this would be a 403 and every
240+
// number below would be meaningless.
241+
await expect(
242+
sharing.checkEdit(OBJECT, 'n_alice_open_1', bobCtx),
243+
).resolves.toBe('deny');
244+
await expect(
245+
security.checkAuthoredRowWrite(OBJECT, 'n_alice_open_1', 'update', bobCtx),
246+
).resolves.toBe('admit');
247+
248+
const res = await stack.apiAs(bobToken, 'PATCH', `/data/${OBJECT}/n_alice_open_1`, { body: 'by-id-widened' });
249+
expect(res.status, await res.text().catch(() => '')).toBeLessThan(300);
250+
expect((await rowById('n_alice_open_1'))?.body).toBe('by-id-widened');
251+
252+
// put it back so the bulk measurement starts from a clean field
253+
await ql.update(OBJECT, { body: 'seed' }, { where: { id: 'n_alice_open_1' }, context: { ...SYS } });
254+
});
255+
256+
it('[C2] the widener has a BOUNDARY: by-id, a row it does NOT admit is still refused (ADR-0112 envelope)', async () => {
257+
await expect(
258+
security.checkAuthoredRowWrite(OBJECT, 'n_alice_closed', 'update', bobCtx),
259+
).resolves.toBe('abstain');
260+
261+
const res = await stack.apiAs(bobToken, 'PATCH', `/data/${OBJECT}/n_alice_closed`, { body: 'should-not-land' });
262+
expect(res.status, 'a row outside the declaration must be refused').toBeGreaterThanOrEqual(400);
263+
const envelope: any = await res.json().catch(() => ({}));
264+
expect(
265+
JSON.stringify(envelope),
266+
'the refusal carries a real error envelope, not a bare throw',
267+
).toMatch(/FORBIDDEN|PERMISSION_DENIED/);
268+
expect((await rowById('n_alice_closed'))?.body, 'the row is untouched').toBe('seed');
269+
});
270+
271+
// ── the measurement ──────────────────────────────────────────────────────
272+
273+
it('[MEASURE update({multi})] the declaration admits 3 rows; the bulk statement touches 1, silently', async () => {
274+
const declaredAdmitted: string[] = [];
275+
for (const id of ['n_bob_open', 'n_alice_open_1', 'n_alice_open_2', 'n_alice_closed']) {
276+
const verdict = await security.checkAuthoredRowWrite(OBJECT, id, 'update', bobCtx);
277+
const owned = (await rowById(id))?.owner_id === bobId;
278+
if (verdict === 'admit' || owned) declaredAdmitted.push(id);
279+
}
280+
281+
// The exact narrowing predicate, named rather than inferred.
282+
const writeFilter = await sharing.buildWriteFilter(OBJECT, bobCtx, 'update');
283+
284+
let threw: unknown = null;
285+
let affected: unknown = null;
286+
try {
287+
affected = await ql.update(
288+
OBJECT, { body: 'bulk-widened' },
289+
{ where: { stage: 'open' }, multi: true, context: bobCtx },
290+
);
291+
} catch (e) { threw = e; }
292+
293+
const touched = (await rowsBySystem({ stage: 'open' }))
294+
.filter((r) => r.body === 'bulk-widened').map((r) => r.id).sort();
295+
296+
// eslint-disable-next-line no-console
297+
console.log('[#6736 PROBE update]', JSON.stringify({
298+
declaredAdmitted, writeFilter, affected, touched,
299+
threw: threw ? String((threw as Error).message ?? threw) : null,
300+
}, null, 2));
301+
302+
expect(declaredAdmitted.sort()).toEqual(['n_alice_open_1', 'n_alice_open_2', 'n_bob_open']);
303+
expect(threw, 'the narrowing is SILENT — no error, no 403').toBeNull();
304+
expect(touched, 'ONLY the caller-owned row is touched').toEqual(['n_bob_open']);
305+
expect(affected, 'the affected-row count is the ONLY signal, and it names no authority').toBe(1);
306+
});
307+
308+
it('[MEASURE delete({multi})] same reading on the delete path — 3 admitted, 1 removed, silently', async () => {
309+
const declaredAdmitted: string[] = [];
310+
for (const id of ['d_bob_stale', 'd_alice_stale_1', 'd_alice_stale_2']) {
311+
const verdict = await security.checkAuthoredRowWrite(OBJECT, id, 'delete', bobCtx);
312+
const owned = (await rowById(id))?.owner_id === bobId;
313+
if (verdict === 'admit' || owned) declaredAdmitted.push(id);
314+
}
315+
const deleteFilter = await sharing.buildWriteFilter(OBJECT, bobCtx, 'delete');
316+
317+
let threw: unknown = null;
318+
let affected: unknown = null;
319+
try {
320+
affected = await ql.delete(OBJECT, { where: { stage: 'stale' }, multi: true, context: bobCtx });
321+
} catch (e) { threw = e; }
322+
323+
const survivors = (await rowsBySystem({ stage: 'stale' })).map((r) => r.id).sort();
324+
325+
// eslint-disable-next-line no-console
326+
console.log('[#6736 PROBE delete]', JSON.stringify({
327+
declaredAdmitted, deleteFilter, affected, survivors,
328+
threw: threw ? String((threw as Error).message ?? threw) : null,
329+
}, null, 2));
330+
331+
expect(declaredAdmitted.sort()).toEqual(['d_alice_stale_1', 'd_alice_stale_2', 'd_bob_stale']);
332+
expect(threw, 'the narrowing is SILENT on delete too').toBeNull();
333+
expect(survivors, "the two rows the declaration admits survive").toEqual(['d_alice_stale_1', 'd_alice_stale_2']);
334+
expect(affected, 'again, only a count').toBe(1);
335+
});
336+
});

0 commit comments

Comments
 (0)