Repository navigation
Description
在 #4778 的实现中发现(PR #4838),未认领,不在那条 issue 的范围内。
现象
packages/plugins/plugin-approvals/src/lifecycle-hooks.ts 有两处 admin 豁免,都读 ctx.session.roles:
bindApprovalLockHook的记录锁:if (Array.isArray(roles) && roles.includes('admin')) return;bindDelegationWriteGuard:if (Array.isArray(roles) && roles.includes('admin')) return;(admin 可以替他人声明 out-of-office 委托)
而 packages/objectql/src/engine.ts 的 buildSession() 逐字段构造 HookContext 的 session(userId / organizationId / positions / accessToken / isSystem / actor / skipTriggers / skipAutomations / preserveAudit),没有 roles。全仓 grep 下来:
session.roles的读取方只有上面两处(都在 plugin-approvals);- 没有任何地方往 HookContext 的 session 上写
roles(engine.ts 里唯一的roles:是ScopedContext.execute()传给 action 的roles: this.context.positions,与 hook session 无关)。
roles 在 packages/spec/src/data/hook.zod.ts 的 HookContext session 里是声明过的(roles: z.array(z.string()).optional()),所以这是一个典型的 declared ≠ enforced:字段存在、消费方在读、生产方从不写。
后果
两处都是失败关闭,不是越权 —— 所以不是安全洞:
- 记录锁:admin 无法用「我是 admin」绕过审批期间的记录锁(Approval routed to an empty position permanently locks the record (no admin override, no recovery) #3424 的 admin 救援走的是
ApprovalService.isOverrideActor,与这里无关,那条路径是好的); - delegation 守卫:admin 无法替他人建立/修改委托,会被 403,只能本人操作。
还有词汇不一致的问题
同一个包里,ApprovalService.isOverrideActor() 判定特权 admin 的方式是 ADR-0095 的词汇:context.permissions(admin_full_access / ORGANIZATION_ADMIN_GRANTS)、context.positions(BUILTIN_IDENTITY_PLATFORM_ADMIN / ORG_OWNER / ORG_ADMIN)、以及派生的 posture。而这两个 hook 用的是 roles.includes('admin') 这个字符串。即便把 roles 接上,它也是第二套 admin 判定方言,与 ADR-0090 D3 / ADR-0095 D3 的方向相反。
需要裁定的地方(所以这条不适合直接开修)
三种方向,选哪一种改的是公共契约:
- 让两个 hook 改用 ADR-0095 的判定(读
permissions/positions/posture,或直接复用isOverrideActor的那套判据)—— 与包内既有姿势一致,但会让 admin 覆盖从「事实上不存在」变成「真的生效」,这是一次实质的权限放宽,需要确认这正是设计意图(尤其记录锁:允许 admin 直接改被锁记录 vs 只允许通过 recall/reject 释放锁)。 - 让
buildSession()填充roles—— 但roles到底映射到什么?better-auth 的成员层级(owner/admin/member)是 ADR-0057 D4 / ADR-0090 D3 / ADR-0095 D3 明令禁止作为 RBAC 权威的,填进去等于新开一条被 ADR 封掉的路。 - 删掉这两处 admin 豁免(ADR-0049 enforce-or-remove),记录锁的 admin 救援已经由
isOverrideActor+ recall/reject 覆盖;delegation 则确认「只能本人管理自己的委托」是不是可接受的最终语义。
倾向 1 或 3,但两者对外可见行为不同,建议由维护者裁定后再开工。若最终选 3,packages/spec 里 HookContext 的 session.roles 是否一并退役(ADR-0049 / ADR-0087)也要一起决定 —— 它退役后就没有任何消费方了。
Activity
claude commentedon Aug 3, 2026
分诊补记:已入队(
pm:queue),但本轮刻意不派 —— 串行依据:生产者修法落点是
packages/objectql/src/engine.ts的buildSession();而在飞 #4889(parent-scopedreadonlyWhen)的 dev 为了在服务端 strip 里绑定parent,大概率同样要动 engine.ts 的 update 路径。同文件面串行,#4889 落地后下一轮派发,届时基于合并后的代码工作。修法方向本身是 restore-invariant(declared ≠ enforced:spec 的 HookContext session 声明了
roles,消费方在读,生产方从不写),不需要维护者拍板;但**roles的取值来源**(从 permission-sets 展开?从 positions 映射?)若在实现中发现有多种真值语义,按规程 needs_decision。Blocked-by: objectstack-ai/objectstack#4889Generated by Claude Code
claude commentedon Aug 4, 2026
分诊更正 + 上交维护者(
needs-user-decision)更正本会话 16:38Z 的分诊:当时判「restore-invariant,不需拍板」的前提是「修法=让
buildSession()填充 roles」。正文选项 2 的分析证伪了这个前提 —— better-auth 成员层级作 RBAC 权威被 ADR-0057 D4 / ADR-0090 D3 / ADR-0095 D3 明令禁止,roles没有合法真值来源,「补生产者」这条路不存在。真实选择是 1 vs 3,外部可见行为不同,过升级门槛。以本条为准。两轴分析
isOverrideActor+ recall/reject 覆盖,锁语义不变;delegation 确认「仅本人管理」为最终语义);1 是实质放权(admin 可直接改审批锁定中的记录,而不是经由 recall/reject 释放锁)—— 放权必须出自正向的设计意图,不能作为修 bug 的副产品发生。session.roles一并退役,则声明面也关死 —— 但 spec 退役是协议变更,需按 ADR-0049/0087 走正式流程);1 若采用,必须复用isOverrideActor判据而非再造字符串比较,否则又是一条方言。建议
取 3(enforce-or-remove),理由:放权(1)需要你正面确认设计意图,而 3 不改变任何现实行为(两处豁免从未生效过)、直接落成 declared = enforced;若你确认「admin 就应当能直接绕过审批记录锁 / 替他人管理委托」,则取 1 并强制复用 ADR-0095 判据。若取 3:
session.roles的 spec 退役需另立协议单(本车道本轮不做协议变更),届时它将零消费方,是标准的 ADR-0049 case。原
Blocked-by: #4889已随其合并解除,但本单在拍板前不派发。Generated by Claude Code
claude commentedon Aug 4, 2026
维护者已裁(2026-08-04,照准 PM 建议):记录锁取 3;delegation 按业务事实条件执行
isOverrideActor+ recall/reject)已存在;合规客户场景下「admin 直接改锁定记录」是负债。isOverrideActor的 ADR-0095 判据,禁止再造 roles 方言。session.roles退役(选 3 后零消费方)为另立协议单,walk ADR-0049/0087,归 spec 车道 —— 不在本单。摘
needs-user-decision,回队,本轮即派。Generated by Claude Code
claude commentedon Aug 4, 2026
🔒 认领:PM 循环第 7 轮(维护者裁决落地批)
会话:
session_01NrmBxj8rK2uGCnh9aipjwX(账号xuyushun441-sys)分支:
claude/issue-4839-approvals-admin-exemptionWorktree:
objectstack-issue-4839域:
domain:services文件面:
packages/plugins/plugin-approvals/src/lifecycle-hooks.ts及同目录测试;approval-service.ts只读查证⛔ 硬约束:
spec/**及生成物零改动;protocol.ts零改动;releases/ 零触碰。全局在飞不相交已核:与 #5025(sys-metadata-repository.ts + 基线)、#4762(packages/lint)、本批其余两单文件面均不交。
Generated by Claude Code
claude commentedon Aug 4, 2026
✅ 验收通过 —— PR #5049(draft,CI 跑完入队)
对照实际 diff 复核(4 文件,+624/-23,全部 plugin-approvals 包内;⛔ objectql / spec / protocol.ts / releases 零触碰,逐一核实):
roles标识符、零'admin'字符串比较)+ 5 种 admin 形状在 by-id 与 predicate 两路都「不豁免」;变异验证双向(恢复锁分支 → 5 红;恢复 delegation 分支 → 2 红)。联动:
session.roles自本 PR 起零消费方,spec 退役单随后立案归 spec 车道(Blocked-by 本 PR 合并)。CI 绿后转 ready 入合并队列。
Generated by Claude Code
fix(approvals): delete the two `session.roles` admin exemptions — rec…
refactor(spec)!: retire HookContext.session.roles — declared, read by…
docs(qa): correct five approvals checklist items against the objectst…
fix(approvals): auto-cancel a record's pending approvals when the rec…