Skip to content

auto-org-admin-grant 的所有 revoke 都是静默空操作 —— ql.delete 调用签名与引擎不符(降级后仍是 tenant admin) #4640

Description

@os-zhuang

由 #4586 的 W3 路由级测试撞出来的既有缺陷,与该 issue 的归因主题无关,按 Prime Directive #10 单独立案,不在 #4586 的 PR 里顺手改(这是一处安全行为变更,值得自己的 review 与 changeset)。

事实

packages/plugins/plugin-security/src/auto-org-admin-grant.ts:94-101:

async function tryDelete(ql: any, object: string, id: string): Promise<boolean> {
  try {
    await ql.delete(object, id, { context: SYSTEM_CTX });   // ← 三参数
    return true;
  } catch {
    return false;
  }
}

引擎的签名是两参数(packages/objectql/src/engine.ts:4562):

async delete(object: string, options?: EngineDeleteOptions): Promise<any>

于是 options 收到的是字符串 'ups_xxx'。rejectUnknownEngineOptions(engine.ts:249)对它做 Object.entries(bag),得到 '0'/'1'/'2'… 这些字符下标当作未知选项,抛错;tryDelete 的 catch 把它吞掉并返回 false。第三个参数({ context: SYSTEM_CTX })连同系统上下文一起被丢弃。

本仓其余 12 处 ql.delete(...) 调用点全部是规范形状 ql.delete(object, { where: { id }, context })(permission-set-projection.ts:324、cleanup-package-permissions.ts:49、suggested-audience-bindings.ts:245、objectql-adapter.ts 多处)——这一处是唯一的例外。

后果(全部是安全方向的)

tryDelete 是该模块唯一的删除通道,所以三条路径同时死掉:

  1. 降级不回收能力。 organization/update-member-role 把 owner/admin 降回 member 后,reconcileOrgAdminGrant 走 revoke 分支、removed 恒为 0,返回 {action:'skipped', reason:'delete_failed'},sys_user_permission_set(organization_admin…) 那行留在原地。该行 → 通配 viewAllRecords/modifyAllRecords → isTenantAdmin():被降级的人仍然是 tenant admin。移除成员(delete membership)同理。
  2. ADR-0105 D4 的 superseded-variant 收敛失效。 换 posture 后旧的 organization_admin / organization_admin_no_bypass 行不会被清掉,wall-less 部署上留着的正是无边界那份。
  3. kernel:ready backfill 的孤儿清扫失效(membership 已删、grant 还在)。

为什么单测全绿

auto-org-admin-grant.test.ts 的 in-memory stub 自己实现的就是三参数形状:

async delete(object: string, id: string) { tables[object] = ...filter(r => r.id !== id); return true; }

stub 与真引擎的签名不一致,于是「revoke 生效」这件事只在测试替身里成立过。正是 #3106 那一类:case 标签/函数体不是 enforcement,要看调用点——只不过这次跑偏的是调用点的签名。

复现

packages/qa/dogfood/test/membership-actor-attribution.dogfood.test.ts(#4586 落的那个文件)里把 demotion 后的断言改回 expect(grants).toHaveLength(0) 即红:真实 organization/update-member-role 降级 200 之后,grant 行仍在。

建议修法

  • tryDelete 改成 ql.delete(object, { where: { id }, context: SYSTEM_CTX });
  • 把 stub 对齐真引擎签名,否则修完仍然测不出来(这才是本 issue 的真正教训);
  • 加一条 dogfood 级断言:真实路由降级后 grant 归零;
  • 考虑存量:已经部署的环境里留着一批本该被回收的 organization_admin 行,kernel:ready backfill 修好后会自愈,但值得在 changeset 里写明这是行为变更(有人会突然失去 tenant admin —— 那正是本该发生的事)。

相关:#4586(撞出它的 issue)· ADR-0105 D4 · #3106

Activity

  1. self-assigned this
    on Aug 2, 2026
  2. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 1 轮
    会话:session_015Br2xsJsczFsTR9bvbh2Ny
    分支:claude/issue-4640-revoke-delete-signature
    Worktree:objectstack-issue-4640

    分诊记录(PM 侧核对,供廉价否决):

    • 落地仓库:objectstack(后端),issue 已带 repo:objectstack,无需跨仓拆分 —— 三条受影响路径(reconcileOrgAdminGrant 的 revoke 分支、ADR-0105 D4 收敛、kernel:ready backfill)全在 packages/plugins/plugin-security,前端无消费面。
    • 前提复核(issue 会过期,main 每天约 18 次合并):已对 origin/main 逐条核对,缺陷仍然成立 ——
      • auto-org-admin-grant.ts:96 仍是三参数 ql.delete(object, id, { context: SYSTEM_CTX });
      • engine.ts:4660 的签名仍是两参数 async delete(object: string, options?: EngineDeleteOptions);
      • 同包其余调用点(cleanup-package-permissions.ts:49、permission-set-projection.ts:324、suggested-audience-bindings.ts:245)全部是规范形状 ql.delete(object, { where: { id }, context }),本处确为唯一例外。
    • 去重:sys_member grade changes lose the true actor — thread attribution through the better-auth seam (validity windows deliberately NOT added) #4586 的 PR feat(auth): 把 better-auth 的真实操作人接到身份表写入上,作为归因 (#4586) #4656 已合并,其正文明确记了「顺带发现、不在本 PR 修」并指向本 issue;仓内无其它 open issue/PR 触碰该模块,不存在影子任务。
    • 不升级维护者:这是 restore-invariant 类修复(声明的 revoke 通道必须真的删除),修法由 issue 正文与仓内既有调用惯例唯一确定,无产品语义分歧 —— PM 直接派发。唯一需要维护者知情的是行为变更本身:修好后,存量环境里一批本不该继续持有 tenant admin 的人会在 kernel:ready backfill 时失去它 —— 那正是本该发生的事,要求在 changeset 里写明。

    Generated by Claude Code

  3. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PM 复核:通过 —— PR #4681(claude/issue-4640-revoke-delete-signature,4 文件 / +391 −47)。

    交付了什么

    1. 修复本体:tryDelete 改为 ql.delete(object, { where: { id }, context: SYSTEM_CTX }),与引擎两参数签名及仓内其余调用点一致。三条 revoke 路径(降级/移除成员、ADR-0105 D4 superseded-variant 收敛、kernel:ready 孤儿清扫)恢复真实删除。
    2. 本 issue 真正的教训已落实(这是复核里我最看重的一条):单测替身重写为按引擎入口契约严格校验 —— 参数个数与角色、rejectUnknownEngineOptions 的「未知键即错误」、无 scope 删除的拒绝、系统上下文必须在场。位置参数落进 bag 槽位会抛错,与真引擎同因同果。另有一条专门的漂移守卫测试,断言替身拒收旧的三参数调用 —— 下次同类签名漂移会红,而不是再次假绿。把生产代码改回旧形状,该文件 8 条测试变红(含 5 条本来就存在、却一直假绿的 revoke 断言)。
    3. dogfood 级断言:正文「复现」一节指的那条 NOTE 换成真断言 —— 真实 organization/update-member-role 降级 200 之后轮询到 grant 归零(reconcile 在响应后异步落库,单次读会 flaky,故轮询)。
    4. 同族检查:tryFind / tryInsert 的 context 在第三参、与引擎一致,无需修改;只有 delete 把 context 收在第二参 —— 这个不对称正是踩坑点,已连同三个签名写成注释钉在调用处上方。
    5. 不再静默(加分项):revoke 失败记 [security] org-admin grant revoke FAILED — capability still in force;「找到 grant 行却一行没删掉」再记一条 warn;读失败降级 debug。noop(无事可删)与 skipped/delete_failed(删除失败)被测试钉成两种可区分的返回。

    复核依据

    对 GitHub 核验而非采信报告自述:PR base main、正文以 Fixes objectstack-ai/objectstack#4640 开头;改动面为源码 + 单测 + dogfood + changeset,无 content/docs/releases/ 改动、无无关文件;patch 逐段读过。远端 CI 19 个 check 全绿(Test Core ×2、Dogfood Regression Gate ×2、Build Core、Dogfood Verify CLI、Temporal Conformance live PG + MySQL、tsc、ESLint、Check Changeset 等),mergeable_state: clean,已进 merge queue,由队列 rebase 到当时的 main 后重跑决定是否落地。

    ⚠️ 这是行为变更,不是纯修复

    changeset 已写明:升级后,存量环境里那批本该被回收的 grant 会在 kernel:ready backfill 时真的被删掉 —— 历史上被降级过、或 membership 已被删除的人会失去 tenant admin。那正是本该发生的事:让他们够格的 grade 早就被拿走了,只有能力行活了下来。确需保留全域可见性的人应显式授予(admin_full_access 或明确编写的 permission set),而不是靠一个 better-auth membership grade 的副作用。启动日志会逐条打印被回收的人。

    dev agent 未返回 needs_decision,也无 out-of-scope findings 需另行立案。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions