|
| 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