Skip to content

fix(plugin-audit,rest)!: sys_comment 的访问权从 thread_id 指向的记录继承 (#4630) - #4758

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-4630-sys-comment-record-authz
Aug 3, 2026
Merged

fix(plugin-audit,rest)!: sys_comment 的访问权从 thread_id 指向的记录继承 (#4630)#4758
os-zhuang merged 3 commits into
mainfrom
claude/issue-4630-sys-comment-record-authz

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

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-storagesys_attachment 装的同一套东西,按 thread_id{object_name}:{record_id} 解析父记录。新文件 packages/plugins/plugin-audit/src/comment-access-hooks.tsattachment-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:

动作 规则
beforeInsert 对父记录可读即可评论,并按会话给 author_id 盖章
beforeUpdate 评论作者本人,或对父记录有 EDIT;若 payload 改了 thread_id,新 thread 还要再过一遍 insert 规则
beforeDelete 同上(attachment 的 uploader-or-parent-editor 规则)

两处需要维护者过目的判断

1. insert 用"可读"而不是 attachment 的 canEdit PM 的裁定要求以 attachment 的既有选择为默认、如认为该更宽/更严就在 PR 里论证 —— 这里选了更宽,三条理由:

  • 验收标准本身这么写:"可读 parent 的用户:读写正常"。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 对象上放行"读不到却能评论"。
  • 改写/删除别人的话是审核,不是协作,所以那两个动词保持 attachment 的严格规则(作者或 parent editor)。读→评论、编辑→审核,这条分界是这次唯一的语义主张。

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.feedsFEEDS_DISABLED):它管"这个对象有没有评论",与记录级授权正交,fixture 里的 cmt_nofeeds 钉住它行为不变。
  • 匿名 401:在这一切之前发生,dogfood 里有断言。
  • packages/spec/**content/docs/releases/:零改动。
  • attachment 的既有行为与测试:一条没动。

测试

文件 结果
端到端(真 HTTP + better-auth 成员 + sharing/security) packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts 10 passed
写侧单测 packages/plugins/plugin-audit/src/comment-access-hooks.test.ts
读侧单测(含 Filter Protocol 求值器自检,照 attachment 的 harness) packages/plugins/plugin-audit/src/comment-read-visibility.test.ts
挂载点 audit-plugin.test.ts 新增两例(注册了什么 + 挂上去的 hook 真的会拒)
REST 映射 packages/rest/src/rest.test.ts 新增两例
pnpm --filter @objectstack/plugin-audit test        Test Files 6 passed (6)   Tests 89 passed (89)
pnpm --filter @objectstack/plugin-audit typecheck   (clean)
pnpm --filter @objectstack/rest test                Test Files 37 passed (37) Tests 561 passed (561)
pnpm --filter @objectstack/service-storage test     Test Files 19 passed (19) Tests 252 passed (252)   ← attachment 回归
dogfood: comments-permission-matrix                 Test Files 1 passed (1)   Tests 10 passed (10)
dogfood: attachments-permission-matrix + membership Test Files 2 passed (2)   Tests 21 passed | 1 skipped
node scripts/check-type-check-coverage.mjs          OK — 60/77 …

可回退证明:把 AuditPlugin 里两处 install 临时关掉重跑 dogfood,10 条里 7 条转红((a) 列表泄露、(b) 写入 403 变成放行、(c) 悬空 thread、(d) 三条、(e) author 盖章),另 3 条是本来就该保持不变的对照(feeds 网关、匿名 401、total 一致)。这些测试不是装饰。

关掉网关重跑时还暴露了一件事:main 上,不带 author_id 的 POST 会被 400 VALIDATION_FAILED 挡下,也就是说客户端必须自己填 author —— 而它爱填谁就填谁。现在服务端盖章,冒充这条路一并关掉了(changeset 里记了这个行为差异)。

顺手记录的越界发现(已单独立 issue,本 PR 不修)

🤖 Generated with Claude Code

https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny


Generated by Claude Code

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
@vercel

vercel Bot commented Aug 3, 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 3, 2026 2:53am

Request Review

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-audit, @objectstack/dogfood, @objectstack/rest.

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

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest)
  • content/docs/deployment/cli.mdx (via @objectstack/plugin-audit)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/plugin-audit)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest)
  • content/docs/permissions/authorization.mdx (via packages/qa/dogfood)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa/dogfood)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-audit, @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-audit, @objectstack/rest)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest)

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.

`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
@os-zhuang
os-zhuang marked this pull request as ready for review August 3, 2026 02:59
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit be90dea Aug 3, 2026
22 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-4630-sys-comment-record-authz branch August 3, 2026 03:11
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sys_comment has no record-level authorization: any org member reads and writes comments on records they cannot see

2 participants