Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/crm-opportunity-line-item-object-grant.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 11 additions & 0 deletions examples/app-crm/access-matrix.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
18 changes: 13 additions & 5 deletions examples/app-crm/src/security/sales-positions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 },
},
});

Expand Down
19 changes: 19 additions & 0 deletions examples/app-crm/test/smoke.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand Down
Loading