Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 40 additions & 10 deletions docs/qa/platform-checklist/areas/access-security.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
176 changes: 166 additions & 10 deletions packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_'));

/**
Expand Down Expand Up @@ -553,6 +555,160 @@ 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<string, unknown>) => {
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<string, Cell>();
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, unknown>) =>
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<string, unknown> =
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<string, unknown> =
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'));
}
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
Expand Down Expand Up @@ -632,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']);
Expand Down
Loading
Loading