fix(approvals): 删除两处读 session.roles 的 admin 豁免 —— 记录锁与委托守卫回到单一权限词汇 (#4839) - #5049
Merged
Merged
Conversation
…ord lock and delegation guard (#4839) `lifecycle-hooks.ts` carried two admin bypasses, both reading `ctx.session.roles`: the approval **record lock** (`bindApprovalLockHook`) and the `sys_approval_delegation` write guard (`bindDelegationWriteGuard`). Both are removed. Neither ever ran. `session.roles` has no producer anywhere in the platform — ObjectQL's `buildSession()` builds its session field by field and never writes it — so both branches were dead on every real engine path: the record lock has always applied to admins, and delegation has always been self-managed. The spec declares `roles` on the HookContext session, two consumers read it, nothing fills it: declared != enforced. They were also a second privilege dialect. Privilege here is judged by the ADR-0095 vocabulary (`permissions` / `positions` / derived posture) — ADR-0090 D3 bans the `role` spelling outright — and the sibling `ApprovalService.isOverrideActor` already does exactly that. Per the maintainer's ruling both sites are DELETED rather than rewired to the correct predicate: - Record lock: the sanctioned admin rescue path already exists (#3424 — recall / reject / reassign, gated by `isOverrideActor`, audited via `via_override`). Finalising the request is what releases the lock, so a record is never edited under a live approval. - Delegation: evidence-first, and the evidence says coverage exists. A delegation is consulted only at slate-resolution time (`applyOooDelegation` inside `resolveApproverSpec`), so forging one for an already-unavailable approver never moved their pending work; `reassign` / `recall` / reject do, and are gated by `isOverrideActor`. "Delegation is self-managed" is now the final semantic. Tests: `admin-exemption-retired.test.ts` turns that evidence into executable assertions and adds a source-level pin — no non-test source in this package may name a `roles` identifier or compare against the string 'admin'. The existing "allows an admin override" tests become "does NOT exempt ..." pins over both the retired dialect and every live ADR-0095 admin shape, on the by-id and predicate paths. The spec-side retirement of `session.roles` (now with zero consumers) is a separate protocol change under ADR-0049 enforce-or-remove — not in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
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 #4839
做了什么
删掉
packages/plugins/plugin-approvals/src/lifecycle-hooks.ts里两处读ctx.session.roles的 admin 豁免:bindApprovalLockHook(审批记录锁)bindDelegationWriteGuard(sys_approval_delegation自助写守卫)这不是行为变更。
session.roles在平台里没有生产者 —— ObjectQL 的buildSession()逐字段构造 session,从不写roles—— 两个分支在任何真实引擎路径上都是死代码。记录锁一直就对 admin 生效,委托一直就只能本人管理。删除只是让代码说出
运行时本来就在做的事(spec 的 HookContext 声明了
roles、两个消费方在读、生产方从不写:典型的 declared ≠ enforced)。
同时关掉的是第二套权限方言:本仓库权限一律由 ADR-0095 词汇裁决(能力授予
permissions、任职positions、由其派生的 posture),ADR-0090 D3 直接禁掉role拼法;同包的
ApprovalService.isOverrideActor已经是这么写的。按维护者裁决,两处都取「删除」而不是改用正确判据。
裁决点 2 的查证结论:覆盖存在 → delegation 同取方案 3
维护者要求先以代码证据回答:「审批人不可用(病假/离职/失联)时,admin 是否已有正规
路径处置其名下在途审批?」结论是 有,而且更关键的是:被删掉的那条 admin
豁免本来也做不到这件事。
证据 A ——
isOverrideActor实际把守的操作packages/plugins/plugin-approvals/src/approval-service.ts中isOverrideActor共 5 个调用点,前 3 个是写操作:
decideNodevia_override留痕(#4466)recallreassignfrom命名那位不可用的审批人时,pending.includes(from)成立,isOverride直接豁免FORBIDDEN,名额换成替代人visibleRequestIdsnull= 不做参与者收窄,admin 能看见全部在途请求(否则无从下手)attachViewersviewer.can_override,让 Console 把决策动作对 admin 显示出来isOverrideActor自身的文档字符串已经把这写成契约:reassign里还有一处细节值得单独点名:改派会把原名额的__approverGroups分组归属一并带给新审批人,所以 per_group / quorum 的计票语义不被破坏 —— 这正是「替不可用的
审批人找人接手」该有的行为。
证据 B —— 委托无法处置在途审批(所以那条豁免是空头支票)
applyOooDelegation(即读sys_approval_delegation的唯一路径)只在resolveApproverSpec/resolveExpressionApprovers内部被调用,也就是请求开启时解析审批人名单的那一刻。名单一旦快照进
pending_approvers,后建的委托不会重新路由它。
换句话说:即使当年那条 admin 豁免真的生效过,admin「替病假审批人建一条委托」也动
不了已经挂在该审批人名下的在途审批 —— 它只影响之后新开的请求。豁免要解决的问题,
它从设计上就解决不了;真正解决它的是证据 A 的
reassign/recall/ 驳回。两条证据都已写成可执行断言(
admin-exemption-retired.test.ts),不是散文:a delegation declared AFTER the request opened does not move the pending slotthe delegation DOES apply to a request opened afterwards — it is an open-time rule, nothing more(guard-the-guard:证明委托机制在该 fixture 里是通的)REASSIGN: an admin hands the unavailable approver's slot to a substitute, who decides normallyRECALL: an admin withdraws the request, and THAT is what releases the record lockREJECT: an admin decides the request instead, which also releases the lock→ 覆盖存在,故 delegation 同取方案 3;「委托仅本人管理」成为最终语义。
测试
does NOT exempt %s from the record lock(by-id 路径)RECORD_LOCKED:退役方言roles:['admin']、admin_full_access、platform_admin任职、org_admin任职、派生posture: 'PLATFORM_ADMIN'does NOT exempt %s(predicate 路径 × 3 种 where 形态)multi: true改写不能变成 by-id 路径已经没有的 admin 后门does NOT let %s forge / relabel a delegationFORBIDDENstill lets an admin declare their OWN delegationan ADR-0095 platform admin may not declare a delegation for someone elsea NON-privileged member gets none of this/a roles:[admin] session gets none of it eitherno non-test source contains ...(3 条模式)roles标识符、.includes('admin')、=== 'admin'pin 会剥掉注释再扫描 —— 解释性文字必须能提到被退役的方言,否则等于把理由赶出代码
(AGENTS.md Prime Directive #13 要防的正是这个)。附带 guard-the-guard:扫描文件数下界、
剥注释器行为、以及「方言真出现时确实会被抓到」。
变异验证(把两处豁免逐一改回去再跑):
no non-test source contains a roles identifier、... a membership test against the string 'admin'、does NOT exempt the retired roles:[admin] dialect from the record lock及两条 predicate 形态does NOT let the retired roles:[admin] dialect forge a delegation for someone else、... relabel an existing delegation on update本地闸门
范围
packages/spec/**、生成物、packages/objectql/**、packages/metadata-protocol/src/protocol.ts、content/docs/releases/:零改动。session.roles退役(至此零消费方)按裁决另立协议单,走 ADR-0049 / ADR-0087,归 spec 车道。本 PR 的 pin 正是让那张单从一个干净事实基线出发。lifecycle-hooks.ts在scripts/adr-anchors.json加一条 ADR-0090 / ADR-0095 锚点。本轮文件面被限定在packages/plugins/plugin-approvals/**,故未动;源码级 pin 在机制强度上已覆盖(锚点是存在性检查,pin 是行为检查)。Generated by Claude Code