Skip to content

Commit bca8103

Browse files
committed
test(dogfood): pin the sys_attachment and sys_comment parent gates on a controlled_by_parent parent
Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e148ca9 commit bca8103

2 files changed

Lines changed: 448 additions & 0 deletions

File tree

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The `sys_attachment` and `sys_comment` parent gates judge a
4+
// `controlled_by_parent` parent (ADR-0055) through its MASTER — the answer the
5+
// parent's own `PATCH` gets — driven end-to-end through the REAL surfaces:
6+
// better-auth sign-up members, the presigned upload, the generic `/data` path,
7+
// plugin-sharing, plugin-security, service-storage and plugin-audit.
8+
//
9+
// ## What this suite pins
10+
//
11+
// Both gates ask the sharing service whether the caller may EDIT the parent.
12+
// `effectiveSharingModel` maps `controlled_by_parent` to `public`, so
13+
// `checkEdit` ABSTAINS on every such parent, and the gates used to read
14+
// `canEdit`, which folds that abstention into `true`. A member holding the
15+
// `sys_attachment` create or delete bit (or the `sys_comment` delete bit) then
16+
// wrote on every master-detail child record of the org, including records whose
17+
// own `PATCH` refuses them and records they cannot even read.
18+
//
19+
// The gates now read `checkEdit`, and on an abstention they ask the security
20+
// service's master-detail write check (`checkControlledByParentWrite`), the
21+
// check a by-id update of the parent runs. So each refusal below is pinned
22+
// beside the `PATCH` of the same record by the same caller, which is the parity
23+
// the fix promises, and beside the security service's own answer.
24+
//
25+
// ## Why the boot is org-bound
26+
//
27+
// The platform's wildcard delete floor binds `org_member` principals, and the
28+
// alternate matches that service-storage and the `sys_comment_moderation`
29+
// policy contribute are what let a delete reach the parent gates at all. An
30+
// org-less boot would measure the gates in the one posture where no floor
31+
// applies; `assertArmed` refuses it.
32+
33+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
34+
import { mkdtempSync } from 'node:fs';
35+
import { promises as fs } from 'node:fs';
36+
import { join } from 'node:path';
37+
import { tmpdir } from 'node:os';
38+
import { resolveAuthzContext } from '@objectstack/core';
39+
import { bootStack, type VerifyStack } from '@objectstack/verify';
40+
import { StorageServicePlugin } from '@objectstack/service-storage';
41+
import { AuditPlugin } from '@objectstack/plugin-audit';
42+
import { cpgStack, cpgSecurity } from './fixtures/cbp-parent-gates-fixture.js';
43+
import { assertArmed, principalArmed } from './armed.js';
44+
45+
const SYS = { isSystem: true } as const;
46+
47+
const DELETE_FLOOR =
48+
"the platform's wildcard row-level delete floor (`owner_only_deletes`, positions ['org_member']) " +
49+
'and the alternate matches that let a delete reach the attachment and comment parent gates';
50+
const DELETE_FLOOR_DISARM =
51+
"an org-less harness: a fresh sign-up then holds only ['everyone'], so no floor applies and the " +
52+
'deletes below measure the gates in a posture no org-bound deployment has. `orgContext: true` arms it.';
53+
54+
async function createdId(res: Response): Promise<string> {
55+
const j = (await res.json()) as any;
56+
const id = j.id ?? j.record?.id ?? j.data?.id;
57+
if (!id) throw new Error(`create response carried no id: ${JSON.stringify(j)}`);
58+
return String(id);
59+
}
60+
61+
/** Status plus the error code, for an assertion message that shows the whole answer. */
62+
async function answer(res: Response): Promise<{ status: number; code: string | undefined; body: string }> {
63+
const body = await res.text();
64+
let code: string | undefined;
65+
try {
66+
const j = JSON.parse(body) as any;
67+
code = j?.code ?? j?.error?.code;
68+
} catch {
69+
code = undefined;
70+
}
71+
return { status: res.status, code, body };
72+
}
73+
74+
/** Drive the REAL presigned three-step upload; returns the fileId. */
75+
async function uploadFile(stack: VerifyStack, token: string): Promise<string> {
76+
const auth: Record<string, string> = { Authorization: `Bearer ${token}` };
77+
const presignRes = await stack.api('/storage/upload/presigned', {
78+
method: 'POST',
79+
headers: { 'Content-Type': 'application/json', ...auth },
80+
body: JSON.stringify({ filename: 'quote.pdf', mimeType: 'text/plain', size: 5, scope: 'attachments' }),
81+
});
82+
expect(presignRes.status, 'presign').toBe(200);
83+
const { data } = (await presignRes.json()) as any;
84+
const putPath = String(data.uploadUrl).replace(/^https?:\/\/[^/]+/, '');
85+
const putRes = await stack.raw(putPath, {
86+
method: 'PUT',
87+
headers: data.headers ?? { 'content-type': 'text/plain' },
88+
body: 'hello',
89+
});
90+
expect(putRes.status, 'raw PUT').toBeLessThan(300);
91+
const completeRes = await stack.api('/storage/upload/complete', {
92+
method: 'POST',
93+
headers: { 'Content-Type': 'application/json', ...auth },
94+
body: JSON.stringify({ fileId: data.fileId }),
95+
});
96+
expect(completeRes.status, 'complete').toBe(200);
97+
return data.fileId as string;
98+
}
99+
100+
describe('controlled_by_parent parent gates: sys_attachment and sys_comment judge the master (ADR-0055)', () => {
101+
let stack: VerifyStack;
102+
let rootDir: string;
103+
let ql: any;
104+
let adminTok: string;
105+
let memberTok: string;
106+
let adminId: string;
107+
let memberId: string;
108+
/** Admin-owned `public_read` master: the member reads it and may not edit it. */
109+
let adminAccountId: string;
110+
/** A child of it: the member reads it and its own PATCH refuses them. */
111+
let contractId: string;
112+
/** Member-owned master and its child: the member may edit both. */
113+
let memberContractId: string;
114+
/** A child of an admin-owned PRIVATE master: the member cannot read it at all. */
115+
let vaultItemId: string;
116+
/** The control: a `public_read_write` parent. */
117+
let boardId: string;
118+
119+
const uid = async (email: string) => (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id;
120+
121+
const attach = (token: string, parentObject: string, parentId: string, fileId: string) =>
122+
stack.apiAs(token, 'POST', '/data/sys_attachment', {
123+
parent_object: parentObject,
124+
parent_id: parentId,
125+
file_id: fileId,
126+
file_name: 'quote.pdf',
127+
mime_type: 'text/plain',
128+
size: 5,
129+
});
130+
131+
/** The security service's own master-detail answer for this caller, read through the real request path. */
132+
const memberAnswer = async (token: string, object: string, recordId: string) => {
133+
const authService = await stack.kernel.getServiceAsync<any>('auth');
134+
let api: any = authService?.api;
135+
if (!api && typeof authService?.getApi === 'function') api = await authService.getApi();
136+
const context = await resolveAuthzContext({
137+
ql,
138+
headers: new Headers({ authorization: `Bearer ${token}` }),
139+
getSession: async (h: any) => api?.getSession?.({ headers: h }),
140+
});
141+
const security = await stack.kernel.getServiceAsync<any>('security');
142+
expect(typeof security?.checkControlledByParentWrite, 'the security service serves the master-detail write check').toBe(
143+
'function',
144+
);
145+
return security.checkControlledByParentWrite(object, recordId, context);
146+
};
147+
148+
beforeAll(async () => {
149+
rootDir = mkdtempSync(join(tmpdir(), 'cpg-dogfood-'));
150+
stack = await bootStack(cpgStack as never, {
151+
security: cpgSecurity(),
152+
orgContext: true,
153+
extraPlugins: [
154+
new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }),
155+
new AuditPlugin(),
156+
],
157+
});
158+
adminTok = await stack.signIn();
159+
memberTok = await stack.signUp('cpg-member@verify.test');
160+
ql = await stack.kernel.getServiceAsync('objectql');
161+
adminId = await uid('admin@objectos.ai');
162+
memberId = await uid('cpg-member@verify.test');
163+
164+
const domainSet = await ql.findOne('sys_permission_set', { where: { name: 'cpg_files_and_threads' }, context: SYS });
165+
expect(domainSet?.id, 'fixture permission set seeded').toBeTruthy();
166+
await ql.insert('sys_user_permission_set', { user_id: memberId, permission_set_id: domainSet.id }, { context: { ...SYS } });
167+
168+
await assertArmed([
169+
principalArmed({
170+
stack,
171+
token: memberTok,
172+
who: 'the member (attachment and comment manager, not the master owner)',
173+
positions: ['org_member'],
174+
permissions: ['cpg_files_and_threads'],
175+
control: DELETE_FLOOR,
176+
disarmedBy: DELETE_FLOOR_DISARM,
177+
}),
178+
]);
179+
180+
adminAccountId = (await ql.insert('cpg_account', { name: 'admin account', owner_id: adminId }, { context: { ...SYS } })).id;
181+
contractId = (await ql.insert('cpg_contract', { name: 'executed contract', account: adminAccountId }, { context: { ...SYS } })).id;
182+
const memberAccountId = (await ql.insert('cpg_account', { name: 'member account', owner_id: memberId }, { context: { ...SYS } })).id;
183+
memberContractId = (
184+
await ql.insert('cpg_contract', { name: 'member contract', account: memberAccountId }, { context: { ...SYS } })
185+
).id;
186+
const vaultId = (await ql.insert('cpg_vault', { name: 'admin vault', owner_id: adminId }, { context: { ...SYS } })).id;
187+
vaultItemId = (await ql.insert('cpg_vault_item', { name: 'vault item', vault: vaultId }, { context: { ...SYS } })).id;
188+
const boardRes = await stack.apiAs(adminTok, 'POST', '/data/cpg_board', { name: 'open board' });
189+
expect(boardRes.status, 'board create').toBeLessThan(300);
190+
boardId = await createdId(boardRes);
191+
}, 120_000);
192+
193+
afterAll(async () => {
194+
await stack?.stop();
195+
if (rootDir) await fs.rm(rootDir, { recursive: true, force: true });
196+
});
197+
198+
it('precondition: the member reads the master and the child, and the PATCH of either refuses them', async () => {
199+
expect((await stack.apiAs(memberTok, 'GET', `/data/cpg_account/${adminAccountId}`)).status, 'member reads the master').toBe(200);
200+
expect((await stack.apiAs(memberTok, 'GET', `/data/cpg_contract/${contractId}`)).status, 'member reads the child').toBe(200);
201+
const patchMaster = await answer(await stack.apiAs(memberTok, 'PATCH', `/data/cpg_account/${adminAccountId}`, { name: 'x' }));
202+
expect(patchMaster.status, `member PATCH of the master: ${patchMaster.body}`).toBe(403);
203+
const patchChild = await answer(await stack.apiAs(memberTok, 'PATCH', `/data/cpg_contract/${contractId}`, { name: 'x' }));
204+
expect(patchChild.status, `member PATCH of the child: ${patchChild.body}`).toBe(403);
205+
expect(patchChild.code).toBe('PERMISSION_DENIED');
206+
});
207+
208+
it('PATCH parity: the security service answers the child as its PATCH does, for the member and for the owner', async () => {
209+
expect(await memberAnswer(memberTok, 'cpg_contract', contractId)).toEqual({ outcome: 'deny', leg: 'record_sharing' });
210+
expect(await memberAnswer(adminTok, 'cpg_contract', contractId)).toEqual({ outcome: 'allow' });
211+
expect(await memberAnswer(memberTok, 'cpg_contract', memberContractId)).toEqual({ outcome: 'allow' });
212+
expect(await memberAnswer(memberTok, 'cpg_board', boardId)).toEqual({ outcome: 'not_applicable' });
213+
214+
const ownerPatch = await answer(await stack.apiAs(adminTok, 'PATCH', `/data/cpg_contract/${contractId}`, { name: 'executed contract' }));
215+
expect(ownerPatch.status, `owner PATCH of the child: ${ownerPatch.body}`).toBe(200);
216+
const memberOwnPatch = await answer(
217+
await stack.apiAs(memberTok, 'PATCH', `/data/cpg_contract/${memberContractId}`, { name: 'member contract' }),
218+
);
219+
expect(memberOwnPatch.status, `member PATCH of a child under their own master: ${memberOwnPatch.body}`).toBe(200);
220+
});
221+
222+
it('attach on a child whose master the member cannot edit is REFUSED (403 ATTACHMENT_PARENT_ACCESS), and nothing is attached', async () => {
223+
const fileId = await uploadFile(stack, memberTok);
224+
const res = await answer(await attach(memberTok, 'cpg_contract', contractId, fileId));
225+
expect(res.status, `attach on the child: ${res.body}`).toBe(403);
226+
expect(res.code).toBe('ATTACHMENT_PARENT_ACCESS');
227+
expect(await ql.findOne('sys_attachment', { where: { file_id: fileId }, context: SYS })).toBeNull();
228+
});
229+
230+
it('attach on a child the member cannot even read is REFUSED (403 ATTACHMENT_PARENT_ACCESS)', async () => {
231+
const read = await stack.apiAs(memberTok, 'GET', `/data/cpg_vault_item/${vaultItemId}`);
232+
expect(read.status, 'precondition: the member cannot read the vault item').toBe(404);
233+
const fileId = await uploadFile(stack, memberTok);
234+
const res = await answer(await attach(memberTok, 'cpg_vault_item', vaultItemId, fileId));
235+
expect(res.status, `attach on the unreadable child: ${res.body}`).toBe(403);
236+
expect(res.code).toBe('ATTACHMENT_PARENT_ACCESS');
237+
expect(await ql.findOne('sys_attachment', { where: { file_id: fileId }, context: SYS })).toBeNull();
238+
});
239+
240+
it('control: the master owner attaches to the child, and the member attaches to a child of their own master', async () => {
241+
const ownerFile = await uploadFile(stack, adminTok);
242+
const owner = await answer(await attach(adminTok, 'cpg_contract', contractId, ownerFile));
243+
expect(owner.status, `owner attach: ${owner.body}`).toBe(201);
244+
const memberFile = await uploadFile(stack, memberTok);
245+
const member = await answer(await attach(memberTok, 'cpg_contract', memberContractId, memberFile));
246+
expect(member.status, `member attach under their own master: ${member.body}`).toBe(201);
247+
});
248+
249+
it('control: a public_read_write parent still admits the member (abstention there is permission)', async () => {
250+
const fileId = await uploadFile(stack, memberTok);
251+
const res = await answer(await attach(memberTok, 'cpg_board', boardId, fileId));
252+
expect(res.status, `attach on the public_read_write board: ${res.body}`).toBe(201);
253+
});
254+
255+
it("delete of another user's file on the child is REFUSED (403 ATTACHMENT_DELETE_DENIED), and the file stays", async () => {
256+
const ownerFile = await uploadFile(stack, adminTok);
257+
const attached = await answer(await attach(adminTok, 'cpg_contract', contractId, ownerFile));
258+
expect(attached.status, `owner attach: ${attached.body}`).toBe(201);
259+
const row = await ql.findOne('sys_attachment', { where: { file_id: ownerFile }, context: SYS });
260+
expect(row?.id).toBeTruthy();
261+
262+
const res = await answer(await stack.apiAs(memberTok, 'DELETE', `/data/sys_attachment/${row.id}`));
263+
expect(res.status, `member delete of the owner's file: ${res.body}`).toBe(403);
264+
expect(res.code).toBe('ATTACHMENT_DELETE_DENIED');
265+
expect(await ql.findOne('sys_attachment', { where: { id: row.id }, context: SYS }), 'the file survives').not.toBeNull();
266+
});
267+
268+
it('control: the uploader deletes their own file on that child', async () => {
269+
const fileId = await uploadFile(stack, memberTok);
270+
const own = await ql.insert(
271+
'sys_attachment',
272+
{
273+
parent_object: 'cpg_contract',
274+
parent_id: contractId,
275+
file_id: fileId,
276+
file_name: 'quote.pdf',
277+
mime_type: 'text/plain',
278+
size: 5,
279+
uploaded_by: memberId,
280+
},
281+
{ context: { ...SYS } },
282+
);
283+
const res = await answer(await stack.apiAs(memberTok, 'DELETE', `/data/sys_attachment/${own.id}`));
284+
expect(res.status, `uploader delete: ${res.body}`).toBe(200);
285+
});
286+
287+
it("comment delete of another user's comment on the child is REFUSED (403 RECORD_NOT_ACCESSIBLE), and the comment stays", async () => {
288+
const posted = await answer(
289+
await stack.apiAs(adminTok, 'POST', '/data/sys_comment', { thread_id: `cpg_contract:${contractId}`, body: 'executed by legal' }),
290+
);
291+
expect(posted.status, `owner comment: ${posted.body}`).toBeLessThan(300);
292+
const row = await ql.findOne('sys_comment', { where: { body: 'executed by legal' }, context: SYS });
293+
expect(row?.id).toBeTruthy();
294+
295+
const res = await answer(await stack.apiAs(memberTok, 'DELETE', `/data/sys_comment/${row.id}`));
296+
expect(res.status, `member delete of the owner's comment: ${res.body}`).toBe(403);
297+
expect(res.code).toBe('RECORD_NOT_ACCESSIBLE');
298+
expect(await ql.findOne('sys_comment', { where: { id: row.id }, context: SYS }), 'the comment survives').not.toBeNull();
299+
});
300+
301+
it('control: the author deletes their own comment on that child, and the master owner moderates it', async () => {
302+
const mine = await answer(
303+
await stack.apiAs(memberTok, 'POST', '/data/sys_comment', { thread_id: `cpg_contract:${contractId}`, body: 'member note one' }),
304+
);
305+
expect(mine.status, `member comment: ${mine.body}`).toBeLessThan(300);
306+
const own = await ql.findOne('sys_comment', { where: { body: 'member note one' }, context: SYS });
307+
const authorDelete = await answer(await stack.apiAs(memberTok, 'DELETE', `/data/sys_comment/${own.id}`));
308+
expect(authorDelete.status, `author delete: ${authorDelete.body}`).toBe(200);
309+
310+
const second = await answer(
311+
await stack.apiAs(memberTok, 'POST', '/data/sys_comment', { thread_id: `cpg_contract:${contractId}`, body: 'member note two' }),
312+
);
313+
expect(second.status, `member comment: ${second.body}`).toBeLessThan(300);
314+
const moderated = await ql.findOne('sys_comment', { where: { body: 'member note two' }, context: SYS });
315+
const ownerDelete = await answer(await stack.apiAs(adminTok, 'DELETE', `/data/sys_comment/${moderated.id}`));
316+
expect(ownerDelete.status, `master owner moderates: ${ownerDelete.body}`).toBe(200);
317+
});
318+
});

0 commit comments

Comments
 (0)