Skip to content

data: batchData 的 upsert 分支用「调用者可见性」而非「存在性」决定 update-or-insert —— RLS 作用域外的既有行会走插入分支 #5099

Description

@os-zhuang

#5088(by-id 批量写补存在性闸门)时在同一个循环里读到的相邻问题,未在本次 PR 范围内修改(#5088 的验收面只含 update / delete 两个分支)。

现状

packages/metadata-protocol/src/protocol.ts,runBatchDataLoopupsert 分支,用调用者的执行上下文去问「这条记录在不在」:

const existing = await this.engine.findOne(object, { where: { id: record.id }, ...ctxOpt } as any);
if (existing) {
    // update
} else {
    // insert,并把 record.id 一起写进去
}

ctxOpt 就是 REST 透传下来的 caller context(#3455 特意接上的),所以 RLS / sharing 会收窄这次读。也就是说:exists-or-insert 这个分岔是按「可见性」判的,不是按「存在性」判的。

同文件 probeRecord 的注释把这条区分写成了承重点(#4435):

Does this row EXIST? A fact about the database — deliberately NOT "may this caller see it".
…probing with the CALLER's context turns the existence gate into an authorization gate

单记录 updateData 因此用系统上下文探针;upsert 这里是同一个区分朝反方向踩空。

影响

一个受行级作用域限制的调用者 upsert 一个确实存在、但在他作用域之外的 id 时,会进入 insert 分支:

顺带一提,即使不谈 RLS,这里也和 #5088 修完之后的 update / delete 分支口径不一致了:那两个分支现在问存在性,upsert 仍问可见性。#4620 对本文件的要求是三个 bulk 写面共用一份口径。

需要拍板的地方(所以没有顺手改)

upsert 的正确语义不是显然的,有两种读法,后果不同:

  1. 改用 probeRecord(存在性):作用域外的既有行走 update 分支,写不写得进去交回 engine.updatefix(security)[P0]: enforce RLS on by-id writes — close member-edits-others'-records hole (#1985) #1994 的 by-id 前像检查决定 —— 与 data: PATCH/DELETE of a nonexistent record answer 200 success instead of RECORD_NOT_FOUND #4435 / data: updateMany runs hooks for a nonexistent id — the row fails INTERNAL_ERROR from a hook-condition abort instead of RECORD_NOT_FOUND #5088 的取向一致,授权判断留在写策略那一层。
  2. 维持可见性语义:则至少要让 insert 失败时报出可理解的错误,而不是裸的 duplicate-key。

倾向 1(和已确立的「探针只问存在性、授权归写策略」一致),但这会改变一个已发布端点在 RLS 部署下的可观察行为,按契约优先的规矩应由维护者定,不由实现方顺手决定。

复现状态

据源码判读,未做端到端复现(需要一套带行级策略的 fixture + upsert 批量请求)。分类与优先级请按 triage 轮次判定 —— 这里只做如实记录。

发现于 #5088 的实现过程。

https://claude.ai/code/session_01BotUP49pqhvqGY393n2HfU

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions