Skip to content

fix(sharing): 共享授权面自身无鉴权——任何登录用户可撤销他人的共享;且 edit 档共享同时放行删除 #3902

Description

@os-zhuang

#3865 / #3901(摘除 full 访问级别)的评估过程中发现两处独立问题,都不属于那次修复的范围。二者共同的根因是共享层缺少"谁有权管理共享"和"哪些动词属于哪一档"的模型——#3901 修的是共享档位的表述,这里是共享的授权语义边界

按影响排序。


1. /shares 端点完全没有鉴权(P0,已端到端实测)

GET / POST / DELETE /api/v1/data/:object/:id/shares 三个路由的 handler 只调用 enforceAuthrest-server.ts:1136 —— 仅检查 auth-gate 与匿名拒绝,不做任何对象级或记录级权限判断),随后直接把请求交给 SharingService,而该服务内部一律以 SYSTEM_CTX 读写 sys_record_sharesharing-service.ts:37)。

结果是:共享这张安全表本身,不受任何访问控制保护。既没有"调用者能否看到这条记录",也没有"调用者是否有权共享/取消共享它"。

实测复现

pnpm dev:crm -- --fresh,用 POST /api/v1/auth/sign-up/email 自助注册一个普通用户 Mallory(无任何 position / permission set),目标是 admin 拥有的 crm_account 记录。

基线 —— Mallory 可读、不可写,符合预期:

GET   /data/crm_account/:id   → 200
PATCH /data/crm_account/:id   → 403 PERMISSION_DENIED (row-level security)

① 撤销他人的共享 —— 直接可利用,没有任何一层拦住:

# admin 给同事授予共享
admin  POST   /data/crm_account/:id/shares      → 201  (recipient=trusted_colleague)

# Mallory 用 share id 撤销它
mallory DELETE /data/crm_account/:id/shares/:sid → 204

# 复查:同事的共享没了
admin  GET    /data/crm_account/:id/shares      → recipient 列表中不再有 trusted_colleague

revoke(shareId) 只按 id 删除,既不校验调用者,也不校验该 share 是否属于这条记录。任何登录用户只要拿到(或枚举到)一个 share id,就能静默剥夺任意用户对任意记录的访问。 这是完整的越权写,无需任何权限。

② 枚举他人记录上的共享 —— 信息泄露:

mallory GET /data/crm_account/:id/shares → 200,列出该记录上全部共享行

泄露"谁能访问什么",同时也把 ① 所需的 share id 直接送到手上。

③ 给自己授权 —— 写入成功,但当前被另一层挡住:

mallory POST /data/crm_account/:id/shares
        {"recipientId":"<自己>","accessLevel":"edit"}   → 201
        → 落库 access_level=edit, granted_by=<Mallory 自己>

mallory PATCH /data/crm_account/:id                    → 仍然 403 (row-level security)

这里要说清楚,避免夸大:共享行确实被写进去了(未经授权的安全数据写入,且 granted_by 记为攻击者自己),但后续写入仍被 RLS 拒绝——拦住她的是 plugin-security 的行级安全,不是 sharing 层。sharing 层是会放行的。所以在 CRM 默认配置下③没有形成读写提权,纵深防御起了作用。

但这层防御是配置相关的、不是设计保证的:在以 sharing 为实际写入门的对象上(sharingModel: private / public_read 且无更严的 RLS 策略收窄),同一个自授就会真正授予编辑权。把安全依赖寄托在"另一层恰好也拦着"上不成立——①已经证明当那一层不存在时会发生什么。

建议方向

ISharingService 需要一个"共享管理权"的概念,并在 REST 层强制:

  • 读/列出 — 至少要求调用者对该记录有可见性(复用 buildReadFilter / canEdit)。
  • 授予 — 参照 Salesforce(记录所有者、其管理层级、Modify All 持有者可共享)与 Dataverse(需要该表的 Share 特权)。最小可行版本:所有者 + 具备该对象管理能力者。
  • 撤销 — 至少要求调用者当初有权授予它;并且校验 shareId 确实属于路径里的 (object, recordId)

注意这同时是 full 档"再共享"语义所缺的地基(#3865):在这个模型建立之前,"可再共享"根本无处安放。


2. edit 档共享同时放行删除(P1,设计分歧)

sharing-plugin.ts:613

if (op === 'update' || op === 'delete') {
  const ok = await service.canEdit(ctx.object, String(id), exec ?? {});

updatedelete 共用一个 canEdit 门,而 canEdit 接受 edit 档共享。也就是说一次"编辑"共享同时授予了删除(前提是调用者的对象级 CRUD 里有 delete)。

主流平台不是这样:

  • Salesforce —— Read/Write 共享明确不能删除;删除保留给所有者、角色层级上级、Modify All。
  • Dataverse —— Delete 是与 Write 并列的独立特权,共享掩码里也是独立位。
  • Odoo —— record rules 的 writeunlink 是两个独立布尔。

即"编辑"和"删除"在业界是不同的动词,共享一个门是语义收缩。#3901 已把 full 那个更糟的谎言摘掉(它宣称授予删除却什么都没给);这一条方向相反——给得比说的多

这是破坏性变更:把删除从 edit 档拿走,会让现有依赖此行为的部署失去删除能力。需要独立 ADR 决定:

  • 是否新增独立的删除档 / 能力位(Dataverse 式掩码),还是删除彻底只归所有权与 DEPTH 范围;
  • 迁移期怎么走(先告警观察,还是直接收紧)。

关联

复现环境

main @ f00d8d4#3901 合并后),pnpm dev:crm -- --fresh,SQLite driver,单租户。第 1 条的①②与第 2 条的代码路径与 #3901 无关,在该 PR 之前即存在。

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecurity

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions