fix(plugin-audit,rest)!: sys_comment 的访问权从 thread_id 指向的记录继承 (#4630) - #4758
Merged
Conversation
sys_attachment 的可见性由父记录派生,sys_comment 什么都不派生:同一条
记录、同一个用户,两者答案不同 —— 读不到 opportunity 的 rep2 依然能列出
它下面的评论,并且能 POST 一条(连 `"crm_opportunity:"` 这种空 id 的悬空
thread 也 201)。sys_comment 是 public、无 owner 列、父记录藏在 thread_id
字符串里,所以 OWD/sharing 与 RLS 从来没有收窄过它;而 enable.feeds 是
opt-out(spec 默认 true),于是每个应用的每个对象都挂着这条组织级可读可写
的旁路。
AuditPlugin 现在装上 service-storage 给 sys_attachment 装的同一套两件套,
按 thread_id 的 `{object_name}:{record_id}` 解析父记录:
- 读侧:find/findOne/count/aggregate 中间件把查询与"调用者真的读得到的
thread"求交(用调用者上下文探测父对象,父对象自己的 OWD/sharing/RLS/
对象级 CRUD 说了算)。count() 与 find() 同样被过滤,列表 total 不会泄露
被隐藏行的存在。
- 写侧:beforeInsert 要求对父记录可读(能看见的记录就能讨论);
beforeUpdate / beforeDelete 要求调用者是评论作者,或对父记录有 EDIT
(attachment 的 uploader-or-parent-editor 规则)。author_id 由服务端按
会话盖章,客户端传的值永远不生效 —— 否则"作者可删"本身就可伪造。
全部 fail closed:解析不出记录的 thread_id(悬空空 id、自由文本、指向
sys_comment 自身)写入拒绝、读取排除;过滤器算不出来就 deny-all。拒绝统一
答 403 `RECORD_NOT_ACCESSIBLE` —— 按 ADR-0112 的账本约定,通用权限条件用
标准目录里的码而不是新造同义词;`error.object` 报父记录的对象名,REST 的
映射分支与 attachment/feeds 两个网关同形。
正交且未改动:enable.feeds(FEEDS_DISABLED)仍然只管"这个对象有没有评论",
匿名调用仍然在这一切之前 401。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
…-comment-record-authz
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
`check:slot-lookup` 报 audit-plugin.ts 的擦除计数 1 → 2:文件的祖父豁免只
保存量点位,新点位必须带 slot 的契约类型。
- `ctx.getService<ISharingService>('sharing')` —— 用真的契约接口。
- `CommentSharingLike` 从本地手写接口改为 `Pick<ISharingService, 'canEdit'>`:
"只用 canEdit"这个窄面是有意的,再手写一份它的形状不是(PD #12,一份契约
不要方言);`callerContext` 的返回类型同步换成 `SharingExecutionContext`。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
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 #4630
问题
attachment 的可见性由父记录派生,comment 什么都不派生。同一条记录、同一个用户,两者答案不同 —— issue 正文的三条实测里,读不到 opportunity 的
rep2依然能列出它下面的评论、能 POST 一条(连"crm_opportunity:"这种空 id 的悬空 thread 也 201),guest_portal(契约是"什么都读不到")同样读得到。根因:
sys_comment是 public、没有 owner 列、父记录藏在thread_id字符串里,所以 OWD/sharing 的静态谓词和 RLS 从来没有收窄过它;唯一的 feed 侧网关enforceFeedsCapability只看enable.feeds开关,从不看记录。而enable.feeds是 opt-out(spec 默认true),于是每个应用的每个对象都挂着这条组织级可读可写的旁路。做法:照 attachment 的两件套,不发明新形状
AuditPlugin现在装上service-storage给sys_attachment装的同一套东西,按thread_id的{object_name}:{record_id}解析父记录。新文件packages/plugins/plugin-audit/src/comment-access-hooks.ts与attachment-access-hooks.ts同构(同样的 duck-typed engine seam、同样的 system/无 session 绕过、同样的 deny-all 哨兵、同样的扫描上限)。读侧
installCommentReadVisibility——find/findOne/count/aggregate中间件(不是 find hook:只有中间件能让count()与find()被同样过滤,否则列表total会泄露隐藏行的存在)。每次读:系统上下文预扫候选thread_id→ 按父对象分组 → 用调用者上下文探测哪些父记录真的读得到(父对象自己的 OWD/sharing/RLS/对象级 CRUD 说了算,这正是guest_portal那条被翻转的原因)→ AND 上{ thread_id: { $in: <可见 thread> } }。thread id 一律原样保留、绝不由分量拼回,所以发出的过滤器只可能命中预扫真的见过的行。写侧
installCommentAccessHooks:beforeInsertauthor_id盖章beforeUpdatethread_id,新 thread 还要再过一遍 insert 规则beforeDelete两处需要维护者过目的判断
1. insert 用"可读"而不是 attachment 的
canEdit。 PM 的裁定要求以 attachment 的既有选择为默认、如认为该更宽/更严就在 PR 里论证 —— 这里选了更宽,三条理由:canEdit会把只读份额的用户挡在门外(fixture 里的cmt_readonly:public_read,人人可读、仅 owner 可改),而"能看见的记录就能讨论"是协作面的基本语义(Salesforce Chatter 也是 read 即可发帖)。attachment 更严是有原因的:往记录上挂文件是在动它的内容面,#2970 item 3明确按 Salesforce 取了 edit;评论不是。sharing.canEdit对 public OWD 的对象一律返回true,且完全不看权限集;guest_portal这类"读不到任何东西"的集合正是被对象级 CRUD 拒的,只有调用者上下文的一次真读能看见。用canEdit反而会在 public 对象上放行"读不到却能评论"。2. 加了
beforeUpdate(PM 只点名了 insert 与 delete)。 update 是 issue 标题里"writes comments on records they cannot see"的一半:不管它,任何成员都能改写任意记录下的任意评论,并且能把自己的评论搬到读不到的记录上(所以thread_id改动要再过 insert 规则)。规则复用 delete 的,没有第三套策略。若认为越界,删掉这个 hook 不影响其余部分。fail closed 的边界
thread_id解析不出记录 —— 悬空空 id(issue 正文那条 POST)、自由文本 thread、指向sys_comment自身(它不是记录级父亲,回复走parent_id;探测它还会让中间件自我重入)—— 写入拒绝、读取排除。过滤器算不出来(父对象探测抛错、预扫抛错)一律 deny-all。无 id 且无where的多行写入直接拒绝,而不是当成"没有行要授权"(attachment 那边的同一处缺口另行开了 #4757)。错误码:用标准目录,不新造同义词
拒绝统一答 403
RECORD_NOT_ACCESSIBLE(StandardErrorCode,注释就是 "Sharing rule restriction"),而不是对称的COMMENT_PARENT_ACCESS/COMMENT_DELETE_DENIED。两个原因:ADR-0112 的账本文档明写 "If the condition is generic (not found / permission / validation / rate limit), use the standard catalog instead of registering a synonym",而"你对这条 thread 背后的记录没有访问权"正是通用权限条件;并且本任务约束packages/spec/**零改动,新扩展码必须登记进ERROR_CODE_LEDGER才能通过信封一致性套件。顺带把一个从未被任何生产者发出过的已声明标准码变成了真的会发出的码。packages/rest加了一个映射分支,理由与 attachment/feeds 两个网关一致:error.object报的是记录的对象名,通用 4xx 透传会把它换成路由上的sys_comment。未改动(正交)
enforceFeedsCapability(enable.feeds→FEEDS_DISABLED):它管"这个对象有没有评论",与记录级授权正交,fixture 里的cmt_nofeeds钉住它行为不变。packages/spec/**、content/docs/releases/:零改动。测试
packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.tspackages/plugins/plugin-audit/src/comment-access-hooks.test.tspackages/plugins/plugin-audit/src/comment-read-visibility.test.tsaudit-plugin.test.ts新增两例(注册了什么 + 挂上去的 hook 真的会拒)packages/rest/src/rest.test.ts新增两例可回退证明:把
AuditPlugin里两处 install 临时关掉重跑 dogfood,10 条里 7 条转红((a)列表泄露、(b)写入 403 变成放行、(c)悬空 thread、(d)三条、(e)author 盖章),另 3 条是本来就该保持不变的对照(feeds 网关、匿名 401、total 一致)。这些测试不是装饰。关掉网关重跑时还暴露了一件事:在
main上,不带author_id的 POST 会被 400VALIDATION_FAILED挡下,也就是说客户端必须自己填 author —— 而它爱填谁就填谁。现在服务端盖章,冒充这条路一并关掉了(changeset 里记了这个行为差异)。顺手记录的越界发现(已单独立 issue,本 PR 不修)
sys_comment.visibilityandsys_comment.reply_countare declared but nothing anywhere reads or maintains them (ADR-0049 enforce-or-remove) #4756 ——sys_comment.visibility/reply_count声明了但全仓无人读写(visibility尤其危险:它看起来像访问控制开关)。sys_attachment.share_type/visibility在 [attachments] v1 follow-ups: sys_file orphan lifecycle + non-admin permission matrix dogfood #2755 里是被删掉的,同一论证在本 PR 之后原样适用。installAttachmentAccessHooksdoes not authorize an UNSCOPED multi-delete: no id + nowherereads as "nothing to authorize" anddeleteManyruns over the whole table #4757 —— attachment 的beforeDelete在"无 id 且无 where"时把"没查到行"当成"没有行要授权"而放行,deleteMany会拿到无谓词 AST。本 PR 的 comment 网关在这里 fail closed。🤖 Generated with Claude Code
https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
Generated by Claude Code