Skip to content

security(engine): reference-cleanup set_null write clears ONLY the object-level CRUD check (__referentialFieldClear-scoped) — FLS/RLS guards stay enforced; cascade untouched (RULED 2026-08-28) #12597

Description

@os-warren

⚖️ RULED — twice. Do not adjudicate or dispatch from the menu below: it is the superseded FIRST-round framing, kept as history.

  • Round 1 (maintainer, 2026-08-27 decision batch): option B of the menu below — the set_null cleanup write moves off the operator identity; cascade untouched; constraint 2 required two gate measurements before any code.
  • Round 2 (maintainer, 2026-08-28, director summon ✨ Set up Copilot instructions #2 — triggered by the constraint-2 measurement: blanket isSystem in this codebase is TOTAL and also bypasses field-level security, RLS using and the RLS post-image check): marker-scoped exemption of the object-level CRUD check ONLY, via the existing __referentialFieldClear marker. The three unrelated guards stay enforced, each pinned; audit attribution keeps the measured sudo() shape; cascade stays operator-identity and excluded; changeset MINOR; Clause-② yes (needs:contract-review attached, dispatch at CONTRACT_REVIEW_TIER).
  • Authoritative record: the 2026-08-28 "Maintainer ruling applied (second round)" comment on this card. Current state: pm:queue, awaiting the domain:engine seat's dispatch.
  • (Card face updated by the director seat, session session_016SG9S6V15MqeAgkehDcTwk, at the maintainer's request — the title and body previously still read as an undecided FYI card.)

一句话

#12166 的裁决(2026-08-26,选项 A)把删除前的引用检查切成了系统身份,约束 1 明确限定「删除路径的其他任何部分身份不变」。因此引用清理的写入半边 —— set_nullUPDATEcascade 的子记录 DELETE —— 仍以操作人身份执行。结果:原报告里那批「有删除权、对引用表无任何权限」的角色,只有在引用表为空时删除才通得过;引用表真的有引用行时,依然 403,只是报错从 find 变成了 update

不是 #12166 的实现缺陷,也不是要推翻裁决 —— 它正是约束 1 划定的边界。开这张卡只是因为该边界的实际影响没有被测量过,而它决定了原报告那 17 组「角色×对象」里到底有多少被真正修好。

证据(PR #12596 里已 pin 成断言)

packages/plugins/plugin-security/src/delete-reference-cleanup-system-identity.test.ts

it('the elevation is the CHECK only — a non-empty referencing table still needs
    the caller\'s own write authority (constraint 1)', ...)

  expect(err.code).toBe('PERMISSION_DENIED');
  expect(err.details?.object).toBe('os_ehr_andon_record');
  expect(err.details?.operation).toBe('update');   // ← 不再是 'find'

夹具与原报告同形:对象 A 被 B 的可空 lookup 引用(解析行为 = set_null),角色对 A 有完整删除权、对 B 无任何授权。B 为空 ⇒ 删除成功(#12166 修好的部分);B 有一行 ⇒ 仍然 403。

为什么值得单独判一次

  • 原报告的 17 组配置里,引用表非空的那些仍然按不动删除按钮,症状与修复前对用户完全一致(同样是一句通用「您没有执行此操作的权限」);从部署方视角看,问题只解决了一部分,而这一点在 删除记录时「引用清理」用操作人身份查询引用表,读权不足即整体 403(应以系统身份执行) #12166 的验收里看不出来。
  • 裁决为引用检查给出的论证 —— 引用完整性动作是引擎责任、主流平台一律以系统身份执行(RDBMS FK 的 SET NULLCASCADE、Salesforce 的 lookup 清空与级联删除均记载为绕过 sharing)—— 在文义上同样覆盖写入半边:FK 基线里真正以引擎身份执行的正是那个 SET NULLDELETE。裁决把范围钉在查询上是刻意的(卡片正文与决策分析通篇只说「查询」),但据我所读,那个范围收窄没有单独论证过写入半边应当保留操作人身份 —— 它更像是照着 issue 的措辞划的,而不是对写入半边下的判断。
  • 反方向也确实存在:cascade 的子删除以系统身份执行意味着「有 A 的删除权 ⇒ 可以删掉自己无权删除的 B 行」,这是一个远比读权外溢更大的权限语义变更,绝不该顺手带过。set_null(只清 FK 列)与 cascade(删整行)在这个维度上不对称,很可能需要分别裁。

建议处置(⚠️ 已被上方两轮裁决取代,仅存档)

  • A. 维持现状(裁决的字面范围):写入半边保持操作人身份。代价:原报告的部署仍需为每个可删角色补齐引用表的权限,「删除权 = 删除权 + 引用表权限」的隐藏耦合只是从读权变成了写权,在权限界面上同样不可见。
  • B. set_null 也切系统身份,cascade 不切:清空 FK 列是纯粹的完整性维护(且已有 属主守卫与级联 set_null 冲突:非特权删除 sys_user 时 owner_id 级联置空被 #3004 守卫拦截(级联中途失败) #3023__referentialFieldClear 标记把它标为引擎内部写入),而删掉整行是数据销毁,保留操作人授权。这一档最贴近 FK 基线。
  • C. 两者都切:完全对齐 Salesforce/Dataverse 的级联语义。权限面变更最大,需要独立论证。

⚠️ 无论哪个方向都是权限边界语义变更,条款②适用,应走 needs-user-decision 而不是直接派发。

出处


Generated by Claude Code

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions