Skip to content

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

Closed

Description

@os-zhuang

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 — beforeInsert requires sharing.canEdit(parent_object, parent_id), beforeDelete requires uploader-or-parent-editor;
  • installAttachmentReadVisibility — a find/findOne/count/aggregate middleware 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:

enforceFeedsCapability beforeInsert hook: explicit feeds:false on the target object rejects sys_comment creation, 403 FEEDS_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:

  1. beforeInsert on sys_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).
  2. A read middleware that intersects sys_comment queries with visible thread parents, fail-closed, same as computeParentVisibilityFilter.
  3. beforeUpdate/beforeDelete → author-or-parent-editor.

Two sub-questions worth deciding explicitly rather than inheriting:

  • Threads whose prefix is not a record (thread_id with 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

  1. self-assigned this
    on Aug 3, 2026
  2. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    🔒 认领 —— 第 2 轮(发布窗口 bug 批)

    • 会话:session_015Br2xsJsczFsTR9bvbh2Ny(PM 循环)
    • 分支:claude/issue-4630-sys-comment-record-authz
    • Worktree:objectstack-issue-4630

    ⚠️ 共用身份,session ID 不符即本单已被占,请另选。

    PM 裁定方向(维护者可否决):正文的「对称方案」成立 —— attachment 已经用两件套回答了同一个问题(installAttachmentAccessHooks 写入侧 + installAttachmentReadVisibility 读侧 AST 改写、fail closed 到 deny-all),sys_comment 按 thread_id 前缀解析 parent 后照同一形状做。这是既有规范决定的,不是新设计。

    约束:

    • 读侧必须 fail closed(解析不出 parent / sharing 不可用 → deny-all filter,照 attachment 的形状);写侧 insert 要求对 parent 可编辑或按 attachment 的等价规则,delete 要求作者或 parent 编辑者。
    • enforceFeedsCapability(plugin-audit)不动 —— 它管 enable.feeds 开关,与记录级授权正交。
    • 匿名 401 行为已正确,别碰。
    • 测试要双向:rep2(不可读 parent)读 0 行、写 403;可读 parent 的用户读写正常;guest_portal 这类 INSERT-only 集也要有用例。回归:attachment 的既有行为不许变。
    • ⛔ packages/spec/** 零改动;⛔ content/docs/releases/ 不碰;资源纪律照旧(flock + 堆上限 + --filter)。

    验收:正文里的三条实测(rep2 读、rep2 写、guest_portal 读)全部翻转为拒绝;有测试钉住;attachment 行为回归绿。


    Generated by Claude Code

  3. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    ✅ PM 验收通过 —— PR #4758,CI 全绿(19 success + 2 skipped,0 失败),已转 ready 并入合并队列

    复核对着 GitHub:

    • 正文三条实测全部翻转:dogfood 以 fixture 复现(私有记录上的 rep2 等价成员、悬空 cmt_private: POST、什么都读不到的 principal),读 0 行 / 写 403 / 悬空拒绝;count 与 find 同滤,列表 total 不泄露隐藏行的存在
    • 可回退证明:关掉两处 install 重跑,10 条里 7 条转红、3 条对照(feeds 门 / 匿名 401 / total 一致)保持绿 —— 测试不是装饰
    • 两处判断采纳(已在 PR 预审时裁定):insert 用「可读」—— sharing.canEdit 对 public OWD 恒 true 且不看权限集,调用者上下文真读更严而非更宽;beforeUpdate 是 issue 标题的另一半,补上是补漏
    • 附带关掉了 author 冒充:main 上客户端必须自己填 author_id(填谁都行),现在服务端按会话盖章,changeset 记录
    • 错误码走 ADR-0112 标准目录(RECORD_NOT_ACCESSIBLE),不造同义词;attachment 行为零改动(service-storage 252 测试全绿,一行未动)
    • 棘轮红一轮:新槽位查找补契约类型修复(159 unswept, none new),未走豁免表

    场外发现处置


    Generated by Claude Code

  4. added a commit that references this issue on Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions