Repository navigation
sys_comment has no record-level authorization: any org member reads and writes comments on records they cannot see #4630
Description
Found while dogfooding enable.feeds for objectstack-ai/hotcrm#602 (HotCRM, @objectstack/* 17.0.0-rc.1). Filed unassigned.
What happens
Attachments derive their visibility from the parent record. Comments do not derive anything. On the same record, with the same user, the two answer differently:
user: rep2 (sales_rep permission set, does NOT own and cannot read the opportunity)
GET /api/v1/data/crm_opportunity?$filter=["id","=","1A7nlQpfEhWxIaeX"] → 200, 0 rows
GET /api/v1/data/sys_attachment?$filter=["parent_id","=","1A7nlQpfEhWxIaeX"] → 200, 0 rows
GET /api/v1/data/sys_comment?$filter=["thread_id","=","crm_opportunity:1A7nlQpfEhWxIaeX"]
→ 200, 1 row:
"Dogfood #602: kickoff call booked with Apex procurement — MSA draft attached above."
The same query as the app's guest_portal holder — a permission set whose stated contract is "reads nothing, INSERT-only on lead/case" — returns the same row.
Writes are equally ungated:
POST /api/v1/data/sys_comment (as rep2, on a record rep2 cannot read)
{ "thread_id": "crm_opportunity:<id>", "body": "rep2 should not be here" }
→ 201 Created
Anonymous is correctly refused (401 UNAUTHENTICATED) on both objects — this is an authenticated-user boundary, not an anonymous leak.
Why
service-storage installs two things for sys_attachment:
installAttachmentAccessHooks—beforeInsertrequiressharing.canEdit(parent_object, parent_id),beforeDeleterequires uploader-or-parent-editor;installAttachmentReadVisibility— afind/findOne/count/aggregatemiddleware that rewrites the AST to intersect with the parents the caller can actually read, failing closed to a deny-all filter.
sys_comment has no counterpart. The only feed-side enforcement is plugin-audit's enforceFeedsCapability, which inspects thread_id's object prefix and throws FEEDS_DISABLED only when the target object declares enable.feeds: false. Access to the record behind the thread is never consulted, on read or on write. The liveness ledger entry for props.enable.children.feeds describes exactly this and nothing more, so the gap looks like an omission rather than a decision:
enforceFeedsCapabilitybeforeInsert hook: explicitfeeds:falseon the target object rejectssys_commentcreation, 403FEEDS_DISABLED
Why it matters
enable.feeds is opt-out (spec default true), so every object in every app has comments on by default. A deployment that carefully authors OWD, sharing rules and RLS on its records still has an org-wide readable side-channel hanging off each of them, and the record feed is exactly where people paste the reasons — pricing approvals, legal positions, personnel notes. It also makes the flag itself misleading: an app can be told "comments follow record access" by analogy with attachments, and nothing contradicts that until an audit.
Suggested shape (not a decision, just the symmetric option)
Mirror what attachments already do, keyed off thread_id's <object>:<recordId> split:
beforeInsertonsys_comment→ require read (posting on a record you cannot see is never right; whether it should require edit is the open question — attachments chose edit).- A read middleware that intersects
sys_commentqueries with visible thread parents, fail-closed, same ascomputeParentVisibilityFilter. beforeUpdate/beforeDelete→ author-or-parent-editor.
Two sub-questions worth deciding explicitly rather than inheriting:
- Threads whose prefix is not a record (
thread_idwith no:; today the capability hook simply returns). Fail open or fail closed? - Post authority = read or edit? A read-only collaborator commenting is a plausible product decision; silently allowing a user with no access at all is not.
Repro
Any 17.x stack with two users, one permission set granting readScope: 'own' on an object, and a record owned by the other user. Post a comment as the owner through the console's Discussion panel, then query sys_comment filtered on that thread_id as the non-owner.
Note on the neighbouring path (separate, smaller)
While confirming the above I found that authorizeDownload in service-storage gates only when file.scope === "attachments" or the file is field-owned:
const fieldOwned = !!file.ref_object && file.ref_id != null && file.ref_id !== "";
const gated = file.scope === "attachments" || fieldOwned;
if (!gated || file.acl === "public_read" || !opts.authorizeFileRead) return presignedTtl;A file uploaded through POST /storage/upload/presigned without scope: "attachments" (the default is "user") and then linked with a sys_attachment row is therefore attached to a record but not gated: GET /storage/files/:fileId/url answers 200 — including unauthenticated — and the signed URL serves the bytes. The console always sends scope: "attachments" so its own path is correctly gated (verified: 401 anonymous, 403 for a user without record access, 200 for one with it). The exposure needs the file UUID, so it is not a browsing hole; but the gate keying off the file's declared scope rather than "is there a sys_attachment row for this file" means a hand-rolled API client can opt out of governance by omitting a field. Happy to split this into its own issue if you'd rather track it separately.
Activity
os-zhuang commentedon Aug 3, 2026
🔒 认领 —— 第 2 轮(发布窗口 bug 批)
session_015Br2xsJsczFsTR9bvbh2Ny(PM 循环)claude/issue-4630-sys-comment-record-authzobjectstack-issue-4630PM 裁定方向(维护者可否决):正文的「对称方案」成立 —— attachment 已经用两件套回答了同一个问题(
installAttachmentAccessHooks写入侧 +installAttachmentReadVisibility读侧 AST 改写、fail closed 到 deny-all),sys_comment按thread_id前缀解析 parent 后照同一形状做。这是既有规范决定的,不是新设计。约束:
enforceFeedsCapability(plugin-audit)不动 —— 它管enable.feeds开关,与记录级授权正交。guest_portal这类 INSERT-only 集也要有用例。回归:attachment 的既有行为不许变。packages/spec/**零改动;⛔content/docs/releases/不碰;资源纪律照旧(flock + 堆上限 + --filter)。验收:正文里的三条实测(rep2 读、rep2 写、guest_portal 读)全部翻转为拒绝;有测试钉住;attachment 行为回归绿。
Generated by Claude Code
os-zhuang commentedon Aug 3, 2026
✅ PM 验收通过 —— PR #4758,CI 全绿(19 success + 2 skipped,0 失败),已转 ready 并入合并队列
复核对着 GitHub:
cmt_private:POST、什么都读不到的 principal),读 0 行 / 写 403 / 悬空拒绝;count 与 find 同滤,列表total不泄露隐藏行的存在sharing.canEdit对 public OWD 恒 true 且不看权限集,调用者上下文真读更严而非更宽;beforeUpdate是 issue 标题的另一半,补上是补漏author_id(填谁都行),现在服务端按会话盖章,changeset 记录RECORD_NOT_ACCESSIBLE),不造同义词;attachment 行为零改动(service-storage 252 测试全绿,一行未动)159 unswept, none new),未走豁免表场外发现处置
installAttachmentAccessHooksdoes not authorize an UNSCOPED multi-delete: no id + nowherereads as "nothing to authorize" anddeleteManyruns over the whole table #4757(attachmentbeforeDelete对「无 id 无 where」fail-open,可达无谓词deleteMany)→ bug + security 入队 —— 本会话暂停,留下轮派发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死键,[attachments] v1 follow-ups: sys_file orphan lifecycle + non-admin permission matrix dogfood #2755 的论证逐字适用)→ spec 车道退役件,留对侧Generated by Claude Code
fix(plugin-audit,rest)!: sys_comment 的访问权从 thread_id 指向的记录继承 (objects…
fix(service-storage): refuse an unscoped multi-delete of sys_attachme…
feat(plugin-audit)!: retire sys_comment.visibility / reply_count (obj…
fix(approvals): hold the record lock for predicate (multi) updates (o…
chore: bump objectui to 665661a
fix(plugin-audit): restore the #4630 unscoped multi-delete refusal on…
fix(plugin-audit): ask enable.files/enable.feeds on the update verb too
test(plugin-audit): pin the update-verb capability gates; re-point the …
fix(plugin-audit): restore the objectstack-ai#4630 unscoped multi-del…
feat(objectql,plugin-audit): refuse an unscoped multi-UPDATE on the s…
fix(plugin-audit): ask enable.files/enable.feeds on the update verb t…
fix(approvals): the record-lock refusal names the record, not its pri…
docs(plugin-audit): re-anchor the dead tracker citations to the commi…
docs(metadata-protocol): re-anchor the dead tracker citations to the …