Skip to content

fix(security): enforce readonlyWhen on the multi-row UPDATE path (#3042)#3044

Merged
os-zhuang merged 1 commit into
mainfrom
claude/fix-readonlywhen-multi-3042
Jul 16, 2026
Merged

fix(security): enforce readonlyWhen on the multi-row UPDATE path (#3042)#3044
os-zhuang merged 1 commit into
mainfrom
claude/fix-readonlywhen-multi-3042

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

背景(#3042)

条件只读 readonlyWhen 的服务端剥离此前只在单条 by-id update 路径上运行(stripReadonlyWhenFields);多行 update({ multi: true, where }) 路径只强制了静态 readonly(#2948),从不强制 readonlyWhen——与单条路径、以及静态 readonly 的多行剥离都不对称。用户上下文下的编程式/嵌入式调用方(或插件)发起多行更新时,可以写入本应被自身 readonlyWhen 谓词锁住的字段。

影响范围校正(重要,诚实说明)

排查后确认:issue 原文「REST POST /data/:object/updateMany 对外可达 → 多行路径」的判断不准确。核实结论:

  • 外部 REST /updateMany 端点与 SDK updateMany 本身是安全的——protocol.updateManyData 是按 id 列表循环单条 engine.update(每条走单条路径、已剥离 readonlyWhen);HTTP dispatcher / hono / SDK 的所有 update 入口也都是单条 where:{id}
  • 真正的缺口是引擎的真·where 谓词多行路径(options.multi=true),它只被编程式/嵌入式消费方触达:内部服务(lifecycle / messaging outbox / settings,均 system 上下文)、插件、以及 embed-objectql 这类直连 ObjectQL 的用户上下文调用方。

因此这是一个对称性 + 纵深防御 + 编程式面封堵的修复,严重性低于 issue 初判。相应地,证明层级也据此选取:集成测试直接驱动真实的 objectql.update(..., {multi:true, where}) 多行路径——这才是真实受影响面;HTTP dogfood 测试不会经过此路径(它循环单条),加了反而误导,故不加。

变更

engine.update 多行路径,仅当载荷确实写了 readonlyWhen 字段时(廉价的 hasReadonlyWhenInPayload 门控):

  • 与写入绑定的同一个已组合 AST(含 RLS/sharing 行级过滤)读回行级匹配集(一次查询);
  • 经新增 stripReadonlyWhenFieldsMulti 剥离「在 ≥1 匹配行上谓词为 TRUE」的字段——一次批量载荷无法对部分行写、部分行跳,故只要任一目标行锁住即对整批 fail-safe 丢弃(想更新未锁行请收窄 where);
  • 无任何匹配行锁住的条件字段照常写入(合法批量编辑不受影响);
  • 与单条 stripReadonlyWhenFields、静态 readonly 多行剥离对称;INSERT 豁免不变。
  • 单条与多行的逐字段谓词求值抽出共享 isReadonlyWhenLocked,零行为漂移。

验证

  • packages/objectql 全套 898 通过(67 文件)。
  • 新单元测试:hasReadonlyWhenInPayload + stripReadonlyWhenFieldsMulti(全锁 / 单锁 / 全不锁 / 空匹配 / 载荷无该字段)。
  • 新集成测试:驱动真实多行 objectql.update(...,{multi:true,where})——伪造字段在有锁行时被剥离、同载荷可编辑字段存活、无锁时保留、且预读用的是与写入相同的行级谓词 AST。
  • tsc --noEmit、objectql DTS build 均绿。

纯行为修复,但会改变嵌入式消费方的可观察行为(multi:true 下曾可写的 readonlyWhen 字段现被静默丢弃),故附 @objectstack/objectql patch changeset。

分支说明:上一轮 #3003 的 designated 分支 PR(#3015)已合并,本次为独立后续修复,从最新 main 起新分支开出,不复用已合并历史。

Closes #3042.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TKR6MTrGunV4p4AUfbKMDU


Generated by Claude Code

Conditional `readonlyWhen` field locks were stripped only on the single-id
UPDATE path (`stripReadonlyWhenFields`). The bulk `update({ multi: true, where })`
path enforced static `readonly` (#2948) but never `readonlyWhen` — an asymmetry
with both the single-id path and the static-readonly bulk strip. A programmatic
or embedded caller (or a plugin) issuing a multi-row update in a user context
could write a field its own `readonlyWhen` predicate should have locked.

Scope note: the external REST/SDK `updateMany` endpoint was already safe — it
loops single-id `engine.update` calls, each of which strips `readonlyWhen`. The
gap was the engine's true where-predicate multi path, reached programmatically
(embedded ObjectQL, plugins, internal services) — plus defense-in-depth for any
future route that wires a bulk where-based update.

engine.update now, on the multi-row path and only when the payload actually
writes a `readonlyWhen` field (cheap `hasReadonlyWhenInPayload` gate), reads the
row-scoped match set with the SAME composed AST the write binds (one query) and
drops any field whose predicate is TRUE for at least one matched row via the new
`stripReadonlyWhenFieldsMulti`. A single bulk payload cannot keep a field for
some rows and drop it for others, so a field locked in any target row is
fail-safe-dropped for the batch; a conditional field no matched row locks is
written normally (legitimate bulk edits unaffected). Symmetric with the single-id
strip; INSERT stays exempt. The per-field predicate eval is factored into a
shared `isReadonlyWhenLocked` helper used by both single-id and bulk strips.

Tests: rule-validator unit tests for `hasReadonlyWhenInPayload` and
`stripReadonlyWhenFieldsMulti` (locked-in-all / locked-in-one / locked-in-none /
empty-match / no-field-in-payload), plus an engine integration test driving the
real `objectql.update(..., { multi: true, where })` path (forge stripped when a
matched row is locked, sibling editable field survives, kept when no row locks
it, pre-read is row-scoped with the write's predicate). objectql suite: 898
passed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKR6MTrGunV4p4AUfbKMDU
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Error Error Jul 16, 2026 10:05am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)
  • content/docs/releases/v9.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 16, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review July 16, 2026 10:39
@os-zhuang
os-zhuang merged commit d687f08 into main Jul 16, 2026
15 of 16 checks passed
@os-zhuang
os-zhuang deleted the claude/fix-readonlywhen-multi-3042 branch July 16, 2026 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

安全:readonlyWhen 服务端剥离仅覆盖单条 update 路径,多行 updateMany 不强制(条件只读可被批量绕过)

2 participants