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
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>
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