在 #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:字段存在、消费方在读、生产方从不写。
后果
两处都是失败关闭,不是越权 —— 所以不是安全洞:
还有词汇不一致的问题
同一个包里,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)也要一起决定 —— 它退役后就没有任何消费方了。
在 #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);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:字段存在、消费方在读、生产方从不写。后果
两处都是失败关闭,不是越权 —— 所以不是安全洞:
ApprovalService.isOverrideActor,与这里无关,那条路径是好的);还有词汇不一致的问题
同一个包里,
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 的方向相反。需要裁定的地方(所以这条不适合直接开修)
三种方向,选哪一种改的是公共契约:
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 封掉的路。isOverrideActor+ recall/reject 覆盖;delegation 则确认「只能本人管理自己的委托」是不是可接受的最终语义。倾向 1 或 3,但两者对外可见行为不同,建议由维护者裁定后再开工。若最终选 3,
packages/spec里 HookContext 的session.roles是否一并退役(ADR-0049 / ADR-0087)也要一起决定 —— 它退役后就没有任何消费方了。