Measured on @objectstack/* 17.1.0 against a real booted kernel (ObjectQL + plugin-security + plugin-sharing), driving the HotCRM app metadata.
Summary
objectstack#5386 fixed the single-level case: a controlled_by_parent child is now filtered to masters the caller can actually read, folding in the master's ownership and sys_record_share grants. That fix does not recurse. When the master is itself controlled_by_parent, both halves of the master-set resolution return "no restriction", the derived id set becomes every master row, and the child goes org-wide — for read and for write.
So a two-level chain grandchild -> child -> root is enforced at level one and unenforced at level two.
Mechanism
computeControlledByParentFilter (plugin-security) resolves the reachable master set from exactly two layers:
const masterRlsFilter = await this.computeRlsFilter(permissionSets, rel.master, "find", context);
const masterSharingFilter = await this.resolveSharingReadFilter(rel.master, context, permissionSets);
const masterFilter = andComposeLayers(masterRlsFilter, masterSharingFilter);
const rows = await this.ql.find(rel.master, { where: masterFilter ?? {}, fields: ["id"],
context: { isSystem: true } });
return { [rel.fk]: { $in: masterIds } };
It never consults the master's own controlled_by_parent derivation. And the sharing half opts out for exactly this model — plugin-sharing.buildReadFilter:
if (effectiveSharingModel(schema) !== "private") return null;
with effectiveSharingModel mapping controlled_by_parent to public. So for a controlled_by_parent master with no authored RLS policy: RLS half null, sharing half null, composed filter null, find(master, {}) runs as system and returns every row.
The write half fails the same way. assertControlledByParentWrite asks resolveSharingCanEdit on the master row, and checkEdit opts out on the same test:
const model = effectiveSharingModel(schema);
if (model === "public") return "abstain";
abstain is not deny, so canEdit answers true for every master row, and the child write is permitted.
Reproduction and measurement
Fixture: two accounts (acct_US, acct_JP) owned by another user; a sales_rep who owns none of them and receives acct_US only through a territory sharing rule (sys_record_share, access_level: edit). Chain under test: crm_quote_line_item -> crm_quote -> crm_account.
crm_quote OWD |
rep reads crm_quote |
rep reads crm_quote_line_item |
rep writes line_JP (its quote unreadable) |
private |
quote_own |
line_own |
DENIED (PermissionDeniedError) |
controlled_by_parent (master crm_account) |
quote_US, quote_own — correct, level one narrows |
line_JP, line_US, line_own — org-wide |
ALLOWED |
The decisive pair, same run, converted config:
READ quote_JP by id = [] <- master NOT readable
READ line_JP by id = [{"unit_price":10,"quantity":3}] <- its child IS readable
WRITE line_JP (grandmaster NOT readable) = ALLOWED <- and writable; quantity 3 is the landed write
Level one is correct in the same measurement (crm_quote narrows to the territory-shared account's quote), which isolates the defect to the second hop rather than to the fixture.
Control: crm_opportunity_line_item is controlled_by_parent under crm_opportunity, which stays private — it reads [] throughout, so the leak tracks the master's model, not the object.
Why this matters beyond the one app
The exposed data here is per-line quote pricing, but the shape is general: any app that models a three-level master-detail chain gets silent org-wide exposure at the bottom level, with metadata that reads as if it were narrowed. That is the same class objectstack#5386 was filed for, one level down, and it is invisible to the app author — the declaration looks identical to the enforced case.
It also blocks a real consumer decision: HotCRM issue 549 has a maintainer ruling to convert crm_quote to controlled_by_parent, and this defect is the reason it cannot land as ruled.
Suggested directions (not deciding)
- Make the derivation compose recursively — resolve the master set through the master's own effective read filter, including its
controlled_by_parent derivation, with cycle protection and a depth bound.
- Fail closed on an unsupported chain — if a
controlled_by_parent master is itself controlled_by_parent, deny rather than return everything, matching the fail-closed branch already present for an unresolvable master sharing filter.
- Publish-time lint — report a chained
controlled_by_parent declaration loudly, in the spirit of issue 7503, so silence stops being the default while (1) is unimplemented.
(2) or (3) is worth having even before (1), because the current failure mode is the dangerous direction: unenforced, and indistinguishable from enforced.
Generated by Claude Code
Measured on
@objectstack/* 17.1.0against a real booted kernel (ObjectQL +plugin-security+plugin-sharing), driving the HotCRM app metadata.Summary
objectstack#5386 fixed the single-level case: a
controlled_by_parentchild is now filtered to masters the caller can actually read, folding in the master's ownership andsys_record_sharegrants. That fix does not recurse. When the master is itselfcontrolled_by_parent, both halves of the master-set resolution return "no restriction", the derived id set becomes every master row, and the child goes org-wide — for read and for write.So a two-level chain
grandchild -> child -> rootis enforced at level one and unenforced at level two.Mechanism
computeControlledByParentFilter(plugin-security) resolves the reachable master set from exactly two layers:It never consults the master's own
controlled_by_parentderivation. And the sharing half opts out for exactly this model —plugin-sharing.buildReadFilter:with
effectiveSharingModelmappingcontrolled_by_parenttopublic. So for acontrolled_by_parentmaster with no authored RLS policy: RLS halfnull, sharing halfnull, composed filternull,find(master, {})runs as system and returns every row.The write half fails the same way.
assertControlledByParentWriteasksresolveSharingCanEditon the master row, andcheckEditopts out on the same test:abstainis notdeny, socanEditanswerstruefor every master row, and the child write is permitted.Reproduction and measurement
Fixture: two accounts (
acct_US,acct_JP) owned by another user; asales_repwho owns none of them and receivesacct_USonly through a territory sharing rule (sys_record_share,access_level: edit). Chain under test:crm_quote_line_item->crm_quote->crm_account.crm_quoteOWDcrm_quotecrm_quote_line_itemline_JP(its quote unreadable)privatequote_ownline_ownPermissionDeniedError)controlled_by_parent(mastercrm_account)quote_US,quote_own— correct, level one narrowsline_JP,line_US,line_own— org-wideThe decisive pair, same run, converted config:
Level one is correct in the same measurement (
crm_quotenarrows to the territory-shared account's quote), which isolates the defect to the second hop rather than to the fixture.Control:
crm_opportunity_line_itemiscontrolled_by_parentundercrm_opportunity, which staysprivate— it reads[]throughout, so the leak tracks the master's model, not the object.Why this matters beyond the one app
The exposed data here is per-line quote pricing, but the shape is general: any app that models a three-level master-detail chain gets silent org-wide exposure at the bottom level, with metadata that reads as if it were narrowed. That is the same class objectstack#5386 was filed for, one level down, and it is invisible to the app author — the declaration looks identical to the enforced case.
It also blocks a real consumer decision: HotCRM issue 549 has a maintainer ruling to convert
crm_quotetocontrolled_by_parent, and this defect is the reason it cannot land as ruled.Suggested directions (not deciding)
controlled_by_parentderivation, with cycle protection and a depth bound.controlled_by_parentmaster is itselfcontrolled_by_parent, deny rather than return everything, matching the fail-closed branch already present for an unresolvable master sharing filter.controlled_by_parentdeclaration loudly, in the spirit of issue 7503, so silence stops being the default while (1) is unimplemented.(2) or (3) is worth having even before (1), because the current failure mode is the dangerous direction: unenforced, and indistinguishable from enforced.
Generated by Claude Code