## 概述 字段声明为**静态** `readonly: true` 时,该限制**只在 UI 层生效**,服务端**不会**从更新载荷里剥离该字段。因此任何能 `update` 该记录的用户,都可以用一次直接的 REST `PATCH` 写入这个字段。凡是依赖 `readonly: true` 来保护「审批状态 / 审批节点 / 金额」等字段完整性的对象,都会被绕过。 对比:`readonlyWhen: <CEL>` 是**服务端强制**的——命中条件的字段在更新时被静默剥离(HTTP 200,值不变)。两者不一致。 - 框架版本:**15.0.0** - 受影响对象的共享模型:`public_read_write`(默认 OWD) ## 影响(真机复现) 对象 `sporadic_application`(零星派工申请单)带 4 级审批流。`approval_status`、`approval_stage` 声明为 `readonly: true`;`confirmed_total` 是金额字段。 通过 **Console UI + 同一登录会话内的浏览器 fetch**(同一个已登录 worker 的 cookie,非管理员,`isSystem=false`)端到端复现: 1. worker 在 Console UI 新建草稿。创建表单**根本不渲染** `approval_status` / `confirmed_total`——UI 层看起来受保护,唯一合法动作是「提交审批」按钮。记录停在 `draft`。 2. 在同一登录会话的浏览器里直接发: ``` PATCH /api/v1/data/sporadic_application/<id> { "approval_status": "approved", "approval_stage": "approved", "confirmed_total": 99999 } → HTTP 200 ``` 3. 刷新详情页:状态显示**审批通过**,流水线跳到终节点,`confirmed_total = 99,999.00`,并冒出下游「派工」动作按钮。 最终效果:申请人**自我批准、完全跳过 4 级审批**,并自定结算金额。 **系统性**——在**第二个对象**上用同样手法坐实:`assessment`(考核单,3 级签核,`assessment_status` 同样是 `readonly: true`)——`PATCH { assessment_status:'approved', approval_stage:'approved' }` → 200,dept_head → gm → finance 三级全跳过。凡是依赖 `readonly: true` 保护审批 / 状态 / 金额 / 判定字段的对象,一律中招。 补充:`RECORD_LOCKED` 只在审批流**活动态(pending)**时拦直接 PATCH。记录仍在 `draft`(流从未启动)时不会被锁,而 `readonly: true` 又只是 UI 层——所以 draft → approved 是一步直跳。 ## 期望行为 `readonly: true` 的字段级写入应当服务端强制,和 `readonlyWhen` 字段在更新时被剥离的方式一致(insert 豁免)。即:对非特权写入者,静态 `readonly: true` 字段应在更新载荷里被静默丢弃。 ## 线索 / 指引 - `readonlyWhen` 已有服务端剥离路径(在 `beforeUpdate` 之后、基于合并的 `{...previous, ...data}` 求值,insert 豁免)。不一致点在于:静态 `readonly: true` 没有走同一条剥离路径。 - 我们应用侧的临时绕行:把 `readonly: true` 改成 `readonlyWhen`,或加一个高优先级 `beforeUpdate` 守卫,在 `!ctx.isSystem` 时剥离这些受保护字段。
Activity
os-zhuang commentedon Jul 16, 2026
排查结论,分三块说:行为缺口本身、为什么 15.0.0 上能复现、以及本 issue 补掉的部分。
1. 行为缺口:已在 main 上修复(#2948 → PR #2957)
你期望的行为——静态
readonly: true与readonlyWhen对称、在 UPDATE 时对非特权写入者做服务端剥离(insert 豁免)——已经由 #2957 实现,落在objectql/engine.ts的 update 路径(单条 + 多行都覆盖):readonlyWhen的剥离语义一致;updated_by/updated_at等服务端戳不受影响;isSystem: true(导入、种子回放、审批引擎、生命周期钩子)豁免;入站 HTTP 永远不会携带isSystem,不可能借此绕过;PATCH/PUT/批量/事务批处理最终都走engine.update,同受保护。2. 为什么你在 15.0.0 上打穿了
时间线问题:#2957 于 7 月 15 日合入 main,在 15.0.0 发布之后,尚未随任何版本发出(changeset 还在
.changeset/里待发布)。所以 15.0.0 上的复现完全成立——draft → approved一步直跳、confirmed_total自定金额,都是修复前的真实行为。下一个发布版本会带上该修复;在此之前,你们应用侧的绕行(改readonlyWhen,或高优先级beforeUpdate在!ctx.isSystem时剥离)是正确的临时方案,升级后可移除。另外两点确认:
confirmed_total若本身没有声明readonly: true,修复后它仍是普通可写字段——金额字段要受保护,需要在元数据上显式声明(或用验证规则/FLS);readonlyWhen对称。你们复现里创建表单不渲染这些字段是 UI 投影,服务端 POST 依然可以带值——如果这条边对你们是风险,建议单独开 issue 讨论是否要收紧。3. 本 issue 补的东西(PR #3015)
修复已存在,但这次事故暴露的是契约被误读 + 没有端到端防回退,PR #3015 补齐:
readonly-static-write行并列入 HIGH_RISK,证明文件删了 CI 就红;field/readonly,liveness 台账标live必须携带证明;FieldSchema.readonly的 Zod 描述原文就是 "Read-only in UI"——这正是这次误读的源头。描述、liveness 台账、手写文档现在都写明服务端剥离语义。PR CI 绿后合入,issue 随 PR 关闭。
Generated by Claude Code
Merge pull request #3015 from objectstack-ai/claude/wizardly-fermi-kr…
test(security): pin static-readonly server enforcement end-to-end + f…
fix(spec): regenerate drifted reference docs and gate them in CI (#3134)
fix(metadata-protocol): strip static `readonly` on INSERT at the data…
fix(metadata-protocol): strip static `readonly` on INSERT at the data…