Skip to content

Commit 5122c29

Browse files
committed
feat(security,spec): master-detail controlled-by-parent permissions (ADR-0055 P1+P2)
Implements derived master-detail access — a detail object's read/write scope is derived from its master record. - spec: `controlled_by_parent` added to the authorable `object.sharingModel` enum (converges with OWDModel). - security (read): for a controlled_by_parent object, resolve the master records the user can read (reusing the master's own RLS via computeRlsFilter, run under a system context — no middleware re-entry) and AND-inject `masterFK IN (accessible master ids)` onto the read AST. Empty set ⇒ fail closed. Zero RLS-compiler changes (builds the $in filter directly). - security (write): extend the by-id-write path — a detail insert/update/delete requires CRUD edit on the master AND the master row visible under the master's write RLS. Closes the #1994-class by-id hole for derived access (the detail carries no authored RLS of its own). - proof (ADR-0054): dogfood RLS proof — a member who can't read the master can neither read nor by-id-write the detail, and isn't over-blocked on a master they own. Bound in the liveness ledger on object.sharingModel. Five high-risk classes now CI-enforced: field types, RLS, controlled-by-parent, analytics, flow nodes. Honest v1 limits documented in ADR-0055 (id-set ceiling, single-level chains). Full dogfood suite green (73 tests); liveness gate green; 18 registry tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent 1696291 commit 5122c29

8 files changed

Lines changed: 393 additions & 9 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Master-detail "controlled by parent" RLS proof (ADR-0055 P2), end-to-end
4+
// through the real HTTP + security stack.
5+
//
6+
// @proof: cbp-controlled-by-parent
7+
// ADR-0055 runtime proof for derived master-detail access. Referenced by the
8+
// liveness ledger entry `object.sharingModel` (packages/spec/liveness/object.json);
9+
// the spec liveness gate fails if this tag is removed. See proof-registry.mts.
10+
//
11+
// The detail (`cbp_note`) carries NO authored RLS — access is DERIVED from the
12+
// master (`cbp_account`, owner-scoped). The proof asserts both directions:
13+
// • a member who cannot read the admin's account can neither READ nor by-id
14+
// WRITE notes under it (the derived guard), and
15+
// • the member CAN read/write notes under an account they own (not over-blocked).
16+
17+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
18+
import { bootStack, type VerifyStack } from '@objectstack/verify';
19+
import { cbpStack, cbpSecurity } from './fixtures/cbp-fixture.js';
20+
21+
describe('objectstack verify: master-detail controlled-by-parent (#cbp)', () => {
22+
let stack: VerifyStack;
23+
let adminToken: string;
24+
let memberToken: string;
25+
26+
// Admin-owned graph (member must NOT reach it).
27+
let adminAccountId: string;
28+
let adminNoteId: string;
29+
30+
beforeAll(async () => {
31+
stack = await bootStack(cbpStack, { security: cbpSecurity() });
32+
adminToken = await stack.signIn();
33+
memberToken = await stack.signUp('cbp-member@verify.test');
34+
35+
const acc = await stack.apiAs(adminToken, 'POST', '/data/cbp_account', { name: 'admin account' });
36+
expect(acc.status, `admin account create: ${acc.status} ${await acc.clone().text()}`).toBeLessThan(300);
37+
adminAccountId = idOf(await acc.json());
38+
39+
const note = await stack.apiAs(adminToken, 'POST', '/data/cbp_note', {
40+
name: 'admin note',
41+
body: 'admin-only',
42+
account: adminAccountId,
43+
});
44+
expect(note.status, `admin note create: ${note.status} ${await note.clone().text()}`).toBeLessThan(300);
45+
adminNoteId = idOf(await note.json());
46+
}, 60_000);
47+
48+
afterAll(async () => {
49+
await stack?.stop();
50+
});
51+
52+
it('precondition: the member cannot read the admin master account (owner RLS)', async () => {
53+
const r = await stack.apiAs(memberToken, 'GET', `/data/cbp_account/${adminAccountId}`);
54+
expect(r.status, 'member must not read the admin account').not.toBe(200);
55+
});
56+
57+
it('DERIVED READ: member cannot read a note under an account they cannot read', async () => {
58+
const r = await stack.apiAs(memberToken, 'GET', `/data/cbp_note/${adminNoteId}`);
59+
expect(r.status, 'member must not read the controlled-by-parent note').not.toBe(200);
60+
});
61+
62+
it('DERIVED WRITE: member cannot by-id mutate a note whose master they cannot edit', async () => {
63+
const w = await stack.apiAs(memberToken, 'PATCH', `/data/cbp_note/${adminNoteId}`, { body: 'hacked' });
64+
expect(w.status, 'member by-id write should be denied').not.toBeLessThan(300);
65+
// Ground truth: admin re-reads — the row must be unchanged.
66+
const after = await stack.apiAs(adminToken, 'GET', `/data/cbp_note/${adminNoteId}`);
67+
expect(after.status).toBe(200);
68+
expect(((await after.json()) as any).record?.body).toBe('admin-only');
69+
});
70+
71+
it('NOT over-blocked: member CAN read + write a note under an account they own', async () => {
72+
// Member owns this account → derived access grants the note under it.
73+
const acc = await stack.apiAs(memberToken, 'POST', '/data/cbp_account', { name: 'member account' });
74+
expect(acc.status).toBeLessThan(300);
75+
const memberAccountId = idOf(await acc.json());
76+
77+
const note = await stack.apiAs(memberToken, 'POST', '/data/cbp_note', {
78+
name: 'member note',
79+
body: 'mine',
80+
account: memberAccountId,
81+
});
82+
expect(note.status, `member note create: ${note.status} ${await note.clone().text()}`).toBeLessThan(300);
83+
const memberNoteId = idOf(await note.json());
84+
85+
// Read it back (derived: account IN [memberAccountId]).
86+
const read = await stack.apiAs(memberToken, 'GET', `/data/cbp_note/${memberNoteId}`);
87+
expect(read.status, 'member should read their own note').toBe(200);
88+
89+
// Edit it (master is member-owned → editable).
90+
const edit = await stack.apiAs(memberToken, 'PATCH', `/data/cbp_note/${memberNoteId}`, { body: 'updated' });
91+
expect(edit.status, 'member should edit their own note').toBeLessThan(300);
92+
const after = await stack.apiAs(memberToken, 'GET', `/data/cbp_note/${memberNoteId}`);
93+
expect(((await after.json()) as any).record?.body).toBe('updated');
94+
});
95+
});
96+
97+
function idOf(j: any): string {
98+
const id = j?.id ?? j?.record?.id;
99+
expect(id, 'expected an id from create').toBeTruthy();
100+
return id as string;
101+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Master-detail "controlled by parent" fixture (ADR-0055 P2).
4+
//
5+
// A two-object app exercising derived access: `cbp_account` (the MASTER) is
6+
// owner-scoped via RLS on `created_by`; `cbp_note` (the DETAIL) declares
7+
// `sharingModel: 'controlled_by_parent'` with a required master_detail field
8+
// pointing at the account. The detail carries NO authored RLS — its access is
9+
// derived from the master by the security layer (read: `account IN (accessible
10+
// account ids)`; write: requires master edit access).
11+
//
12+
// The proof then asserts both directions: a member who cannot read the admin's
13+
// account cannot read OR by-id-write notes under it, but CAN read/write notes
14+
// under an account they own.
15+
16+
import { defineStack } from '@objectstack/spec';
17+
import { ObjectSchema, Field } from '@objectstack/spec/data';
18+
import { PermissionSetSchema, RLS, type PermissionSet } from '@objectstack/spec/security';
19+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
20+
21+
/** MASTER — owner-scoped account. */
22+
export const CbpAccount = ObjectSchema.create({
23+
name: 'cbp_account',
24+
label: 'CBP Account',
25+
pluralLabel: 'CBP Accounts',
26+
fields: {
27+
name: Field.text({ label: 'Name', required: true }),
28+
},
29+
});
30+
31+
/** DETAIL — note whose access is controlled by its parent account. */
32+
export const CbpNote = ObjectSchema.create({
33+
name: 'cbp_note',
34+
label: 'CBP Note',
35+
pluralLabel: 'CBP Notes',
36+
sharingModel: 'controlled_by_parent',
37+
fields: {
38+
name: Field.text({ label: 'Name', required: true }),
39+
body: Field.text({ label: 'Body' }),
40+
account: Field.masterDetail('cbp_account', { label: 'Account', required: true }),
41+
},
42+
});
43+
44+
export const cbpStack = defineStack({
45+
manifest: {
46+
id: 'com.dogfood.cbp_fixture',
47+
namespace: 'cbp',
48+
version: '0.0.0',
49+
type: 'app',
50+
name: 'Controlled-by-Parent Fixture',
51+
description: 'Master-detail app exercising controlled_by_parent derived access (ADR-0055).',
52+
},
53+
objects: [CbpAccount, CbpNote],
54+
});
55+
56+
const FIXTURE_MEMBER_SET = 'cbp_fixture_member';
57+
58+
/**
59+
* The fallback permission set for a fresh member: full CRUD on both objects (so
60+
* requests reach the RLS layer rather than being denied by RBAC) and an OWNER
61+
* policy on the MASTER account. The detail `cbp_note` gets NO RLS — its scoping
62+
* is derived from the master by `sharingModel: 'controlled_by_parent'`.
63+
*/
64+
export const cbpMemberSet: PermissionSet = PermissionSetSchema.parse({
65+
name: FIXTURE_MEMBER_SET,
66+
label: 'CBP Fixture Member — owner-scoped account, controlled-by-parent notes',
67+
isProfile: true,
68+
objects: {
69+
cbp_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
70+
cbp_note: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
71+
},
72+
rowLevelSecurity: [RLS.ownerPolicy('cbp_account', 'created_by')],
73+
});
74+
75+
export function cbpSecurity(): SecurityPlugin {
76+
return new SecurityPlugin({
77+
defaultPermissionSets: [...securityDefaultPermissionSets, cbpMemberSet],
78+
fallbackPermissionSet: FIXTURE_MEMBER_SET,
79+
});
80+
}

0 commit comments

Comments
 (0)