Repository navigation
controlled_by_parent children are readable regardless of parent access — contacts and quote line items reach every holder of object read (measured) #694
Description
Found while verifying #549's recorded Option 2 decision against the engine. This is a shipped defect, independent of whatever #549 decides.
What was measured
A real boot of the shipped enforcement stack — ObjectQL + plugin-security + plugin-sharing over this app's own objectstack.config.ts, i.e. the plugins objectstack serve mounts — with a fixture of two accounts owned by another user, and one sales_rep who holds the na_sales_team position. The harness is committed as test/parent-derived-reach.test.ts (5 assertions, runs in ~3s).
Result, as that rep:
| object | OWD | rep sees |
|---|---|---|
crm_account |
private | acct_US only — the one north_america_territory shared to them. Correct. |
crm_quote |
private + readScope: 'own' |
nothing (the #549 keyhole) |
crm_contract |
private + readScope: 'own' |
nothing |
crm_contact |
controlled_by_parent (account) |
BOTH accounts' contacts, including the account they cannot read |
crm_quote_line_item |
controlled_by_parent (quote) |
BOTH quotes' lines, though they can read NEITHER quote |
So controlled_by_parent does not mean "follows the parent's sharing". In this app it means "readable by every holder of object-level read".
Cause
plugin-security's computeControlledByParentFilter derives the child filter as parentFK IN (master ids), where the master ids come from computeRlsFilter(master, 'find', ctx) — Layer 0 (tenant) AND Layer 1 (rowLevelSecurity policies) — executed under a system context. Owner scope and sys_record_share grants are applied by a different plugin (plugin-sharing.buildReadFilter), which returns null for any object whose effective sharing model is not private — and controlled_by_parent maps to public. The platform documents the gap on the function itself:
v1 scope (ADR-0055): single level — the master's OWN controlled_by_parent is not traversed transitively; master accessibility is the master's RLS filter (sharing-service grants on the master are not folded in).
HotCRM authors no rowLevelSecurity policy on crm_account or crm_quote, so the master set is every row and the derived filter restricts nothing.
The write side has the same hole: assertControlledByParentWrite checks the master against computeRlsFilter(master, 'update') — also empty here — so on a controlled_by_parent object a caller with allowEdit can write children of parents they cannot see. (Measured with #549's proposed conversion applied: the rep could edit a quote on the account they cannot read.)
Affected objects today
crm_contact (master crm_account), crm_quote_line_item (master crm_quote), crm_opportunity_line_item (master crm_opportunity), crm_event_attendee (master crm_event), crm_campaign_member (master crm_campaign, whose OWD is public_read anyway, so no delta there).
The exposure that matters: quote and opportunity line items carry per-line pricing and discounts, and they are readable org-wide by every persona granted read on them — including on quotes that are otherwise own-only. Contacts (names, emails, phone numbers) are likewise readable for accounts the caller cannot open.
Metadata and docs currently assert the opposite, and are wrong in the same way:
src/objects/quote_line_item.object.ts: "reads are filtered to lines whosecrm_quotethe caller can read".src/profiles/sales-rep.profile.tsoncrm_contact: "its rows derive from the account the rep can read — territory- and team-shared accounts included".content/docs/administration/sharing-and-security.mdxrelated-list table: Contacts "follows the account".
Where the fix belongs
Producer side, not consumer side: the derivation should resolve master accessibility through the same path a direct read of the master would take (owner scope + share rows), not through RLS policies alone. That is a platform change (@objectstack/plugin-security, ADR-0055 v1 scope). Nothing HotCRM can author fixes it: an app-side rowLevelSecurity policy on crm_account would narrow the master set, but it is ANDed with the sharing filter, so it would also cut off the territory-shared accounts themselves — measured to be a dead end.
App-side, what can be done now is to stop claiming the derivation is narrow (the three places listed above), and to keep the measured behaviour pinned so a platform fix is noticed: test/parent-derived-reach.test.ts fails loudly the day the engine starts narrowing.
Why it blocks #549
The 2026-08-02 decision converts crm_quote and crm_contract to controlled_by_parent on the reading that "read widens to anyone who reads the account". Measured, it widens to anyone who reads the OBJECT: with the conversion applied, this same rep saw both quotes and both contracts, on an account they cannot read, and could edit a quote there. Reported back on #549.
Activity
test(sharing): measure what a controlled_by_parent child is really re…
yinlianghui commentedon Aug 5, 2026
分诊(PM 派发循环):加
upstream:objectstack+prio:p1,不入pm:queue—— 修法在平台侧,已按跨仓协议立单 objectstack-ai/objectstack#5386(查重后无重复;含建议:要么把主记录的归属/共享折进派生,要么在发布期对这种声明响亮报错,让静默不再是默认)。本仓侧有一件可以先做且应该做的事,与平台排期无关:正文列出的三处元数据/文档当前正在说反话 ——
quote_line_item.object.ts(「reads are filtered to lines whosecrm_quotethe caller can read」)、sales-rep.profile.ts对crm_contact的注释、sharing-and-security.mdx相关列表表格(Contacts「follows the account」)。它们描述的是这个名字听起来的语义,不是实测语义,会继续误导下一个读者(包括 AI 作者)。按本仓既有姿态(文档必须描述已发货的东西),纠偏本身不需要拍板。但它与 #549 的待决裁定同处一个叙事:若维护者选了会改变可达范围的方案,这些文字要按结果一次写对,而不是先改一遍再改回去。因此暂缓到 #549 有裁定后再派发,届时与裁定结果一并落地。
test/parent-derived-reach.test.ts已随 #549 的测量落在claude/hotcrm-bug-triage-ks55ze(PR #677),平台一旦收窄派生范围即变红 —— 这是本单最可靠的回归钩子,不依赖任何人记得。Generated by Claude Code
yinlianghui commentedon Aug 5, 2026
#549 已裁定方案 C(OWD 推迟、先修平台),解锁本单的纠偏部分:三处元数据/文档按实测语义改写,不再按「名字听起来的语义」说话。派生可达范围的事实基准 =
test/parent-derived-reach.test.ts(main 上已有)。Generated by Claude Code
docs: controlled_by_parent 的可达范围按实测改写(Refs #694)
yinlianghui commentedon Aug 5, 2026
纠偏第一批已落地(提交
f6e556be,PR 见上方链接),PM 复核通过:四处站点全部按实测语义改写,守卫parent-derived-reach.test.ts原样跑绿(散文与发货行为一致),全量 55 文件 / 1317 通过。认领面扩展(同一认领、同一分支,PM 循环第 4 轮):dev 在面外发现 4 处同类反话站点,属本单纠偏范围的自然延伸,追加派发收尾扫描 ——
完成后本单保持 open,继续跟踪上游 objectstack#5386。
Generated by Claude Code
docs: controlled_by_parent 可达范围改写补齐剩余站点(Refs #694)
yinlianghui commentedon Aug 5, 2026
纠偏第二批验收 ACCEPT(PM 复核,对照远端):提交
1bc70ea9(merge153e7982),PR #699 已转 ready 并挂入合并队列。复核要点:
git diff -- src/的新增行经「排除注释前缀」过滤后为空 —— 零行为字节变更。system-admin.profile.ts:40判定为不改(对比句、无收窄断言)—— 判定正确。campaign_member的写方向确有一条被折入的 master RLS(platformmember_defaultowner-only-writes)—— 这是全仓唯一「派生真的在收窄」的方向,dev 没有把它连坐反转,只加了注解。纠偏不矫枉,这个分寸是对的。sharing-and-security.mdx:48原写「HotCRM authors none(RLS)」,实际本仓有一条(crm_opportunity的 private-deal 策略)。第一批往「宽」方向说过了头,第二批把它修准 —— 自我纠错如实报告。衍生观察已立单 #710(
finding:OWD 表漏列 Event Attendee、「four」实为五),待发现分诊轮。PR #699 合并后,本单保持 open,回到唯一等待项:上游 objectstack#5386。届时
test/parent-derived-reach.test.ts变红即为重启信号(同时解锁 #549 的 Option 2 重评)。Generated by Claude Code
controlled_by_parent的读取是全组织开放而非随父派生 —— service_agent 在 0 个可见商机下读到全部 74 条行项目(金额、单价、合计) #704 as a duplicate of this issueyinlianghui commentedon Aug 6, 2026
📡 上游动静(修复线 PM 例行扫描,2026-08-06):objectstack#5386 已修复关闭(平台 PR #5816,读侧把主档读 RLS 与 sharing read filter 合成解析主档 id 集,写侧新增无条件 sharing canEdit 追问,两侧 fail-closed;平台 PM 验收记录含 7/10 反向红,其中就有本单「rep 编辑了读不到的账户下的报价」的单元层复现)。
消费时点与清单(留给下一次平台升级单,勿提前动):
test/parent-derived-reach.test.ts的钉子按设计变红(本单立的「fails loudly the day the engine starts narrowing」),升级单的逐红项修复表须:①把钉子翻转为收窄后的实测行为;②改正三处「宣称收窄」的残留(quote_line_item 对象注释、sales-rep profile 注释、sharing-and-security 三语文档的 related-list 表与「Measured against rc.2」段);③通知 Sharing coverage gap: territory/team-shared accounts show quote, task and contract related lists the rep cannot read #549 重新评估其 Option 2 转换决定的前提。Generated by Claude Code
fix: bug 批次修复(PM 派发循环)— objectstack-ai#606 objectstack-ai#673 objects…
docs: controlled_by_parent 可达范围按实测改写(objectstack-ai#694 本仓侧纠偏) (objec…
docs(sales): document the meeting/call model, and guard docs coverage…
docs(contracts): bring the zh sharing close to the measured parent re…