fix(sharing): 停用规则时撤回已物化授权,并修复 DELETE 500 (#4433, #4434) - #4495
Conversation
An over-granting sharing rule had no withdrawal path on the API surface: deactivating it never withdrew its materialized grants, and the DELETE route answered 500 for both address forms. RC-exit blockers, since v17 advertises "switching a rule off actually withdraws access". #4434 — deleteRule purged sys_record_share with a predicate-shaped engine.delete carrying neither a scalar id nor multi:true, the one shape the engine's dispatch refuses. It threw before ever reaching the rule row, so every DELETE /sharing/rules/:idOrName 500'd. Routed through purgeRuleGrants instead of adding multi:true, so a rule's grants retire exactly one way — SharingService.revoke, as every other withdrawal path already does (AGENTS.md PD #5). #4433 — three independent gaps, one per path the issue walked: - deactivation: the rule-write trigger skipped reconcile on every isSystem write. defineRule (the only implementation behind POST /sharing/rules) writes with SYSTEM_CTX unconditionally, so the skip caught 100% of REST authoring. Now gated on boot phase, the question the skip actually meant to ask. - record touch: evaluateAllForRecord listed activeOnly rules, so a deactivated rule was absent from the loop and its grants were never examined. Lists every rule; inactive ones desire nothing and take reconcileForRecord's existing revoke branch. - boot: the backfill was handed activeOnly rules, making it structurally incapable of revoking. Walks every rule, plus a new sweepOrphanedRuleGrants for grants whose rule row is gone entirely (unreachable by rule iteration). Test fakes: makeEngine().delete now mirrors the real engine's dispatch guard. The looser fake is why #4434 shipped green — the pre-existing "deleteRule drops rule + all its grants" test passed against a delete the running server always rejected. Progress: service + plugin fixes done, regression tests added for both issues. Remaining: rule-rebind.test.ts still pins the old isSystem skip and needs updating; full gates + live-server verification pending. Refs #4433, #4434 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
rule-rebind.test.ts asserted the defect: 'skips system-context writes' passed a mocked session the real REST path never sends, then asserted the reconcile did NOT happen. Replaced with the corrected contract — deferral is about WHEN a write happens (before the kernel:bootstrapped backfill), not WHO made it — plus cases for both session kinds after boot and the seeding deferral during it. pnpm --filter @objectstack/plugin-sharing test: 243 passed (11 files). Refs #4433 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 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:
|
Live verification on a persistent SQLite datastore confirmed all three withdrawal moments and both DELETE address forms, so the guarantee is now worth stating: an over-granting sharing rule is always recoverable from the API surface. The CI docs-drift advisory flagged six hand-written pages against this change. None asserted anything false — the withdrawal timing was simply undocumented, which is how the behaviour drifted unnoticed in the first place. Added the missing section to permissions/sharing-rules.mdx (the three reconcile moments, delete-by-id-or-name, the boot sweep for grants whose rule row is gone) and sharpened permissions-matrix.mdx, which said rules re-evaluate on record insert/update and stopped there — true but partial now that rule writes and boot also reconcile. The other four flagged pages only list the plugin or its layer and needed no change. content/docs/references/ is generated and was not touched. Changeset: minor on @objectstack/plugin-sharing. Not patch — a `source: 'rule'` grant whose rule is inactive or gone now disappears on upgrade, and DELETE starts succeeding where it used to 500, so callers that treated that 500 as "unsupported" will now really delete. Refs #4433, #4434 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
CI 全绿,验证完成
关键几项: 之前挂掉的
跨重启撤回与 DELETE 两种寻址形式均已在真实服务器上实测通过,证据表格见 PR 正文「验证证据」一节。端口 38102 已释放,临时数据库已清理。 PR 仍为 draft,等待人工 review 后再决定是否转 ready。 Generated by Claude Code |
v17 验收缺陷清单 #4482 的第一批修复。这一对缺陷合在一起,会让一条过度授权的共享规则在 API 层面无法挽回。
修了什么
#4433 —— 停用规则从不撤回已物化的授权
共享规则的授权是物化的:评估规则会真的往
sys_record_share写入source:'rule'行。这些行的存活期超过产生它们的那次评估,所以"撤回"必须是一个明确的动作。原先三条路径全部失效,各自的成因互相独立:1. 停用动作本身(主因)。
sys_sharing_rule的 reconcile trigger 对所有isSystem写入跳过,理由是"那些是启动期播种"。但SharingRuleService.defineRule——POST /sharing/rules背后唯一的实现,也是文档规定的停用规则的方式 —— 无条件使用SYSTEM_CTX写入,因为它必须触达一张 sharing 中间件本来会拦截的平台表。于是这个跳过条件命中了 100% 的 REST 授权写入。#3821 建好的撤销通道存在、有测试(测的是真实路径根本不会发送的 mocksession),且在生产中不可达。现改为按启动阶段判断 —— 这才是那个跳过条件真正想问的问题。2. 记录触碰。
evaluateAllForRecord只列 active 规则,停用的规则根本不在循环里,它物化的授权连看都没被看一眼。现在遍历全部规则;停用的规则"期望授权集合为空",直接走reconcileForRecord已有的 revoke 分支。3. 启动回填。
backfillRuleGrants拿到的是activeOnly列表,结构上不可能撤销任何东西。现在遍历全部规则,并新增sweepOrphanedRuleGrants清理规则行已彻底消失的授权 —— 靠遍历规则永远够不到它们,所以需要独立的清扫。#4434 —— DELETE 两种寻址形式都 500
deleteRule用where:{source,source_id}谓词删sys_record_share,既无标量 id 也无multi:true,正好是 engine dispatch 唯一拒绝的形状,在触达规则行之前就抛了。修复方式是走
purgeRuleGrants(而不是补一个multi:true):让规则授权只有一条回收路径 ——SharingService.revoke,与其它所有撤销路径(evaluateRule遇到 inactive、数据 API 删除后的revokeRuleGrants)完全一致。补multi:true能消掉 500,却会让 delete 成为唯一绕过 sharing service 的撤销路径(AGENTS.md PD #5)。测试假引擎的保真度
makeEngine().delete原先接受任意where—— 这正是 #4434 能带着绿灯发布的原因:已有的deleteRule drops rule + all its grants测试一直在对着一个真实服务器必然拒绝的删除断言成功。现已对齐真实 engine 的 dispatch 守卫,该测试在修复前确实失败。验证证据
单元 / 集成测试
pnpm --filter @objectstack/plugin-sharing test→ 243 passed (11 files)。pnpm typecheck(全仓库 turbo)→ 122/122 successful。新增回归测试均已确认撤掉修复后会失败(8 条):#4434 的两种寻址形式、按服务路径撤销、触碰撤销、孤儿清扫、跨启动撤销,以及把旧的
activeOnly调用点作为缺陷钉死的用例。rule-rebind.test.ts中原本skips system-context writes那条测试钉死的正是缺陷行为,已替换为正确契约。真实启动验证(持久化 SQLite,端口 38102)
用持久化数据库而非
--fresh,以便做真正的重启。showcase 的showcase_private_note(sharingModel: 'private'),admin 持有笔记,member B 为收件人。404active:true200active:false404404404规则在重启后仍为
active: false,授权保持已撤回 —— #4433 的核心断言(issue 里"下次记录改动时不撤,完整重启后也不撤"两条路径)均已实测通过。启动清扫的修复能力也单独验证过:直接往 SQLite 注入一条
source:'rule'、source_id指向不存在规则的孤儿授权(模拟从缺陷版本升级上来的数据库),重启后日志出现[sharing-rule] revoked rule grants whose rule row no longer exists {"grants":1},孤儿行消失,同时合法的种子规则授权原样保留。#4434 两种寻址形式:
rc1_del_byname)204404404srule_2719031d-…)204404404两者均从原先的无条件
500 RULE_DELETE_FAILED变为204,且授权同步撤回。验证结束后已kill $(lsof -ti tcp:38102)并清理临时数据库。文档
CI 的 docs-drift advisory 点了 6 篇手写文档。逐篇核对后:没有一篇存在失真断言 —— 撤回时机本来就没有被写进文档,这也正是该行为得以悄悄漂移的原因。
permissions/sharing-rules.mdx—— 补上缺失的章节:三个 reconcile 时机、delete 支持 id 与 name、启动清扫。permissions/permissions-matrix.mdx—— 原文只说规则在记录 insert/update 时重新评估,属实但不完整(现在规则写入与启动也会 reconcile),已补正。protocol/objectql/security.mdx、kernel/services-checklist.mdx、plugins/packages.mdx、releases/implementation-status.mdx)仅列出该插件或其所属层次,无需改动。content/docs/references/为自动生成,未触碰。Changeset
@objectstack/plugin-sharing: minor。定 minor 而非 patch 的理由:这改变了可观察的运行时行为 —— 规则处于 inactive 或已消失的source:'rule'授权会在升级后消失(包括为绕开旧行为而手工修补出来的数据),且DELETE从 500 变为真正删除,原先把该 500 当作"不支持"的调用方现在会真的删掉规则。边界
未触及
packages/spec、公开导出或任何协议层;无可作者化 spec key 的移除或重命名。无需决策拍板。分支:
claude/v17-verification-defects-gnf9e6-sharingFixes #4433
Fixes #4434