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
14 changes: 14 additions & 0 deletions .changeset/26474-flow-toggle-actor.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 13 additions & 10 deletions packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,23 @@
// ## 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
// with it) and the audit row is the REAL audit plugin's, reading the same
// 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';
Expand All @@ -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<Array<Record<string, unknown>>>;
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
});
}
17 changes: 16 additions & 1 deletion packages/runtime/src/domains/activation-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) : {};
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 } : {};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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());
});
});

Expand Down
64 changes: 64 additions & 0 deletions packages/runtime/src/domains/automation-toggle-caller.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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<string, unknown>) => ({
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' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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());
});
});

Expand Down
6 changes: 4 additions & 2 deletions packages/runtime/src/domains/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }) };
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading