fix(service-automation): runAs:'system' 的 create_record 按 ADR-0118 染全三列——组织、属主、创建者禁 NULL (#5494) - #6153
Merged
Merged
Conversation
…eate_record per ADR-0118 (#5494) resolveRunDataContext's system branch discarded the trigger's userId/ tenantId, and every platform stamp for the three columns keys on exactly what was discarded: created_by on the write context's userId (ObjectQL audit hook), owner_id on the acting user in the security middleware (whole chain short-circuits on isSystem), organization_id on the context tenantId (driver-level tenant machinery). Rows a user-triggered system sweep created were born with all three NULL - outside the org partition and untouchable even by the triggering member (#5494's admin-403). Fix, at the writer's seam (runAs is authorization posture, not identity, ADR-0073 D2; elevation is not anonymity, #3783): - the system branch now carries the trigger's userId + tenantId through, the same { ...caller, isSystem: true } envelope the action-body seam ships (the hotcrm#548-family fix): created_by/updated_by stamp again, audit rows credit the human, the driver fills organization_id on born rows, and record-change cascades keep the triggering identity; - create_record fills owner_id (fill-only, schema-guarded) for system runs with a known acting user - the anchor's platform stamp is behind the isSystem short-circuit, so the payload is the only channel; the value is the acting user, never a system identity (ADR-0118 D6, ADR-0073 D3), and flow-authored fields always win; - genuinely user-less runs (schedules) stamp nothing: no acting user exists, and ADR-0118 D1 bans sentinel/pseudo-user stand-ins; the svc:flow actor label + flowRunId remain the provenance channels. Acceptance replays the issue's step-3->5 flip against the real SecurityPlugin: the same member context is denied on the NULL-born row and admitted on the stamped row - row content, not caller, decides. Fixes #5494 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD
…as-system-stamping
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
hotlong
marked this pull request as ready for review
August 7, 2026 03:53
hotlong
enabled auto-merge
August 7, 2026 03:53
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5494
根因
resolveRunDataContext(packages/services/service-automation/src/runtime-identity.ts)的runAs:'system'分支把触发上下文的userId/tenantId整个丢弃,只返回{ isSystem: true, actor, positions: [], permissions: [] }。而三列的平台盖章恰好全部键在被丢弃的信息上:created_by—— ObjectQL 审计钩子sys_stamp_audit_insert从session.userId盖章(packages/objectql/src/plugin.ts:840),上下文无 user ⇒ NULL;owner_id—— 安全中间件 3.5 步「空 owner 自动染 acting user」(plugin-security/src/security-plugin.ts:1353),但整条中间件在isSystem上第一行短路(:772),盖章步骤连同被跳过 ⇒ NULL;organization_id—— 驱动层租户机制从上下文tenantId填充(driver-sql:「injects organization_id on inserts that omit it」,sql-driver.ts:1443),cloud 侧 org 中间件也在isSystem上短路 ⇒ 上下文无 org ⇒ NULL。于是用户手动触发的 system 清扫流程建出的每一行三列全 NULL:落在组织分区之外(唯一索引跨 NULL 不生效、org 作用域查询看不见,hotcrm#698 的重复编号即此),也落在所有 owner/creator 作用域授权之外——issue 步骤 3 的「admin 都 403」。步骤 4/5 的翻转(补 owner 后同一调用 200)证明判定键在行内容而非调用者。
接缝选择
修在 writer 侧(
service-automation),两点,均以「上下文携带完整染色信息」为主、节点侧只补平台机制够不到的一列:userId+tenantId)。runAs声明的是授权姿态而非身份(ADR-0073 D2),提权不等于匿名(Approvals status mirror drops the acting user, forcing every downstream record-change flow torunAs:'system'#3783 审批镜像的{ isSystem: true, userId }即此形);与 action-body 缝已合入的{ ...caller, isSystem: true }信封(hotcrm#548 同族修复,changesetaction-body-execution-context)同形——「different writer, same root shape」,平台对「提权但有触发人」的写者从此一个姿态。isSystem独自决定授权(中间件在读到userId之前就短路,带上身份不放大任何权限);身份驱动的是归因盖章(created_by/updated_by、审计 actor)、驱动层organization_id填充、以及下游 record-change 级联的触发身份(原来 system 流程自己的写触发的runAs:'user'流程一律被 The #1888 user-less fail-open is wider than the lint that guards it — record-change flows fired by a system write run UNSCOPED, unlinted #3760 拒绝,现在与审批镜像一致地继承触发人)。create_record节点补owner_id填充(stampSystemInsertOwner,策略与resolveRunDataContext同文件共置):所有权锚的平台盖章在isSystem短路之后没有任何引擎侧通道,payload 是唯一载体。fill-only、schema 声明该列才染;染 acting user——与同一触发在runAs:'user'下会得到的默认一致。不是把系统身份塞进 owner:ADR-0118 D6 明确owner_id归业务归属轴、系统不拥有业务记录;ADR-0073 D3 明确禁止把自动化主体染进 owner(owner-RLS 会把行藏起来)。无用户的运行(schedule)三列保持不染,这是契约而非缺口:没有 acting user 时,ADR-0118 D1 规定 actor 类列的系统表示就是 null,哨兵串(#4556 的病灶)与伪用户(ADR-0118 背景节论证)都是被禁的替代品;ADR-0073 的 automation principal(真正给这类运行的身份)是 M2、按「首个真实消费者」门控,本 PR 不越权预建。
svc:flow:*actor 标签 +flowRunId继续承担溯源(ADR-0118 D5)。issue 期望里的「flow 声明的运行身份」在现 spec 中不存在(FlowSchema.runAs仅'system' | 'user'),流程作者今天可用fields.owner_id显式指派(fill-only 保证作者值优先)。复现翻转证据(测试内复刻 issue 步骤 3→5)
runas-system-stamping.integration.test.ts,真栈:ObjectKernel + ObjectQLPlugin(审计钩子)+ driver-sql(better-sqlite3:memory:,租户列注入)+ 真 AutomationEngine/CRUD 节点 + 真 SecurityPlugin(默认权限集,owner_only_writes/owner_only_deletesRLS 键在created_by):runAs:'system'清扫 → 行三列 = 触发人/触发人/触发 org → 同一成员上下文 PATCH、DELETE 均放行(200 半边);反向验证(方向先于运行判定):把修复 patch 撤掉重跑验收文件,预测「三列用例、fill-only 用例、翻转正例变红;无用户用例、user 路径回归、翻转反例保持绿」——实测 3 红 3 绿与预测完全一致(红:三列全 NULL、
created_by断言失败、成员 PATCH 被拒;绿:三处边界行为不变)。行为变化说明(评审请重点看这条)
runAs:'system'且触发上下文带 org 的运行,其数据操作(读/改/删)在驱动层按(org = 触发 org OR org IS NULL)作用域——与 action-body 缝已合入的姿态一致:租户墙在提权下依然成立,org-NULL(修复前遗留/全局)行经 OR-NULL 臂仍可达。schedule 触发的运行不带 org,行为不变。既有 in-repo 流程(showcase/CRM 清扫)均单 org,不受影响。旧测试中「system 运行不带 userId」的钉子(crud-runas / runas-grant-resolution / record-lookup-expand 共 4 处)按新契约改写并注明 #5494——那些钉子钉的正是本缺陷。验证清单
@objectstack/service-automation全量 test:66 files / 781 tests 全绿(含合并最新 main 后复跑)trigger-schedule42/42(schedule 无用户钉子不变)、trigger-record-change5 files、plugin-approvals19 files(Approvals status mirror drops the acting user, forcing every downstream record-change flow torunAs:'system'#3783 镜像级联)全绿tsc --noEmit新增 0 错(存量 5 处为 main 既有,check:type-check-coverageOK)service-automation及依赖闭包构建通过check:nul-bytes/check:adr-anchors/check:durability-log-level/check:startup-registry-verdict/check:engine-double-contract/check:resume-authority-declared全 OK;改动文件 eslint 0 错@objectstack/service-automationpatch(修缺陷,契约为 ADR-0118 既有)packages/speccheck:generated:10 artifacts up to date(合并 main 后)