fix(plugin-sharing): 谓词式(multi)写入重算共享规则 —— 批量更新后 sys_record_share 不再陈旧 (#4779) - #5102
Merged
Merged
Conversation
…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
…ring-bulk-recompute
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 4, 2026
Closed
os-zhuang
marked this pull request as ready for review
August 4, 2026 04:39
os-zhuang
enabled auto-merge
August 4, 2026 04:39
This was referenced Aug 4, 2026
Closed
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4779
按维护者 2026-08-04 的裁定实现方案 C:超上界时同步撤销、异步重发;不退 A,不实现 B。
第一步:异步重入执行路径的核实证据
裁定要求「dev 第一步核实异步执行路径(job/queue/boot backfill 类)是否存在且可靠 —— 存在则实现 C,不存在则实现 A 并在 PR body 写明核实证据」。核实结果:存在,因此实现 C。证据三条:
IJobService存在但不可靠 —— 契约在packages/spec/src/contracts/job-service.ts(schedule/trigger),实现是packages/services/service-job的JobServicePlugin。但它是可选能力:packages/cli/src/commands/serve.ts的CAPABILITY_PROVIDERS把job和sharing列为两个互不依赖的 token,plugin-sharing/package.json也不依赖@objectstack/service-job。把重发路由过去,等于让「共享规则最终一致」这条保证在装了 job 的编排里成立、在没装的编排里静默不成立 —— 正是 PD chore: version packages #10 要禁的「声明 ≠ 强制」。所以没有采用它。sharing-plugin.ts的kernel:bootstrapped钩子跑backfillRuleGrants,对每一条规则(sharing: deactivating a rule never withdraws its materialized grants — not on touch, not at boot #4433 起含 inactive)跑evaluateRule;evaluateRule是 diff 式的、幂等的。这就是补偿执行者:异步重发若因崩溃丢失,下次启动原样修复。sys_sharing_rule写入都会触发bindRuleRebindTriggers里的 reconcile;sweepOrphanedRuleGrants在每次 boot 扫孤儿。所以异步那一半用进程内串行队列(
RuleRegrantQueue),不引入新依赖、不随编排变化,持久性由上面第 2 条兜底。失败方向是安全的:重发挂了是有人暂时少看见东西(会被报障),不是有人多看见(不会被报障)。缺陷
rule-hooks.ts的处理函数第一步就用单条 id 定位要重算的行:而
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.ts的STASH_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;#4630 的sys_commentresolve)。三个刻意的选择
object_name整体撤,不按record_id: {$in: [...]}缩小。 缩小需要完整 id 列表,而「拿不到完整 id 列表」正是走进这个分支的原因 —— 缩小会把上界重新塞回唯一一个本来没有上界的操作里。牵连未被写入的行是刻意的,方向安全:多几行暂时少看见,没有一行保住本该失去的权限。afterUpdate而不是beforeUpdate。 放在 before 的话,一次因校验失败而整批回滚的写入会白白撤掉几千行的共享,而没有任何东西触发重发(after 钩子不会跑)。放在 after,撤销仍在update()返回给调用方之前完成 —— 对调用方而言就是同步的。installAttachmentAccessHooksdoes not authorize an UNSCOPED multi-delete: no id + nowherereads as "nothing to authorize" anddeleteManyruns 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)。改回后全绿。仓内门禁:
check-engine-double-contractOK(12 pinned,含本文件)、check-durability-degradation-log-levelOK、check-startup-registry-verdictOK、改动文件 eslint 干净。rule-rebind.test.ts里三处硬编码的「每对象 2 个钩子」改成具名常量RULE_HOOKS_PER_OBJECT = 5—— 那几例断言的是 rebind 记账(绑上 → 解绑 → 重绑),不是需要哪些事件,不该看起来像后者变了。范围
packages/spec/**零改动,content/docs/releases/零改动,packages/objectql与packages/services/**零改动(只读地核实了 engine 的 hook 语义与 job 服务的可选性)。改动全部在packages/plugins/plugin-sharing/**加一个 changeset。Generated by Claude Code