fix(security): controlled_by_parent 折入主档的归属与共享授权 —— 声明的收窄真正被兑现 (#5386) - #5816
Merged
Conversation
主档可达性此前只走 `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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
baozhoutao
marked this pull request as ready for review
August 6, 2026 06:36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5386
前提复核(先于实现)
issue 是对
17.0.0-rc.2的测量,先在origin/main(1624f4ad2)上核对,前提成立且未漂移:packages/plugins/plugin-security/src/security-plugin.ts的computeControlledByParentFilter仍然只用computeRlsFilter(master, 'find')推导主档 id 集,没有任何 sharing 输入;assertControlledByParentWrite的主档行检查仍然写在if (masterWriteFilter)里面 —— 主档没写 RLS 时整段跳过;plugin-sharing/src/sharing-service.ts的effectiveSharingModel仍把controlled_by_parent映射为public,而buildReadFilter对非private一律返回null。两半确实从未相遇。issue 正文那句「sharing grants 未折入」的后果比它读起来严重:主档上不写 RLS 的应用得到的是不受限的主档 id 集,派生过滤器等于没收窄。
方向
采用 issue 的第一方案:主档可达性走与直接读 / 直接写主档完全相同的路径。第二方案(publish 时警告)不采用 —— 声明的收窄必须被兑现,警告不是兑现。
关键实现原则是复用既有合成点,不在 plugin-security 里手刻一份 sharing 语义(手刻副本是未来分叉的温床):
computeControlledByParentFilter现在把主档读 RLS 与resolveSharingReadFilterAND 起来再解析主档 id 集。resolveSharingReadFilter是 analytics: /analytics/query ignores record-level scoping — a member counts and reads dimension values of records they cannot read #4467 为getReadFilter建的那个 OWD/共享半边(含 ADR-0057 D1 的深度 stash),这里原样复用,只多加一个可选参数把中间件已解析好的 permission sets 传进去,省掉一次重复解析。assertControlledByParentWrite在原有的 CRUDupdate+ 写 RLS 之外,无条件追问 plugin-sharing 的单记录写闸canEdit(归属按写深度放宽、edit级共享、modifyAllRecords旁路)—— 新增的resolveSharingCanEdit是resolveSharingReadFilter的写侧对偶,调用的正是 plugin-sharing 自己的中间件对 by-id 写所用的那个闸。无条件,正因为写 RLS 那一半在最常见的情形下会被整段跳过。哪一半生效由主档自己的有效共享模型决定 ——
buildReadFilter/canEdit各自对非private、无 owner 字段、bypass 对象自行返回「不施加限制」—— 所以派生出的可见集与直接 find 主档逐点一致,而不是另立一套判定。两侧解析失败一律 fail closed(主档 id 集为空 / 拒绝写),并写日志,而不是悄悄放宽回全员可见。
范围守恒
controlled_by_parent仍不递归下钻,这条边界继续写在函数文档注释里,并新增一条测试把它钉住(主档解析恰好一次系统上下文读)。packages/plugins/plugin-security/**+ 一个 changeset。plugin-sharing 一行未改 —— 需要的复用点(buildReadFilter/canEdit)已经是它的公开面。没有触碰packages/spec或packages/objectql。测试
新增
packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts(10 例),用同一组 fixture 驱动读过滤与写断言两个面 —— 它们是同一个契约(「子随主档访问」)的两个实现面,分开测就看不见它们互相不一致。fixture 复刻 issue 的测量表:主档
crm_account(OWDprivate,不写任何 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的样子后:保持绿的恰好是预测中对本次改动不敏感的三例:直接 find 主档得到的 id 集(不经过被改的函数)、「本就可写的行仍可写」这条正向用例、以及未装 plugin-sharing 的部署。
promise resolved "undefined" instead of rejecting就是 issue 正文里那句「rep 编辑了自己读不到的账户下的报价」在单元层的复现。命令与结果:
消费半径的扫描:本规则的真实集成 pin 在
packages/qa/dogfood,不在被改的包里,所以单独跑了两个 CBP 集成用例 ——controlled-by-parent.dogfood.test.ts与showcase-invoice-cbp.dogfood.test.ts(两者都启动了完整 stack,plugin-sharing 在内):Test Files 2 passed | Tests 8 passed。它们不受影响的原因可核对:两个 fixture 的主档都是public_read_write,buildReadFilter返回null、canEdit直接为真。兼容性 / 影响面
这是一次安全收紧。升级后,此前被越权看到 / 写到的明细行会读不到、写不了 —— 那正是声明本来就要求的边界。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