Skip to content

controlled_by_parent children are readable regardless of parent access — contacts and quote line items reach every holder of object read (measured) #694

Closed

Description

@yinlianghui

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 whose crm_quote the caller can read".
  • src/profiles/sales-rep.profile.ts on crm_contact: "its rows derive from the account the rep can read — territory- and team-shared accounts included".
  • content/docs/administration/sharing-and-security.mdx related-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

  1. added
    bugSomething isn't working
    upstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstream
    on Aug 5, 2026
  2. yinlianghui commented on Aug 5, 2026

    @yinlianghui
    ContributorAuthor

    分诊(PM 派发循环):加 upstream:objectstack + prio:p1,不入 pm:queue —— 修法在平台侧,已按跨仓协议立单 objectstack-ai/objectstack#5386(查重后无重复;含建议:要么把主记录的归属/共享折进派生,要么在发布期对这种声明响亮报错,让静默不再是默认)。

    本仓侧有一件可以先做且应该做的事,与平台排期无关:正文列出的三处元数据/文档当前正在说反话 —— quote_line_item.object.ts(「reads are filtered to lines whose crm_quote the 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

  3. self-assigned this
    on Aug 5, 2026
  4. yinlianghui commented on Aug 5, 2026

    @yinlianghui
    ContributorAuthor

    认领:PM 循环第 4 轮(本仓侧文档/注释纠偏部分;派生语义本体仍阻塞于上游 objectstack#5386)
    会话:session_0194QUW67v7vyLToEd9NSeRM
    分支:claude/hotcrm-bug-triage-ks55ze(已按规程从最新 main 重置 —— 上一个 PR #677 已合并,不可复用;本单落新 PR)
    Worktree:hotcrm-issue-694
    文件面:src/objects/quote_line_item.object.ts(仅注释)、src/profiles/sales-rep.profile.ts(仅注释)、content/docs/administration/sharing-and-security.mdx、必要时 test/sharing-coverage.test.ts 的台账散文、changeset。零行为变更。

    #549 已裁定方案 C(OWD 推迟、先修平台),解锁本单的纠偏部分:三处元数据/文档按实测语义改写,不再按「名字听起来的语义」说话。派生可达范围的事实基准 = test/parent-derived-reach.test.ts(main 上已有)。


    Generated by Claude Code

  5. yinlianghui commented on Aug 5, 2026

    @yinlianghui
    ContributorAuthor

    纠偏第一批已落地(提交 f6e556be,PR 见上方链接),PM 复核通过:四处站点全部按实测语义改写,守卫 parent-derived-reach.test.ts 原样跑绿(散文与发货行为一致),全量 55 文件 / 1317 通过。

    认领面扩展(同一认领、同一分支,PM 循环第 4 轮):dev 在面外发现 4 处同类反话站点,属本单纠偏范围的自然延伸,追加派发收尾扫描 ——

    文件面追加:src/objects/event_attendee.object.ts(仅注释;其中「An attendee row is therefore never more visible than the meeting it belongs to」是全仓该错误断言的最强形式)、src/objects/opportunity_line_item.object.ts(仅注释,quote_line_item 的孪生措辞)、content/docs/administration/profiles.mdx:46、src/profiles/sales-manager.profile.ts 与 marketing-user.profile.ts 的相关注释。零行为变更不变。

    完成后本单保持 open,继续跟踪上游 objectstack#5386。


    Generated by Claude Code

  6. yinlianghui commented on Aug 5, 2026

    @yinlianghui
    ContributorAuthor

    纠偏第二批验收 ACCEPT(PM 复核,对照远端):提交 1bc70ea9(merge 153e7982),PR #699 已转 ready 并挂入合并队列。

    复核要点:

    • 纯注释/散文经机械验证:git diff -- src/ 的新增行经「排除注释前缀」过滤后为空 —— 零行为字节变更。
    • 全仓最强的错误断言(「An attendee row is therefore never more visible than the meeting it belongs to」)已删除而非软化。
    • 16 处 grep 命中逐一裁决并留了判定清单,含 2 处 grep 未命中但同类的站点;system-admin.profile.ts:40 判定为不改(对比句、无收窄断言)—— 判定正确。
    • 值得记录的两个细节:
      1. campaign_member 的写方向确有一条被折入的 master RLS(platform member_default owner-only-writes)—— 这是全仓唯一「派生真的在收窄」的方向,dev 没有把它连坐反转,只加了注解。纠偏不矫枉,这个分寸是对的。
      2. dev 纠正了批次一自己的过度陈述:sharing-and-security.mdx:48 原写「HotCRM authors none(RLS)」,实际本仓有一条(crm_opportunity 的 private-deal 策略)。第一批往「宽」方向说过了头,第二批把它修准 —— 自我纠错如实报告。
    • 测试:targeted(sharing-coverage / parent-derived-reach / docs-drift / authorization-coverage / line-item-conventions)+ 全量 55 文件 / 1317 通过;事实基准守卫前后均绿。

    衍生观察已立单 #710(finding:OWD 表漏列 Event Attendee、「four」实为五),待发现分诊轮。

    PR #699 合并后,本单保持 open,回到唯一等待项:上游 objectstack#5386。届时 test/parent-derived-reach.test.ts 变红即为重启信号(同时解锁 #549 的 Option 2 重评)。


    Generated by Claude Code

  7. yinlianghui commented on Aug 6, 2026

    @yinlianghui
    ContributorAuthor

    📡 上游动静(修复线 PM 例行扫描,2026-08-06):objectstack#5386 已修复关闭(平台 PR #5816,读侧把主档读 RLS 与 sharing read filter 合成解析主档 id 集,写侧新增无条件 sharing canEdit 追问,两侧 fail-closed;平台 PM 验收记录含 7/10 反向红,其中就有本单「rep 编辑了读不到的账户下的报价」的单元层复现)。

    消费时点与清单(留给下一次平台升级单,勿提前动):


    Generated by Claude Code

  8. added
    priority:p1High: required for production / M2
    and removed on Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingpm:dispatchedDispatched to a dev agent by /pm-dispatchpriority:p1High: required for production / M2upstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstream

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions