fix(approvals): 记录锁对谓词式(multi)更新同样生效 (#4778) - #4838
Conversation
…4778) The ADR-0019 record lock only ran for updates carrying an `input.id`, which the engine extracts from a SCALAR `where.id` alone. Any other predicate is a multi-row write that routes to `updateMany` and reached the hook with no id, so `if (!id) return` read "no row was resolved" as "there is nothing to authorize" when the truth was "nothing was ever queried" — the #4757 / #4630 fail-open shape, here reachable with no privilege at all: rewriting the same edit as `multi: true` bypassed the lock without admin, `isSystem`, `lockRecord: false` or a whitelisted field. The hook now resolves the rows a write touches before deciding. By-id writes are unchanged. A predicate write is decided by intersecting the caller's predicate with the object's LOCKED records, so the query is bounded by pending approvals rather than by the update's match set; an unscoped whole-table `multi` update reaches every locked row and is refused while any is held. Past 1 000 locked records, or if the intersection query fails, the write fails closed. Every exemption moves with the guard — `isSystem`, admin, the `approvalStatusField` mirror, `lockRecord: false` and the owning run's `flowRunId` (#3456 / #3712) — each pinned on both predicate shapes, plus a real-engine integration test that reproduces the issue's three lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
…roval-lock-multi-update
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
复核通过 —— ACCEPT,已标 ready 并送合并队列逐条核过,记录几点判断依据: 1. 修复位置对。 守卫不是在六个调用点各补一遍,而是把"这次写入会碰到哪些行"的解析放进 hook 本身,与 #4757( 2. 复杂度的方向选对了,这是本 PR 最容易做错而没做错的地方。 上界落在该对象上待审批的记录数(正常为 0,一次查询就返回),而不是更新的匹配集。所以 5 万行无锁的批量更新只多一次 bookkeeping 查询、照常放行。若把上界放在匹配集上,这个守卫会把每一次大批量更新都变成灾难。 3. 五条豁免在多行路径上确实各有测试。 我第一次核查时用 4. 唯一保留的 fail-open 是被论证过的,不是遗漏。 5. changeset 6. 反向验证做得对。 把 范围外发现已妥善处理#4839(admin 豁免读 值得指出它与 #4837 是同一个形态:声明了、消费端也有读取代码、gate 全绿,但没有任何生产者传值。#4837 是 Generated by Claude Code |
Fixes #4778
问题
bindApprovalLockHook注册的全局beforeUpdate(ADR-0019 记录锁)开头是if (!id) return。而update()只在where.id是标量时才提取input.id,任何谓词式更新都走updateMany且input.id为undefined—— 于是「没解析到行」被当成「没有东西需要授权」,实际是「从来没有查询过」。这是 #4757 / #4630 同一族的 fail-open,但绕过成本最低:不需要 admin、不需要
isSystem、不需要lockRecord: false、也不需要命中approvalStatusField白名单,只要把同一次编辑写成multi: true:修法
hook 现在先解析这次写入会碰到哪些行,再逐行判定。
where的其余部分不参与,所以这里不做任何收窄 —— 收窄反而会变成新的 fail-open(where: { id: 'rec_1', stage: 'x' }匹配不上时会误放行,而 driver 照写不误)。where与该对象当前被锁的记录求交({ $and: [where, { id: { $in: lockedIds } }] })。方向是反过来的,这正是它便宜的原因 —— 上界落在对象上的 pending 审批数,而不是更新的匹配集:5 万行的无锁批量更新只多一次 bookkeeping 查询,照常放行。multi更新碰到该对象的每一条被锁记录,只要有一条被锁就拒绝。MULTI_DELETE_AUTH_LIMIT/MULTI_WRITE_AUTH_LIMIT同一上界),或求交查询本身失败 —— 都拒绝写入,因为锁无法证明这次写入避开了被锁行。sys_approval_request的 kernel 会拒绝掉部署里的每一次更新。这一条是既有行为,注释里写明了为什么它与前面几条不同(攻击者无法操纵它)。豁免逐条随守卫一起搬过去
把守卫扩到多行最常见的错误是只搬拒绝逻辑,于是从 fail open 直接翻成误伤。五条既有豁免在多行路径上都逐一有测试(且在
$in与非 id 谓词两种形状上各测一遍):isSystem(引擎自写 / 状态镜像)approvalStatusField镜像写lockRecord: falseflowRunId同源写(#3456 / #3712)测试
approval-service.test.ts—— 在现有record-lock hook (node era)用例旁新增record-lock hook — predicate (multi) updates (#4778):拒绝、不误伤(谓词匹配不到被锁行时放行)、上界失败关闭、求交查询失败时失败关闭、无 pending 时完全不扫描业务表(用_finds钉住),以及上表五条豁免 × 两种谓词形状。record-lock-multi-update.integration.test.ts—— 真实ObjectQL引擎 + 支持$in/$and/updateMany的内存 driver,由引擎自己决定走 by-id 还是updateMany并构造 hook context(即产生这个 bug 的那一跳,不 stub),复现 issue 里的三行。if (!id) return临时放回去,这两个文件里 14 条用例失败;恢复后pnpm --filter @objectstack/plugin-approvals test402 passed / 18 files,typecheck干净,eslint干净。范围
改动只在
packages/plugins/plugin-approvals/src/与一个 changeset(patch:没有新增包根导出)。未改packages/objectql/src/engine.ts—— engine 的 id 提取规则是刻意的,这次修的是消费方对「解析不到行」的错误推理。顺带发现(未在本 PR 处理)
session.roles在 ObjectQL 的buildSession()里从未被填充,所以记录锁与 delegation 守卫里的roles.includes('admin')在真实引擎路径上大概率永不生效,而且与 ADR-0095 /isOverrideActor的permissions/positions/posture词汇不一致。两处都是失败关闭(admin 被误拒,不是越权),因此按 PD #10 单独立 issue,不在本 PR 扩范围。🤖 Generated with Claude Code
https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
Generated by Claude Code