由 #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 是该模块唯一的删除通道,所以三条路径同时死掉:
- 降级不回收能力。
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)同理。
- ADR-0105 D4 的 superseded-variant 收敛失效。 换 posture 后旧的
organization_admin / organization_admin_no_bypass 行不会被清掉,wall-less 部署上留着的正是无边界那份。
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
事实
packages/plugins/plugin-security/src/auto-org-admin-grant.ts:94-101:引擎的签名是两参数(
packages/objectql/src/engine.ts:4562):于是
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是该模块唯一的删除通道,所以三条路径同时死掉: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)同理。organization_admin/organization_admin_no_bypass行不会被清掉,wall-less 部署上留着的正是无边界那份。kernel:readybackfill 的孤儿清扫失效(membership 已删、grant 还在)。为什么单测全绿
auto-org-admin-grant.test.ts的 in-memory stub 自己实现的就是三参数形状: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 });organization_admin行,kernel:readybackfill 修好后会自愈,但值得在 changeset 里写明这是行为变更(有人会突然失去 tenant admin —— 那正是本该发生的事)。相关:#4586(撞出它的 issue)· ADR-0105 D4 · #3106