Skip to content

fix(approvals): 删除两处读 session.roles 的 admin 豁免 —— 记录锁与委托守卫回到单一权限词汇 (#4839) - #5049

Merged
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-4839-approvals-admin-exemption
Aug 4, 2026
Merged

fix(approvals): 删除两处读 session.roles 的 admin 豁免 —— 记录锁与委托守卫回到单一权限词汇 (#4839)#5049
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-4839-approvals-admin-exemption

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

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.tsisOverrideActor
共 5 个调用点,前 3 个是写操作:

方法 admin(非名额持有人)可做什么
1703 decideNode 直接 approve / reject 任一 pending 请求;不在名单内也放行,并以 via_override 留痕(#4466)
2158 recall 撤回任一 pending 请求(注释原文:"a privileged admin may recall any pending request to release a stuck record (#3424)")
2523 reassign 改派:from 命名那位不可用的审批人时,pending.includes(from) 成立,isOverride 直接豁免 FORBIDDEN,名额换成替代人
3700 visibleRequestIds 返回 null = 不做参与者收窄,admin 能看见全部在途请求(否则无从下手)
3984 attachViewers viewer.can_override,让 Console 把决策动作对 admin 显示出来

isOverrideActor 自身的文档字符串已经把这写成契约:

A platform or tenant admin — the same posture the engine's superuser bypass
already trusts — may always act on a PENDING request to release it: approve,
reject, reassign it to a real approver, or recall it.

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 slot
  • the 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 normally
  • RECALL: an admin withdraws the request, and THAT is what releases the record lock
  • REJECT: an admin decides the request instead, which also releases the lock

→ 覆盖存在,故 delegation 同取方案 3;「委托仅本人管理」成为最终语义。


测试

测试 钉住什么
does NOT exempt %s from the record lock(by-id 路径) 5 种 admin 形态全部 RECORD_LOCKED:退役方言 roles:['admin']admin_full_accessplatform_admin 任职、org_admin 任职、派生 posture: 'PLATFORM_ADMIN'
does NOT exempt %s(predicate 路径 × 3 种 where 形态) 同上,15 组 —— multi: true 改写不能变成 by-id 路径已经没有的 admin 后门
does NOT let %s forge / relabel a delegation 同 5 种形态,FORBIDDEN
still lets an admin declare their OWN delegation 守卫管的是 delegator 身份,不是调用者身份(不过度封锁)
an ADR-0095 platform admin may not declare a delegation for someone else 活着的权限词汇也不豁免 —— 裁决是「没有 admin override」,不是「换个拼法」
a NON-privileged member gets none of this / a roles:[admin] session gets none of it either 正规 override 是真闸门;退役方言在任何地方都不授权
no non-test source contains ...(3 条模式) 源码级 pin:本包非测试源码中不得出现 roles 标识符、.includes('admin')=== 'admin'

pin 会剥掉注释再扫描 —— 解释性文字必须能提到被退役的方言,否则等于把理由赶出代码
(AGENTS.md Prime Directive #13 要防的正是这个)。附带 guard-the-guard:扫描文件数下界、
剥注释器行为、以及「方言真出现时确实会被抓到」。

变异验证(把两处豁免逐一改回去再跑):

  • 记录锁改回 → 5 条失败: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 形态
  • 委托守卫改回 → 2 条失败:does NOT let the retired roles:[admin] dialect forge a delegation for someone else... relabel an existing delegation on update

本地闸门

pnpm --filter @objectstack/plugin-approvals test    Test Files 19 passed (19) / Tests 441 passed (441)
pnpm --filter @objectstack/plugin-approvals typecheck   tsc --noEmit,无输出
pnpm lint                                           eslint . --no-inline-config,无输出
pnpm check:engine-double-contract                   OK — 10 pinned, 30 DEBT, 1 exempt
                                                    (新 fake engine 的 delete 已 pin 到 assertEngineDeleteDispatch;
                                                     objectql 本就是本包 devDependency,无新增依赖、无环)
pnpm check:role-word                                OK (43 baselined file(s), no new occurrences)
pnpm check:adr-anchors                              OK (19 anchored file(s))
pnpm check:nul-bytes / check:release-notes /
check:published-files / check:stall-guard /
check:type-check-coverage                           全部 OK

范围

  • packages/spec/**、生成物、packages/objectql/**packages/metadata-protocol/src/protocol.tscontent/docs/releases/:零改动
  • spec 侧 session.roles 退役(至此零消费方)按裁决另立协议单,走 ADR-0049 / ADR-0087,归 spec 车道。本 PR 的 pin 正是让那张单从一个干净事实基线出发。
  • 可选后续:给 lifecycle-hooks.tsscripts/adr-anchors.json 加一条 ADR-0090 / ADR-0095 锚点。本轮文件面被限定在 packages/plugins/plugin-approvals/**,故未动;源码级 pin 在机制强度上已覆盖(锚点是存在性检查,pin 是行为检查)。

Generated by Claude Code

…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
@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 1:03am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l and removed documentation Improvements or additions to documentation tests tooling labels Aug 4, 2026
@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-approvals.

5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/approvals.mdx (via @objectstack/plugin-approvals)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-approvals)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-approvals)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-approvals)
  • content/docs/releases/v9.mdx (via @objectstack/plugin-approvals)

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.

@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review August 4, 2026 01:27
@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 7e5ac28 Aug 4, 2026
24 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/issue-4839-approvals-admin-exemption branch August 4, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants