Skip to content

Commit 3ce5795

Browse files
committed
feat(showcase): controlled-by-parent invoice lines + HTTP verification (ADR-0055)
Adds the canonical master-detail scenario to the showcase: `showcase_invoice_line` now declares `sharingModel: 'controlled_by_parent'`, so a line's access is derived from its parent `showcase_invoice` (a line is meaningless apart from its invoice). Admin/default single-tenant behavior is unchanged (admin sees all invoices → all lines); the showcase's own 20 tests still pass. Verification (real HTTP stack — the boundary is server-side; no browser needed): a dogfood proof boots the REAL showcase app with a member permission set carrying an owner policy on the invoice, then asserts a member who cannot read an admin-owned invoice can neither read nor by-id-write its lines, but can read/edit lines under an invoice they own. 4/4 green; full dogfood suite 77 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent 70f3423 commit 3ce5795

2 files changed

Lines changed: 123 additions & 0 deletions

File tree

examples/app-showcase/src/objects/invoice.object.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ export const InvoiceLine = ObjectSchema.create({
103103
icon: 'list',
104104
description: 'A single billable line on an invoice.',
105105

106+
// ADR-0055: a line's access is CONTROLLED BY ITS PARENT invoice — a user sees
107+
// and edits a line only if they can see/edit its `invoice` master. No RLS is
108+
// authored here; the security layer derives it from the required master_detail
109+
// relationship (`invoice`). This is the canonical master-detail use of
110+
// controlled_by_parent (a line is meaningless apart from its invoice).
111+
sharingModel: 'controlled_by_parent',
112+
106113
fields: {
107114
invoice: Field.masterDetail('showcase_invoice', {
108115
label: 'Invoice',
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// SHOWCASE scenario proof for ADR-0055 — `showcase_invoice_line` is declared
4+
// `sharingModel: 'controlled_by_parent'`, so a line's access is derived from its
5+
// parent `showcase_invoice`. This exercises the feature on the REAL showcase
6+
// metadata, end-to-end through the real HTTP stack (the same requests a browser
7+
// would issue; the security boundary is server-side).
8+
//
9+
// Setup uses system inserts (to sidestep the showcase's authoring-validation
10+
// rules — not what's under test); the controlled-by-parent READ/WRITE behavior
11+
// is then exercised as a real member over HTTP.
12+
13+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
14+
import showcaseStack from '@objectstack/example-showcase';
15+
import { bootStack, type VerifyStack } from '@objectstack/verify';
16+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
17+
import { PermissionSetSchema, RLS } from '@objectstack/spec/security';
18+
19+
const MEMBER_EMAIL = 'showcase-cbp-member@verify.test';
20+
const ADMIN_EMAIL = 'admin@objectos.ai';
21+
22+
// Member fallback set: full CRUD on the invoice graph (so requests reach the RLS
23+
// layer) + an OWNER policy on the MASTER invoice. The detail line carries no RLS
24+
// — its scoping is derived from the master by `controlled_by_parent`.
25+
const memberSet = PermissionSetSchema.parse({
26+
name: 'showcase_cbp_member',
27+
label: 'Showcase CBP Member',
28+
isProfile: true,
29+
objects: {
30+
showcase_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
31+
showcase_product: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
32+
showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
33+
showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
34+
},
35+
rowLevelSecurity: [RLS.ownerPolicy('showcase_invoice', 'created_by')],
36+
});
37+
38+
describe('showcase: invoice-line controlled-by-parent (ADR-0055)', () => {
39+
let stack: VerifyStack;
40+
let memberToken: string;
41+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42+
let ql: any;
43+
let adminInvoiceId: string;
44+
let adminLineId: string;
45+
let memberLineId: string;
46+
47+
beforeAll(async () => {
48+
stack = await bootStack(showcaseStack, {
49+
security: new SecurityPlugin({
50+
defaultPermissionSets: [...securityDefaultPermissionSets, memberSet],
51+
fallbackPermissionSet: 'showcase_cbp_member',
52+
}),
53+
});
54+
await stack.signIn(); // seed dev admin
55+
memberToken = await stack.signUp(MEMBER_EMAIL);
56+
57+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
58+
ql = await stack.kernel.getServiceAsync<any>('objectql');
59+
const sys = { context: { isSystem: true } };
60+
const idOf = (r: any) => r?.id ?? r?.record?.id ?? r;
61+
62+
const users = (await ql.find('sys_user', {
63+
where: { email: { $in: [ADMIN_EMAIL, MEMBER_EMAIL] } },
64+
context: { isSystem: true },
65+
})) as Array<{ id: string; email: string }>;
66+
const adminId = users.find((u) => u.email === ADMIN_EMAIL)!.id;
67+
const memberId = users.find((u) => u.email === MEMBER_EMAIL)!.id;
68+
69+
// Shared (non-owner-scoped) account + product.
70+
const acc = idOf(await ql.insert('showcase_account', { name: 'CBP Co', status: 'prospect' }, sys));
71+
const prod = idOf(await ql.insert('showcase_product', { name: 'Widget' }, sys));
72+
73+
// Two invoices: one owned by the admin, one by the member.
74+
adminInvoiceId = idOf(await ql.insert('showcase_invoice', { name: 'INV-ADMIN', account: acc, status: 'draft', created_by: adminId }, sys));
75+
const memberInvoiceId = idOf(await ql.insert('showcase_invoice', { name: 'INV-MEMBER', account: acc, status: 'draft', created_by: memberId }, sys));
76+
77+
// A line under each invoice (the detail under test).
78+
adminLineId = idOf(await ql.insert('showcase_invoice_line', { invoice: adminInvoiceId, product: prod, quantity: 1, unit_price: 10, description: 'admin line' }, sys));
79+
memberLineId = idOf(await ql.insert('showcase_invoice_line', { invoice: memberInvoiceId, product: prod, quantity: 1, unit_price: 20, description: 'member line' }, sys));
80+
81+
// Premise sanity: the admin invoice really is admin-owned (else the proof is void).
82+
const adminInv = await ql.findOne('showcase_invoice', { where: { id: adminInvoiceId }, context: { isSystem: true } });
83+
expect(adminInv?.created_by, 'admin invoice must be admin-owned').toBe(adminId);
84+
}, 60_000);
85+
86+
afterAll(async () => {
87+
await stack?.stop();
88+
});
89+
90+
it('precondition: member cannot read the admin-owned master invoice (owner RLS)', async () => {
91+
const r = await stack.apiAs(memberToken, 'GET', `/data/showcase_invoice/${adminInvoiceId}`);
92+
expect(r.status).not.toBe(200);
93+
});
94+
95+
it('DERIVED READ: member cannot read a line under an invoice they cannot read', async () => {
96+
const r = await stack.apiAs(memberToken, 'GET', `/data/showcase_invoice_line/${adminLineId}`);
97+
expect(r.status, 'line under unreadable invoice must be hidden').not.toBe(200);
98+
});
99+
100+
it('DERIVED WRITE: member cannot by-id mutate a line whose master they cannot edit', async () => {
101+
const w = await stack.apiAs(memberToken, 'PATCH', `/data/showcase_invoice_line/${adminLineId}`, { description: 'hacked' });
102+
expect(w.status).not.toBeLessThan(300);
103+
const after = await ql.findOne('showcase_invoice_line', { where: { id: adminLineId }, context: { isSystem: true } });
104+
expect(after?.description, 'admin line must be unchanged').toBe('admin line');
105+
});
106+
107+
it('NOT over-blocked: member CAN read + edit a line under an invoice they own', async () => {
108+
const read = await stack.apiAs(memberToken, 'GET', `/data/showcase_invoice_line/${memberLineId}`);
109+
expect(read.status, 'member should read their own line').toBe(200);
110+
111+
const edit = await stack.apiAs(memberToken, 'PATCH', `/data/showcase_invoice_line/${memberLineId}`, { description: 'updated' });
112+
expect(edit.status, 'member should edit their own line').toBeLessThan(300);
113+
const after = await ql.findOne('showcase_invoice_line', { where: { id: memberLineId }, context: { isSystem: true } });
114+
expect(after?.description).toBe('updated');
115+
});
116+
});

0 commit comments

Comments
 (0)