Repository navigation
fix(sharing): 共享授权面自身无鉴权——任何登录用户可撤销他人的共享;且 edit 档共享同时放行删除 #3902
Description
在 #3865 / #3901(摘除 full 访问级别)的评估过程中发现两处独立问题,都不属于那次修复的范围。二者共同的根因是共享层缺少"谁有权管理共享"和"哪些动词属于哪一档"的模型——#3901 修的是共享档位的表述,这里是共享的授权与语义边界。
按影响排序。
1. /shares 端点完全没有鉴权(P0,已端到端实测)
GET / POST / DELETE /api/v1/data/:object/:id/shares 三个路由的 handler 只调用 enforceAuth(rest-server.ts:1136 —— 仅检查 auth-gate 与匿名拒绝,不做任何对象级或记录级权限判断),随后直接把请求交给 SharingService,而该服务内部一律以 SYSTEM_CTX 读写 sys_record_share(sharing-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 ?? {});update 与 delete 共用一个 canEdit 门,而 canEdit 接受 edit 档共享。也就是说一次"编辑"共享同时授予了删除(前提是调用者的对象级 CRUD 里有 delete)。
主流平台不是这样:
- Salesforce —— Read/Write 共享明确不能删除;删除保留给所有者、角色层级上级、Modify All。
- Dataverse ——
Delete是与Write并列的独立特权,共享掩码里也是独立位。 - Odoo —— record rules 的
write与unlink是两个独立布尔。
即"编辑"和"删除"在业界是不同的动词,共享一个门是语义收缩。#3901 已把 full 那个更糟的谎言摘掉(它宣称授予删除却什么都没给);这一条方向相反——给得比说的多。
这是破坏性变更:把删除从 edit 档拿走,会让现有依赖此行为的部署失去删除能力。需要独立 ADR 决定:
- 是否新增独立的删除档 / 能力位(Dataverse 式掩码),还是删除彻底只归所有权与 DEPTH 范围;
- 迁移期怎么走(先告警观察,还是直接收紧)。
关联
- fix(sharing): 「完全访问」声明了删除/转移/共享,实现上与「编辑」等价 #3865 / fix(sharing)!: remove the
fullaccess level — it promised delete/transfer/share and grantededit(#3865) #3901 —— 摘除full档;该 PR 描述里把这两条记为 "后续(不在本 PR 范围)"。 - 第 1 条与 fix(sharing)!: remove the
fullaccess level — it promised delete/transfer/share and grantededit(#3865) #3901 中"再共享语义缺地基"的结论直接相关。 - ADR-0057(DEPTH 范围)、ADR-0090(权限模型 v2)是第 2 条的设计上下文。
复现环境
main @ f00d8d4(#3901 合并后),pnpm dev:crm -- --fresh,SQLite driver,单租户。第 1 条的①②与第 2 条的代码路径与 #3901 无关,在该 PR 之前即存在。
Activity
docs(adr): ADR-0111 — record-share management authority and the verb …
fix(sharing)!: give the share-management surface the authorization la…
os-zhuang commentedon Jul 30, 2026
Resolved — both defects fixed (framework side complete)
Both issues reported here are fixed and merged to
main, under ADR-0111 (Record-share management authority and the verb boundary).The two defects
/shares(and/sharing/rules) had no authorization of their own — every route authenticated the caller then ran the service underSYSTEM_CTX. Fixed: record sharing gained a first-class management-authority model (canManageShares), enforced in the service so every caller is covered — the revoke/enumerate/self-grant/rule-define paths in the report all now return 403/404/409/422 instead of silently succeeding.edit-level share also granted delete —updateanddeleteshared onecanEditgate. Fixed: the verb boundary is now explicit — a share widens which rows a principal reaches, never which verbs;canDeleteis ownership + write-DEPTH + Modify All only, and noedit/fullshare confers it.While writing the ADR the same root cause turned up more exits, all closed in the same work: the
/sharing/rulessurface, inert non-userrecipient grants, grants on objects sharing never enforces, manual-vs-rule row clobbering, and the open/data/sys_record_shareread surface.Shipped PRs
edit≠delete(D3)Grounded against Salesforce / Dataverse / Odoo, which converge on both "who may manage a share" and "edit ≠ delete".
Deferred follow-up (not blocking this issue)
The cloud-side landing (
objectstack-ai/cloud: bump.objectstack-sha+security-enterpriseintegration tests) is intentionally deferred. Verified during this work that ADR-0111's cloud surface is clean —security-enterprisetypechecks and its suite passes green against the new framework. But the.objectstack-shabump is atomic and drags in the full 11949fc→1ea6bce framework range (~93 PRs), whose fallout is unrelated to sharing and touches security-sensitive control-plane paths, so it belongs to a separate framework-upgrade effort rather than this fix. Head-start notes for that effort:driver-turso/date-bucket-parity.test.ts— the "stores datetime as INTEGER epoch" precondition is stale post-[17.0.0-rc.0] SQLite datetime window filters return empty: filter comparands coerced to epoch-ms while writes store ISO TEXT #3912 (SQLiteField.datetimeconverged to canonical ISO TEXT); asserttext.service-cloud/test/control-plane-org-scope.integration.test.ts— 5 tests fail because 降级 analytics shim 把 ExecutionContext 丢在门口 ——/analytics/query在没装 service-analytics 的装配里不注入 RLS/租户谓词,契约字段where也被静默忽略 #3891 now 404s an object not registered in metadata (the harness onlydriver.creates rows) and findData 从请求体里取执行上下文 ——context.isSystem能整条跳过安全中间件(expand 本身已核实是干净的) #3960 drops caller-suppliedfindDatacontext; the harness needs the object schemas registered and the context supplied the server way.test:integration/boot-smokenot yet run against the new pin.Closing as completed — the reported defects are fixed on
main.Generated by Claude Code