Skip to content

fix(plugin-sharing): 谓词式(multi)写入重算共享规则 —— 批量更新后 sys_record_share 不再陈旧 (#4779) - #5102

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-4779-sharing-bulk-recompute
Aug 4, 2026
Merged

fix(plugin-sharing): 谓词式(multi)写入重算共享规则 —— 批量更新后 sys_record_share 不再陈旧 (#4779)#5102
os-zhuang merged 2 commits into
mainfrom
claude/issue-4779-sharing-bulk-recompute

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #4779

按维护者 2026-08-04 的裁定实现方案 C:超上界时同步撤销、异步重发;不退 A,不实现 B。

第一步:异步重入执行路径的核实证据

裁定要求「dev 第一步核实异步执行路径(job/queue/boot backfill 类)是否存在且可靠 —— 存在则实现 C,不存在则实现 A 并在 PR body 写明核实证据」。核实结果:存在,因此实现 C。证据三条:

  1. IJobService 存在但不可靠 —— 契约在 packages/spec/src/contracts/job-service.ts(schedule / trigger),实现是 packages/services/service-jobJobServicePlugin。但它是可选能力:packages/cli/src/commands/serve.tsCAPABILITY_PROVIDERSjobsharing 列为两个互不依赖的 token,plugin-sharing/package.json 也不依赖 @objectstack/service-job。把重发路由过去,等于让「共享规则最终一致」这条保证在装了 job 的编排里成立、在没装的编排里静默不成立 —— 正是 PD chore: version packages #10 要禁的「声明 ≠ 强制」。所以没有采用它。
  2. plugin-sharing 自己就有 boot backfill,而且是裁定点名的那一类 —— sharing-plugin.tskernel:bootstrapped 钩子跑 backfillRuleGrants,对每一条规则(sharing: deactivating a rule never withdraws its materialized grants — not on touch, not at boot #4433 起含 inactive)跑 evaluateRule;evaluateRule 是 diff 式的、幂等的。这就是补偿执行者:异步重发若因崩溃丢失,下次启动原样修复。
  3. 另有两条既有的自愈入口 —— 任何 sys_sharing_rule 写入都会触发 bindRuleRebindTriggers 里的 reconcile;sweepOrphanedRuleGrants 在每次 boot 扫孤儿。

所以异步那一半用进程内串行队列(RuleRegrantQueue),不引入新依赖、不随编排变化,持久性由上面第 2 条兜底。失败方向是安全的:重发挂了是有人暂时少看见东西(会被报障),不是有人多看见(不会被报障)。

缺陷

rule-hooks.ts 的处理函数第一步就用单条 id 定位要重算的行:

const id = String(data?.id ?? ctx?.input?.id ?? '');
if (!id) return;

ObjectQL.update() 只在 where.id 是标量时填 input.id。谓词式(multi: true)更新走 updateMany,input.id 为 undefined,input.data 里也没有 id —— 于是批量写入一次都不重算。后果是授权侧的 fail open:基于 criteria 的规则发过 sys_record_share,管理员批量把这些记录改成不再匹配,重算没发生,共享行原样留着继续授权。反向(批量改成匹配却不发共享)同样断着。

改法

入口从「单条 id」换成「本次写入的行集合」。beforeUpdate / beforeDelete 用谓词解析出受影响的 id 并暂存到共享 hook ctx —— 必须在 before,因为写入本身就是让那些行变得查不到的那件事(primary-bu-projection.tsSTASH_KEY 同款,engine 的 before/after 复用同一个 HookContext 实例)。after 钩子再据此动作:

行集合 做法
有界(≤ RULE_RECOMPUTE_ROW_CAP = 1000) 逐行 evaluateAllForRecord,同步。diff 式,所以两个方向都覆盖:移出 criteria 的撤销,移入的发放。
无界(超上界 / multi 且完全没有 where / 解析本身失败) 同步集合式撤销该对象所有 source:'rule' 的共享(一条语句,没有上界问题),再异步evaluateAllRulesForObject 把该有的补回来。

写入永不被拒绝。 拒绝会把一个内部重算上界泄漏成「管理员一次能改多少行」的业务语义,而报错来自他从未配置过的子系统。它交易的不对称是:多给权限是安全事故,少给权限是可用性抖动 —— 所以安全那一半永远同步且完整,只有昂贵的恢复那一半异步。

上界 1000 沿用同族守卫的既有先例(service-storage attachment hooks 的 MULTI_DELETE_AUTH_LIMIT,#4757;#4630sys_comment resolve)。

三个刻意的选择

  • 无界撤销按 object_name 整体撤,不按 record_id: {$in: [...]} 缩小。 缩小需要完整 id 列表,而「拿不到完整 id 列表」正是走进这个分支的原因 —— 缩小会把上界重新塞回唯一一个本来没有上界的操作里。牵连未被写入的行是刻意的,方向安全:多几行暂时少看见,没有一行保住本该失去的权限。
  • 同步撤销放在 afterUpdate 而不是 beforeUpdate 放在 before 的话,一次因校验失败而整批回滚的写入会白白撤掉几千行的共享,而没有任何东西触发重发(after 钩子不会跑)。放在 after,撤销仍在 update() 返回给调用方之前完成 —— 对调用方而言就是同步的。
  • 「解析失败」当作无界处理,不当作「零行」。 这是 installAttachmentAccessHooks does not authorize an UNSCOPED multi-delete: no id + no where reads as "nothing to authorize" and deleteMany runs over the whole table #4757 自己的教训:「什么都没查到」和「查询压根没跑成」不是同一个判决,把后者读成前者就是 fail open。

一并修掉的孤儿行(issue 末尾记的)

afterDelete 补上了,撤销被删记录的规则共享。别的路径够不到它们:evaluateRule 遍历的是还存在的记录,所以记录一没,它发出的共享行就脱离了每一条 reconcile 路径,并且能活过重启。今天危害有限的前提是 id 不可复用 —— 那是个没有任何门禁保护的假设,所以边际成本很小的时候就该关掉。

SharingRuleService 新增 revokeRuleGrantsForObject / revokeRuleGrantsForRecords / evaluateAllRulesForObject。集合式删除都带 multi: true —— 这不是装饰:resolveEngineDeleteDispatch 会拒绝没有声明批量意图的谓词删除,正是 #4434 里让每个 DELETE /sharing/rules/:id 都 500 的那个形状。三个方法都只碰 source: 'rule',手工共享一行不动。

测试

新增 bulk-recompute.test.ts,24 例。fake engine 复现了这个修复真正依赖的两处管道语义:before/after 共用同一个 HookContext,以及谓词更新不填 input.id;它的 delete 调用 assertEngineDeleteDispatch,所以集合式撤销是按真实 engine 的判决验的(check:engine-double-contract 已自动把这个文件收进 pinned 名单)。

revert-proof 已实测:把 afterUpdate 换回旧的 if (!id) return,24 例中 7 例转红,包括正面复现那条(共享行从 0 变回 2)。改回后全绿。

 Test Files  12 passed (12)          # pnpm --filter @objectstack/plugin-sharing test
      Tests  267 passed (267)
> tsc --noEmit                       # pnpm --filter @objectstack/plugin-sharing typecheck (clean)
 Test Files  83 passed | 1 skipped (84)   # @objectstack/dogfood 全量(真实 booted stack)
      Tests  483 passed | 3 skipped (486)

仓内门禁:check-engine-double-contract OK(12 pinned,含本文件)、check-durability-degradation-log-level OK、check-startup-registry-verdict OK、改动文件 eslint 干净。

rule-rebind.test.ts 里三处硬编码的「每对象 2 个钩子」改成具名常量 RULE_HOOKS_PER_OBJECT = 5 —— 那几例断言的是 rebind 记账(绑上 → 解绑 → 重绑),不是需要哪些事件,不该看起来像后者变了。

范围

packages/spec/** 零改动,content/docs/releases/ 零改动,packages/objectqlpackages/services/** 零改动(只读地核实了 engine 的 hook 语义与 job 服务的可选性)。改动全部在 packages/plugins/plugin-sharing/** 加一个 changeset。


Generated by Claude Code

claude added 2 commits August 4, 2026 04:33
…ites (#4779)

`bindRuleHooks` located the rows to recompute from a single record id
(`if (!id) return`), and `ObjectQL.update()` only populates `input.id` for a
scalar `where.id`. A predicate write routes to `updateMany` and carries no id,
so every bulk write skipped sharing-rule recompute entirely: records bulk-moved
out of a rule's criteria kept the `sys_record_share` rows the rule had issued,
and their recipients kept access the rules no longer implied. Fail-open on the
authorization side; same family as #4757 and #4778.

Keyed off the write's ROW SET instead of one id. `beforeUpdate`/`beforeDelete`
resolve the affected rows from the predicate and stash them on the shared hook
context (the before hook is where it must happen — the write is what makes those
rows unfindable); the after hook acts on them.

Per the maintainer's ruling (option C):

  - bounded set (<= RULE_RECOMPUTE_ROW_CAP = 1000) -> per-row
    `evaluateAllForRecord`, synchronous, diff-based so both directions are
    covered (out of the criteria revokes, into it grants);
  - unbounded set (over cap / `multi` with no `where` / failed resolve) ->
    synchronous set-based revoke of the object's rule grants, then asynchronous
    re-grant via `evaluateAllRulesForObject`.

The write is never refused: that would leak an internal recompute bound out as a
business limit on how many rows an admin may update. The asymmetry it trades on
is that over-granting is a security incident while under-granting is an
availability wobble, so the safety half is always synchronous and complete and
only the expensive restoration half is deferred. The re-grant is in-process
rather than routed through the OPTIONAL `IJobService`, which would make the
guarantee composition-dependent; durability comes from the plugin's existing
`kernel:bootstrapped` backfill, which re-runs the same idempotent reconcile.

Also binds `afterDelete` and retires the deleted records' rule grants (the
orphan noted at the tail of the issue). Nothing else could reach them:
`evaluateRule` iterates records that still exist, so a grant whose record is
gone outlived every reconcile path and every restart.

New on SharingRuleService: revokeRuleGrantsForObject, revokeRuleGrantsForRecords,
evaluateAllRulesForObject. Manual shares are never touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t
@vercel

vercel Bot commented Aug 4, 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 4, 2026 4:36am

Request Review

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing.

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

  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-sharing)
  • content/docs/permissions/authorization.mdx (via packages/plugins/plugin-sharing)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-sharing)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-sharing)
  • content/docs/protocol/objectql/security.mdx (via packages/plugins/plugin-sharing)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-sharing)

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.

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.

共享规则 hook 对谓词式(multi)写入不重算:if (!id) returnsys_record_share 授权在批量更新后变陈旧

2 participants