Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/runas-system-stamping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/service-automation": patch
---

fix(service-automation): runAs:'system' 的 create_record 按 ADR-0118 染全三列——组织、属主、创建者禁 NULL (#5494)

修的是缺陷,不是新语义——契约是 ADR-0118(#4608)既有的:显式 `isSystem`、fail-closed、
禁 NULL 歧义;`runAs` 声明的是授权姿态而非身份(ADR-0073 D2),提权不等于匿名。

根因:`resolveRunDataContext` 的 system 分支把触发上下文的 `userId` / `tenantId` 整个丢弃,
而三列的平台盖章恰好全部键在被丢弃的信息上——`created_by` 键在写上下文的 `userId`
(ObjectQL 审计钩子)、`owner_id` 键在安全中间件的 acting user(而整条中间件含盖章步骤在
`isSystem` 上短路)、`organization_id` 键在上下文 `tenantId`(驱动层租户机制)。于是用户
触发的 system 清扫流程建出的每一行三列全 NULL:落在组织分区之外(唯一索引跨 NULL 不生效、
org 作用域查询看不见),也落在所有 owner/creator 作用域授权之外——issue 里"admin 都
403"的由来。

修复(writer 侧,`packages/services/service-automation`):

- system 分支把触发身份原样带过去(`userId` + `tenantId`),与 action-body 缝的
`{ ...caller, isSystem: true }` 信封(hotcrm#548 同族修复)同形:`isSystem` 独自决定
授权(中间件在读到 `userId` 之前就短路),身份只驱动归因盖章(`created_by`/`updated_by`、
审计 actor)、驱动层的 `organization_id` 填充,以及下游 record-change 级联的触发身份;
- `create_record` 对 system 运行补 `owner_id` 填充(fill-only、schema 存在才染):所有权锚
的平台盖章在 `isSystem` 上被短路,payload 是唯一通道;染的是 acting user——与同一触发在
`runAs:'user'` 下会得到的默认一致,不是把系统身份塞进 owner(ADR-0118 D6 / ADR-0073 D3);
- 流程 `fields` 显式给值一律优先;真正无用户的运行(schedule)保持三列不染——没有 acting
user 时按 ADR-0118 D1,哨兵串与伪用户都是被禁的替代品,`svc:flow:*` actor 标签 +
`flowRunId` 继续承担溯源。

行为变化:`runAs:'system'` 且触发上下文带 org 的运行,其数据操作在驱动层按
`(org = 触发 org OR org IS NULL)` 作用域——与 action-body 缝一致的姿态;schedule 触发的
运行不带 org,行为不变。
2 changes: 2 additions & 0 deletions packages/services/service-automation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
"@objectstack/driver-sql": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@types/node": "^26.1.2",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
Expand Down
12 changes: 11 additions & 1 deletion packages/services/service-automation/src/builtin/crud-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type { AutomationEngine } from '../engine.js';
import { interpolate, interpolateFilter, type VariableMap } from './template.js';
import { refuseNode } from '../guard-refusal.js';
import { parseNodeConfig } from './parse-config.js';
import { resolveRunDataContext } from '../runtime-identity.js';
import { resolveRunDataContext, stampSystemInsertOwner } from '../runtime-identity.js';

/**
* A filter condition that an author WROTE but that interpolation erased
Expand Down Expand Up @@ -300,7 +300,17 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
}

// #1888 — honor flow.runAs (system → RLS-bypassing; user → trigger user).
// #5494 — a BORN row must not escape the platform stamps. The run
// context now carries the trigger's user + org even under system
// elevation (so the audit hook stamps `created_by` and the driver's
// tenant machinery fills `organization_id`, exactly like a user-path
// insert); the ownership anchor has no such engine-side channel for
// system writes — the security middleware that stamps it
// short-circuits on `isSystem` — so the writer fills it here.
// Fill-only — flow-authored `fields` win. Policy + rationale live
// beside `resolveRunDataContext` in runtime-identity.ts.
const dataCtx = resolveRunDataContext(context);
stampSystemInsertOwner(fields, dataCtx, data, objectName);
try {
// #3407 — symmetric with update_record. Today the engine's
// insert path strips nothing (INSERT is readonly-exempt and
Expand Down
30 changes: 20 additions & 10 deletions packages/services/service-automation/src/builtin/crud-runas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,24 @@ describe('flow.runAs identity enforcement at the data layer (#1888)', () => {
engine.registerFlow('sys', allOpsFlow('sys', 'system'));

// Triggered by a normal user — `runAs:'system'` must still elevate.
const res = await engine.execute('sys', { userId: 'u1' });
const res = await engine.execute('sys', { userId: 'u1', tenantId: 'org1' });
expect(res.success).toBe(true);

expect(calls.map((c) => c.op).sort()).toEqual(['delete', 'find', 'findOne', 'insert', 'update']);
for (const c of calls) {
expect(c.ctx, `${c.op} got no context`).toBeTruthy();
expect(c.ctx.isSystem, `${c.op} not elevated`).toBe(true);
// An elevated run is NOT attributed to the triggering user…
expect(c.ctx.userId).toBeUndefined();
// …but it IS attributed to the flow, so audit rows never read
// "Unknown user" (ADR-0014 D2, #4366).
// #5494 — elevation is not anonymity: the triggering user and org are
// CARRIED THROUGH (attribution: created_by/updated_by stamps, audit
// actor, downstream record-change identity; the org drives the driver's
// organization_id fill on born rows), while `isSystem` alone decides
// authorization. Dropping them here is what inserted rows with all
// three platform columns NULL — untouchable even by the triggering
// member, and outside the org partition.
expect(c.ctx.userId, `${c.op} lost the acting user (#5494)`).toBe('u1');
expect(c.ctx.tenantId, `${c.op} lost the trigger org (#5494)`).toBe('org1');
// …and it stays attributed to the flow as well, so audit rows name
// WHICH automation wrote them (ADR-0014 D2, #4366; ADR-0118 D5).
expect(c.ctx.actor, `${c.op} lost the service-principal label`).toBe('svc:flow:sys');
}
});
Expand Down Expand Up @@ -174,19 +181,22 @@ describe('flow.runAs identity enforcement at the data layer (#1888)', () => {
edges: [{ id: 'e1', source: 'start', target: 'mk' }, { id: 'e2', source: 'mk', target: 'end' }],
} as any);

// Trigger as a restricted user. If the engine ignored runAs, the insert would
// carry that user's identity (or none) instead of the elevated principal.
// Trigger as a restricted user. If the engine ignored runAs, the insert
// would run under that user's AUTHORIZATION (isSystem false) instead of the
// elevated principal. Since #5494 the user still rides the context — as
// attribution, which grants nothing under the isSystem short-circuit — so
// the regression tell is the `isSystem` flag, never the userId's absence.
await engine.execute('reg', { userId: 'restricted' });
const insert = calls.find((c) => c.op === 'insert');
expect(insert?.ctx?.isSystem, 'runAs:system did not elevate the data op (#1888 regressed)').toBe(true);
expect(insert?.ctx?.userId).not.toBe('restricted');
expect(insert?.ctx?.userId, 'the acting user must ride the elevated context (#5494)').toBe('restricted');
});
});

describe('resolveRunDataContext (#1888 unit)', () => {
it("maps runAs:'system' to an elevated context attributed to the flow (#4366)", () => {
it("maps runAs:'system' to an elevated context attributed to the flow AND the acting user (#4366, #5494)", () => {
expect(resolveRunDataContext({ runAs: 'system', userId: 'u1', flowName: 'mirror_status' })).toEqual({
isSystem: true, actor: 'svc:flow:mirror_status', positions: [], permissions: [],
isSystem: true, actor: 'svc:flow:mirror_status', userId: 'u1', positions: [], permissions: [],
});
});

Expand Down
6 changes: 5 additions & 1 deletion packages/services/service-automation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ export type { AutomationServicePluginOptions } from './plugin.js';
// outright (#3760). Exported for hosts building custom data nodes: call
// `resolveRunDataContext` and let the error propagate, so a custom node inherits
// the same posture as the built-ins instead of re-opening the fail-open.
export { resolveRunDataContext, UnscopedRunDataAccessError } from './runtime-identity.js';
export {
resolveRunDataContext,
stampSystemInsertOwner,
UnscopedRunDataAccessError,
} from './runtime-identity.js';
export type { RunDataContext, RunIdentityContext, RunProvenanceContext } from './runtime-identity.js';

// Built-in node executors (ADR-0018). These are seeded by AutomationServicePlugin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ describe('record-change lookup expansion (#3475)', () => {

const read = crud.find((c) => c.op === 'findOne' && c.obj === 'lead');
expect(read!.ctx?.isSystem).toBe(true);
expect(read!.ctx?.userId).toBeUndefined();
// #5494 — the acting user rides the elevated context as attribution; what
// makes this read ELEVATED is `isSystem` (the middleware short-circuits
// before any gate reads `userId`), not the absence of a user.
expect(read!.ctx?.userId).toBe('u1');

await kernel.shutdown();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,13 @@ describe("AutomationServicePlugin bridges the runAs:'user' grant resolver (#3356
await automation.execute('sys', { userId: 'u1', params: { noteId: 'n1' } });
const update = crud.find((c) => c.op === 'update' && c.obj === 'runas_thing');
expect(update!.ctx.isSystem).toBe(true);
expect(update!.ctx.userId).toBeUndefined();
// #5494 — the acting user rides the elevated context as ATTRIBUTION (it
// drives the created_by/updated_by stamps and the audit actor; the
// isSystem short-circuit precedes every gate that reads it). What proves
// "the resolver is not consulted" is the untouched authz envelope:
expect(update!.ctx.userId).toBe('u1');
expect(update!.ctx.positions).toEqual([]);
expect(update!.ctx.permissions).toEqual([]);

await kernel.shutdown();
});
Expand Down
Loading
Loading