Skip to content

Commit c4624f0

Browse files
os-zhuangclaude
andauthored
fix(example-crm): grant crm_opportunity_line_item in crm_sales_user, matching its master crm_opportunity (#8164) (#8228)
* fix(example-crm): grant crm_opportunity_line_item in crm_sales_user, matching its master crm_opportunity (#8164) crm_opportunity_line_item is a master-detail CHILD of crm_opportunity (sharingModel: 'controlled_by_parent', inlineEdit: 'grid' on the Opportunity form), but crm_sales_user granted object-level CRUD on 5 CRM objects and never the line item. Record-level access always follows the master (ADR-0055), but object-level CRUD is a SEPARATE gate the platform never derives -- every role-bound (non-admin) user got a silent 403 the moment they tried to add/edit a product line on an Opportunity they otherwise fully own. The platform's own build-time lint (security-master-detail-ungranted) already flagged this independently. Grant shape matches the master exactly: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }. Adds one purely-additive test asserting the grant exists and equals the master's -- there was zero prior coverage of crm_sales_user's grant contents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 * fix(example-crm): refresh access-matrix.json for the new line-item grant (ADR-0090 D6) Companion to the sales-positions.ts fix (#8164): the ADR-0090 D6 access-matrix snapshot gate fails the build on any undocumented capability drift, and granting crm_opportunity_line_item is exactly such a drift. Regenerated with `objectstack build --update-access-matrix` per the gate's own instructions -- the diff is the review artifact: crm_sales_user gains create/read/edit on crm_opportunity_line_item, recorded with the object's OWN sharingModel (controlled_by_parent), not the master's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b41f51a commit c4624f0

4 files changed

Lines changed: 72 additions & 5 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/example-crm": patch
3+
---
4+
5+
fix(example-crm): grant `crm_opportunity_line_item` in `crm_sales_user`, matching its master `crm_opportunity` (#8164)
6+
7+
`crm_opportunity_line_item` is a master-detail CHILD of `crm_opportunity`
8+
(`sharingModel: 'controlled_by_parent'`, `inlineEdit: 'grid'` on the
9+
Opportunity form), but `crm_sales_user` — the app's only non-guest
10+
permission set — granted object-level CRUD on 5 CRM objects and never the
11+
line item. Record-level access always follows the master (ADR-0055), but
12+
object-level CRUD is a SEPARATE gate the platform never derives: every
13+
role-bound (non-admin) user, including the three positions this app ships
14+
to demonstrate selling, got a silent 403 the moment they tried to add or
15+
edit a product line on an Opportunity they otherwise fully own. The
16+
platform's own build-time lint (`security-master-detail-ungranted`) already
17+
flagged this independently.
18+
19+
Added `crm_opportunity_line_item` to `crm_sales_user`'s `objects` map with
20+
the exact same grant shape as its master `crm_opportunity`
21+
(`{ allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }`)
22+
— the line item's own access is meant to follow its master, not invent an
23+
independent policy.
24+
25+
Measured with `objectstack verify --app examples/app-crm/objectstack.config.ts
26+
--rls`: every position persona's `probeBlocked` count dropped from 1 (the
27+
line item, the sole remaining gap left open by #8060) to 0, with zero RLS
28+
holes introduced or found. The build's `security-master-detail-ungranted`
29+
warning for this object is gone.

examples/app-crm/access-matrix.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@
5656
"modifyAllRecords": false,
5757
"sharingModel": "public_read_write"
5858
},
59+
{
60+
"permissionSet": "crm_sales_user",
61+
"object": "crm_opportunity_line_item",
62+
"create": true,
63+
"read": true,
64+
"edit": true,
65+
"delete": false,
66+
"viewAllRecords": false,
67+
"modifyAllRecords": false,
68+
"sharingModel": "controlled_by_parent"
69+
},
5970
{
6071
"permissionSet": "guest_portal",
6172
"object": "crm_lead",

examples/app-crm/src/security/sales-positions.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,19 @@ export const SalesUserPermissionSet = definePermissionSet({
3535
name: 'crm_sales_user',
3636
label: 'CRM Sales User',
3737
objects: {
38-
crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
39-
crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
40-
crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
41-
crm_lead: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
42-
crm_activity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
38+
crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
39+
crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
40+
crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
41+
// [#8164] crm_opportunity_line_item is a master-detail CHILD of
42+
// crm_opportunity (sharingModel: 'controlled_by_parent') — its RECORD
43+
// scope always follows the parent (ADR-0055), but object-level CRUD is a
44+
// SEPARATE gate that is never derived (security-master-detail-ungranted).
45+
// Grant shape matches the master exactly: same set, same bits, so the
46+
// line-item grid on the Opportunity form (inlineEdit: 'grid') opens for
47+
// the same users who can already work the parent record.
48+
crm_opportunity_line_item: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
49+
crm_lead: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
50+
crm_activity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
4351
},
4452
});
4553

examples/app-crm/test/smoke.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,25 @@ describe('app-crm minimal metadata bundle', () => {
118118
expect(offenders, `un-acknowledged generic password field(s): ${offenders.join(', ')}`).toEqual([]);
119119
});
120120

121+
// #8164 — crm_opportunity_line_item is a master_detail CHILD of
122+
// crm_opportunity (sharingModel: 'controlled_by_parent'). Record-level
123+
// access always follows the parent (ADR-0055), but object-level CRUD is a
124+
// SEPARATE gate the platform never derives (security-master-detail-ungranted):
125+
// a permission set that grants the master but forgets the child denies
126+
// role-bound non-admin users a 403 before parent-derived access is ever
127+
// consulted. Assert `crm_sales_user` grants the child, with the SAME shape
128+
// as its master — not an independently-invented one.
129+
it('grants crm_opportunity_line_item in crm_sales_user, matching its master crm_opportunity (#8164)', () => {
130+
const salesUserSet = (stack.permissions ?? []).find((p: any) => p.name === 'crm_sales_user') as any;
131+
expect(salesUserSet, 'crm_sales_user permission set not found').toBeDefined();
132+
133+
const masterGrant = salesUserSet.objects?.crm_opportunity;
134+
const childGrant = salesUserSet.objects?.crm_opportunity_line_item;
135+
expect(masterGrant, 'crm_opportunity itself must remain granted').toBeDefined();
136+
expect(childGrant, 'crm_opportunity_line_item has no object-level CRUD grant').toBeDefined();
137+
expect(childGrant).toEqual(masterGrant);
138+
});
139+
121140
});
122141

123142
describe('Pipeline dashboard', () => {

0 commit comments

Comments
 (0)