Repository navigation
安全/设计:静态 readonly 的 INSERT 豁免让审批/状态字段可在创建时被直接播种(比 #3003 少一步) #3043
Description
Activity
落地说明(已修复 · 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),又不误伤内部写入方。关键性质:
- 仅剥离 caller 显式提交的键(入口 payload 是纯 caller 输入,
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_。
与「判据/需核实」的对应
- blast radius:正是它把方案从引擎级推到了入口级(内部写入方保持不变)。
- 与 FLS
requiredPermissions(ADR-0066 D3)的关系:二者正交——FLS 按能力 AND-gate 拒绝写;本剥离针对「字段声明 readonly、但无 FLS/无专属 guard」的默认面。平台对象的专属 guard(ADR-0086/安全:owner_id(属主锚点)客户端可写、服务端无守卫 → 非属主可伪造/转移记录属主 #3004)优先,不被抢先。 - dogfood 证明 + authz 矩阵行:已补齐(见下),与 test(security): pin static-readonly server enforcement end-to-end + fix its declared contract (#3003) #3015 的 update 侧证明对称。
行为变化(需应用侧知悉)
非 system 经数据 API(REST/GraphQL/MCP/import)对自定义对象的 create,不再能从 payload 播种 readonly 列。需要在创建时写只读列的合法流程(导入历史 provenance、迁移、程序化 seed)须走 system 上下文——与 UPDATE 侧剥离早已施加的要求相同。
证明
- 入口单测
metadata-protocol/src/protocol.readonly-insert.test.ts(6 例:伪造剥离 + 默认回落、system 放行、批量逐行、平台对象豁免)。 - dogfood
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 同步。- 全量 dogfood 294 passed / 0 failed,CI 全绿。
关联:#3003 / #2948(UPDATE 面,PR #2957 / #3015)—— 本 issue 是其 INSERT 面,现已闭合。
Generated by Claude Code
- 仅剥离 caller 显式提交的键(入口 payload 是纯 caller 输入,
- added 4 commits that reference this issue
on Jul 18, 2026 - added a commit that references this issue
on Jul 28, 2026 - added a commit that references this issue
on Aug 31, 2026 - added a commit that references this issue
on Sep 1, 2026
概述
#2948 / #3003 修复后,静态
readonly: true字段在非 system 的 UPDATE 上被服务端剥离——但 INSERT 被有意豁免(与readonlyWhen对称:创建时可播种 readonly 列,服务于defaultValue/ 导入 / 迁移)。对审批 / 状态 / 判定类字段,这个豁免意味着:与其像 #3003 那样先建 draft 再 PATCH 提权,攻击者可以直接 POST 一条已经
approval_status: 'approved'的新记录——比原复现还少一步,且 update 剥离完全拦不到(它只作用于 UPDATE)。本 issue 请求 决策该豁免是否收紧(设计级,先定方向再实现)。
定位
packages/objectql/src/engine.tsinsert()(L2185 起):applyFieldDefaults只填undefined字段,不剥离 caller 提交的 readonly 列;stripReadonlyFields调用(该函数只在update()里被调,见 L2423 / L2442)。对照 UPDATE 侧:
stripReadonlyFields(rule-validator.ts)显式只在 update 调用,注释也写明「INSERT 豁免,symmetric with readonlyWhen」。备选方向
state_machine验证规则(强制初始状态)或requiredPermissionsFLS 兜底」写成 skill / 文档硬性约束。缺点:仍是「declared ≠ enforced」的认知负担,和 安全:静态字段 readonly: true 仅 UI 层生效、服务端不强制 → 审批/状态/金额字段可被直接 PATCH 绕过写入(FLS 缺口) #3003 同类误读风险。defaultValue,caller 显式提交的值剥离。保留导入/迁移(走isSystem)与默认值播种,堵掉「直接 POST approved」。需评估对现有依赖「创建时带 readonly 初值」的应用侧写法的兼容影响。readonly增加一个readonlyOnInsert(或immutable)维度,让作者显式声明「连创建都不可由用户设」,审批/状态字段声明它。最精确,但扩了 spec 面。判据 / 需核实
requiredPermissions(ADR-0066 D3,写拒绝)的关系:是否已能覆盖一部分,从而只需文档而非引擎改动?关联
requiredPermissions字段级写拒绝)—— 可能的纵深兜底readonlyWhen多行路径缺口 issue —— 同属「readonly 家族强制一致性」