概述
readonlyWhen(条件只读)的服务端剥离 只在单条 by-id update 路径上运行;options.multi 的 多行 updateMany 路径不做 readonlyWhen 剥离。因此一个命中 readonlyWhen 条件、本应被锁的字段,可以用一次多行更新绕过——例如「invoice 变 paid 后锁 tax_rate / 锁行项」这类条件锁,PATCH … /updateMany 就能改写。
这是 #3003 / #2948 的同族问题:静态 readonly: true 的剥离(#2957)已同时覆盖单条 + 多行两条路径,但 readonlyWhen 的剥离只补了单条路径,两者不对称。
定位
packages/objectql/src/engine.ts,update():
- 单条 by-id 路径(
hookContext.input.id 分支):
- L2416
stripReadonlyWhenFields(...) ✅
- L2423
stripReadonlyFields(...)(静态 readonly,非 system)✅
- 多行
options.multi && driver.updateMany 路径(L2427 起):
- L2442
stripReadonlyFields(...)(静态 readonly)✅
- 缺
stripReadonlyWhenFields(...) ❌
REST 侧多行入口存在且默认开启:packages/rest/src/rest-server.ts L6075 POST /data/:object/updateMany(operations.updateMany 默认 true,L1646),所以该路径对外可达。
根因
stripReadonlyWhenFields 需要 prior record 求值合并态 {...previous, ...data};多行路径出于成本考虑不逐行 fetch prior record(现有代码对 state_machine/cross_field 规则同样只 logger.warn 跳过,见 L2434-2436),readonlyWhen 顺势没接上剥离。
期望行为
多行 update 也应强制 readonlyWhen,与单条路径及静态 readonly 的多行覆盖对称。需要决策实现方式:
- 方案 A(简单、可能过严):多行路径对任何声明了
readonlyWhen 的字段,若出现在载荷里就无条件剥离(不看 per-row prior,等价「宁可锁死」)。对「锁字段」语义通常可接受,但对少数 readonlyWhen 依赖行间差异的场景会误剥。
- 方案 B(精确、成本高):多行路径按匹配集逐行拉 prior、逐行求值剥离——与
state_machine/cross_field 多行强制是同一笔账,可一并解决(去掉那处 warn 跳过)。
- 方案 C(收口):若判定多行路径无法安全强制条件规则,则对「声明了
readonlyWhen 或 object-level 转换规则的对象」在多行 update 上 fail-closed 拒绝,把 warn 升级为 error。
倾向 A 或 C 作为短期封堵(和「剥离优先、兼容性好」的既有取向一致),B 作为长期精确解。
关联
概述
readonlyWhen(条件只读)的服务端剥离 只在单条 by-id update 路径上运行;options.multi的 多行updateMany路径不做readonlyWhen剥离。因此一个命中readonlyWhen条件、本应被锁的字段,可以用一次多行更新绕过——例如「invoice 变 paid 后锁tax_rate/ 锁行项」这类条件锁,PATCH … /updateMany就能改写。这是 #3003 / #2948 的同族问题:静态
readonly: true的剥离(#2957)已同时覆盖单条 + 多行两条路径,但readonlyWhen的剥离只补了单条路径,两者不对称。定位
packages/objectql/src/engine.ts,update():hookContext.input.id分支):stripReadonlyWhenFields(...)✅stripReadonlyFields(...)(静态 readonly,非 system)✅options.multi && driver.updateMany路径(L2427 起):stripReadonlyFields(...)(静态 readonly)✅stripReadonlyWhenFields(...)❌REST 侧多行入口存在且默认开启:
packages/rest/src/rest-server.tsL6075POST /data/:object/updateMany(operations.updateMany默认 true,L1646),所以该路径对外可达。根因
stripReadonlyWhenFields需要 prior record 求值合并态{...previous, ...data};多行路径出于成本考虑不逐行 fetch prior record(现有代码对state_machine/cross_field规则同样只logger.warn跳过,见 L2434-2436),readonlyWhen顺势没接上剥离。期望行为
多行 update 也应强制
readonlyWhen,与单条路径及静态readonly的多行覆盖对称。需要决策实现方式:readonlyWhen的字段,若出现在载荷里就无条件剥离(不看 per-row prior,等价「宁可锁死」)。对「锁字段」语义通常可接受,但对少数readonlyWhen依赖行间差异的场景会误剥。state_machine/cross_field多行强制是同一笔账,可一并解决(去掉那处 warn 跳过)。readonlyWhen或 object-level 转换规则的对象」在多行 update 上 fail-closed 拒绝,把 warn 升级为 error。倾向 A 或 C 作为短期封堵(和「剥离优先、兼容性好」的既有取向一致),B 作为长期精确解。
关联
readonlyWhen对称缺口 + 多行路径面engine.tsL2434-2436 多行state_machine/cross_field跳过的 warn —— 同一笔「多行路径不强制转换类规则」的账