diff --git a/.changeset/crm-opportunity-line-item-object-grant.md b/.changeset/crm-opportunity-line-item-object-grant.md new file mode 100644 index 0000000000..c3deb17510 --- /dev/null +++ b/.changeset/crm-opportunity-line-item-object-grant.md @@ -0,0 +1,29 @@ +--- +"@objectstack/example-crm": patch +--- + +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` — the app's only non-guest +permission set — 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, including the three positions this app ships +to demonstrate selling, got a silent 403 the moment they tried to add or +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. + +Added `crm_opportunity_line_item` to `crm_sales_user`'s `objects` map with +the exact same grant shape as its master `crm_opportunity` +(`{ allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }`) +— the line item's own access is meant to follow its master, not invent an +independent policy. + +Measured with `objectstack verify --app examples/app-crm/objectstack.config.ts +--rls`: every position persona's `probeBlocked` count dropped from 1 (the +line item, the sole remaining gap left open by #8060) to 0, with zero RLS +holes introduced or found. The build's `security-master-detail-ungranted` +warning for this object is gone. diff --git a/examples/app-crm/access-matrix.json b/examples/app-crm/access-matrix.json index ecfe192469..46b7a11a96 100644 --- a/examples/app-crm/access-matrix.json +++ b/examples/app-crm/access-matrix.json @@ -56,6 +56,17 @@ "modifyAllRecords": false, "sharingModel": "public_read_write" }, + { + "permissionSet": "crm_sales_user", + "object": "crm_opportunity_line_item", + "create": true, + "read": true, + "edit": true, + "delete": false, + "viewAllRecords": false, + "modifyAllRecords": false, + "sharingModel": "controlled_by_parent" + }, { "permissionSet": "guest_portal", "object": "crm_lead", diff --git a/examples/app-crm/src/security/sales-positions.ts b/examples/app-crm/src/security/sales-positions.ts index 691f4ee454..0dce46983e 100644 --- a/examples/app-crm/src/security/sales-positions.ts +++ b/examples/app-crm/src/security/sales-positions.ts @@ -35,11 +35,19 @@ export const SalesUserPermissionSet = definePermissionSet({ name: 'crm_sales_user', label: 'CRM Sales User', objects: { - crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, - crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, - crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, - crm_lead: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, - crm_activity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + // [#8164] crm_opportunity_line_item is a master-detail CHILD of + // crm_opportunity (sharingModel: 'controlled_by_parent') — its RECORD + // scope always follows the parent (ADR-0055), but object-level CRUD is a + // SEPARATE gate that is never derived (security-master-detail-ungranted). + // Grant shape matches the master exactly: same set, same bits, so the + // line-item grid on the Opportunity form (inlineEdit: 'grid') opens for + // the same users who can already work the parent record. + crm_opportunity_line_item: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + crm_lead: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + crm_activity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, }, }); diff --git a/examples/app-crm/test/smoke.test.ts b/examples/app-crm/test/smoke.test.ts index d65d9eca13..1fb560b54c 100644 --- a/examples/app-crm/test/smoke.test.ts +++ b/examples/app-crm/test/smoke.test.ts @@ -118,6 +118,25 @@ describe('app-crm minimal metadata bundle', () => { expect(offenders, `un-acknowledged generic password field(s): ${offenders.join(', ')}`).toEqual([]); }); + // #8164 — crm_opportunity_line_item is a master_detail CHILD of + // crm_opportunity (sharingModel: 'controlled_by_parent'). Record-level + // access always follows the parent (ADR-0055), but object-level CRUD is a + // SEPARATE gate the platform never derives (security-master-detail-ungranted): + // a permission set that grants the master but forgets the child denies + // role-bound non-admin users a 403 before parent-derived access is ever + // consulted. Assert `crm_sales_user` grants the child, with the SAME shape + // as its master — not an independently-invented one. + it('grants crm_opportunity_line_item in crm_sales_user, matching its master crm_opportunity (#8164)', () => { + const salesUserSet = (stack.permissions ?? []).find((p: any) => p.name === 'crm_sales_user') as any; + expect(salesUserSet, 'crm_sales_user permission set not found').toBeDefined(); + + const masterGrant = salesUserSet.objects?.crm_opportunity; + const childGrant = salesUserSet.objects?.crm_opportunity_line_item; + expect(masterGrant, 'crm_opportunity itself must remain granted').toBeDefined(); + expect(childGrant, 'crm_opportunity_line_item has no object-level CRUD grant').toBeDefined(); + expect(childGrant).toEqual(masterGrant); + }); + }); describe('Pipeline dashboard', () => {