## 概述 #2948 / #3003 修复后,静态 `readonly: true` 字段在**非 system 的 UPDATE** 上被服务端剥离——但 **INSERT 被有意豁免**(与 `readonlyWhen` 对称:创建时可播种 readonly 列,服务于 `defaultValue` / 导入 / 迁移)。 对**审批 / 状态 / 判定**类字段,这个豁免意味着:与其像 #3003 那样先建 draft 再 PATCH 提权,攻击者可以**直接 POST 一条已经 `approval_status: 'approved'` 的新记录**——比原复现还少一步,且 update 剥离完全拦不到(它只作用于 UPDATE)。 本 issue 请求 **决策该豁免是否收紧**(设计级,先定方向再实现)。 ## 定位 `packages/objectql/src/engine.ts` `insert()`(L2185 起): - L2206-2212 `applyFieldDefaults` 只填 `undefined` 字段,**不剥离** caller 提交的 readonly 列; - 全 insert 路径无 `stripReadonlyFields` 调用(该函数只在 `update()` 里被调,见 L2423 / L2442)。 对照 UPDATE 侧:`stripReadonlyFields`(`rule-validator.ts`)显式只在 update 调用,注释也写明「INSERT 豁免,symmetric with readonlyWhen」。 ## 备选方向 1. **维持现状 + 硬性文档指引(最省)**:承认 insert 可播种 readonly,把「审批/状态/金额/判定字段必须叠 `state_machine` 验证规则(强制初始状态)或 `requiredPermissions` FLS 兜底」写成 skill / 文档硬性约束。缺点:仍是「declared ≠ enforced」的认知负担,和 #3003 同类误读风险。 2. **收紧 insert:非 system 的 insert,readonly 列只允许取 `defaultValue`,caller 显式提交的值剥离**。保留导入/迁移(走 `isSystem`)与默认值播种,堵掉「直接 POST approved」。需评估对现有依赖「创建时带 readonly 初值」的应用侧写法的兼容影响。 3. **字段级 opt-in**:给 `readonly` 增加一个 `readonlyOnInsert`(或 `immutable`)维度,让作者显式声明「连创建都不可由用户设」,审批/状态字段声明它。最精确,但扩了 spec 面。 ## 判据 / 需核实 - 现有多少对象**合法依赖** insert 时由用户上下文写入 readonly 列(而非 system / defaultValue)?这决定方案 2 的 blast radius。 - 与 FLS `requiredPermissions`(ADR-0066 D3,写拒绝)的关系:是否已能覆盖一部分,从而只需文档而非引擎改动? - 若走方案 2/3,需配套 dogfood 证明(POST 伪造 readonly → 剥离 / 拒绝)+ authz 矩阵行,与 #3015 的 update 侧证明对称。 ## 关联 - #3003 / #2948(静态 readonly UPDATE 强制,已修 · PR #2957 / #3015)—— 本 issue 是其 **INSERT 面** - ADR-0066 D3(`requiredPermissions` 字段级写拒绝)—— 可能的纵深兜底 - 同批开的 `readonlyWhen` 多行路径缺口 issue —— 同属「readonly 家族强制一致性」
Activity
docs(spec): regenerate field reference for the readonly INSERT-strip …
fix(metadata-protocol): strip static `readonly` on INSERT at the data…
fix(metadata-protocol): strip static `readonly` on INSERT at the data…
os-zhuang commentedon Jul 18, 2026
落地说明(已修复 · PR #3162,已合并
beaf2de)方向决策
在三个备选里选了收紧(方案 2 的方向),但实现落点从「引擎
insert()」调整到了外部数据写入入口——原因见下。放弃了方案 1(仅文档:其设想的state_machine初始态强制在 insert 上今天是 no-op,挡不住播种)和方案 3(字段级 opt-in:不扩 spec 面即可覆盖本威胁)。为什么不在引擎
insert()剥离最初按与 #2948 对称的思路在引擎
insert()里剥离,但 blast radius 远超本 issue「判据」一节的预估:better-auth adapter、metadata-repo 等核心框架写入方都用非 system 上下文经engine.insert播种 readonly 列(email/凭据、event_seq/id),被剥离后 dev-admin 登录失败、元数据事件日志 NOT NULL 崩溃——core/platform 里约 40+ 处同类调用点。即「合法依赖 insert 时由非 system 上下文写入 readonly 列」的对象比预估多得多。最终实现:入口级剥离
落在
DataProtocol的 create 汇聚点(createData/createManyData/batchData/cloneData)——REST CRUD、GraphQL/MCP dispatcher(bridge.create→callData→createData)、批量 import 全部经此,而可信内部写入方直接调engine.insert、天然绕过。一处拦截覆盖所有 caller/agent 路径(含 MCP),又不误伤内部写入方。关键性质:
owner_id/organization_idstamp 稍后在引擎内注入)。defaultValue(伪造approval_status→draft,非 NULL)。isSystem豁免;静默(HTTP 2xx);批量/import 逐行;readonlyWhen保持 INSERT 豁免。managedBy或sys_)交由其自有字段治理——例如 ADR-0086 对sys_permission_set伪造managed_by:'package'/package_id是硬拒绝 403,若在此静默剥离会吞掉本应被拒的 payload。这与applySystemFields的边界一致,本 issue 的威胁面(app 审批/状态字段)从不涉及sys_。与「判据/需核实」的对应
requiredPermissions(ADR-0066 D3)的关系:二者正交——FLS 按能力 AND-gate 拒绝写;本剥离针对「字段声明 readonly、但无 FLS/无专属 guard」的默认面。平台对象的专属 guard(ADR-0086/安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004)优先,不被抢先。行为变化(需应用侧知悉)
非 system 经数据 API(REST/GraphQL/MCP/import)对自定义对象的 create,不再能从 payload 播种 readonly 列。需要在创建时写只读列的合法流程(导入历史 provenance、迁移、程序化 seed)须走 system 上下文——与 UPDATE 侧剥离早已施加的要求相同。
证明
metadata-protocol/src/protocol.readonly-insert.test.ts(6 例:伪造剥离 + 默认回落、system 放行、批量逐行、平台对象豁免)。showcase-static-readonly.dogfood.test.ts:非 system POST 伪造lead_score端到端被剥离;同时two-doors-permission的 ADR-0086 provenance 伪造仍 403。authz-conformance.matrix.tsreadonly-static-write行扩展到 INSERT 面;field.zoddescribe + spec liveness note 同步。关联:#3003 / #2948(UPDATE 面,PR #2957 / #3015)—— 本 issue 是其 INSERT 面,现已闭合。
Generated by Claude Code
fix(plugin-auth): run better-auth adapter writes as system context (#…
feat(validation): state_machine.initialStates enforces the FSM entry …
feat(rest): surface silently-dropped write fields on PATCH/POST /data (…
feat(rest/protocol): extend droppedFields write-observability to bulk…
fix(rest): route batch creates through the create ingress so `readonl…
fix(approvals,rest): tell the truth about approval writes — lock poli…
fix(objectql): a readonly lookup is not the caller's reference to ans…
fix(data): 审计锚点归引擎所有,lookup 必须能解析 (objectstack-ai#4447, objectstack-a…
fix(objectql): autonumber 是运行时拥有的字段,写路径不再接受调用者提交的单号 (objectstack-ai#5503
docs(spec): strictReadonlyWrites contract no longer claims INSERT ign…
docs(kernel): align data-engine contract page with the shipped engine…
feat(lint): gate a hook body's ctx.api write to a readonly field (#13653
feat(lint): gate a hook body's ctx.api write to a readonly field (obj…