Skip to content

fix(security): controlled_by_parent 折入主档的归属与共享授权 —— 声明的收窄真正被兑现 (#5386) - #5816

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-5386-cbp-master-access
Aug 6, 2026
Merged

fix(security): controlled_by_parent 折入主档的归属与共享授权 —— 声明的收窄真正被兑现 (#5386)#5816
baozhoutao merged 1 commit into
mainfrom
claude/issue-5386-cbp-master-access

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #5386

前提复核(先于实现)

issue 是对 17.0.0-rc.2 的测量,先在 origin/main(1624f4ad2)上核对,前提成立且未漂移:

  • packages/plugins/plugin-security/src/security-plugin.tscomputeControlledByParentFilter 仍然只用 computeRlsFilter(master, 'find') 推导主档 id 集,没有任何 sharing 输入;
  • assertControlledByParentWrite 的主档行检查仍然写在 if (masterWriteFilter) 里面 —— 主档没写 RLS 时整段跳过;
  • plugin-sharing/src/sharing-service.tseffectiveSharingModel 仍把 controlled_by_parent 映射为 public,而 buildReadFilter 对非 private 一律返回 null

两半确实从未相遇。issue 正文那句「sharing grants 未折入」的后果比它读起来严重:主档上不写 RLS 的应用得到的是不受限的主档 id 集,派生过滤器等于没收窄。

方向

采用 issue 的第一方案:主档可达性走与直接读 / 直接写主档完全相同的路径。第二方案(publish 时警告)不采用 —— 声明的收窄必须被兑现,警告不是兑现。

关键实现原则是复用既有合成点,不在 plugin-security 里手刻一份 sharing 语义(手刻副本是未来分叉的温床):

  • :computeControlledByParentFilter 现在把主档读 RLS 与 resolveSharingReadFilter AND 起来再解析主档 id 集。resolveSharingReadFilteranalytics: /analytics/query ignores record-level scoping — a member counts and reads dimension values of records they cannot read #4467getReadFilter 建的那个 OWD/共享半边(含 ADR-0057 D1 的深度 stash),这里原样复用,只多加一个可选参数把中间件已解析好的 permission sets 传进去,省掉一次重复解析。
  • :assertControlledByParentWrite 在原有的 CRUD update + 写 RLS 之外,无条件追问 plugin-sharing 的单记录写闸 canEdit(归属按写深度放宽、edit 级共享、modifyAllRecords 旁路)—— 新增的 resolveSharingCanEditresolveSharingReadFilter 的写侧对偶,调用的正是 plugin-sharing 自己的中间件对 by-id 写所用的那个闸。无条件,正因为写 RLS 那一半在最常见的情形下会被整段跳过。

哪一半生效由主档自己的有效共享模型决定 —— buildReadFilter / canEdit 各自对非 private、无 owner 字段、bypass 对象自行返回「不施加限制」—— 所以派生出的可见集与直接 find 主档逐点一致,而不是另立一套判定。

两侧解析失败一律 fail closed(主档 id 集为空 / 拒绝写),并写日志,而不是悄悄放宽回全员可见。

范围守恒

  • v1 的单层语义不变:主档自身的 controlled_by_parent 仍不递归下钻,这条边界继续写在函数文档注释里,并新增一条测试把它钉住(主档解析恰好一次系统上下文读)。
  • 只动 packages/plugins/plugin-security/** + 一个 changeset。plugin-sharing 一行未改 —— 需要的复用点(buildReadFilter / canEdit)已经是它的公开面。没有触碰 packages/specpackages/objectql

测试

新增 packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts(10 例),用同一组 fixture 驱动读过滤与写断言两个面 —— 它们是同一个契约(「子随主档访问」)的两个实现面,分开测就看不见它们互相不一致。

fixture 复刻 issue 的测量表:主档 crm_account(OWD private,不写任何 RLS),明细 crm_contact(controlled_by_parent,必填 master_detail)。三条主档 —— 他人所有且恰好一条 share grant 的 acct_us、他人所有且未共享的 acct_eu(被排除的那一行,没有它「一致」什么也证明不了)、调用者自己拥有的 acct_own。sharing 侧用的是真实的 SharingService(plugin-security 已有的 devDependency),不是 stub —— 复用的是不是同一套语义,由此直接可见。

覆盖 issue 的三条 Acceptance:主档不可读 ⇒ 子不可读;写侧同理;归属/共享路径专门有 fixture。另有:read 级 grant 只开读不开写(与直接访问主档的逐动词答案一致)、未装 plugin-sharing 的部署行为不变、sharing 抛错时两个面都 fail closed。

反向验证 —— 方向是先预测再执行的,预测为「红」并逐条落实。 把实现还原成 origin/main 的样子后:

Tests  7 failed | 3 passed (10)
AssertionError: expected [ 'ct_us', 'ct_eu', 'ct_own' ] to deeply equal [ 'ct_us', 'ct_own' ]
AssertionError: expected [ 'ct_us', 'ct_eu', 'ct_own' ] to deeply equal [ 'ct_own' ]
AssertionError: promise resolved "undefined" instead of rejecting
AssertionError: expected [ 'ct_eu', 'ct_own', 'ct_us' ] to not include 'ct_eu'
AssertionError: expected [ 'crm_account' ] to include 'sys_record_share'
AssertionError: expected [ 'ct_us', 'ct_eu', 'ct_own' ] to deeply equal []

保持绿的恰好是预测中对本次改动不敏感的三例:直接 find 主档得到的 id 集(不经过被改的函数)、「本就可写的行仍可写」这条正向用例、以及未装 plugin-sharing 的部署。promise resolved "undefined" instead of rejecting 就是 issue 正文里那句「rep 编辑了自己读不到的账户下的报价」在单元层的复现。

命令与结果:

pnpm --filter @objectstack/plugin-security test    Test Files 35 passed | Tests 741 passed
pnpm --filter @objectstack/plugin-security typecheck   (tsc --noEmit, clean)
pnpm --filter @objectstack/plugin-sharing  test    Test Files 13 passed | Tests 347 passed
pnpm --filter @objectstack/plugin-sharing  typecheck   (tsc --noEmit, clean)
node scripts/check-nul-bytes.mjs                   OK (5678 files)
node scripts/check-engine-double-contract.mjs      OK — 37 pinned, 2 exempt
npx eslint (两个改动文件)                          clean

消费半径的扫描:本规则的真实集成 pin 在 packages/qa/dogfood,不在被改的包里,所以单独跑了两个 CBP 集成用例 —— controlled-by-parent.dogfood.test.tsshowcase-invoice-cbp.dogfood.test.ts(两者都启动了完整 stack,plugin-sharing 在内):Test Files 2 passed | Tests 8 passed。它们不受影响的原因可核对:两个 fixture 的主档都是 public_read_write,buildReadFilter 返回 nullcanEdit 直接为真。

兼容性 / 影响面

这是一次安全收紧。升级后,此前被越权看到 / 写到的明细行会读不到、写不了 —— 那正是声明本来就要求的边界。changeset 里把这点写在最前面。未装 plugin-sharing 的部署行为完全不变。

顺带发现(未在本 PR 修)

getReadFilter(analytics/raw-SQL 的读作用域提供者)根本没有调用派生过滤器,所以 controlled_by_parent 对象在那条路径上完全没有主档收窄 —— 与本 issue 同族但不同的缺陷,修好本 issue 也不会碰到它。已另立 #5815

新增测试文件为何不需要 assertEngineDeleteDispatch

新测试里的 in-memory store 只声明引擎的面(find / findOne / getSchema),不声明任何写动词 —— 被测代码不经它写入,而一个根本没有该动词的 double 不可能在该动词上比引擎更宽松。check:engine-double-contract 因此不把它计入(已实跑确认,见上)。


Generated by Claude Code

主档可达性此前只走 `computeRlsFilter`(租户 Layer 0 + RLS 策略),归属与
`sys_record_share` 授权由 plugin-sharing 贡献,而它对非 `private` 有效共享模型
返回 null —— `controlled_by_parent` 恰好映射为 public,两半从未相遇。主档没写
RLS 的应用因此拿到不受限的主档 id 集,声明的收窄什么也没收窄;写这半更甚,主档
写 RLS 为空时整段行检查被跳过。

读:`computeControlledByParentFilter` 把主档读 RLS 与 `resolveSharingReadFilter`
(`getReadFilter` 已在用的 OWD/共享半边)AND 起来再解析主档 id 集。
写:`assertControlledByParentWrite` 无条件追问 plugin-sharing 的单记录写闸
`canEdit`。两侧解析失败一律 fail closed。

v1 单层语义不变,未装 plugin-sharing 的部署行为不变。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwwiU9bjhwy2SWj13ho8uv
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 6:21am

Request Review

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-security)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 6, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review August 6, 2026 06:36
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 846ed1f Aug 6, 2026
24 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-5386-cbp-master-access branch August 6, 2026 06:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

controlled_by_parent derivation ignores the master's ownership and share grants — children are readable (and writable) regardless of parent access

2 participants