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_null 的 UPDATE 与 cascade 的子记录 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 NULL/CASCADE、Salesforce 的 lookup 清空与级联删除均记载为绕过 sharing)—— 在文义上同样覆盖写入半边:FK 基线里真正以引擎身份执行的正是那个 SET NULL/DELETE。裁决把范围钉在查询上是刻意的(卡片正文与决策分析通篇只说「查询」),但据我所读,那个范围收窄没有单独论证过写入半边应当保留操作人身份 —— 它更像是照着 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions