diff --git a/.changeset/26474-flow-toggle-actor.md b/.changeset/26474-flow-toggle-actor.md new file mode 100644 index 00000000000..99db4ddfd38 --- /dev/null +++ b/.changeset/26474-flow-toggle-actor.md @@ -0,0 +1,14 @@ +--- +'@objectstack/service-automation': patch +'@objectstack/runtime': patch +--- + +fix(service-automation): switching a packaged flow off now records which administrator did it + +Clause-②: yes (widening) +Metadata-protocol: none + +The flow toggle (`POST /api/v1/automation/:name/toggle`) writes a row in the activation ledger (`sys_metadata_activation`). Until now, that row and its audit entry named nobody: `created_by` / `updated_by` on the ledger row and `user_id` / `actor` on the `sys_audit_log` row were null, even when a signed-in administrator made the request. The action and security catalog doors were fixed in the previous release; the flow door reaches the ledger through `IAutomationService.toggleFlow`, which could not carry the caller until its `caller` parameter landed. + +- `@objectstack/runtime`: the flow toggle door passes `toggleFlow(name, enabled, { userId })`, the signed-in user's id and nothing else. +- `@objectstack/service-automation`: `AutomationEngine.toggleFlow(name, enabled, caller?)` implements the contract's `caller`. The ledger write runs as that user, elevated and with no positions or permissions, so the engine and the audit plugin record the user's id on both rows. Only `userId` is read from `caller`. With no caller, or a caller without an id, the write stays unattributed, as before. `FlowActivationStore.setActive(row, options?)` takes the same optional `{ context }` as the shared activation store in `@objectstack/core`; a store that ignores it keeps working. diff --git a/packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts b/packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts index 66169319a8e..8e7848d919c 100644 --- a/packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts +++ b/packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts @@ -7,13 +7,13 @@ // ## The measurement this file turns around // // Stock showcase, fresh database, signed in as the dev admin. Switching off a -// packaged action (`POST /api/v1/actions/_activation/:object/:action`) and a -// declared position (`POST /api/v1/security/_activation/position/:name`) -// answered 200 and wrote the row — and all four actor fields read null. The -// doors wrote through the activation store's constant system context, which -// names nobody, so neither the engine's audit stamps nor the audit plugin had a -// user to record. The same request window's own sign-in rows did carry the -// admin's id. +// packaged action (`POST /api/v1/actions/_activation/:object/:action`), a +// declared position (`POST /api/v1/security/_activation/position/:name`) and a +// packaged flow (`POST /api/v1/automation/:name/toggle`) answered 200 and wrote +// the row — and all four actor fields read null. The doors wrote through the +// activation store's constant system context, which names nobody, so neither +// the engine's audit stamps nor the audit plugin had a user to record. The same +// request window's own sign-in rows did carry the admin's id. // // What only a boot can measure: the stamp lands through the REAL engine (the // unit suites pin the context each door hands down, not what the engine does @@ -21,9 +21,9 @@ // session. The showcase boot composes no audit plugin of its own, so this file // adds `AuditPlugin`, as `objectstack serve` composes it. // -// ⛔ The flow toggle (`POST /api/v1/automation/:name/toggle`) is not here: it -// reaches the ledger through `IAutomationService.toggleFlow(name, enabled)`, a -// contract with no caller channel yet. +// The flow door reaches the ledger through the automation service, not the +// engine: `IAutomationService.toggleFlow`'s `caller` carries the admin's id +// across that seam, so its case is the one that proves the id survives it. import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; @@ -40,6 +40,8 @@ const ACTION = 'showcase_mark_done'; const ACTION_OBJECT = 'showcase_task'; /** A position the showcase declares and nobody needs switched on during this file. */ const POSITION = 'auditor'; +/** A packaged showcase flow that no other flow calls as a subflow, so the §7.3 guard never refuses it. */ +const FLOW = 'showcase_scheduled_digest'; interface Engine { find(object: string, options?: unknown): Promise>>; @@ -89,6 +91,7 @@ describe('the activation ledger names the administrator who switched an item', ( for (const door of [ { label: 'a packaged action', type: 'action', name: ACTION, path: `/actions/_activation/${ACTION_OBJECT}/${ACTION}` }, { label: 'a declared position', type: 'position', name: POSITION, path: `/security/_activation/position/${POSITION}` }, + { label: 'a packaged flow', type: 'flow', name: FLOW, path: `/automation/${FLOW}/toggle` }, ] as const) { it(`${door.label}: switching it OFF names the admin on the row and on its audit row`, async () => { const res = await stack.apiAs(token, 'POST', door.path, { enabled: false }); diff --git a/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts b/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts index a87e79bab2a..8a236422588 100644 --- a/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts +++ b/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts @@ -256,7 +256,7 @@ for (const posture of ['group', 'isolated'] as const) { expect({ nameRead, gate: statusOf(response) === 200, rung }).toEqual({ nameRead: true, gate: true, rung: true, }); - expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false); + expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false, expect.anything()); }); }); } diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index 651aa0c046f..9f686ca87c1 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -439,10 +439,25 @@ export function refuseUngrantedActivationAuthoring( * bare system context, which the store falls back to, names nobody. * * Shared by every door that writes a `sys_metadata_activation` row for a - * caller, so the doors cannot disagree about who a row names. + * caller, so the doors cannot disagree about who a row names. The flow door is + * the exception that proves it: its write happens behind + * `IAutomationService.toggleFlow`, so it hands over {@link activationCaller} + * and the automation service builds the context. */ export function activationWriteContext(context: HttpProtocolContext): MetadataActivationWriteContext { const ec: unknown = context?.executionContext; const caller = ec && typeof ec === 'object' ? (ec as Record) : {}; return { ...caller, isSystem: true }; } + +/** + * [#26474] Who an activation door's caller is, in the shape + * `IAutomationService.toggleFlow`'s `caller` declares: the signed-in user's id + * and nothing else. No positions, permissions or tenant: the gates above have + * already judged the caller, and the channel is attribution only. A context + * with no user yields `{}`, an unattributed write. + */ +export function activationCaller(context: HttpProtocolContext): { userId?: string } { + const userId = (context?.executionContext as { userId?: unknown } | undefined)?.userId; + return typeof userId === 'string' && userId !== '' ? { userId } : {}; +} diff --git a/packages/runtime/src/domains/automation-activation-posture-gate.test.ts b/packages/runtime/src/domains/automation-activation-posture-gate.test.ts index 5c76cf8d09d..2ac738a8e04 100644 --- a/packages/runtime/src/domains/automation-activation-posture-gate.test.ts +++ b/packages/runtime/src/domains/automation-activation-posture-gate.test.ts @@ -156,7 +156,7 @@ describe('ADR-0126 §5 — the activation write is operator-gated in walled post const { response } = await toggle(h, TENANT_ADMIN()); expect(statusOf(response)).toBe(200); - expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false); + expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false, expect.anything()); }); it('so may the platform operator', async () => { @@ -165,7 +165,7 @@ describe('ADR-0126 §5 — the activation write is operator-gated in walled post const { response } = await toggle(h, PLATFORM_OPERATOR()); expect(statusOf(response)).toBe(200); - expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false); + expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false, expect.anything()); }); it('no tenancy service at all behaves like `single` (ADR-0093 D4/D5)', async () => { @@ -223,7 +223,7 @@ describe('ADR-0126 §5 — the activation write is operator-gated in walled post const { response } = await toggle(h, PLATFORM_OPERATOR()); expect(statusOf(response)).toBe(200); - expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false); + expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false, expect.anything()); }); it('[#15981] REFUSES a tenant admin holding only a MINTED `platform_admin` name', async () => { diff --git a/packages/runtime/src/domains/automation-body-validation.test.ts b/packages/runtime/src/domains/automation-body-validation.test.ts index eaf95c14795..f7c85e484b6 100644 --- a/packages/runtime/src/domains/automation-body-validation.test.ts +++ b/packages/runtime/src/domains/automation-body-validation.test.ts @@ -129,14 +129,14 @@ describe('#3899 ③ — POST /automation/:name/toggle is { enabled?: boolean }, const result = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: false }, CTX); expect(result.response?.status).toBe(200); expect(result.response?.body?.data ?? result.response?.body).toMatchObject({ enabled: false }); - expect(spies.toggleFlow).toHaveBeenCalledWith('welcome_flow', false); + expect(spies.toggleFlow).toHaveBeenCalledWith('welcome_flow', false, expect.anything()); }); it('keeps the documented legacy shape: an empty body enables', async () => { const { dispatcher, spies } = makeDispatcher(); const result = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', undefined, CTX); expect(result.response?.status).toBe(200); - expect(spies.toggleFlow).toHaveBeenCalledWith('welcome_flow', true); + expect(spies.toggleFlow).toHaveBeenCalledWith('welcome_flow', true, expect.anything()); }); }); diff --git a/packages/runtime/src/domains/automation-packaged-base-lock.test.ts b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts index f2506139386..33bd133c09a 100644 --- a/packages/runtime/src/domains/automation-packaged-base-lock.test.ts +++ b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts @@ -365,7 +365,7 @@ describe('what the lock leaves open', () => { `/${PACKAGED}/toggle`, 'POST', { enabled: false }, AUTHOR(), undefined, ); expect(statusOf(response)).toBe(200); - expect(h.toggleFlow).toHaveBeenCalledWith(PACKAGED, false); + expect(h.toggleFlow).toHaveBeenCalledWith(PACKAGED, false, expect.anything()); }); it('[ADR-0131 D6] the operator hatch (OS_METADATA_WRITABLE) opens this door exactly as it opens /meta: not at all', async () => { @@ -396,7 +396,7 @@ describe('what the lock leaves open', () => { `/${PACKAGED}/toggle`, 'POST', { enabled: false }, AUTHOR(), undefined, ); expect(statusOf(toggle.response)).toBe(200); - expect(h.toggleFlow).toHaveBeenCalledWith(PACKAGED, false); + expect(h.toggleFlow).toHaveBeenCalledWith(PACKAGED, false, expect.anything()); }); }); diff --git a/packages/runtime/src/domains/automation-toggle-caller.test.ts b/packages/runtime/src/domains/automation-toggle-caller.test.ts new file mode 100644 index 00000000000..ea0d075f475 --- /dev/null +++ b/packages/runtime/src/domains/automation-toggle-caller.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26474] `POST /automation/:name/toggle` hands the service who asked. + * + * The flow door called `toggleFlow(name, enabled)` with no caller, so the + * activation-ledger row it writes, and that row's audit entry, named nobody. + * `IAutomationService.toggleFlow` now declares `caller` (`{ userId }`, #26545): + * a door that serves a person passes that person's id, and nothing else — no + * positions, permissions or tenant, since the door has already judged the + * caller's authority and the write itself runs elevated. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from '../http-dispatcher.js'; + +function makeDispatcher() { + const toggleFlow = vi.fn(async (_name: string, _on: boolean, _caller?: { userId?: string }) => undefined); + const services: Record = { + automation: { + getFlow: async (name: string) => (name === 'welcome_flow' ? { name, trigger: { type: 'manual' } } : null), + toggleFlow, + }, + }; + const resolve = (name: string) => services[name]; + const kernel: any = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + return { dispatcher: new HttpDispatcher(kernel), toggleFlow }; +} + +/** The toggle is a `manage_metadata` write (commit 266436a7f); the caller holds it. */ +const ctxOf = (executionContext: Record) => ({ + request: {}, + executionContext: { systemPermissions: ['manage_metadata'], ...executionContext }, +}) as any; + +describe('#26474 — the flow toggle door passes its caller to toggleFlow', () => { + it('names the signed-in user, in both directions', async () => { + const { dispatcher, toggleFlow } = makeDispatcher(); + const ctx = ctxOf({ userId: 'u_admin' }); + + const off = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: false }, ctx); + expect(off.response?.status).toBe(200); + expect(toggleFlow).toHaveBeenLastCalledWith('welcome_flow', false, { userId: 'u_admin' }); + + const on = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: true }, ctx); + expect(on.response?.status).toBe(200); + expect(toggleFlow).toHaveBeenLastCalledWith('welcome_flow', true, { userId: 'u_admin' }); + }); + + it('passes the id and nothing else of the caller\'s context', async () => { + const { dispatcher, toggleFlow } = makeDispatcher(); + const ctx = ctxOf({ userId: 'u_admin', positions: ['admin'], tenantId: 'org_1', isSystem: false }); + + await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: false }, ctx); + + const caller = toggleFlow.mock.calls.at(-1)?.[2]; + expect(caller).toEqual({ userId: 'u_admin' }); + }); +}); diff --git a/packages/runtime/src/domains/automation-toggle-deny-message.test.ts b/packages/runtime/src/domains/automation-toggle-deny-message.test.ts index 1d326cd6694..89da74a506c 100644 --- a/packages/runtime/src/domains/automation-toggle-deny-message.test.ts +++ b/packages/runtime/src/domains/automation-toggle-deny-message.test.ts @@ -298,11 +298,11 @@ describe('#11666 — the enablement door refuses in its own words', () => { const off = await h.dispatch('POST', `/automation/${FLOW}/toggle`, { enabled: false }); expect(statusOf(off)).toBe(200); - expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, false); + expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, false, expect.anything()); const on = await h.dispatch('POST', `/automation/${FLOW}/toggle`, { enabled: true }); expect(statusOf(on)).toBe(200); - expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, true); + expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, true, expect.anything()); }); it('an entitled caller still writes definitions', async () => { diff --git a/packages/runtime/src/domains/automation-toggle-unknown-flow.test.ts b/packages/runtime/src/domains/automation-toggle-unknown-flow.test.ts index d3a3ad9b599..91e0011867d 100644 --- a/packages/runtime/src/domains/automation-toggle-unknown-flow.test.ts +++ b/packages/runtime/src/domains/automation-toggle-unknown-flow.test.ts @@ -112,18 +112,18 @@ describe('#7535 — toggling a flow that does not exist is 404, not 500', () => const off = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: false }, CTX); expect(off.response?.status).toBe(200); expect(off.response?.body?.data ?? off.response?.body).toMatchObject({ name: 'welcome_flow', enabled: false }); - expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', false); + expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', false, expect.anything()); expect(enabled.get('welcome_flow')).toBe(false); const on = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: true }, CTX); expect(on.response?.status).toBe(200); - expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', true); + expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', true, expect.anything()); expect(enabled.get('welcome_flow')).toBe(true); // The documented legacy shape: a bodyless toggle enables. const legacy = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', undefined, CTX); expect(legacy.response?.status).toBe(200); - expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', true); + expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', true, expect.anything()); }); it('leaves #3899 strict-body validation intact — a bad body is still a located 400', async () => { @@ -171,6 +171,6 @@ describe('#7535 — toggling a flow that does not exist is 404, not 500', () => const result = await dispatcher.handleAutomation('/whatever/toggle', 'POST', { enabled: false }, CTX); expect(result.response?.status).toBe(200); - expect(toggleFlow).toHaveBeenCalledWith('whatever', false); + expect(toggleFlow).toHaveBeenCalledWith('whatever', false, expect.anything()); }); }); diff --git a/packages/runtime/src/domains/automation-write-capability-gate.test.ts b/packages/runtime/src/domains/automation-write-capability-gate.test.ts index 6b8f4140481..f6e63651052 100644 --- a/packages/runtime/src/domains/automation-write-capability-gate.test.ts +++ b/packages/runtime/src/domains/automation-write-capability-gate.test.ts @@ -319,11 +319,11 @@ describe('#10145 — /automation authoring writes require `manage_metadata`', () const off = await h.dispatcher.handleAutomation(`/${FLOW}/toggle`, 'POST', { enabled: false }, AUTHOR(), undefined); expect(statusOf(off.response)).toBe(200); - expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, false); + expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, false, expect.anything()); const on = await h.dispatcher.handleAutomation(`/${FLOW}/toggle`, 'POST', { enabled: true }, AUTHOR(), undefined); expect(statusOf(on.response)).toBe(200); - expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, true); + expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, true, expect.anything()); }); it('[#10243] the gate is ahead of the body checks on toggle too', async () => { @@ -352,7 +352,7 @@ describe('#10145 — /automation authoring writes require `manage_metadata`', () const { response } = await h.dispatcher.handleAutomation(`/${FLOW}/toggle`, 'POST', { enabled: false }, SYSTEM(), undefined); expect(statusOf(response)).toBe(200); - expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false); + expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false, expect.anything()); }); }); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 3824ff12724..10f891facdb 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -19,7 +19,7 @@ import { handleInboundHookRequest } from './automation-hooks.js'; // [ADR-0126 §5] The shared activation write-authority gate — one // implementation, one refusal envelope, per-door wording. See its header for // the posture rule and the measurement behind it (commit 02b41232d). -import { refuseUngrantedActivationWrite, FLOW_ACTIVATION_SUBJECT, readEffectiveTenancyPosture } from './activation-gate.js'; +import { refuseUngrantedActivationWrite, FLOW_ACTIVATION_SUBJECT, readEffectiveTenancyPosture, activationCaller } from './activation-gate.js'; // [ADR-0131 D3 · D12 items 7–8] The walled refusal's one predicate: under a // wall the environment's metadata, flow definitions included, is the platform // operator's. The same verdict every `/meta` and `/packages` write door asks. @@ -3075,7 +3075,9 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str return { handled: true, response: deps.error(`Flow '${name}' not found`, 404) }; } } - await automationService.toggleFlow(name, enabled); + // [#26474] Who asked, so the ledger row and its audit row name + // them. The id only: this door has already judged the caller. + await automationService.toggleFlow(name, enabled, activationCaller(context)); return { handled: true, response: deps.success({ name, enabled }) }; } } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 0b92e0cfc69..9884f6bb2e1 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -453,7 +453,7 @@ describe('HttpDispatcher', () => { // so only the caller changes. const result = await dispatcher.handleAutomation('flow_a/toggle', 'POST', { enabled: false }, FLOW_AUTHOR()); expect(result.handled).toBe(true); - expect(mockAutomationService.toggleFlow).toHaveBeenCalledWith('flow_a', false); + expect(mockAutomationService.toggleFlow).toHaveBeenCalledWith('flow_a', false, expect.anything()); }); it('should list runs via GET /:name/runs', async () => { diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 486ddfe9ac6..1946c20fab9 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -11,6 +11,7 @@ import type { FlowRunSummary, } from '@objectstack/spec/automation'; import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, RunListResult, ScreenSpec, ScreenFieldSpec, ConnectorSourcePullRequest, ConnectorSourcePullResult } from '@objectstack/spec/contracts'; +import type { MetadataActivationWriteOptions } from '@objectstack/core'; import type { FlowRegistration, FlowRegistrationRefusal, FlowInnerRefusal } from '@objectstack/spec/api'; import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; import { @@ -2326,8 +2327,12 @@ export interface FlowActivationRow { export interface FlowActivationStore { /** Every flow activation row — the ledger is deployment-wide. */ list(): Promise; - /** Insert or update the row for one packaged flow. */ - setActive(row: FlowActivationRow): Promise; + /** + * Insert or update the row for one packaged flow. `options.context` is the + * caller the write is made for, already elevated (#26474); absent, the + * store writes under its own system context and the row names nobody. + */ + setActive(row: FlowActivationRow, options?: MetadataActivationWriteOptions): Promise; } /** @@ -5288,11 +5293,17 @@ export class AutomationEngine implements IAutomationService { * * ADR-0126 §5 gates the write on posture and the platform-operator * capability. That gate lives at the automation domain's toggle route, - * because it needs the CALLER — and `toggleFlow(name, enabled)` is a - * `packages/spec` contract with no caller channel and no room to grow one - * on this line. In-process callers are trusted here exactly as they are at - * every other service method; the wire is the untrusted surface, and the - * wire goes through the gate. + * because it needs the caller's grants, and the contract's `caller` + * carries none: it is attribution only. In-process callers are trusted + * here exactly as they are at every other service method; the wire is the + * untrusted surface, and the wire goes through the gate. + * + * ## Who the row names (#26474) + * + * `caller.userId`, when there is one: the ledger write runs as that user, + * elevated, so the engine stamps `created_by` / `updated_by` and the audit + * plugin records the same id. Only the id crosses; an absent `caller` is an + * unattributed write under the store's own system context. * * @throws when the flow is unknown, when no package ships it * (`RESOURCE_CONFLICT` / 409, before anything is written or changed), @@ -5302,7 +5313,7 @@ export class AutomationEngine implements IAutomationService { * reported flip that did not persist is the failure mode this whole leg * exists to remove. */ - toggleFlow(name: string, enabled: boolean): Promise { + toggleFlow(name: string, enabled: boolean, caller?: Pick): Promise { // The body lives in ./flow-definitions.ts (#25222). const readFlowActivationStore = () => this.flowActivationStore; const host: ThisParameterType = { @@ -5321,7 +5332,7 @@ export class AutomationEngine implements IAutomationService { deactivateFlowTrigger: (...a) => this.deactivateFlowTrigger(...a), rejudgeSubflowCallers: (...a) => this.rejudgeSubflowCallers(...a), }; - return toggleFlow.call(host, name, enabled); + return toggleFlow.call(host, name, enabled, caller); } /** Get flow version history */ diff --git a/packages/services/service-automation/src/flow-definitions.ts b/packages/services/service-automation/src/flow-definitions.ts index f711ccaddc7..b6dcde998e5 100644 --- a/packages/services/service-automation/src/flow-definitions.ts +++ b/packages/services/service-automation/src/flow-definitions.ts @@ -17,7 +17,8 @@ */ import type { ConnectorProviderFactory } from '@objectstack/spec/integration'; -import type { FlowUnboundCause } from '@objectstack/spec/contracts'; +import type { AutomationContext, FlowUnboundCause } from '@objectstack/spec/contracts'; +import type { MetadataActivationWriteOptions } from '@objectstack/core'; import type { ConversionNotice, ConversionTodoNotice } from '@objectstack/spec'; import { applyConversionsToFlow } from '@objectstack/spec'; import { FLOW_HOOK_SECRET_KEY, flowCredentialClassLabel, flowCredentialPositions } from './flow-credential-projection.js'; @@ -469,8 +470,24 @@ export async function hydrateFlowActivations(this: Pick, name: string, enabled: boolean): Promise { +/** + * [#26474] The ledger write's options for one toggle's `caller`: the caller's + * id under the store's own grant-less system context, or nothing at all. + * + * Only `userId` is read, so a caller object that carries positions, + * permissions or a tenant contributes none of them: the contract's `caller` is + * attribution only, and the door has already judged the caller's authority. + * No id means no options, so the store writes under its own system context and + * the row names nobody, which is true of it. + */ +function flowActivationWriteOptions(caller: Pick | undefined): MetadataActivationWriteOptions | undefined { + const userId = caller?.userId; + if (typeof userId !== 'string' || userId === '') return undefined; + return { context: { isSystem: true, positions: [], permissions: [], userId } }; +} + +/** The body of {@link AutomationEngine.toggleFlow}; that method's docblock is its contract. */ +export async function toggleFlow(this: Pick, name: string, enabled: boolean, caller?: Pick): Promise { const flow = this.flows.get(name); if (!flow) { throw new Error(`Flow '${name}' not found`); @@ -522,7 +539,7 @@ export async function toggleFlow(this: Pick l; + return l; +} + +/** A packaged flow (ADR-0029 D9.6 provenance), so the toggle door accepts it. */ +function packagedFlow(name: string) { + return { + name, + label: name, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: {} }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + _packageId: 'crm', + }; +} + +/** A ledger store that records each write and the options it was handed. */ +function recordingStore() { + const writes: Array<{ row: FlowActivationRow; options: MetadataActivationWriteOptions | undefined }> = []; + const store: FlowActivationStore = { + list: async () => [], + setActive: async (row: FlowActivationRow, options?: MetadataActivationWriteOptions) => { + writes.push({ row, options }); + }, + }; + return { store, writes }; +} + +function engineWith(store: FlowActivationStore) { + const engine = withLoaderSetFromPull(new AutomationEngine(createTestLogger())); + engine.setFlowActivationStore(store); + engine.registerFlow('welcome', packagedFlow('welcome')); + return engine; +} + +describe('#26474 — toggleFlow writes the ledger row under the caller it was handed', () => { + it('a caller with an id: the write runs as that user, elevated, in both directions', async () => { + const { store, writes } = recordingStore(); + const engine = engineWith(store); + + await engine.toggleFlow('welcome', false, { userId: 'u_admin' }); + await engine.toggleFlow('welcome', true, { userId: 'u_admin' }); + + expect(writes.map((w) => w.row.active)).toEqual([false, true]); + for (const { options } of writes) { + expect(options?.context).toEqual({ isSystem: true, positions: [], permissions: [], userId: 'u_admin' }); + } + }); + + it('the channel grants nothing: only the id crosses, whatever else the object carries', async () => { + const { store, writes } = recordingStore(); + const engine = engineWith(store); + + // A JS caller is not held to the `Pick` + // type. Grants it smuggles in must not reach the write's context. + const caller = { userId: 'u_admin', positions: ['admin'], permissions: ['manage_metadata'], tenantId: 'org_1' }; + await engine.toggleFlow('welcome', false, caller as { userId: string }); + + expect(writes[0].options?.context).toEqual({ isSystem: true, positions: [], permissions: [], userId: 'u_admin' }); + }); + + it.each([ + ['no caller', undefined], + ['a caller without an id', {}], + ['a caller with an empty id', { userId: '' }], + ])('%s: an unattributed write, under the store\'s own system context', async (_label, caller) => { + const { store, writes } = recordingStore(); + const engine = engineWith(store); + + await engine.toggleFlow('welcome', false, caller); + + expect(writes).toHaveLength(1); + expect(writes[0].options).toBeUndefined(); + }); +});