From dc807ea7b9a83086cc4f0140de45a057ccedfbed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:55:52 +0000 Subject: [PATCH 1/2] wip(dogfood): access-security slice 1 pins (#26571) Claude-Session: https://claude.ai/code/session_01JQwxbWKBLcQCG7FBS17aMA Co-authored-by: Claude --- ...ner-anchor-and-bulk-writes.dogfood.test.ts | 36 ++++ ...owcase-crud-persona-matrix.dogfood.test.ts | 155 ++++++++++++++++++ ...oice-line-contributor-pair.dogfood.test.ts | 120 ++++++++++++++ .../showcase-permission-zoo.dogfood.test.ts | 23 +++ .../test/showcase-private-owd.dogfood.test.ts | 44 ++++- .../showcase-public-read-owd.dogfood.test.ts | 22 ++- ...wcase-readonly-when-parent.dogfood.test.ts | 90 +++++++++- .../showcase-static-readonly.dogfood.test.ts | 18 +- packages/qa/dogfood/vitest.config.ts | 1 + 9 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 packages/qa/dogfood/test/showcase-invoice-line-contributor-pair.dogfood.test.ts diff --git a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts index 95c4e71b769..ac303ca0f25 100644 --- a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts +++ b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts @@ -160,6 +160,42 @@ describe('owner anchor guard + owner-scoped bulk writes (#3004 / #2982)', () => expect(back.status).toBeLessThan(300); }); + // ── access-security.write-path-guards negative[0] + acceptance[4] (#26571) ── + // The guard gates on privilege, not on the key: the forge and the disown the + // member is refused above succeed for the privileged caller. And the + // REST bulk create stamps the caller as owner on every row it leaves empty. + + it('a privileged caller CAN insert a record owned by someone else (forge is privilege-gated)', async () => { + const r = await stack.apiAs(adminToken, 'POST', OBJ, { title: 'admin-planted for bob', owner_id: bobId }); + expect(r.status, 'platform admin forge-insert must pass').toBeLessThan(300); + expect(await ownerOf(idOf(await r.json())), 'the row is owned by the named user').toBe(bobId); + }); + + it('a privileged caller CAN disown a record (owner_id: null)', async () => { + const made = await stack.apiAs(adminToken, 'POST', OBJ, { title: 'admin disown probe', owner_id: aliceId }); + expect(made.status).toBeLessThan(300); + const id = idOf(await made.json()); + const r = await stack.apiAs(adminToken, 'PATCH', `${OBJ}/${id}`, { owner_id: null }); + expect(r.status, 'platform admin disown must pass').toBeLessThan(300); + expect(await ownerOf(id) ?? null, 'the anchor is cleared').toBeNull(); + }); + + it('a member’s REST createMany with no owner_id stamps the caller on every row', async () => { + const r = await stack.apiAs(aliceToken, 'POST', `${OBJ}/createMany`, [ + { title: 'alice bulk 1' }, + { title: 'alice bulk 2' }, + { title: 'alice bulk 3' }, + ]); + const body: any = await r.json(); + expect(r.status, `bulk create as a member — got ${JSON.stringify(body)}`).toBeLessThan(300); + const rows = await ql.find('showcase_private_note', { + where: { title: { $in: ['alice bulk 1', 'alice bulk 2', 'alice bulk 3'] } }, + context: { isSystem: true }, + }); + expect(rows.length, 'all three rows were created').toBe(3); + for (const row of rows) expect(row.owner_id, `${row.title} owner`).toBe(aliceId); + }); + // ── #3004 × #2982 — bulk change-set carrying owner_id fails closed ───────── it('a bulk update whose change-set carries owner_id fails closed for a member', async () => { diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index fc0371f00fa..2caa56cdaf9 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -553,6 +553,161 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { expect(afterPlain?.title, 'and the row is untouched').toBe(opsValue); }, 120_000); + // ── VAMA on the rest of the auditor's objects (clause 3, #26571) ───────── + // The private-note case above is one of the four objects the auditor's + // viewAllRecords names. The other three each hide a foreign row from a + // persona that HOLDS read but not the bit, by a different mechanism: the + // private OWD (inquiry), the contributor's owner RLS (invoice) and the + // master-derived scope (line, controlled_by_parent). + it('viewAllRecords: the auditor reads a FOREIGN inquiry, invoice and invoice line by id AND in lists; a contributor gets none of them', async () => { + const auditor = persona.get('showcase_auditor')!; + const contributor = persona.get('showcase_contributor')!; + const mark = `MX-vama3-${Date.now()}`; + const make = async (object: string, body: Record) => { + const r = await stack.apiAs(adminTok, 'POST', `/data/${object}`, body); + const json = await bodyOf(r); + expect(r.status, `admin creates the foreign ${object} — got ${JSON.stringify(json)}`).toBeLessThan(300); + return String(idOf(json)); + }; + const inquiry = await make('showcase_inquiry', PAYLOAD.showcase_inquiry(ctxFor('admin@objectos.ai', mark))); + const invoice = await make('showcase_invoice', PAYLOAD.showcase_invoice(ctxFor('admin@objectos.ai', mark))); + const line = await make('showcase_invoice_line', PAYLOAD.showcase_invoice_line(ctxFor('admin@objectos.ai', mark, invoice))); + + for (const [object, id] of [ + ['showcase_inquiry', inquiry], + ['showcase_invoice', invoice], + ['showcase_invoice_line', line], + ] as const) { + const listPath = `/data/${object}?$filter=${encodeURIComponent(JSON.stringify({ id }))}`; + const byId = await stack.apiAs(auditor.token, 'GET', `/data/${object}/${id}`); + expect(byId.status, `auditor reads the foreign ${object} by id`).toBe(200); + const listed = rowsOf(await bodyOf(await stack.apiAs(auditor.token, 'GET', listPath))).map((r: any) => r.id); + expect(listed, `and in the ${object} list`).toContain(id); + + const otherById = await stack.apiAs(contributor.token, 'GET', `/data/${object}/${id}`); + expect(otherById.status, `the contributor cannot read the foreign ${object} by id`).toBeGreaterThanOrEqual(300); + const otherList = await stack.apiAs(contributor.token, 'GET', listPath); + expect(otherList.status, `the contributor's ${object} list itself is served`).toBe(200); + expect(rowsOf(await bodyOf(otherList)).map((r: any) => r.id), `nor sees the ${object} in a list`).not.toContain(id); + } + }, 120_000); + + // ── Wrong persona, both sides (negative[0], #26571) ────────────────────── + // One denied cell per permission set, re-run as the admin: a denial that also + // refuses the admin is a broken route or payload, not the persona's grant. + it('one denied cell per set succeeds when re-run as the admin', async () => { + const firstDenied = new Map(); + for (const cell of VERDICTS) { + if (cell.expected === 'deny' && !firstDenied.has(cell.set)) firstDenied.set(cell.set, cell); + } + const setsWithDenials = [...new Set(VERDICTS.filter((c) => c.expected === 'deny').map((c) => c.set))].sort(); + expect([...firstDenied.keys()].sort(), 'every set that has a denied cell is re-run').toEqual(setsWithDenials); + expect(setsWithDenials.length, 'the sweep has denials to re-run').toBeGreaterThan(0); + + const adminMake = async (object: string, mark: string) => { + const r = await stack.apiAs(adminTok, 'POST', `/data/${object}`, PAYLOAD[object](ctxFor('admin@objectos.ai', mark))); + expect(r.status, `admin seeds a ${object} target`).toBeLessThan(300); + return String(idOf(await bodyOf(r))); + }; + for (const cell of firstDenied.values()) { + const label = `${cell.set} × ${cell.object} ${cell.verb}, re-run as admin`; + const mark = `MX-n0-${cell.set}-${cell.object}`; + let r: Response; + if (cell.verb === 'create') { + r = await stack.apiAs(adminTok, 'POST', `/data/${cell.object}`, PAYLOAD[cell.object](ctxFor('admin@objectos.ai', mark))); + } else if (cell.verb === 'read') { + r = await stack.apiAs(adminTok, 'GET', `/data/${cell.object}`); + } else if (cell.verb === 'edit') { + const { field, value } = EDIT_PROBE[cell.object](mark); + r = await stack.apiAs(adminTok, 'PATCH', `/data/${cell.object}/${await adminMake(cell.object, mark)}`, { [field]: value }); + } else { + r = await stack.apiAs(adminTok, 'DELETE', `/data/${cell.object}/${await adminMake(cell.object, mark)}`); + } + expect(r.status, `${label} — got ${JSON.stringify(await bodyOf(r))}`).toBeLessThan(300); + } + }, 180_000); + + // ── The delegate × sys_* rows (clauses 0, 1 and 6, #26571) ─────────────── + // The four rows the sweep above leaves out, driven by a delegate holding + // exactly `showcase_field_ops_delegate`. Two cells are not permission + // verdicts at all: sys_user declares no create and no delete over the API, so + // the API-exposure gate answers 405 for every caller, the admin included. + // The sys_user_position cells are judged on rows the DELEGATE made, inside + // its Field Operations subtree: a row another principal made meets the + // platform's write-ownership floor instead, and an out-of-subtree write the + // ADR-0090 D12 gate (the permission zoo pins both sides of that gate). + it('the 16 showcase_field_ops_delegate × sys_* cells answer the matrix', async () => { + const DELEGATE = 'crudmx-field-ops-delegate@verify.test'; + const TARGET = 'crudmx-delegate-target@verify.test'; + const delegateTok = await stack.signUp(DELEGATE); + await stack.signUp(TARGET); + const uid = async (email: string) => + String((await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id ?? ''); + const delegateId = await uid(DELEGATE); + const targetId = await uid(TARGET); + expect(delegateId && targetId, 'delegate and target provisioned').toBeTruthy(); + const ps = await catalogPermissionSet(ql, 'showcase_field_ops_delegate'); + expect(ps, 'delegate set served by the security catalog').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: delegateId, permission_set: ps.name }, { context: SYS }); + await ql.insert('sys_business_unit_member', { business_unit_id: 'bu_field_ops', user_id: delegateId }, { context: SYS }); + await ql.insert('sys_business_unit_member', { business_unit_id: 'bu_west_coast', user_id: targetId }, { context: SYS }); + + const sysMake = async (object: string, data: Record) => + String(idOf(await ql.insert(object, data, { context: SYS }))); + const delegateRows = UNJUDGED_ROWS.filter((r) => r.permissionSet === 'showcase_field_ops_delegate'); + const cells: Array<{ cell: string; status: number; code?: string; expected: string }> = []; + const drive = (object: string, verb: Verb, status: number, json: any, expected: 'allow' | 'deny' | 405) => { + cells.push({ cell: `${object} ${verb}`, status, code: json?.code, expected: String(expected) }); + return json; + }; + const send = async (object: string, verb: Verb, r: Response, expected: 'allow' | 'deny' | 405) => + drive(object, verb, r.status, await bodyOf(r), expected); + + for (const row of delegateRows) { + const o = row.object; + const expectOf = (verb: Verb): 'allow' | 'deny' | 405 => + o === 'sys_user' && (verb === 'create' || verb === 'delete') ? 405 : row[verb] ? 'allow' : 'deny'; + if (o === 'sys_user_position') { + const created = await stack.apiAs(delegateTok, 'POST', `/data/${o}`, { + user_id: targetId, position: 'contributor', business_unit_id: 'bu_west_coast', + }); + const own = String(idOf(await send(o, 'create', created, expectOf('create')))); + await send(o, 'read', await stack.apiAs(delegateTok, 'GET', `/data/${o}`), expectOf('read')); + await send(o, 'edit', await stack.apiAs(delegateTok, 'PATCH', `/data/${o}/${own}`, { reason: 'crudmx delegate edit' }), expectOf('edit')); + await send(o, 'delete', await stack.apiAs(delegateTok, 'DELETE', `/data/${o}/${own}`), expectOf('delete')); + continue; + } + const createBody: Record = + o === 'sys_business_unit' ? { name: 'crudmx delegate BU' } + : o === 'sys_business_unit_member' ? { business_unit_id: 'bu_west_coast', user_id: delegateId } + : { email: 'crudmx-forged@verify.test', name: 'forged' }; + const probe = + o === 'sys_business_unit' ? await sysMake(o, { name: 'crudmx probe BU' }) + : o === 'sys_business_unit_member' ? await sysMake(o, { business_unit_id: 'bu_hq_finance', user_id: targetId }) + : targetId; + const editBody: Record = + o === 'sys_business_unit_member' ? { business_unit_id: 'bu_acme' } : { name: 'crudmx delegate edit' }; + await send(o, 'create', await stack.apiAs(delegateTok, 'POST', `/data/${o}`, createBody), expectOf('create')); + await send(o, 'read', await stack.apiAs(delegateTok, 'GET', `/data/${o}`), expectOf('read')); + await send(o, 'edit', await stack.apiAs(delegateTok, 'PATCH', `/data/${o}/${probe}`, editBody), expectOf('edit')); + await send(o, 'delete', await stack.apiAs(delegateTok, 'DELETE', `/data/${o}/${probe}`), expectOf('delete')); + } + console.log('[crud-matrix delegate cells]', JSON.stringify(cells)); + expect(cells.length, 'four rows × four verbs').toBe(16); + for (const c of cells) { + if (c.expected === 'allow') expect(c.status, `${c.cell}: allowed`).toBeLessThan(300); + else if (c.expected === '405') expect([c.status, c.code], `${c.cell}: not exposed over the API`).toEqual([405, 'OBJECT_API_METHOD_NOT_ALLOWED']); + else expect([c.status, c.code], `${c.cell}: withheld`).toEqual([403, 'PERMISSION_DENIED']); + } + + // The admin control on the two 405 cells: the exposure gate answers before + // any permission is judged, so the admin is refused the same way. + const adminCreate = await stack.apiAs(adminTok, 'POST', '/data/sys_user', { email: 'crudmx-admin-forged@verify.test', name: 'x' }); + expect([adminCreate.status, (await bodyOf(adminCreate))?.code], 'admin sys_user create').toEqual([405, 'OBJECT_API_METHOD_NOT_ALLOWED']); + const adminDelete = await stack.apiAs(adminTok, 'DELETE', `/data/sys_user/${targetId}`); + expect([adminDelete.status, (await bodyOf(adminDelete))?.code], 'admin sys_user delete').toEqual([405, 'OBJECT_API_METHOD_NOT_ALLOWED']); + }, 180_000); + // ── The verdict table itself (clause 6) ────────────────────────────────── // // This is the anti-vacuity check. Without it the sweep could shrink — a diff --git a/packages/qa/dogfood/test/showcase-invoice-line-contributor-pair.dogfood.test.ts b/packages/qa/dogfood/test/showcase-invoice-line-contributor-pair.dogfood.test.ts new file mode 100644 index 00000000000..3aa07c317ff --- /dev/null +++ b/packages/qa/dogfood/test/showcase-invoice-line-contributor-pair.dogfood.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `access-security.owd-sharing-matrix` acceptance[4] and acceptance[5] (#26571) +// — the `controlled_by_parent` model on the STOCK showcase grants, over HTTP. +// +// `showcase-invoice-cbp.dogfood.test.ts` proves the derivation mechanism with a +// test-local fallback set. This file drives the personas the checklist item +// names, holding only what the showcase ships: two contributors C1/C2 +// (`showcase_contributor` — invoice owner RLS `owner == current_user.email`, +// line access derived from the master, no line rule authored) and an auditor +// (`showcase_auditor` — `viewAllRecords` on `showcase_invoice_line`). +// +// - acceptance[4]: C2 cannot list, read by id or PATCH C1's line; C1 reads and +// writes it by id. Derivation is the only mechanism in play. +// - acceptance[5]: the auditor's viewAllRecords crosses the derived scope — C1's +// line is in the auditor's list (and by-id read) whatever owns the master. +// +// Shared-stack eligible: grants are `sys_user_permission_set` rows for this +// file's own sign-ups, and every list is filtered to the rows this file made. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { getSharedShowcase } from './shared-showcase.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; + +const SYS = { isSystem: true } as const; +const LINES = '/data/showcase_invoice_line'; +const C1 = 'cbp-pair-c1@verify.test'; +const C2 = 'cbp-pair-c2@verify.test'; +const AUDITOR = 'cbp-pair-auditor@verify.test'; +const MARK = 'cbp-pair C1 line'; + +const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId; +const rowsOf = (b: any) => b?.records ?? b?.data ?? (Array.isArray(b) ? b : []); +const filterOf = (where: Record) => encodeURIComponent(JSON.stringify(where)); + +describe('showcase: controlled_by_parent lines across stock contributors and the auditor (ADR-0055)', () => { + let stack: VerifyStack; + let ql: any; + const tok: Record = {}; + let c1LineId: string; + + const lineDescription = async () => + (await ql.findOne('showcase_invoice_line', { where: { id: c1LineId }, context: SYS }))?.description; + const listedIds = async (who: string) => { + const r = await stack.apiAs(tok[who], 'GET', `${LINES}?$filter=${filterOf({ id: c1LineId })}`); + expect(r.status, `${who} lists lines`).toBe(200); + return rowsOf(await r.json()).map((x: any) => x.id); + }; + + beforeAll(async () => { + stack = await getSharedShowcase(); + await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + const grants: Array<[string, string]> = [ + [C1, 'showcase_contributor'], + [C2, 'showcase_contributor'], + [AUDITOR, 'showcase_auditor'], + ]; + for (const [email, set] of grants) { + tok[email] = await stack.signUp(email); + const ps = await catalogPermissionSet(ql, set); + expect(ps, `permission set ${set} served by the security catalog`).toBeTruthy(); + const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + expect(userId, `persona ${email} provisioned`).toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: ps.name }, { context: SYS }); + } + + // An invoice may not be issued against a churned account (#18682). + const account = (await ql.find('showcase_account', { where: { status: { $ne: 'churned' } }, limit: 1, context: SYS }))?.[0]?.id; + const product = (await ql.find('showcase_product', { limit: 1, context: SYS }))?.[0]?.id; + expect(account && product, 'the showcase seed provides an account and a product').toBeTruthy(); + + // C1 builds the master and its line through the REST door, as the item's + // steps do: the invoice owner is C1's email, so the owner RLS selects it. + const inv = await stack.apiAs(tok[C1], 'POST', '/data/showcase_invoice', { + name: 'INV-CBP-PAIR-C1', account, status: 'draft', owner: C1, + }); + expect(inv.status, 'C1 creates their invoice').toBeLessThan(300); + const invoiceId = idOf(await inv.json()); + const line = await stack.apiAs(tok[C1], 'POST', LINES, { + invoice: invoiceId, product, quantity: 1, description: MARK, + }); + expect(line.status, 'C1 creates a line under it').toBeLessThan(300); + c1LineId = idOf(await line.json()); + expect(c1LineId, 'line id').toBeTruthy(); + }, 120_000); + + it('acceptance[4]: C2 does not see C1’s line in a list', async () => { + expect(await listedIds(C2), 'the derived scope hides the line from the list').not.toContain(c1LineId); + // Contrast on the same filtered request, so the exclusion is not an empty route. + expect(await listedIds(C1), 'the owner’s identical list carries it').toContain(c1LineId); + }); + + it('acceptance[4]: C2 cannot read C1’s line by id', async () => { + const r = await stack.apiAs(tok[C2], 'GET', `${LINES}/${c1LineId}`); + expect(r.status, 'a line under an unreadable master is hidden by id').toBeGreaterThanOrEqual(300); + }); + + it('acceptance[4]: C2’s PATCH of C1’s line answers >= 400 and leaves it unchanged', async () => { + const r = await stack.apiAs(tok[C2], 'PATCH', `${LINES}/${c1LineId}`, { description: 'forged by C2' }); + expect(r.status, 'the derived write gate refuses').toBeGreaterThanOrEqual(400); + expect(await lineDescription(), 'the row is unchanged').toBe(MARK); + }); + + it('acceptance[4]: C1 reads and writes their own line by id (the entitled side)', async () => { + const read = await stack.apiAs(tok[C1], 'GET', `${LINES}/${c1LineId}`); + expect(read.status, 'C1 reads their line').toBe(200); + const write = await stack.apiAs(tok[C1], 'PATCH', `${LINES}/${c1LineId}`, { description: `${MARK} (edited)` }); + expect(write.status, 'C1 edits their line').toBeLessThan(300); + expect(await lineDescription(), 'and the edit persisted').toBe(`${MARK} (edited)`); + }); + + it('acceptance[5]: the auditor’s viewAllRecords surfaces C1’s line regardless of master ownership', async () => { + expect(await listedIds(AUDITOR), 'the auditor’s list carries the foreign line').toContain(c1LineId); + const byId = await stack.apiAs(tok[AUDITOR], 'GET', `${LINES}/${c1LineId}`); + expect(byId.status, 'and the by-id read').toBe(200); + }); +}); diff --git a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts index 7a9ac6b57ae..3e0c627ad71 100644 --- a/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts @@ -235,6 +235,9 @@ describe('showcase: ADR-0090 permission-model zoo', () => { name: 'Renamed by contributor (FLS probe)', }); expect(nameEdit.status, 'ordinary field edit passes').toBeLessThan(300); + // [#26571] fls-mask-and-strip acceptance[0]: the value oracle, not the status. + const renamed = await ql.findOne('showcase_project', { where: { id: projectId }, context: SYS }); + expect(renamed.name, 'the editable field really changed').toBe('Renamed by contributor (FLS probe)'); // Masked field → denied. A LARGE budget keeps the spent_within_budget // validation rule satisfied, so a rejection here can only be the FLS @@ -248,4 +251,24 @@ describe('showcase: ADR-0090 permission-model zoo', () => { const after = await ql.findOne('showcase_project', { where: { id: projectId }, context: SYS }); expect(after.budget, 'budget unchanged after the denied write').toBe(budgetBefore); }); + + // [#26571] fls-mask-and-strip acceptance[2] — the other side of the same lock: + // it keys on the caller's FLS, not on the field, so the admin's identical + // budget write lands. Raised (spent <= budget stays true) and put back, so the + // seeded row other shared files read is left as found. + it('FLS: the admin\'s budget write on the same project succeeds and persists', async () => { + const projects = await ql.find('showcase_project', { where: { budget: { $gt: 0 } }, limit: 1, context: SYS }); + const projectId = projects?.[0]?.id; + const budgetBefore = Number(projects?.[0]?.budget); + expect(projectId, 'a seeded project with a budget available').toBeTruthy(); + + const raised = budgetBefore + 1000; + const write = await stack.apiAs(adminTok, 'PATCH', `/data/showcase_project/${projectId}`, { budget: raised }); + expect(write.status, 'the entitled caller writes the FLS-locked field').toBeLessThan(300); + const after = await ql.findOne('showcase_project', { where: { id: projectId }, context: SYS }); + expect(Number(after.budget), 'and the write persisted').toBe(raised); + + const restore = await stack.apiAs(adminTok, 'PATCH', `/data/showcase_project/${projectId}`, { budget: budgetBefore }); + expect(restore.status, 'the seeded budget is put back').toBeLessThan(300); + }); }); diff --git a/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts b/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts index a11693176de..8aa5e8bb593 100644 --- a/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts @@ -18,30 +18,38 @@ import { getSharedShowcase } from './shared-showcase.js'; const OBJ = '/data/showcase_private_note'; const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId; +const recordOf = (b: any) => b?.record ?? b?.data ?? b; +const rowsOf = (b: any) => b?.records ?? b?.data ?? (Array.isArray(b) ? b : []); +const filterOf = (where: Record) => encodeURIComponent(JSON.stringify(where)); describe('showcase: declarative private OWD (ADR-0056)', () => { let stack: VerifyStack; + let adminToken: string; let aToken: string; let bToken: string; let aNoteId: string; + let aNote2Id: string; let bNoteId: string; beforeAll(async () => { stack = await getSharedShowcase(); - await stack.signIn(); // seed dev admin (first user) + adminToken = await stack.signIn(); // seed dev admin (first user) aToken = await stack.signUp('owd-alice@verify.test'); bToken = await stack.signUp('owd-bob@verify.test'); const a1 = await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice private 1', body: 'a-body' }); expect(a1.status, 'alice creates note').toBeLessThan(300); aNoteId = idOf(await a1.json()); - await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice private 2' }); + const a2 = await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice private 2' }); + expect(a2.status, 'alice creates a second note').toBeLessThan(300); + aNote2Id = idOf(await a2.json()); const b1 = await stack.apiAs(bToken, 'POST', OBJ, { title: 'Bob private 1' }); expect(b1.status, 'bob creates note').toBeLessThan(300); bNoteId = idOf(await b1.json()); expect(aNoteId, 'alice note id').toBeTruthy(); + expect(aNote2Id, 'alice second note id').toBeTruthy(); expect(bNoteId, 'bob note id').toBeTruthy(); }, 60_000); @@ -83,4 +91,36 @@ describe('showcase: declarative private OWD (ADR-0056)', () => { const foreign = await stack.apiAs(bToken, 'GET', `${OBJ}/${aNoteId}`); expect(foreign.status).not.toBe(200); }); + + // ── access-security.rls-both-sides a2/a3/n0 + owd-sharing-matrix a0 (#26571) ── + // The two halves the member-only cases above leave open: the admin re-read + // that proves a refused foreign write changed nothing (a 4xx over a mutated + // row is still the failure), and the entitled side of the same gate. + + it('a foreign by-id read is non-2xx, not merely not-200', async () => { + const r = await stack.apiAs(aToken, 'GET', `${OBJ}/${bNoteId}`); + expect(r.status, 'any 2xx is a read of a row owner isolation hides').toBeGreaterThanOrEqual(300); + }); + + it('a refused foreign PATCH leaves the row byte-identical on the admin re-read', async () => { + const read = async () => { + const r = await stack.apiAs(adminToken, 'GET', `${OBJ}/${bNoteId}`); + expect(r.status, 'the admin reads the foreign note').toBe(200); + return recordOf(await r.json()); + }; + const before = await read(); + const forged = await stack.apiAs(aToken, 'PATCH', `${OBJ}/${bNoteId}`, { body: 'forged' }); + expect(forged.status, 'the forged foreign write is refused').toBeGreaterThanOrEqual(400); + const after = await read(); + expect(after, 'the refused write changed nothing on the row').toEqual(before); + expect(after.body, 'the forged value never landed').not.toBe('forged'); + }); + + it('the admin (platform posture) lists every note this file created — the entitled side', async () => { + const ids = [aNoteId, aNote2Id, bNoteId]; + const r = await stack.apiAs(adminToken, 'GET', `${OBJ}?$filter=${filterOf({ id: { $in: ids } })}`); + expect(r.status).toBe(200); + const listed = rowsOf(await r.json()).map((x: any) => x.id); + expect([...listed].sort(), 'both owners’ rows, from the server row set').toEqual([...ids].sort()); + }); }); diff --git a/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts index f9d3b8db48c..afb6e2a5e6c 100644 --- a/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts @@ -17,16 +17,18 @@ import { getSharedShowcase } from './shared-showcase.js'; const OBJ = '/data/showcase_announcement'; const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId; +const recordOf = (b: any) => b?.record ?? b?.data ?? b; describe('showcase: public-read OWD (ADR-0056)', () => { let stack: VerifyStack; + let adminToken: string; let aToken: string; let bToken: string; let aAnnId: string; beforeAll(async () => { stack = await getSharedShowcase(); - await stack.signIn(); + adminToken = await stack.signIn(); aToken = await stack.signUp('pr-alice@verify.test'); bToken = await stack.signUp('pr-bob@verify.test'); @@ -59,4 +61,22 @@ describe('showcase: public-read OWD (ADR-0056)', () => { const r = await stack.apiAs(bToken, 'GET', `${OBJ}/${aAnnId}`); expect(r.status).toBe(200); }); + + // ── access-security.owd-sharing-matrix a1 (#26571) ────────────────────────── + // The clause wants the foreign write REFUSED (>= 400, not merely not-2xx) and + // the row unchanged on an entitled re-read — a 4xx over a mutated row is + // still the failure. + it('a non-owner PATCH answers >= 400 and the admin re-read shows the row unchanged', async () => { + const read = async () => { + const r = await stack.apiAs(adminToken, 'GET', `${OBJ}/${aAnnId}`); + expect(r.status, 'the admin reads the announcement').toBe(200); + return recordOf(await r.json()); + }; + const before = await read(); + const forged = await stack.apiAs(bToken, 'PATCH', `${OBJ}/${aAnnId}`, { body: 'forged by bob' }); + expect(forged.status, 'the public_read OWD refuses the non-owner write').toBeGreaterThanOrEqual(400); + const after = await read(); + expect(after, 'the refused write changed nothing on the row').toEqual(before); + expect(after.body, 'the forged value never landed').not.toBe('forged by bob'); + }); }); diff --git a/packages/qa/dogfood/test/showcase-readonly-when-parent.dogfood.test.ts b/packages/qa/dogfood/test/showcase-readonly-when-parent.dogfood.test.ts index e63ad85d81c..e4643f18a7d 100644 --- a/packages/qa/dogfood/test/showcase-readonly-when-parent.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-readonly-when-parent.dogfood.test.ts @@ -39,17 +39,19 @@ describe('showcase: parent-scoped readonlyWhen is server-enforced (#4889)', () = let draftInvoiceId: string; let paidLineId: string; let draftLineId: string; + let ql: any; + let product: string; beforeAll(async () => { stack = await getSharedShowcase(); token = await stack.signIn(); // the issue's own principal: admin@objectos.ai - const ql = await stack.kernel.getServiceAsync('objectql'); + ql = await stack.kernel.getServiceAsync('objectql'); const sys = { context: { isSystem: true } }; // Own fixtures (uniquely named — the stack is shared across files). const account = idOf(await ql.insert('showcase_account', { name: 'Frozen Lines Co #4889', status: 'prospect' }, sys)); - const product = idOf(await ql.insert('showcase_product', { name: 'Frozen Widget #4889', unit_price: 49.99 }, sys)); + product = idOf(await ql.insert('showcase_product', { name: 'Frozen Widget #4889', unit_price: 49.99 }, sys)); // A SETTLED invoice — the INV-1003 shape from the issue. `issued_on` / // `paid_on` satisfy the header's own `requiredWhen` rules for `paid`. @@ -123,4 +125,88 @@ describe('showcase: parent-scoped readonlyWhen is server-enforced (#4889)', () = expect(open.status).toBe(200); expect((await readInvoice(draftInvoiceId)).tax_rate, 'draft ⇒ tax rate editable').toBe(12); }); + + // ── access-security.write-path-guards acceptance[5] and [6] (#26571) ──────── + + const storedLine = async (id: string) => + ql.findOne('showcase_invoice_line', { where: { id }, context: { isSystem: true } }); + const resultFor = (body: any, id: string) => (body?.results ?? []).find((r: any) => r.id === id); + const draftLine = async (description: string, quantity: number) => + idOf(await ql.insert('showcase_invoice_line', { + invoice: draftInvoiceId, product, description, quantity, unit_price: 49.99, + }, { context: { isSystem: true } })); + + it("the paid invoice's line keeps its product too — the third locked field", async () => { + const other = idOf(await ql.insert('showcase_product', { name: 'Swap Widget #26571', unit_price: 1 }, { context: { isSystem: true } })); + const res = await stack.apiAs(token, 'PATCH', `${LINES}/${paidLineId}`, { product: other }); + expect(res.status, 'the strip is silent').toBe(200); + expect(res.headers.get('X-ObjectStack-Dropped-Fields') ?? '').toContain('product;reason=readonly_when'); + expect((await storedLine(paidLineId)).product, 'frozen product must survive the PATCH').toBe(product); + }); + + it('a mixed updateMany is judged PER ROW: the locked row reports droppedFields, the open row in the same batch lands', async () => { + const res = await stack.apiAs(token, 'POST', `${LINES}/updateMany`, { + records: [ + { id: paidLineId, data: { quantity: 77 } }, + { id: draftLineId, data: { quantity: 77 } }, + ], + }); + expect(res.status).toBe(200); + const body: any = await res.json(); + const locked = resultFor(body, paidLineId); + const open = resultFor(body, draftLineId); + expect(locked?.success, `the locked row still succeeds — got ${JSON.stringify(body)}`).toBe(true); + expect(locked?.droppedFields, 'the locked row names the field it dropped').toEqual( + expect.arrayContaining([expect.objectContaining({ fields: ['quantity'], reason: 'readonly_when' })]), + ); + expect(open?.success).toBe(true); + expect(open?.droppedFields, 'the open row dropped nothing').toBeUndefined(); + expect((await storedLine(paidLineId)).quantity, 'locked row unchanged').toBe(6); + expect((await storedLine(draftLineId)).quantity, 'open row changed').toBe(77); + }); + + it('updateMany evaluates validation PER ROW under continueOnError: the violating row fails alone', async () => { + const before = await draftLine('validation row before', 3); + const bad = await draftLine('validation row violating', 3); + const after = await draftLine('validation row after', 3); + const res = await stack.apiAs(token, 'POST', `${LINES}/updateMany`, { + records: [ + { id: before, data: { quantity: 5 } }, + { id: bad, data: { quantity: -1 } }, // below the field's min: 0 + { id: after, data: { quantity: 7 } }, + ], + options: { continueOnError: true }, + }); + expect(res.status).toBe(200); + const body: any = await res.json(); + expect([body.total, body.succeeded, body.failed], JSON.stringify(body)).toEqual([3, 2, 1]); + expect(resultFor(body, before)?.success).toBe(true); + expect(resultFor(body, after)?.success, 'the row AFTER the violation is still applied').toBe(true); + const failed = (body.results ?? []).find((r: any) => r.success === false); + expect(failed?.errors?.[0]?.code, 'the violating row carries its own validation error').toBe('VALIDATION_FAILED'); + expect((await storedLine(before)).quantity).toBe(5); + expect((await storedLine(bad)).quantity, 'the violating row is unchanged').toBe(3); + expect((await storedLine(after)).quantity).toBe(7); + }); + + it('CONTRAST: without continueOnError the run stops at the first failure and reports the tail NOT_ATTEMPTED', async () => { + const before = await draftLine('stop row before', 3); + const bad = await draftLine('stop row violating', 3); + const after = await draftLine('stop row after', 3); + const res = await stack.apiAs(token, 'POST', `${LINES}/updateMany`, { + records: [ + { id: before, data: { quantity: 5 } }, + { id: bad, data: { quantity: -1 } }, + { id: after, data: { quantity: 7 } }, + ], + }); + expect(res.status).toBe(200); + const body: any = await res.json(); + expect(resultFor(body, before)?.success, JSON.stringify(body)).toBe(true); + const tail = (body.results ?? [])[2]; + expect(tail?.errors?.[0]?.code, 'the row after the failure is reported, not omitted').toBe('NOT_ATTEMPTED'); + expect((await storedLine(before)).quantity, 'rows before the failure stay written').toBe(5); + expect((await storedLine(bad)).quantity).toBe(3); + expect((await storedLine(after)).quantity, 'the un-attempted row is unchanged').toBe(3); + }); }); diff --git a/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts b/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts index b67bba3b79b..79d3ff4a698 100644 --- a/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts @@ -46,6 +46,8 @@ describe('showcase: static readonly write enforcement (#2948 / #3003 / #3043)', let stack: VerifyStack; let token: string; let contactId: string; + let createdBody: any; + let createdDroppedHeader: string | null; beforeAll(async () => { stack = await getSharedShowcase(); @@ -62,7 +64,9 @@ describe('showcase: static readonly write enforcement (#2948 / #3003 / #3043)', lead_score: 10, // forged: the UI never renders this on the create form }); expect(created.status, 'create succeeds; the readonly key is dropped, not rejected').toBeLessThan(300); - contactId = idOf(await created.json()); + createdBody = await created.json(); + createdDroppedHeader = created.headers.get('X-ObjectStack-Dropped-Fields'); + contactId = idOf(createdBody); expect(contactId).toBeTruthy(); }, 60_000); @@ -76,6 +80,18 @@ describe('showcase: static readonly write enforcement (#2948 / #3003 / #3043)', expect(rec.lead_score ?? null, 'insert-forged readonly value must NOT persist').toBeNull(); }); + // [#26571] write-path-guards acceptance[0]: the INSERT strip is silent on the + // status, never on the answer — the caller is told which key was dropped and + // why (#3431), in the body and in the header. + it('the INSERT strip is ADVERTISED: droppedFields and X-ObjectStack-Dropped-Fields name lead_score', () => { + const dropped = (createdBody?.droppedFields ?? []) as Array<{ fields: string[]; reason: string }>; + expect( + dropped.some((d) => d.reason === 'readonly' && d.fields.includes('lead_score')), + `body droppedFields names the stripped key with reason readonly — got ${JSON.stringify(dropped)}`, + ).toBe(true); + expect(createdDroppedHeader ?? '', 'and the response header carries it').toContain('lead_score;reason=readonly'); + }); + it('a direct PATCH forging the readonly field is silently stripped — sibling editable fields still land', async () => { // The #3003 move: same logged-in session, straight to the REST API with a // payload the UI would never produce. diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index 6705b3105c7..09a58ed35d2 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -69,6 +69,7 @@ const SHARED_SHOWCASE = [ 'test/showcase-anonymous-deny.dogfood.test.ts', 'test/showcase-anonymous-deny-surfaces.dogfood.test.ts', 'test/showcase-declarative-rbac-seeding.dogfood.test.ts', + 'test/showcase-invoice-line-contributor-pair.dogfood.test.ts', 'test/showcase-permission-zoo.dogfood.test.ts', 'test/showcase-private-owd.dogfood.test.ts', 'test/showcase-public-read-owd.dogfood.test.ts', From 87216875ecc3a4f621f4caa9ef6b246a7cba2019 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:03:22 +0000 Subject: [PATCH 2/2] test(dogfood): pin access-security slice 1 clauses at the api oracle's layer (#26571) Items: rls-both-sides, write-path-guards, crud-permission-matrix, owd-sharing-matrix, fls-mask-and-strip. Each item's automated.ref names the new cases, revision bumped, history row cites the card. Claude-Session: https://claude.ai/code/session_01JQwxbWKBLcQCG7FBS17aMA Co-authored-by: Claude --- .../areas/access-security.json | 50 +++++++++++++++---- ...owcase-crud-persona-matrix.dogfood.test.ts | 23 +++++---- 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 18b0f690a0d..a2eee3b4940 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -9,7 +9,7 @@ "title": "Row-level security: restricted member sees only their rows; admin sees all", "since": "v15", "status": "active", - "revision": 8, + "revision": 9, "priority": "P0", "surface": "api", "personas": [ @@ -83,7 +83,7 @@ ], "automated": { "kind": "verify", - "ref": "`objectstack verify --rls` (packages/verify/src/rls.ts runRlsProofs) + packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts. packages/verify/src/rls.ts is a source file, not a test: acceptance[4] scores from a run that executes the command over the showcase, and no pin does — rls-fixture.dogfood.test.ts and rls-multitenant.dogfood.test.ts run runRlsProofs on purpose-built fixtures only. The showcase pin's foreign PATCH asserts only status >= 300; the row-unchanged re-read acceptance[2] and negative[0] require is pinned for a fixture object in packages/qa/dogfood/test/rls-fixture.dogfood.test.ts, not for showcase_private_note, so on the showcase that re-read is hand-driven" + "ref": "`objectstack verify --rls` (packages/verify/src/rls.ts runRlsProofs) + packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts. packages/verify/src/rls.ts is a source file, not a test: acceptance[4] scores from a run that executes the command over the showcase, and no pin does — rls-fixture.dogfood.test.ts and rls-multitenant.dogfood.test.ts run runRlsProofs on purpose-built fixtures only. The showcase pin drives acceptance[0]–[3] and negative[0] on showcase_private_note through the in-process HTTP stack: each member's list (presence and absence), the foreign by-id read answering non-2xx, the foreign PATCH answering >= 400 with the admin's re-read of the row unchanged ('a refused foreign PATCH leaves the row byte-identical on the admin re-read'), and the admin's filtered list carrying every note the file created, both owners' ('the admin (platform posture) lists every note this file created'). acceptance[5], negative[1] and negative[2] stay with the run: they are procedural (the per-skip explanation is a reading of the verify report; the record-once and naming rules govern a run record), and the D11 spot-check needs the private write-up (knownGaps[0])" }, "source": [ "packages/verify/src/rls.ts", @@ -136,6 +136,12 @@ "date": "2026-10-26", "change": "Two checklist-accuracy findings of Tier 1 record #25815, re-read on main b4199c96. a4: automated.ref named packages/verify/src/rls.ts, a source file; no pin runs runRlsProofs over the showcase (rls-fixture / rls-multitenant run it on purpose-built fixtures), so the ref now names the command and says the clause scores from a run of it. a2/n0: the showcase pin's foreign PATCH asserts only status >= 300; the row-unchanged re-read is pinned for a fixture object in rls-fixture.dogfood.test.ts, which the ref now says. No clause or status changed", "ref": "#25835" + }, + { + "revision": 9, + "date": "2026-10-27", + "change": "Access-security pin upgrade, slice 1 (#26571; Tier 1 record #26468 scored the item PARTIAL). acceptance[2], acceptance[3] and negative[0] are now pinned at their oracle's layer (api, in-process HTTP) in showcase-private-owd.dogfood.test.ts: the admin re-read proving the refused foreign PATCH changed nothing, and the admin's list of every row the file created. automated.ref names both cases and says why acceptance[5], negative[1] and negative[2] stay with the run. No clause or status changed", + "ref": "#26571" } ] }, @@ -144,7 +150,7 @@ "title": "Write-path guards: readonly strip, owner_id forge/transfer denied, bulk validation", "since": "v16", "status": "active", - "revision": 7, + "revision": 8, "priority": "P0", "surface": "api", "personas": [ @@ -218,7 +224,7 @@ ], "automated": { "kind": "dogfood", - "ref": "packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts (+ showcase-static-readonly.dogfood.test.ts, showcase-readonly-when-parent.dogfood.test.ts); negative[1] (the isSystem exemption, which no REST door reaches by design) by packages/objectql/src/engine-insert-static-readonly-strip.test.ts ('isSystem seeds the readonly column — the exemption the ruling names', the INSERT path) + packages/objectql/src/engine-readonly-strip-caller-values.test.ts ('an isSystem caller is untouched by any of this', the UPDATE path on a static readonly column), both on a real engine over a recording / fake driver and their own test objects, not showcase_contact's lead_score — they score the one engine function negative[1] names, and the showcase field is not observed. What the dogfood pins do not assert: acceptance[0]'s droppedFields on the INSERT strip (showcase-static-readonly asserts the stripped value, not the advertisement); acceptance[4] and acceptance[6] — owner-anchor-and-bulk-writes' bulk cases are engine-direct ql.update / ql.delete owner scoping, with no createMany stamping and no per-row validation; acceptance[5]'s mixed updateMany (results[i].droppedFields) and the line's product field — showcase-readonly-when-parent pins the single-row PATCH strip and the X-ObjectStack-Dropped-Fields header only. Those legs are hand-driven" + "ref": "packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts + showcase-static-readonly.dogfood.test.ts + showcase-readonly-when-parent.dogfood.test.ts, all through the in-process HTTP stack; negative[1] (the isSystem exemption, which no REST door reaches by design) by packages/objectql/src/engine-insert-static-readonly-strip.test.ts ('isSystem seeds the readonly column — the exemption the ruling names', the INSERT path) + packages/objectql/src/engine-readonly-strip-caller-values.test.ts ('an isSystem caller is untouched by any of this', the UPDATE path on a static readonly column), both on a real engine over a recording / fake driver and their own test objects, not showcase_contact's lead_score — they score the one engine function negative[1] names, and the showcase field is not observed. Per clause: acceptance[0] and [1] — showcase-static-readonly: a member's forged lead_score is stripped on INSERT and on UPDATE, and the INSERT strip is advertised ('the INSERT strip is ADVERTISED': body droppedFields and the X-ObjectStack-Dropped-Fields header name lead_score with reason readonly); acceptance[2], [3] and negative[0] — owner-anchor-and-bulk-writes: the member's forge, transfer and disown refused with the owner unchanged, the self-owner insert passing, and the platform admin's forge-insert, transfer and disown passing ('a privileged caller CAN …'); acceptance[4] — owner-anchor-and-bulk-writes: a member's REST createMany of three rows with no owner_id reads back owner_id == the caller on every row (its older bulk cases are engine-direct ql.update / ql.delete owner scoping, #2982); acceptance[5] — showcase-readonly-when-parent, as the admin: the paid invoice's tax_rate and its line's quantity, unit_price and product survive a PATCH, and a mixed updateMany is judged per row — the locked row's results[i].droppedFields names quantity with reason readonly_when while the open row in the same batch lands; acceptance[6] — showcase-readonly-when-parent: updateMany with options.continueOnError true over [compliant, quantity below min 0, compliant] lines answers total 3 / succeeded 2 / failed 1, the violating row VALIDATION_FAILED and unchanged, both neighbours applied; the default stop (rows before applied, the tail NOT_ATTEMPTED and unchanged) is pinned beside it as the contrast" }, "traps": [ "wrong-persona" @@ -273,6 +279,12 @@ "date": "2026-10-26", "change": "Four checklist-accuracy findings of Tier 1 record #25815, re-read on main b4199c96. a4/a6: automated.ref named owner-anchor-and-bulk-writes for bulk writes, but its bulk cases are engine-direct ql.update / ql.delete owner scoping, with no createMany stamping and no per-row validation. a5: showcase-readonly-when-parent pins the single-row PATCH strip and the header; the mixed updateMany leg and the product field are unpinned. a0: showcase-static-readonly asserts no droppedFields on the INSERT strip. n1: the two objectql pins run a recording / fake driver on their own objects, not showcase lead_score. The ref names each. No clause or status changed", "ref": "#25835" + }, + { + "revision": 8, + "date": "2026-10-27", + "change": "Access-security pin upgrade, slice 1 (#26571; Tier 1 record #26468 scored the item PARTIAL). Pinned at the api oracle's layer (in-process HTTP): acceptance[0]'s droppedFields advertisement on the INSERT strip (showcase-static-readonly); acceptance[4]'s createMany owner stamping and negative[0]'s admin forge-insert and disown (owner-anchor-and-bulk-writes); acceptance[5]'s mixed updateMany per-row droppedFields and the line's product field, and acceptance[6]'s per-row validation under continueOnError with the default-stop contrast (showcase-readonly-when-parent). automated.ref now maps every clause to its case. No clause or status changed", + "ref": "#26571" } ] }, @@ -481,7 +493,7 @@ "title": "CRUD × permission-set matrix: every access-matrix.json row holds — allowed verbs succeed, withheld verbs 403, VAMA bounded", "since": "v15", "status": "active", - "revision": 8, + "revision": 9, "priority": "P0", "surface": "api", "personas": [ @@ -573,7 +585,7 @@ ], "automated": { "kind": "verify", - "ref": "`objectstack verify --rls` (packages/verify/src/verify.ts runCrudVerification + packages/verify/src/rls.ts runRlsProofs) for the OBJECT-level CRUD and cross-owner RLS half — a CLI proof a runner invokes, not a test file: no pin in this ref drives it over the showcase (rls-fixture and rls-multitenant dogfood run runRlsProofs on purpose-built fixtures only), so that half scores only from a run that executes the command (the flag is load-bearing: bare verify prints no RLS section) + packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts for the PERSONA-grained cells: every showcase_* row of access-matrix.json (41 at revision 4) driven over all four verbs by one fresh member per permission set — 164 cells, 87 effective-allow and 77 effective-deny, each judged against the BASELINE-UNIONED expectation (row OR showcase_member_default row, ADR-0090 D5), which is also asserted as load-bearing on the 9 cells whose only grant is the baseline. Reaches clauses 0, 1, 2 and 4 for those rows; clause 3 only on showcase_private_note (the auditor's viewAllRecords reads of showcase_inquiry, showcase_invoice and showcase_invoice_line are not asserted — drive them by hand); clause 6 only for the 41 showcase_* rows (the pin excludes the 4 sys_* rows by name, so clause 6 cannot be pinned whole); every denial is checked to be 403 PERMISSION_DENIED specifically, every allow to persist, and an admin payload control runs first so a persona 403 cannot be a 400 in disguise. STILL MANUAL: (a) the 4 showcase_field_ops_delegate x sys_* rows — the platform's OWN baseline sets grant part of that surface independently of the app matrix (measured: a member holding nothing but the app baseline reads /data/sys_user 200 — the organization's user directory, not only its own row — though the matrix lists sys_user read only for the delegate), and the delegate's sys_user_position writes are decided by the ADR-0090 D12 adminScope subtree gate that showcase-permission-zoo.dogfood.test.ts pins ON THE VERIFY HARNESS — its in-subtree assignment asserts a 2xx and its out-of-subtree create asserts only a non-2xx, not the 403 PERMISSION_DENIED named below, so the code is a hand-scored answer — a raw cell verdict there would be false in one direction or the other. That pin runs on the verify harness, so drive those cells by hand on the dev boot and score them against the live answer: since #21057 closed (2026-10-08) the seeded business units carry organization_id and the delegate's in-subtree sys_user_position create answers 201 while the out-of-subtree create (bu_hq_finance) answers 403 PERMISSION_DENIED 'outside the delegated subtree'; (b) clause 5's guest-anchor intake asymmetry (guest_portal create-without-read) — NOT observable on a member persona at all, because the everyone baseline grants read on showcase_inquiry and capability is a union, so the member-persona expectation is READ ALLOWED; the asymmetry lives on the unauthenticated guest anchor, a different admission lane." + "ref": "`objectstack verify --rls` (packages/verify/src/verify.ts runCrudVerification + packages/verify/src/rls.ts runRlsProofs) for the OBJECT-level CRUD and cross-owner RLS half — a CLI proof a runner invokes, not a test file: no pin in this ref drives it over the showcase (rls-fixture and rls-multitenant dogfood run runRlsProofs on purpose-built fixtures only), so that half scores only from a run that executes the command (the flag is load-bearing: bare verify prints no RLS section) + packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts for the PERSONA-grained cells, through the in-process HTTP stack. The sweep drives every showcase_* row of access-matrix.json (41 at revision 4) over all four verbs with one fresh member per permission set — 164 cells, 87 effective-allow and 77 effective-deny, each judged against the BASELINE-UNIONED expectation (row OR showcase_member_default row, ADR-0090 D5), which is also asserted as load-bearing on the 9 cells whose only grant is the baseline; every denial is checked to be 403 PERMISSION_DENIED, every allow to persist, and an admin payload control runs first so a persona 403 cannot be a 400 in disguise. That reaches clauses 0, 1, 2 and 4 for those rows. The same file carries: clause 3 on all four objects the auditor's viewAllRecords names — showcase_private_note, showcase_inquiry, showcase_invoice and showcase_invoice_line, by id and in a filtered list, against a showcase_contributor member that holds read without the bit and gets none of the foreign rows (private OWD, owner RLS and the master-derived scope respectively); negative[0] — the first denied cell of every set, re-run as the admin, answers 2xx; and the 4 showcase_field_ops_delegate × sys_* rows as one case ('the 16 showcase_field_ops_delegate × sys_* cells answer the matrix'): a member holding exactly that set reads sys_business_unit, sys_business_unit_member and sys_user (200), is refused their withheld verbs 403 PERMISSION_DENIED, meets 405 OBJECT_API_METHOD_NOT_ALLOWED on sys_user create and delete (the admin control answers 405 too: sys_user declares apiMethods ['get','list','update','bulk']), and runs sys_user_position's four verbs 2xx on a row it made inside its Field Operations subtree (the out-of-subtree side of that ADR-0090 D12 gate is showcase-permission-zoo.dogfood.test.ts's, which asserts 403 DELEGATION_SCOPE_VIOLATION). So clause 6 is pinned over the whole table: 45 rows × 4 verbs. STILL MANUAL: clause 5's guest-anchor intake asymmetry (guest_portal create-without-read) — NOT observable on a member persona at all, because the everyone baseline grants read on showcase_inquiry and capability is a union, so the member-persona expectation is READ ALLOWED; the asymmetry lives on the unauthenticated guest anchor, a different admission lane (knownGaps[2] records it blocked(fixture))" }, "traps": [ "wrong-persona", @@ -628,6 +640,12 @@ "date": "2026-10-26", "change": "Six checklist-accuracy findings of the 18.2.0 sweep, re-read on main b4199c96. From P0 record #25812: knownGaps[1] said a plain member reads /data/sys_user self-scoped; member_default's sys_user_org_members policy (id in current_user.org_user_ids) serves the organization's whole directory, so the gap and automated.ref now say so. From Tier 1 record #25815: automated.ref no longer reads `objectstack verify --rls` as a pin (it is a CLI proof no test here drives over the showcase); it no longer claims clauses 3 and 6 whole (the viewAllRecords case probes showcase_private_note only, and the pin excludes the 4 sys_* rows by name); it says the zoo's out-of-subtree case asserts a non-2xx, not 403 PERMISSION_DENIED; acceptance[6].verify says which 164 cells the pin judges and which 16 are hand-driven. The pin header comment's '6 delegate × sys_* rows' is in the test file, outside this ledger: the ledger already says 4. No status changed", "ref": "#25835" + }, + { + "revision": 9, + "date": "2026-10-27", + "change": "Access-security pin upgrade, slice 1 (#26571; Tier 1 record #26468 scored the item PARTIAL). showcase-crud-persona-matrix.dogfood.test.ts gains three cases at the api oracle's layer (in-process HTTP): the auditor's viewAllRecords over the foreign inquiry, invoice and line with a contributor as the contrast (acceptance[3] on all four named objects); one denied cell per set re-run as the admin (negative[0]); and the 16 showcase_field_ops_delegate × sys_* cells, sys_user create and delete scored 405 OBJECT_API_METHOD_NOT_ALLOWED for the delegate and the admin (acceptance[0], [1] and [6] over the whole table). acceptance[5] stays blocked(fixture) as knownGaps[2] says. automated.ref restated; it no longer calls the delegate rows hand-driven. acceptance[6].verify and steps[6] still describe those 16 cells as hand-driven, outside this revision's edit surface. No clause or status changed", + "ref": "#26571" } ] }, @@ -636,7 +654,7 @@ "title": "Sharing-model / OWD matrix: private, public_read, public_read_write, controlled_by_parent each enforce their declared baseline", "since": "v15", "status": "active", - "revision": 5, + "revision": 6, "priority": "P0", "surface": "api", "personas": [ @@ -719,7 +737,7 @@ ], "automated": { "kind": "dogfood", - "ref": "packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts + showcase-public-read-owd.dogfood.test.ts + controlled-by-parent.dogfood.test.ts + showcase-invoice-cbp.dogfood.test.ts. Not every model is pinned, and the ones that are fall short of their clauses in named ways: acceptance[0] — showcase-private-owd asserts the denied PATCH's status only (no admin re-read of the row) and the by-id GET as not-200 rather than non-2xx; acceptance[2] (modifyAllRecords crossing the owner-writes baseline), acceptance[3] (public_read_write) and acceptance[5] (the auditor's viewAllRecords over derived lines) have no pin; acceptance[4] — showcase-invoice-cbp boots a test-local fallback set (showcase_cbp_member), not the C1/C2 contributor personas, and asserts by-id read and write but no list exclusion, while controlled-by-parent.dogfood.test.ts runs a synthetic cbp_account / cbp_note fixture, not showcase_invoice_line. Those legs are hand-driven" + "ref": "packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts + showcase-public-read-owd.dogfood.test.ts + showcase-invoice-line-contributor-pair.dogfood.test.ts + showcase-crud-persona-matrix.dogfood.test.ts, all through the in-process HTTP stack, with controlled-by-parent.dogfood.test.ts (a synthetic cbp_account / cbp_note fixture) and showcase-invoice-cbp.dogfood.test.ts (a test-local fallback set) pinning the ADR-0055 derivation beneath. Per clause: acceptance[0] — showcase-private-owd: the member's list excludes the foreign note, the by-id GET answers non-2xx, the PATCH >= 400 with the admin's re-read of the row unchanged, the owner's own read and write 2xx; acceptance[1] — showcase-public-read-owd: the non-owner reads the announcement by id and in the list, and the non-owner PATCH answers >= 400 with the admin re-read unchanged; acceptance[2] — showcase-crud-persona-matrix's modifyAllRecords case: showcase_ops PATCHes the admin's announcement 2xx and the re-read shows it, the plain member's identical PATCH 403 with the row untouched; acceptance[3] — the same file's sweep cells: showcase_contributor × showcase_project edit (2xx and persisted on the admin's project, a set that cannot create edits the admin's row) and showcase_member_default × showcase_project edit (403 PERMISSION_DENIED, unchanged); acceptance[4] and [5] — showcase-invoice-line-contributor-pair on the STOCK grants: two showcase_contributor members C1/C2, C1's invoice (owner = C1's email) and line made over REST, C2's filtered list excludes the line while C1's identical list carries it, C2's by-id GET non-2xx and PATCH >= 400 with the row unchanged, C1 reads and edits it by id, and a showcase_auditor member's list and by-id read carry it (viewAllRecords over the derived scope). acceptance[6] is procedural: run-record completeness over the four models" }, "traps": [ "wrong-persona" @@ -761,6 +779,12 @@ "date": "2026-10-26", "change": "Three checklist-accuracy findings of Tier 1 record #25815, re-read on main b4199c96; automated.ref read as if all four OWD models were pinned. a0: the private pin asserts the denied PATCH by status with no admin re-read, and the by-id GET as not-200. a2/a3/a5: no pin covers the modifyAllRecords bypass, public_read_write or the auditor's VAMA over derived lines. a4: showcase-invoice-cbp uses a test-local fallback set and no list exclusion, and controlled-by-parent.dogfood.test.ts is a synthetic fixture. The ref names each gap. No clause or status changed", "ref": "#25835" + }, + { + "revision": 6, + "date": "2026-10-27", + "change": "Access-security pin upgrade, slice 1 (#26571; Tier 1 record #26468 scored the item PARTIAL). Pinned at the api oracle's layer (in-process HTTP): acceptance[0]'s admin re-read of the refused PATCH and its non-2xx by-id read (showcase-private-owd); acceptance[1]'s >= 400 non-owner PATCH with the admin re-read (showcase-public-read-owd); acceptance[4] on the stock C1/C2 contributors with C2's list exclusion, and acceptance[5]'s auditor listing of derived lines (new showcase-invoice-line-contributor-pair). automated.ref also names the existing showcase-crud-persona-matrix cases that already pin acceptance[2] and acceptance[3], which revision 5 recorded as unpinned. acceptance[6] stays procedural. No clause or status changed", + "ref": "#26571" } ] }, @@ -769,7 +793,7 @@ "title": "Field-level security: editable:false strips/denies writes API-side and renders read-only in the UI; masked-read half needs an authored readable:false grant", "since": "v15", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "mixed", "personas": [ @@ -840,7 +864,7 @@ ], "automated": { "kind": "dogfood", - "ref": "packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts (FLS budget case — WRITE half: the showcase's own readable:true/editable:false grant) + packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts (READ half, asserted server-side: a readable:false field is STRIPPED — the KEY is absent from the by-id record, from every listed row and from an explicit `select` that names it, and it is neither nulled nor replaced by a placeholder — while the entitled caller still receives it on the same row and request; filtering or sorting on the stripped field is refused 403 PERMISSION_DENIED naming the field, and the entitled caller can still do both; the read-deny's write complement is refused and the stored value is unchanged. The scratch readable:false set is authored at runtime, per this item's own steps). STILL MANUAL: (a) clause 3 and the UI half of clause 4 — the console render of the FLS state — have no pin; (a2) clause 2, the admin's budget write on the other side of the lock, is asserted by neither pin; (a0) clause 0's re-read leg — the zoo FLS case asserts the contributor's name PATCH status (< 300) only, never a re-read showing the name changed; (b) the partial-MASKING path (#8993 `maskingRule`: key PRESENT, value replaced by a mask image) is unpinned and has no fixture anywhere in this repo — NO object declares a maskingRule, so the masked-value wire shape cannot be asserted on the stock showcase. The read pin covers the STRIP mechanism only; do not read it as covering the masked one." + "ref": "packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts (FLS budget cases — WRITE half: the showcase's own readable:true/editable:false grant; the contributor's name PATCH re-reads changed, the budget PATCH is refused with the stored value unchanged, and the admin's budget PATCH on the same project lands and re-reads changed — acceptance[0], [1] and [2], api, in-process HTTP) + packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts (READ half, asserted server-side: a readable:false field is STRIPPED — the KEY is absent from the by-id record, from every listed row and from an explicit `select` that names it, and it is neither nulled nor replaced by a placeholder — while the entitled caller still receives it on the same row and request; filtering or sorting on the stripped field is refused 403 PERMISSION_DENIED naming the field, and the entitled caller can still do both; the read-deny's write complement is refused and the stored value is unchanged. The scratch readable:false set is authored at runtime, per this item's own steps). STILL MANUAL: (a) clause 3 and the UI half of clause 4 — the console render of the FLS state — have no pin and stay a run's browser leg: the dogfood suite boots the in-process HTTP stack and drives no browser, and the card that upgraded these pins (#26571) adds no CI lane; (b) the partial-MASKING path (#8993 `maskingRule`: key PRESENT, value replaced by a mask image) is unpinned and has no fixture anywhere in this repo — NO object declares a maskingRule, so the masked-value wire shape cannot be asserted on the stock showcase. The read pin covers the STRIP mechanism only; do not read it as covering the masked one." }, "source": [ "examples/app-showcase/src/security/permission-sets.ts (contributor FLS)", @@ -883,6 +907,12 @@ "date": "2026-10-26", "change": "Two checklist-accuracy findings of Tier 1 record #25815, re-read on main b4199c96 in showcase-permission-zoo.dogfood.test.ts and showcase-fls-read-mask-strip.dogfood.test.ts: acceptance[2]'s admin-side budget write is asserted by neither pin, and acceptance[0]'s re-read leg is unpinned (the zoo asserts the name PATCH status only). automated.ref's STILL MANUAL list names both. No clause or status changed", "ref": "#25835" + }, + { + "revision": 7, + "date": "2026-10-27", + "change": "Access-security pin upgrade, slice 1 (#26571; Tier 1 record #26468 scored the item PARTIAL). acceptance[0]'s re-read of the contributor's name edit and acceptance[2]'s admin budget write with its re-read are now pinned in showcase-permission-zoo.dogfood.test.ts at the api oracle's layer (in-process HTTP). acceptance[3] and the UI half of acceptance[4] stay a live browser leg; automated.ref says why (no browser in the dogfood suite, no new CI lane). No clause or status changed", + "ref": "#26571" } ] }, diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index 2caa56cdaf9..a835088323f 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -41,17 +41,19 @@ // goes red naming the object, instead of every persona's denial staying green // for the wrong reason. // -// ── What this file deliberately does NOT cover ─────────────────────────────── +// ── What the sweep does not judge as plain cells ───────────────────────────── // -// • The 6 `showcase_field_ops_delegate` × `sys_*` rows. Their verdicts are not +// • The 4 `showcase_field_ops_delegate` × `sys_*` rows. Their verdicts are not // plain cells: the platform's OWN baseline sets grant some of that surface // independently of the app's matrix (measured: a member holding no app set -// beyond the baseline reads `/data/sys_user` 200 — self-scoped — while the -// matrix lists `sys_user` read only for the delegate), and the delegate's -// `sys_user_position` writes are decided by the ADR-0090 D12 adminScope -// subtree gate, which `showcase-permission-zoo.dogfood.test.ts` already pins -// on both sides. A raw cell verdict for those rows would be a false negative -// or a false positive depending on the row. They stay manual. +// beyond the baseline reads `/data/sys_user` 200 — the organization's +// directory — while the matrix lists `sys_user` read only for the delegate), +// `sys_user` exposes no create or delete over the API at all (405 for every +// caller), and the delegate's `sys_user_position` writes are decided by the +// ADR-0090 D12 adminScope subtree gate. So the sweep leaves them out and one +// dedicated case drives all 16 cells with those three facts spelled out +// (#26571): a delegate holding exactly the set, `sys_user_position` judged on +// rows the delegate made inside its subtree. // • The guest-anchor intake asymmetry (`guest_portal`: create without read). // It is NOT observable on a member persona: the everyone baseline grants read // on `showcase_inquiry`, so the union says read — and this file asserts the @@ -101,7 +103,7 @@ const ROWS = matrix.entries.filter((e) => e.object.startsWith('showcase_')); const effective = (row: MatrixRow, verb: Verb): boolean => Boolean(row[verb]) || Boolean(baselineByObject.get(row.object)?.[verb]); -/** The `sys_*` rows this file does not judge — named so the exclusion is visible. */ +/** The `sys_*` rows the sweep leaves out — named so the exclusion is visible; one dedicated case drives them. */ const UNJUDGED_ROWS = matrix.entries.filter((e) => !e.object.startsWith('showcase_')); /** @@ -692,7 +694,6 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { await send(o, 'edit', await stack.apiAs(delegateTok, 'PATCH', `/data/${o}/${probe}`, editBody), expectOf('edit')); await send(o, 'delete', await stack.apiAs(delegateTok, 'DELETE', `/data/${o}/${probe}`), expectOf('delete')); } - console.log('[crud-matrix delegate cells]', JSON.stringify(cells)); expect(cells.length, 'four rows × four verbs').toBe(16); for (const c of cells) { if (c.expected === 'allow') expect(c.status, `${c.cell}: allowed`).toBeLessThan(300); @@ -787,7 +788,7 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { }); // ── The exclusion is declared, not silent ──────────────────────────────── - it('names the matrix rows this file does not judge (the sys_* delegate surface)', () => { + it('names the matrix rows the sweep leaves to the delegate case (the sys_* delegate surface)', () => { // The two retired catalog row objects (ADR-0131 D3) leave the matrix with // C3 stage 8; the rest of the delegate surface stays. const RETIRED = new Set(['sys_permission_set', 'sys_position']);