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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 4, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 04:39
@os-zhuang
os-zhuang enabled auto-merge August 4, 2026 04:39
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit c272e48 Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-4779-sharing-bulk-recompute branch August 4, 2026 04:53
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 4, 2026
…在性的孤儿清扫 (objectstack-ai#5103) (objectstack-ai#5196)

* fix(sharing): revoke every share on a deleted record, not just rule grants (objectstack-ai#5103)

A `sys_record_share` row says "principal P has level L on (object O, record
R)". Delete R and the row describes nothing — yet it stayed in the table
forever.

objectstack-ai#4779 (PR objectstack-ai#5102) bound an `afterDelete` for this, but inside the sharing-RULE
package, where two conditions fenced it in: it revokes only `source: 'rule'`
rows, and `bindRuleHooks` binds only on objects that appear in
`sys_sharing_rule`. So an object using nothing but MANUAL shares had no delete
hook at all, and manual share + record delete = a permanent orphan.

Harm is bounded today only because record ids are never reused — an assumption
no gate enforces. A custom primary key, an import preserving ids, or any future
recycling turns those rows into real escalation: a new record on a recycled id
inherits the dead record's recipients.

Maintainer ruling (2026-08-04, on the issue): option A. Option B (a platform
polymorphic weak-reference cascade) is a separate engine-lane design card
(objectstack-ai#5180); when it lands these hooks collapse into it.

- `record-share-cascade.ts` binds ONE global `beforeDelete`/`afterDelete` pair
  and judges the object's sharing posture from `sharingModel` metadata PER
  DELETE. Nothing is enumerated at boot, so nothing goes stale — an object that
  gains sharing at runtime is covered on its next delete with no rebind, which
  is a stronger answer to the ruling's hot-update requirement than a metadata
  subscription would have been. Bounded row sets are revoked synchronously and
  set-based; an unbounded delete queues an object-scoped orphan sweep rather
  than the rule path's revoke-then-regrant, which is unavailable here because
  nothing can re-create a manual share. System-context deletes cascade too.
- `SharingService.sweepOrphanedRecordShares` is the record-existence twin of
  `sweepOrphanedRuleGrants` (objectstack-ai#4433) — that one asks whether the RULE row still
  exists and therefore can never see a manual share. Runs on
  `kernel:bootstrapped`, keyset-paged with one batched existence probe per
  object per page and a scan cap that reports itself. An object whose probe
  FAILS keeps its rows: "could not ask" is not "the record is gone".
- The `beforeDelete` row-set stash moves from `rule-hooks.ts` into
  `bulk-recompute.ts` beside its resolver, so both hook packages share one
  answer per write instead of resolving the same predicate twice.

Rule recompute still never touches a manual share (objectstack-ai#5102's pin, re-asserted in
this branch's tests). Only the record's DELETION revokes it, and only because
there is no longer anything to have access to.

Fixes objectstack-ai#5103

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

* fix(sharing): the deferred-sweep breadcrumb is info, not warn (objectstack-ai#5103)

The rule path warns on its unbounded branch because recipients visibly lose
access to records they still qualify for until the re-grant lands. Nothing
equivalent happens here: the sweep only removes rows whose record is gone, so
a deferred reclaim takes nothing from a surviving record and has no
user-visible consequence. A warn on every predicate delete would only erode
the level (AGENTS.md's own caution against over-applying it). The sweep still
warns when it actually revokes rows.

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

* docs(sharing): record why referential cascades are already covered (objectstack-ai#5103)

`cascadeDeleteRelations` removes a `deleteBehavior: 'cascade'` child through
the public `delete()` rather than the driver, so a detail record swept away
with its master reaches this hook like any other delete. That is the fact
behind treating `controlled_by_parent` as sharing-capable: manual grants are
refused there, but the rule evaluator can still materialise rows under system
context, and this is the path that reclaims them.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 4, 2026
…除级联 (objectstack-ai#5190) (objectstack-ai#5214)

* fix(plugin-sharing): 记录删除后 sys_share_link 令牌立即失效 —— resolve 存在性校验 + 删除级联 (objectstack-ai#5190)

resolveToken 逐项检查 token/revoked_at/expires_at/audience/密码,唯独不问
「(object_name, record_id) 指向的记录还在不在」;sys_share_link 也没有任何
删除级联(objectstack-ai#5103 的级联只覆盖 sys_record_share)。分享链接是无身份的能力令牌,
持有 URL 即拥有权限,所以这类孤儿比 objectstack-ai#5103 更危险:记录 id 一旦被复用,早该
随记录消失的链接会直接对新记录生效。

两半同时落地,且第一半不依赖任何钩子跑过:

1. resolveToken 增加记录存在性检查,走与 revoked/expired 完全相同的分支返回
   null(不区分、不另立错误码,避免把记录存在与否泄露给未授权持有者)。位置
   在内存态检查之后、use_count/last_used_at 打点之前,因此死记录不再被计数;
   探测抛错时 fail-closed(问不到 ≠ 放行)。
2. 记录删除级联到 sys_share_link,复用 objectstack-ai#5103 已有的 seam:同一对全局
   beforeDelete 行集暂存 + afterDelete 按 id 集合撤销、同一条串行 sweep 队列、
   同一个 kernel:bootstrapped 孤儿清扫(keyset 分页、自报截断、每对象每页一次
   批量存在性探测、探测失败一行不删)。两半互相隔离,撤销 grant 失败不会连带
   跳过令牌。

链接一侧的姿态判定读 publicSharing(与 sharingModel 正交:最可能挂链接的对象
恰恰是记录共享谓词跳过的那类),且 publicSharing 声明过就算数——enabled 关掉
之前铸出的链接必须仍被清理。

机制本身抽到 record-orphan-cleanup.ts 由两张表共用,避免出现第二份必须与之
保持一致的 walk(chunk、上限、失败探测规则)。objectstack-ai#5103 的既有行为逐字未变,其
305 条测试全绿即为证据。

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

* test(plugin-sharing): 链接级联在「有共享规则」的对象上同样被钉住 (objectstack-ai#5190)

objectstack-ai#5190 的链接级联用例全部跑在没有共享规则的对象上(contract / sys_report),
漏掉了唯一只在「携带最多共享机制」的对象上才存在的交互:一旦对象有规则,
objectstack-ai#5102 的 bindRuleHooks 会以另一个 hook package 注册自己的 beforeDelete /
afterDelete,两个 package 读同一份 AFFECTED_ROWS_STASH_KEY 行集。只在自己是
唯一 beforeDelete 写入者时才成立的链接级联,能通过上面全部用例,却恰好在风险
最高的对象上继续泄漏令牌。

补三条:同一次删除里规则授权 + 手工共享 + 能力令牌三者一起消失;有界谓词删除
跨两个 hook package 仍只发一条集合式 sys_share_link 语句;两个 package 的绑定
互不干扰。

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
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