|
16 | 16 | * 3. A caller who can read a matched row but may not write it keeps the 403 a |
17 | 17 | * per-row gate answers, as before. |
18 | 18 | * 4. A visible, writable match is still written (control). |
19 | | - * 5. The read question's three outcomes: a read the read door REFUSES keeps |
20 | | - * the write's previous answer; a store fault propagates; a readable set |
| 19 | + * 5. The read question's three outcomes: a read the read door REFUSES is the |
| 20 | + * write's answer (8, below); a store fault propagates; a readable set |
21 | 21 | * larger than one write may name is refused, nothing written. |
22 | 22 | * 6. A predicate write the platform issues under the caller's context (a |
23 | 23 | * hook's own write) is not addressed by the caller and keeps the answer it |
24 | 24 | * had before. |
25 | 25 | * 7. The by-id doors keep their answers (regression). |
| 26 | + * 8. A write from a caller the read door refuses is refused, whatever rows it |
| 27 | + * addresses: the predicate update and delete that caller addresses are |
| 28 | + * answered the read door's own refusal before the write runs, with |
| 29 | + * row-level policies and without, and nothing changes. The by-id leg |
| 30 | + * refuses the same caller too; with no row-level policy it gives the same |
| 31 | + * answer. A caller who reads keeps its scoped |
| 32 | + * write, and a write the platform issues under the caller's context, or a |
| 33 | + * system write, keeps the answer it had. (The read here is refused by a |
| 34 | + * capability the object requires for a read and not for a write, ADR-0066 |
| 35 | + * ⑤: since an object permission that edits or deletes a record also reads |
| 36 | + * it, the one shape in which the write door admits a caller the read door |
| 37 | + * refuses.) |
26 | 38 | * |
27 | 39 | * ## The rig |
28 | 40 | * |
@@ -86,7 +98,23 @@ function outcome(p: Promise<unknown>): Promise<Outcome> { |
86 | 98 | ); |
87 | 99 | } |
88 | 100 |
|
89 | | -async function boot(opts: { bulkRows?: number } = {}): Promise<Rig> { |
| 101 | +/** |
| 102 | + * The rig's knobs. Defaults are the rig the first suites run on; the |
| 103 | + * read-refused caller's suite (8) gates the child's read on a capability, and |
| 104 | + * runs with and without the guard set's row-level policies. |
| 105 | + */ |
| 106 | +interface BootOptions { |
| 107 | + /** Rows on the bulk object, for the readable-set cap. */ |
| 108 | + bulkRows?: number; |
| 109 | + /** `requiredPermissions` on the child object (ADR-0066 D3/⑤). */ |
| 110 | + childRequiredPermissions?: Record<string, string[]>; |
| 111 | + /** Keep the guard set's row-level policies (the default) or run with none. */ |
| 112 | + rls?: boolean; |
| 113 | + /** Capabilities the guard set grants. */ |
| 114 | + capabilities?: string[]; |
| 115 | +} |
| 116 | + |
| 117 | +async function boot(opts: BootOptions = {}): Promise<Rig> { |
90 | 118 | const engine = new ObjectQL(); |
91 | 119 | engine.registerDriver( |
92 | 120 | new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, |
@@ -114,7 +142,13 @@ async function boot(opts: { bulkRows?: number } = {}): Promise<Rig> { |
114 | 142 | sharingModel: 'public_read_write', |
115 | 143 | fields: { id: { name: 'id', type: 'text', primaryKey: true }, name: { name: 'name', type: 'text' } }, |
116 | 144 | }, |
117 | | - { name: CHILD, label: 'Child', sharingModel: 'public_read_write', fields: childFields }, |
| 145 | + { |
| 146 | + name: CHILD, |
| 147 | + label: 'Child', |
| 148 | + sharingModel: 'public_read_write', |
| 149 | + ...(opts.childRequiredPermissions ? { requiredPermissions: opts.childRequiredPermissions } : {}), |
| 150 | + fields: childFields, |
| 151 | + }, |
118 | 152 | { name: BULK, label: 'Bulk', sharingModel: 'public_read_write', fields: childFields }, |
119 | 153 | ], |
120 | 154 | } as never); |
@@ -151,8 +185,9 @@ async function boot(opts: { bulkRows?: number } = {}): Promise<Rig> { |
151 | 185 | ]; |
152 | 186 | const guard = PermissionSetSchema.parse({ |
153 | 187 | name: 'qa_pw_guard', |
| 188 | + ...(opts.capabilities ? { systemPermissions: opts.capabilities } : {}), |
154 | 189 | objects: { [PARENT]: crud, [CHILD]: crud, [BULK]: crud }, |
155 | | - rowLevelSecurity: [...rls(CHILD), ...rls(BULK)], |
| 190 | + rowLevelSecurity: opts.rls === false ? [] : [...rls(CHILD), ...rls(BULK)], |
156 | 191 | }); |
157 | 192 | // Edit and delete on the child, and no read: the read door refuses this |
158 | 193 | // caller outright, which is not a hidden row. |
@@ -366,3 +401,93 @@ describe('the by-id doors keep their answers', () => { |
366 | 401 | expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403, message: GATE_SENTENCE }); |
367 | 402 | }); |
368 | 403 | }); |
| 404 | + |
| 405 | +describe('a write from a caller the read door refuses is refused, whatever rows it addresses', () => { |
| 406 | + // The child's read is gated on a capability the caller lacks, and its writes |
| 407 | + // are not (ADR-0066 ⑤), so the write door admits a caller the read door |
| 408 | + // refuses. The rig is a `single` install (its Default Organization), so every |
| 409 | + // answer here is the `single` posture's. |
| 410 | + const READ_CAP = 'qa_pw_read_cap'; |
| 411 | + const READ_GATED = { childRequiredPermissions: { read: [READ_CAP] } }; |
| 412 | + const EVERY_ROW = ids('c_own', 'c_hidden_locked', 'c_hidden_open', 'c_shared_locked', 'c_shared'); |
| 413 | + |
| 414 | + for (const verb of ['update', 'delete'] as const) { |
| 415 | + const write = (r: Rig, where: Record<string, unknown>, context?: Record<string, unknown>) => |
| 416 | + verb === 'update' ? r.update(where, context) : r.remove(where, context); |
| 417 | + |
| 418 | + for (const rls of [true, false]) { |
| 419 | + const policies = rls ? 'under row-level policies' : 'with no row-level policy'; |
| 420 | + it(`${verb}, ${policies}: the caller gets the read door's refusal for every predicate, and nothing changes`, async () => { |
| 421 | + const r = await boot({ ...READ_GATED, rls }); |
| 422 | + const read = await outcome(r.engine.find(CHILD, { where: EVERY_ROW, context: { ...CALLER } } as never)); |
| 423 | + expect(read).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); |
| 424 | + const before = await names(r); |
| 425 | + |
| 426 | + const answers = [ |
| 427 | + await write(r, EVERY_ROW), |
| 428 | + await write(r, ids('c_own')), |
| 429 | + await write(r, ids('c_hidden_open')), |
| 430 | + await write(r, { team: 'ops' }), |
| 431 | + await write(r, ids('c_nowhere')), |
| 432 | + ]; |
| 433 | + |
| 434 | + for (const got of answers) expect(got).toEqual(read); |
| 435 | + // Refused means untouched, every row. |
| 436 | + expect(await names(r)).toEqual(before); |
| 437 | + }); |
| 438 | + } |
| 439 | + |
| 440 | + it(`${verb}: the by-id leg gives that caller the same answer`, async () => { |
| 441 | + const r = await boot({ ...READ_GATED, rls: false }); |
| 442 | + const read = await outcome(r.engine.find(CHILD, { where: ids('c_own'), context: { ...CALLER } } as never)); |
| 443 | + const predicate = await write(r, ids('c_own')); |
| 444 | + const byId = await outcome( |
| 445 | + verb === 'update' |
| 446 | + ? r.engine.update(CHILD, { name: 'swept' }, { where: { id: 'c_own' }, context: { ...CALLER } } as never) |
| 447 | + : r.engine.delete(CHILD, { where: { id: 'c_own' }, context: { ...CALLER } } as never), |
| 448 | + ); |
| 449 | + expect(read).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); |
| 450 | + expect(predicate).toEqual(read); |
| 451 | + expect(byId).toEqual(read); |
| 452 | + expect((await r.stored(CHILD, 'c_own'))?.name).toBe('mine'); |
| 453 | + }); |
| 454 | + |
| 455 | + it(`${verb}: a caller holding the capability the read asks for keeps its write, scoped to the rows it reads`, async () => { |
| 456 | + const r = await boot({ ...READ_GATED, capabilities: [READ_CAP] }); |
| 457 | + expect(await outcome(r.engine.find(CHILD, { where: ids('c_own'), context: { ...CALLER } } as never))) |
| 458 | + .toMatchObject({ kind: 'landed' }); |
| 459 | + |
| 460 | + const got = await write(r, ids('c_own', 'c_hidden_locked', 'c_hidden_open')); |
| 461 | + |
| 462 | + expect(got).toEqual({ kind: 'landed', result: 1 }); |
| 463 | + const after = await names(r); |
| 464 | + if (verb === 'update') expect(after.c_own).toBe('swept'); |
| 465 | + else expect(after).not.toHaveProperty('c_own'); |
| 466 | + expect(after.c_hidden_locked).toBe('theirs, hidden, locked'); |
| 467 | + expect(after.c_hidden_open).toBe('theirs, hidden, open'); |
| 468 | + }); |
| 469 | + } |
| 470 | + |
| 471 | + it('a predicate write the platform issues under that caller\'s context, and a system write, keep the answers they had', async () => { |
| 472 | + const r = await boot(READ_GATED); |
| 473 | + r.engine.registerHook( |
| 474 | + 'beforeUpdate', |
| 475 | + async (hookCtx: any) => { |
| 476 | + await hookCtx.api.object(CHILD).update({ name: 'written by the hook' }, { where: ids('c_hidden_open'), multi: true }); |
| 477 | + }, |
| 478 | + { object: PARENT, packageId: PACKAGE }, |
| 479 | + ); |
| 480 | + |
| 481 | + const viaHook = await outcome( |
| 482 | + r.engine.update(PARENT, { name: 'renamed' }, { where: { id: 'p_hook' }, context: { ...CALLER } } as never), |
| 483 | + ); |
| 484 | + const system = await outcome( |
| 485 | + r.engine.update(CHILD, { name: 'written by the system' }, { where: ids('c_shared'), multi: true, context: { isSystem: true } } as never), |
| 486 | + ); |
| 487 | + |
| 488 | + expect(viaHook).toMatchObject({ kind: 'landed' }); |
| 489 | + expect((await r.stored(CHILD, 'c_hidden_open'))?.name).toBe('written by the hook'); |
| 490 | + expect(system).toEqual({ kind: 'landed', result: 1 }); |
| 491 | + expect((await r.stored(CHILD, 'c_shared'))?.name).toBe('written by the system'); |
| 492 | + }); |
| 493 | +}); |
0 commit comments