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

vercelBot commented Jul 16, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specErrorErrorJul 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-actionsgithub-actionsBot 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 mainJul 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-zhuang@claude