Skip to content

plugin-approvals 的 admin 豁免读 session.roles,而 ObjectQL 的 buildSession() 从不填充它 —— 记录锁/delegation 守卫的 admin 覆盖在真实引擎路径上永不生效 #4839

Closed

Description

@os-zhuang

在 #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 的方向相反。

需要裁定的地方(所以这条不适合直接开修)

三种方向,选哪一种改的是公共契约:

  1. 让两个 hook 改用 ADR-0095 的判定(读 permissions / positions / posture,或直接复用 isOverrideActor 的那套判据)—— 与包内既有姿势一致,但会让 admin 覆盖从「事实上不存在」变成「真的生效」,这是一次实质的权限放宽,需要确认这正是设计意图(尤其记录锁:允许 admin 直接改被锁记录 vs 只允许通过 recall/reject 释放锁)。
  2. 让 buildSession() 填充 roles —— 但 roles 到底映射到什么?better-auth 的成员层级(owner/admin/member)是 ADR-0057 D4 / ADR-0090 D3 / ADR-0095 D3 明令禁止作为 RBAC 权威的,填进去等于新开一条被 ADR 封掉的路。
  3. 删掉这两处 admin 豁免(ADR-0049 enforce-or-remove),记录锁的 admin 救援已经由 isOverrideActor + recall/reject 覆盖;delegation 则确认「只能本人管理自己的委托」是不是可接受的最终语义。

倾向 1 或 3,但两者对外可见行为不同,建议由维护者裁定后再开工。若最终选 3,packages/spec 里 HookContext 的 session.roles 是否一并退役(ADR-0049 / ADR-0087)也要一起决定 —— 它退役后就没有任何消费方了。

Activity

  1. added
    bugSomething isn't working
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 3, 2026
  2. claude commented on Aug 3, 2026

    @claude
    Contributor

    分诊补记:已入队(pm:queue),但本轮刻意不派 —— 串行依据:

    生产者修法落点是 packages/objectql/src/engine.ts 的 buildSession();而在飞 #4889(parent-scoped readonlyWhen)的 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#4889


    Generated by Claude Code

  3. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 4, 2026
  4. claude commented on Aug 4, 2026

    @claude
    Contributor

    分诊更正 + 上交维护者(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,外部可见行为不同,过升级门槛。以本条为准。

    两轴分析

    • 项目长远合理性:两案都终结「第二套 admin 方言」,符合 ADR-0090/0095 的单一词汇路线。差别在语义:3 维持现状语义(admin 救援已由 isOverrideActor + recall/reject 覆盖,锁语义不变;delegation 确认「仅本人管理」为最终语义);1 是实质放权(admin 可直接改审批锁定中的记录,而不是经由 recall/reject 释放锁)—— 放权必须出自正向的设计意图,不能作为修 bug 的副产品发生。
    • 防 AI 写元数据/代码犯错:3 结构上消灭「对着幻影字段写 admin 判定」的可能性(删消费方;若连 spec 的 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

  5. removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 4, 2026
  6. claude commented on Aug 4, 2026

    @claude
    Contributor

    维护者已裁(2026-08-04,照准 PM 建议):记录锁取 3;delegation 按业务事实条件执行

    • 审批记录锁的 admin 豁免:删(方案 3)。正规救援路径(isOverrideActor + recall/reject)已存在;合规客户场景下「admin 直接改锁定记录」是负债。
    • delegation 豁免:先查证后定 —— dev 须先以证据回答「审批人不可用时,admin 是否已有正规路径处置其名下在途审批(recall / reject / 重新发起等)」:覆盖 → delegation 同取 3(「仅本人管理委托」为最终语义);不覆盖 → delegation 单独取 1,且必须复用 isOverrideActor 的 ADR-0095 判据,禁止再造 roles 方言。
    • spec 的 session.roles 退役(选 3 后零消费方)为另立协议单,walk ADR-0049/0087,归 spec 车道 —— 不在本单。

    摘 needs-user-decision,回队,本轮即派。


    Generated by Claude Code

  7. claude commented on Aug 4, 2026

    @claude
    Contributor

    🔒 认领:PM 循环第 7 轮(维护者裁决落地批)
    会话:session_01NrmBxj8rK2uGCnh9aipjwX(账号 xuyushun441-sys)
    分支:claude/issue-4839-approvals-admin-exemption
    Worktree: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

  8. claude commented on Aug 4, 2026

    @claude
    Contributor

    ✅ 验收通过 —— PR #5049(draft,CI 跑完入队)

    对照实际 diff 复核(4 文件,+624/-23,全部 plugin-approvals 包内;⛔ objectql / spec / protocol.ts / releases 零触碰,逐一核实):

    1. 证据分支干净落定:裁定第 2 条的业务问题(审批人不可用时 admin 有无正规处置路径)用可执行测试回答 —— REASSIGN(转槽给替补)与 RECALL(撤回并释放记录锁)都在,故 delegation 与记录锁同取方案 3(删),「委托仅本人管理」为最终语义,PR 正文与 changeset 均已明示。
    2. 不做 ADR-0095 重接线的克制是对的:裁定是「无 admin 覆盖」,不是「换个拼法的覆盖」。
    3. 方言复活防线:源码 pin(去注释后全包非测试源零 roles 标识符、零 'admin' 字符串比较)+ 5 种 admin 形状在 by-id 与 predicate 两路都「不豁免」;变异验证双向(恢复锁分支 → 5 红;恢复 delegation 分支 → 2 红)。
    4. 441 全过,零行为变化(两分支本就是死代码),engine-double-contract 用本包已有的 objectql devDep 正规接线(无环,核实过)。

    联动:session.roles 自本 PR 起零消费方,spec 退役单随后立案归 spec 车道(Blocked-by 本 PR 合并)。

    CI 绿后转 ready 入合并队列。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions