在 #4757 的排查中发现(那条 issue 末尾要求「看看仓库里还有没有别的 beforeDelete 守卫共享『没解析到行 ⇒ 放行』这个形状」)。未认领,归属 plugin-approvals,不在 #4757 的 PR(#4780)范围内。
现象
packages/plugins/plugin-approvals/src/lifecycle-hooks.ts,bindApprovalLockHook 注册的全局 beforeUpdate(ADR-0019 记录锁)开头就是:
engine.registerHook('beforeUpdate', async (ctx: any) => {
const id = String((ctx?.input?.id ?? '') as string);
if (!id) return;
而 packages/objectql/src/engine.ts 的 update() 只在 where.id 是标量时才把它提取成 input.id(与 delete() 同源的规则,注释里写明了:操作符对象 { $in: [...] } 是多行谓词,要走 updateMany)。于是任何谓词式更新到达这个 hook 时 input.id 都是 undefined,锁直接 return 放行:
// 记录 rec_1 上挂着 pending 的 sys_approval_request,lockRecord 未关闭
await ql.update('crm_opportunity', { where: { id: 'rec_1' }, data: { amount: 999 } }); // 被 RECORD_LOCKED 拦下 ✅
await ql.update('crm_opportunity', { where: { id: { $in: ['rec_1'] } }, multi: true, data: { amount: 999 } }); // 通过 ❌
await ql.update('crm_opportunity', { where: { name: 'x' }, multi: true, data: { amount: 999 } }); // 通过 ❌
也就是说,只要把同一次编辑改写成 multi: true 的形式,审批期间的记录锁就形同虚设 —— 不需要 admin 角色,不需要 isSystem,不需要 lockRecord: false,也不需要命中 approvalStatusField 白名单。SDK / ObjectQL / flow 的 update_record 类调用方都能产生这个形状。
为什么这是同一个 fail-open 家族
和 #4757(sys_attachment 的 beforeDelete)是同一个错误推理:「没有解析到目标行」被当作「没有东西需要授权」,而实际上是「从来没有查询过」。#4630 给 sys_comment 立的 resolveTargetRows 是正确姿势的参考。
区别在于修法不同 —— 这里不能照抄「无 id 无 where ⇒ 拒绝」:记录锁是按行判定的守卫(某条记录是否有 pending 审批),不是「整表操作要不要拒绝」。正确的做法应该是把 where 解析成匹配行集合(带上界,超限失败关闭),再对每一行查 pending 请求 —— 与 attachment / comment 两个守卫解析行集合的方式一致。
验收建议
同轮排查的其它位置(仅记录,不必在本 issue 处理)
同一形状在这些地方也存在,但它们不是授权守卫,后果是数据/可观测性而非越权,且多数在注释里已被声明为已知边界:
packages/plugins/plugin-audit/src/audit-writers.ts 的 captureBefore:if (!id) return; // bulk update/delete — too costly to snapshot every row here —— 批量删除不留审计快照(已声明的取舍)。
plugin-webhooks / plugin-email / plugin-sharing 三处 provenance 盖章 hook:if (!id) return; // multi-row update — see boundary note above(注释里明确写了 known boundary)。
plugin-sharing/src/primary-bu-projection.ts 的 beforeDelete:批量删成员行时 sys_user.primary_business_unit_id 投影不更新(设计上 best-effort,靠下次写入或 boot backfill 自愈)。
plugin-auth/src/identity-write-guard.ts 不在此列 —— 它的 beforeDelete 是无条件拒绝,beforeUpdate 走字段白名单,不依赖行解析。plugin-sharing/src/rule-hooks.ts 单独立在 #4779。
在 #4757 的排查中发现(那条 issue 末尾要求「看看仓库里还有没有别的
beforeDelete守卫共享『没解析到行 ⇒ 放行』这个形状」)。未认领,归属plugin-approvals,不在 #4757 的 PR(#4780)范围内。现象
packages/plugins/plugin-approvals/src/lifecycle-hooks.ts,bindApprovalLockHook注册的全局beforeUpdate(ADR-0019 记录锁)开头就是:而
packages/objectql/src/engine.ts的update()只在where.id是标量时才把它提取成input.id(与delete()同源的规则,注释里写明了:操作符对象{ $in: [...] }是多行谓词,要走updateMany)。于是任何谓词式更新到达这个 hook 时input.id都是undefined,锁直接return放行:也就是说,只要把同一次编辑改写成
multi: true的形式,审批期间的记录锁就形同虚设 —— 不需要 admin 角色,不需要isSystem,不需要lockRecord: false,也不需要命中approvalStatusField白名单。SDK / ObjectQL / flow 的update_record类调用方都能产生这个形状。为什么这是同一个 fail-open 家族
和 #4757(
sys_attachment的beforeDelete)是同一个错误推理:「没有解析到目标行」被当作「没有东西需要授权」,而实际上是「从来没有查询过」。#4630 给sys_comment立的resolveTargetRows是正确姿势的参考。区别在于修法不同 —— 这里不能照抄「无 id 无 where ⇒ 拒绝」:记录锁是按行判定的守卫(某条记录是否有 pending 审批),不是「整表操作要不要拒绝」。正确的做法应该是把
where解析成匹配行集合(带上界,超限失败关闭),再对每一行查 pending 请求 —— 与 attachment / comment 两个守卫解析行集合的方式一致。验收建议
multi: true更新命中被锁记录时抛RECORD_LOCKED(409),与按 id 更新的行为一致。lockRecord: false、admin 覆盖、approvalStatusField镜像写、isSystem、flowRunId同源写(Approval: a dead approval run can leave the record RECORD_LOCKED (#3424 follow-up, expected-behavior 3) #3456 / Approval: a schedule-triggered run still can't write its own locked record — it carries no ObjectQL context to holdflowRunId(#3456 residual) #3712)这些既有豁免在多行路径上同样生效,不能只在单 id 路径上生效。MULTI_DELETE_AUTH_LIMIT的 1000 行上界)。lifecycle-hooks现有用例旁边。同轮排查的其它位置(仅记录,不必在本 issue 处理)
同一形状在这些地方也存在,但它们不是授权守卫,后果是数据/可观测性而非越权,且多数在注释里已被声明为已知边界:
packages/plugins/plugin-audit/src/audit-writers.ts的captureBefore:if (!id) return; // bulk update/delete — too costly to snapshot every row here—— 批量删除不留审计快照(已声明的取舍)。plugin-webhooks/plugin-email/plugin-sharing三处 provenance 盖章 hook:if (!id) return; // multi-row update — see boundary note above(注释里明确写了 known boundary)。plugin-sharing/src/primary-bu-projection.ts的beforeDelete:批量删成员行时sys_user.primary_business_unit_id投影不更新(设计上 best-effort,靠下次写入或 boot backfill 自愈)。plugin-auth/src/identity-write-guard.ts不在此列 —— 它的beforeDelete是无条件拒绝,beforeUpdate走字段白名单,不依赖行解析。plugin-sharing/src/rule-hooks.ts单独立在 #4779。