Skip to content

跨对象事务批 POST /batch 绕过 create 侧的 readonly 入口剥离(#3043) #3835

Description

@baozhoutao

现象

POST /api/v1/batch(跨对象事务批,ADR-0034 / #1604)在事务里直接调 ql.insert / ql.update

// packages/rest/src/rest-server.ts — /batch handler
if (op.action === 'create') {
    out.push(await ql.insert(op.object, data, { context: trxCtx, onFieldsDropped }));
}

而单条创建走的是 protocol.createData,它在进引擎之前先跑一次 stripReadonlyForInsert

// packages/metadata-protocol/src/protocol.ts — createData
const data = stripReadonlyForInsert(
    this.engine.registry?.getObject(request.object),
    request.data,
    request.context,
);

这层入口剥离存在的原因是引擎的 INSERT 路径对静态 readonly 免疫#3413stripReadonlyFields 只在 update 分支跑)。也就是说:

  • POST /api/v1/data/:object → 非系统调用者塞 readonly 列 → 被剥离;
  • POST /api/v1/batchaction: 'create')→ 同一个列 → 原样落库

同一个语义,两条写路径两个结果。

复现思路

对象 A 上声明一个 readonly: true 字段(例如 approval_status),以普通用户身份:

  1. POST /api/v1/data/A {"approval_status":"approved", ...} → 该字段被丢弃,取默认值(并在 droppedFields 里报出来);
  2. POST /api/v1/batch {"operations":[{"object":"A","action":"create","data":{"approval_status":"approved", ...}}]} → 该字段被写进去。

影响

readonly#2948/#3043 之后是写路径约束,不只是渲染提示。批量创建这条路留着口子,意味着任何能调 /batch 的客户端都能给审计戳、来源字段、审批状态镜像等只读列赋任意值——而这类字段常常正是别处逻辑信赖的。Console 的主记录表单(master-detail)走的就是 /batch,所以这不是一条冷门路径。

建议方向

按 PD #12「修生产者、别在消费者兜底」:把 create 侧的入口剥离提到两条路共用的位置,而不是在 /batch 里再抄一遍 stripReadonlyForInsert。两种可行落点:

第二种更接近「一个契约一处执行」,但要先确认 #3413 当初豁免 insert 的理由(系统种子 / seed 写入路径)在下沉后仍走得通——那批调用是 isSystem,本来就在剥离的豁免名单里。

来源

#3794 排查中发现(修复 PR:#3834,该 PR 只给 /batch 接上了 onFieldsDropped 的写可观测性,没有动这里的剥离行为——按 AGENTS.md PD #10 单独立此 issue,不扩大原范围)。

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions