You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[finding] controlled_by_parent master selection is silently decided by FIELD DECLARATION ORDER when an object declares several required lookups — reordering fields moves a security boundary with no diagnostic #14747
Measured on @objectstack/* 17.2.0 against a real booted kernel (ObjectQL + plugin-security + plugin-sharing) driving the HotCRM app metadata. No decision requested — filing the measurement.
Summary
SecurityPlugin.resolveCbpRelation picks the master relation with this precedence:
pick is entries.find(...), so within each tier the winner is whichever candidate appears first in the field map — declaration order. When an object declares two or more required lookups (and no master_detail), that order is the only thing deciding which object the record-level security derives from. Nothing reports the ambiguity: not objectstack validate, not objectstack lint, not a boot warning.
The failure direction is the dangerous one. Moving a field up or down in a schema file is a review-invisible edit that reads as cosmetic, and it can silently repoint the security master.
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). Subject: HotCRM's crm_contract, which declares two required lookups — crm_account (declared first) and crm_contact — and no master_detail. crm_contract was set to controlled_by_parent for the measurement (an in-test stack override; nothing shipped).
crm_contact is itself controlled_by_parent under crm_account.
crm_contract field order
resolved master
rep reads crm_contract
crm_account first (as authored)
crm_account
contract_US, contract_mixed — correct, narrowed to the shared account
crm_contact first (same fields, order swapped, nothing else changed)
contract_mixed is the discriminator row: crm_account: acct_US (readable) with crm_contact: contact_JP (not readable). contract_JP appearing in the second row is the whole finding — one reordering, and a record on an account the caller cannot see becomes readable.
Control, live in the same runs: crm_opportunity_line_item (controlled_by_parent under crm_opportunity, which stays private) reads [oli_own] in both configurations, and oli_JP reads 0 rows and refuses the write. So the flip tracks the resolved master, not the harness.
Same-run sanity: the sibling object crm_quote declares only ONE required lookup (crm_account; its crm_contact is optional and conditionally required), and its reach was identical in both runs — the order swap moved only the object that actually had two candidates.
Why the second row is org-wide rather than merely "wrong master"
On 17.2.0 the derivation does not compose across a chain, so a controlled_by_parent master resolves to no restriction and the child goes org-wide. That half is objectstack#11082, fixed by merged PR objectstack#11183 — the fix is present in this repo's main source and absent from every published version (17.2.0, the latest published, went out 2026-08-23T07:01:00Z, about seven hours before #11082 closed).
The ambiguity survives that fix. Once the chain composes, picking crm_contact instead of crm_account no longer leaks org-wide — it silently derives access from a different object, so contract_mixed would follow the primary contact's account rather than the contract's own account. Narrower, still not what the author declared, and still unreported. The two are independent defects; this one is about which master gets chosen, not what happens after it is chosen.
Prior art checked (not duplicates)
objectstack#7503 (closed) — publish-time lint for controlled_by_parent with no relation to derive from. This is the opposite case: too many candidates, not zero.
Let the author name the master explicitly — an authored key beats any precedence chain, and makes the security boundary greppable instead of positional.
At minimum, document that the tie-break is declaration order, so an author reordering fields knows what they are moving.
Reported by a HotCRM consumer: hotcrm#549's ruled conversion turns crm_contract into exactly this shape, so the app would inherit an order-dependent security master the day it lands.
Measured on
@objectstack/* 17.2.0against a real booted kernel (ObjectQL +plugin-security+plugin-sharing) driving the HotCRM app metadata. No decision requested — filing the measurement.Summary
SecurityPlugin.resolveCbpRelationpicks the master relation with this precedence:pickisentries.find(...), so within each tier the winner is whichever candidate appears first in the field map — declaration order. When an object declares two or more required lookups (and nomaster_detail), that order is the only thing deciding which object the record-level security derives from. Nothing reports the ambiguity: notobjectstack validate, notobjectstack lint, not a boot warning.The failure direction is the dangerous one. Moving a field up or down in a schema file is a review-invisible edit that reads as cosmetic, and it can silently repoint the security master.
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). Subject: HotCRM'scrm_contract, which declares two required lookups —crm_account(declared first) andcrm_contact— and nomaster_detail.crm_contractwas set tocontrolled_by_parentfor the measurement (an in-test stack override; nothing shipped).crm_contactis itselfcontrolled_by_parentundercrm_account.crm_contractfield ordercrm_contractcrm_accountfirst (as authored)crm_accountcontract_US,contract_mixed— correct, narrowed to the shared accountcrm_contactfirst (same fields, order swapped, nothing else changed)crm_contactcontract_JP,contract_US,contract_mixed— org-widecontract_mixedis the discriminator row:crm_account: acct_US(readable) withcrm_contact: contact_JP(not readable).contract_JPappearing in the second row is the whole finding — one reordering, and a record on an account the caller cannot see becomes readable.Control, live in the same runs:
crm_opportunity_line_item(controlled_by_parentundercrm_opportunity, which staysprivate) reads[oli_own]in both configurations, andoli_JPreads 0 rows and refuses the write. So the flip tracks the resolved master, not the harness.Same-run sanity: the sibling object
crm_quotedeclares only ONE required lookup (crm_account; itscrm_contactis optional and conditionally required), and its reach was identical in both runs — the order swap moved only the object that actually had two candidates.Why the second row is org-wide rather than merely "wrong master"
On 17.2.0 the derivation does not compose across a chain, so a
controlled_by_parentmaster resolves to no restriction and the child goes org-wide. That half is objectstack#11082, fixed by merged PR objectstack#11183 — the fix is present in this repo'smainsource and absent from every published version (17.2.0, the latest published, went out 2026-08-23T07:01:00Z, about seven hours before #11082 closed).The ambiguity survives that fix. Once the chain composes, picking
crm_contactinstead ofcrm_accountno longer leaks org-wide — it silently derives access from a different object, socontract_mixedwould follow the primary contact's account rather than the contract's own account. Narrower, still not what the author declared, and still unreported. The two are independent defects; this one is about which master gets chosen, not what happens after it is chosen.Prior art checked (not duplicates)
controlled_by_parentwith no relation to derive from. This is the opposite case: too many candidates, not zero.required: trueon amaster_detailundercontrolled_by_parent. Adjacent, and lint: promoterelationship/master-detail-requiredfrom warning to error, scoped tocontrolled_by_parent— ruled for the v18 boundary (Direction 1 of #8772) #9139's v18 direction would push authors toward an explicitmaster_detail, which would cover the lookup-tier case by construction. It does not address ambiguity within a tier (twomaster_detailfields pick by order the same way), and it is not landed.Suggested directions (not deciding)
sharingModel: controlled_by_parentwith nomaster_detailrelation is statically detectable and unreported #7503. Silence is the part that makes this expensive.Reported by a HotCRM consumer: hotcrm#549's ruled conversion turns
crm_contractinto exactly this shape, so the app would inherit an order-dependent security master the day it lands.