diff --git a/.changeset/26474-activation-ledger-actor.md b/.changeset/26474-activation-ledger-actor.md new file mode 100644 index 00000000000..da7125508a0 --- /dev/null +++ b/.changeset/26474-activation-ledger-actor.md @@ -0,0 +1,20 @@ +--- +'@objectstack/core': patch +'@objectstack/objectql': patch +'@objectstack/runtime': patch +--- + +fix(runtime): switching a packaged action or a security catalog item off now records which administrator did it + +Clause-②: yes (widening) +Metadata-protocol: none + +The activation ledger (`sys_metadata_activation`) is written by the ADR-0126 activation doors. Until now, its row and that row's 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 store wrote every row under a fixed system context that carries no user. + +The action door (`POST /api/v1/actions/_activation/:object/:action`) and the security catalog door (`POST /api/v1/security/_activation/:type/:name`, for positions, permission sets, sharing rules and webhooks) now write the row for the caller. They pass down the caller's own execution context, elevated (`{ ...callerContext, isSystem: true }`). The elevation keeps the engine-owned ledger writable, and the engine and the audit plugin record the caller's user id on both rows. + +- `@objectstack/core`: `MetadataActivationStore.setActive(row, options?)` takes an optional `{ context }`. The new `MetadataActivationWriteContext` type requires `isSystem: true`, so a door cannot pass the caller's context down without the elevation. Without a context, the store keeps writing under its system context, so a write that no caller requested still names nobody. `MetadataActivationWriteOptions` is exported too. +- `@objectstack/objectql`: `ObjectQL.setActionActive(row, options?)` and `ActionActivationProjection.setActive(row, options?)` pass the options to the store unchanged. `ActionActivationWriteOptions` is exported. +- `@objectstack/runtime`: both doors pass the caller's elevated context. + +The flow toggle (`POST /api/v1/automation/:name/toggle`) is not changed yet. It reaches the ledger through `IAutomationService.toggleFlow(name, enabled)`, and that contract has no way to carry the caller. diff --git a/packages/core/src/utils/metadata-activation-store.test.ts b/packages/core/src/utils/metadata-activation-store.test.ts index d4d1837e700..d74818a0851 100644 --- a/packages/core/src/utils/metadata-activation-store.test.ts +++ b/packages/core/src/utils/metadata-activation-store.test.ts @@ -346,6 +346,49 @@ describe('ObjectStoreMetadataActivationStore — an item no package ships', () = }); }); +// [#26474] A door writes the row ON BEHALF of the administrator who asked, so +// the write runs under that caller's own envelope, elevated — the engine stamps +// `created_by` / `updated_by` from its `userId`, and the audit plugin reads +// `user_id` / `actor` from the same session. The store's constant system +// context names nobody, which is what left both rows' actor null. +describe('ObjectStoreMetadataActivationStore — the write names the caller who asked for it', () => { + const CALLER = { userId: 'usr_admin', organizationId: 'org_acme', positions: ['org_admin'], isSystem: true as const }; + + it('INSERTS under the context it is handed, and reads the existing row under the same one', async () => { + const { engine, calls } = makeStoreEngine([]); + + await new ObjectStoreMetadataActivationStore(engine, 'action') + .setActive({ name: 'convert_lead', packageId: 'crm', active: false }, { context: CALLER }); + + expect(calls.map((c) => [c.op, c.options?.context])).toEqual([ + ['find', CALLER], + ['insert', CALLER], + ]); + }); + + it('UPDATES under the context it is handed', async () => { + const { engine, calls } = makeStoreEngine([ + { id: 'r1', metadata_type: 'action', name: 'convert_lead', package_id: 'crm', active: false }, + ]); + + await new ObjectStoreMetadataActivationStore(engine, 'action') + .setActive({ name: 'convert_lead', packageId: 'crm', active: true }, { context: CALLER }); + + expect(calls.find((c) => c.op === 'update')?.options?.context).toEqual(CALLER); + }); + + it('with no caller, writes under the system context it always has — a boot-time write names nobody', async () => { + const { engine, calls } = makeStoreEngine([]); + + await new ObjectStoreMetadataActivationStore(engine, 'flow').setActive({ name: 'nightly_sync', active: false }); + + expect(calls.map((c) => c.options?.context)).toEqual([ + { isSystem: true, positions: [], permissions: [] }, + { isSystem: true, positions: [], permissions: [] }, + ]); + }); +}); + describe('InMemoryMetadataActivationStore', () => { it('round-trips a row and reflects the latest flip', async () => { const store = new InMemoryMetadataActivationStore(); diff --git a/packages/core/src/utils/metadata-activation-store.ts b/packages/core/src/utils/metadata-activation-store.ts index b8fe921e559..9631123dc5e 100644 --- a/packages/core/src/utils/metadata-activation-store.ts +++ b/packages/core/src/utils/metadata-activation-store.ts @@ -82,9 +82,46 @@ */ export const METADATA_ACTIVATION_TABLE = 'sys_metadata_activation'; -/** Infrastructure rows, not tenant data — the `sys_metadata_activation` posture. */ +/** + * Infrastructure rows, not tenant data — the `sys_metadata_activation` posture. + * The context of every read, and of a write no caller is named for (a boot-time + * or in-process flip): such a row names nobody, which is true of it. + */ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; +/** + * The context one ledger WRITE runs under when a caller asked for it: that + * caller's own execution context, ELEVATED — `{ ...callerContext, isSystem: true }`, + * the `sudo()`-shaped elevation the engine's own trusted paths use. + * + * Both halves are load-bearing: + * + * - **the elevation** — the ledger object is `managedBy: 'engine-owned'` and + * declares `apiMethods: ['get', 'list']`, so nothing but an elevated write + * reaches it. The door has already judged the caller's authority (ADR-0126 + * §5); the write itself is the platform's; + * - **the caller** — the engine stamps `created_by` / `updated_by` from its + * `userId`, and the audit plugin records `user_id` / `actor` from the same + * session, so the record of an install-wide switch names the administrator + * who threw it. A bare system context names nobody. + * + * `isSystem: true` is in the type, so a door cannot hand the caller's bare + * context down by accident. Runtime doors build it with one shared helper + * (`activationWriteContext` in `@objectstack/runtime`), so they cannot disagree + * about who a row names. + */ +export type MetadataActivationWriteContext = { readonly isSystem: true; readonly userId?: string } & Record; + +/** Options for one {@link MetadataActivationStore.setActive} call. */ +export interface MetadataActivationWriteOptions { + /** + * The caller this write is made on behalf of, already elevated + * ({@link MetadataActivationWriteContext}). Absent ⇒ the store's own system + * context, and the row names nobody. + */ + context?: MetadataActivationWriteContext; +} + /** * [ADR-0126 §4] One artifact's install-level activation row. * @@ -120,8 +157,11 @@ export interface MetadataActivationRow { export interface MetadataActivationStore { /** Every activation row for this type — the ledger is deployment-wide. */ list(): Promise; - /** Insert or update the row for one packaged artifact. */ - setActive(row: MetadataActivationRow): Promise; + /** + * Insert or update the row for one packaged artifact — written on behalf of + * `options.context` when a caller asked for it. + */ + setActive(row: MetadataActivationRow, options?: MetadataActivationWriteOptions): Promise; } /** @@ -176,7 +216,8 @@ export class InMemoryMetadataActivationStore implements MetadataActivationStore return [...this.rows.values()]; } - async setActive(row: MetadataActivationRow): Promise { + /** The caller is not recorded: a map has no `created_by` to stamp. */ + async setActive(row: MetadataActivationRow, _options?: MetadataActivationWriteOptions): Promise { refuseEmptyPackageId(row, `'${row.name}'`); this.rows.set(row.name, { ...row }); } @@ -187,10 +228,13 @@ export class InMemoryMetadataActivationStore implements MetadataActivationStore * `sys_metadata_activation` object (ADR-0126 §4), scoped to one * `metadata_type`. * - * All access uses a system context: the object is `managedBy: 'engine-owned'` - * and declares `apiMethods: ['get', 'list']`, i.e. the generic data API cannot + * All access is elevated: the object is `managedBy: 'engine-owned'` and + * declares `apiMethods: ['get', 'list']`, i.e. the generic data API cannot * write it at all — these rows are written by the ADR-0126 enable/disable - * doors and by nothing else. + * doors and by nothing else. Reads use the store's own system context; a write + * a door makes for a caller runs under that caller's context, elevated, so the + * row and its audit row name who switched the item + * ({@link MetadataActivationWriteContext}). */ export class ObjectStoreMetadataActivationStore implements MetadataActivationStore { constructor( @@ -256,13 +300,19 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto * `packageId` absent writes NO package (`null`), the ledger's spelling for * an item no package ships. An empty string is refused before anything is * read or written ({@link refuseEmptyPackageId}). + * + * `options.context` is the caller the write is made for, elevated + * ({@link MetadataActivationWriteContext}). The keyed lookup runs under it + * too, so the read-then-write is one principal's act; absent, both run + * under the store's system context and the row names nobody. */ - async setActive(row: MetadataActivationRow): Promise { + async setActive(row: MetadataActivationRow, options?: MetadataActivationWriteOptions): Promise { refuseEmptyPackageId(row, `${this.metadataType} '${row.name}'`); + const context = options?.context ?? SYSTEM_CTX; const packageId = row.packageId ?? null; const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, { where: { metadata_type: this.metadataType, name: row.name }, - context: SYSTEM_CTX, + context, }); const current = Array.isArray(existing) ? existing[0] : undefined; @@ -270,7 +320,7 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto await this.engine.update( METADATA_ACTIVATION_TABLE, { id: (current as { id: unknown }).id, active: row.active, package_id: packageId }, - { context: SYSTEM_CTX }, + { context }, ); return; } @@ -283,7 +333,7 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto package_id: packageId, active: row.active, }, - { context: SYSTEM_CTX }, + { context }, ); } diff --git a/packages/objectql/src/action-activation.test.ts b/packages/objectql/src/action-activation.test.ts index 19147605586..ac0642592d1 100644 --- a/packages/objectql/src/action-activation.test.ts +++ b/packages/objectql/src/action-activation.test.ts @@ -366,6 +366,23 @@ describe('ObjectQL engine — the projection the dispatch doors consult', () => expect(await store.list()).toEqual([{ name: 'convert_lead', packageId: 'crm', active: true }]); }); + // [#26474] The door hands the engine the caller it writes on behalf of; the + // engine and the projection hand it on to the durable store unchanged, so + // the row's `created_by` / `updated_by` and its audit row name that caller. + it('setActionActive hands the caller\'s write context through to the durable store', async () => { + const { engine: ledger, calls } = makeStoreEngine([]); + const engine = new ObjectQL(); + engine.setActionActivationStore(new ObjectStoreActionActivationStore(ledger)); + const context = { userId: 'usr_admin', positions: ['org_admin'], isSystem: true as const }; + + await engine.setActionActive({ name: 'convert_lead', packageId: 'crm', active: false }, { context }); + + expect(calls.map((c) => [c.op, c.options?.context])).toEqual([ + ['find', context], + ['insert', context], + ]); + }); + it('⛔ executeAction does NOT consult the ledger — the key is not the identity (ADR-0110 D2)', async () => { // A target-bound script action registers under its `target`, so a check // inside `executeAction` would be asking a question the arguments diff --git a/packages/objectql/src/action-activation.ts b/packages/objectql/src/action-activation.ts index 1eb59976a8a..b817cd2a2da 100644 --- a/packages/objectql/src/action-activation.ts +++ b/packages/objectql/src/action-activation.ts @@ -94,6 +94,7 @@ import { type MetadataActivationRow, type MetadataActivationStore, type MetadataActivationStoreEngine, + type MetadataActivationWriteOptions, } from '@objectstack/core'; /** @@ -142,6 +143,13 @@ export type ActionActivationStore = MetadataActivationStore; */ export type ActionActivationStoreEngine = MetadataActivationStoreEngine; +/** + * Options for one flip: the caller it is made on behalf of, elevated — the + * shared ledger's write options, so the row and its audit row name who switched + * the action (`@objectstack/core`'s `MetadataActivationWriteContext`). + */ +export type ActionActivationWriteOptions = MetadataActivationWriteOptions; + /** * In-memory {@link ActionActivationStore} — process-lifetime only, for tests * and for hosts with no durable plane. It is NOT a sanctioned production @@ -253,8 +261,11 @@ export class ActionActivationProjection { * it is a deployment whose ledger table was never registered or synced. * Reporting a durable install-wide switch that did not persist is the exact * failure ADR-0126 §6 wall 3 exists to close, so this throws instead. + * + * `options` reaches the store unchanged: the door that asked names its + * caller there, and the store writes on that caller's behalf. */ - async setActive(row: ActionActivationRow): Promise { + async setActive(row: ActionActivationRow, options?: ActionActivationWriteOptions): Promise { if (!this.store) { throw Object.assign( new Error( @@ -269,7 +280,7 @@ export class ActionActivationProjection { { code: 'SERVICE_UNAVAILABLE', status: 503 }, ); } - await this.store.setActive(row); + await this.store.setActive(row, options); if (row.active) this.disabled.delete(row.name); else this.disabled.add(row.name); } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 4e64ab23e37..e06efe3429e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -49,7 +49,12 @@ import { lowerFilterCondition, type FilterCondition, type FilterLoweringOptions import { MAX_BULK_PER_ROW_HOOK_ROWS } from '@objectstack/spec/data'; // [ADR-0126 §8] The packaged-action activation ledger: its row contract, its // stores and the engine-held projection the dispatch doors consult. -import { ActionActivationProjection, type ActionActivationRow, type ActionActivationStore } from './action-activation.js'; +import { + ActionActivationProjection, + type ActionActivationRow, + type ActionActivationStore, + type ActionActivationWriteOptions, +} from './action-activation.js'; import { assertListComparandShapes, assertFilterIsMaterializable, @@ -5798,10 +5803,12 @@ export class ObjectQL implements IObjectQLEngine { * the projection only after that write returns. Throws (503 * `SERVICE_UNAVAILABLE`) when no ledger is attached rather than reporting a * durable switch that did not persist; the write AUTHORITY (§5) is enforced - * one tier up, at the route, because it needs the caller. + * one tier up, at the route, because it needs the caller. That route also + * names the caller in `options.context` (elevated), so the ledger row and + * its audit row record who switched the action. */ - async setActionActive(row: ActionActivationRow): Promise { - await this.actionActivation.setActive(row); + async setActionActive(row: ActionActivationRow, options?: ActionActivationWriteOptions): Promise { + await this.actionActivation.setActive(row, options); } /** diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 190bb932366..f27fbd8524d 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -30,6 +30,7 @@ export type { ActionActivationRow, ActionActivationStore, ActionActivationStoreEngine, + ActionActivationWriteOptions, } from './action-activation.js'; // Export Registry diff --git a/packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts b/packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts new file mode 100644 index 00000000000..66169319a8e --- /dev/null +++ b/packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0126 §4] An administrator who switches a packaged artifact off is named +// on the record of it: the activation-ledger row (`created_by` / `updated_by`) +// and the audit row the write produces (`user_id` / `actor`). +// +// ## 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. +// +// 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. + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { bootShowcase } from './showcase-boot.js'; + +const LEDGER = 'sys_metadata_activation'; +const AUDIT = 'sys_audit_log'; +/** Infrastructure rows, not tenant data — read the way the store reads them. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +/** A real showcase action, shipped by the showcase package. */ +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'; + +interface Engine { + find(object: string, options?: unknown): Promise>>; +} + +describe('the activation ledger names the administrator who switched an item', () => { + let stack: VerifyStack; + let token: string; + let adminId: string; + let ql: Engine; + + beforeAll(async () => { + stack = await bootShowcase({ extraPlugins: [new AuditPlugin()] }); + token = await stack.signIn(); + ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Engine; + const me: any = await (await stack.apiAs(token, 'GET', '/auth/get-session')).json(); + adminId = String(me?.user?.id ?? ''); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + const rowOf = async (type: string, name: string) => { + const rows = await ql.find(LEDGER, { where: { metadata_type: type, name }, context: SYSTEM_CTX }); + expect(rows, `${LEDGER} rows for ${type} '${name}'`).toHaveLength(1); + return rows[0]; + }; + + /** The audit rows the ledger writes of ONE row produced, oldest first. */ + const auditRowsOf = async (recordId: unknown, count: number) => { + let rows: Array> = []; + await vi.waitFor(async () => { + rows = await ql.find(AUDIT, { + where: { object_name: LEDGER, record_id: String(recordId) }, + context: SYSTEM_CTX, + }); + expect(rows.length, `audit rows for ${LEDGER} ${String(recordId)}`).toBeGreaterThanOrEqual(count); + }, { timeout: 5_000 }); + return [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at))); + }; + + it('anti-vacuity: the signed-in admin has an id to be named by', () => { + expect(adminId, 'get-session named no user — every assertion below would compare against nothing').toBeTruthy(); + }); + + 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}` }, + ] 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 }); + expect(res.status, `switch off answered ${res.status}: ${await res.clone().text()}`).toBe(200); + + const row = await rowOf(door.type, door.name); + expect(row.active === false || row.active === 0).toBe(true); + expect(row.created_by).toBe(adminId); + expect(row.updated_by).toBe(adminId); + + const [created] = await auditRowsOf(row.id, 1); + expect(created.action).toBe('create'); + expect(created.user_id).toBe(adminId); + expect(created.actor).toBe(adminId); + }, 30_000); + + it(`${door.label}: switching it back ON updates the row, and the update is the admin's too`, async () => { + const res = await stack.apiAs(token, 'POST', door.path, { enabled: true }); + expect(res.status, `switch on answered ${res.status}: ${await res.clone().text()}`).toBe(200); + + const row = await rowOf(door.type, door.name); + expect(row.active === true || row.active === 1).toBe(true); + expect(row.updated_by).toBe(adminId); + + const audits = await auditRowsOf(row.id, 2); + const updated = audits.find((a) => a.action === 'update'); + expect(updated, `no update audit row: ${JSON.stringify(audits)}`).toBeDefined(); + expect(updated!.user_id).toBe(adminId); + expect(updated!.actor).toBe(adminId); + }, 30_000); + } +}); diff --git a/packages/runtime/src/domains/action-activation-posture-gate.test.ts b/packages/runtime/src/domains/action-activation-posture-gate.test.ts index 756a2048322..54a8b65c770 100644 --- a/packages/runtime/src/domains/action-activation-posture-gate.test.ts +++ b/packages/runtime/src/domains/action-activation-posture-gate.test.ts @@ -176,7 +176,7 @@ describe('ADR-0126 §5 — the action activation write is operator-gated in wall const res = await flip(h, TENANT_ADMIN()); expect(statusOf(res)).toBe(200); - expect(h.setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }); + expect(h.setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }, expect.anything()); }); it('so may the platform operator', async () => { @@ -350,7 +350,7 @@ describe('the activation door\'s own contract', () => { const res = await h.dispatcher.handleActions(`/_activation/${OBJECT}/${ACTION}`, 'POST', {}, TENANT_ADMIN()); expect(statusOf(res)).toBe(200); - expect(h.setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: true }); + expect(h.setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: true }, expect.anything()); }); it('an UNDECLARED action is 404, and writes no row', async () => { @@ -410,3 +410,26 @@ describe('the activation door\'s own contract', () => { expect(messageOf(res)).toContain('ADR-0126 §8'); }); }); + +// [#26474] The row is written ON BEHALF of the administrator who asked, so the +// door hands the engine that caller's own envelope, elevated: the elevation +// keeps the engine-owned ledger writable, and the caller's `userId` is what the +// engine stamps on `created_by` / `updated_by` and the audit plugin records as +// `user_id` / `actor`. A bare system context — what the store used before — +// names nobody, which left all four fields null. +describe('the activation write names the caller who asked for it', () => { + it.each([ + ['a tenant admin under `single`', 'single', TENANT_ADMIN], + ['the platform operator under `isolated`', 'isolated', PLATFORM_OPERATOR], + ] as const)('%s: the engine is handed the caller\'s envelope, elevated', async (_label, posture, caller) => { + const h = boot(posture); + const ctx = caller(); + + expect(statusOf(await flip(h, ctx))).toBe(200); + + expect(h.setActionActive).toHaveBeenCalledTimes(1); + const [, options] = h.setActionActive.mock.calls[0]; + expect(options?.context).toEqual({ ...(ctx.executionContext as object), isSystem: true }); + expect(options?.context?.userId).toBe((ctx.executionContext as { userId: string }).userId); + }); +}); diff --git a/packages/runtime/src/domains/action-activation-unpackaged.test.ts b/packages/runtime/src/domains/action-activation-unpackaged.test.ts index ea6a8957085..0905371a9ac 100644 --- a/packages/runtime/src/domains/action-activation-unpackaged.test.ts +++ b/packages/runtime/src/domains/action-activation-unpackaged.test.ts @@ -141,7 +141,7 @@ function boot(opts: { writeError?: Error } = {}): Harness { }, isActionEnabled: (name: string) => projection.isEnabled(name), describeDisabledAction: (name: string) => projection.describeDisabled(name), - setActionActive: (row: any) => projection.setActive(row), + setActionActive: (row: any, options?: any) => projection.setActive(row, options), }; projection.attach(new ObjectStoreActionActivationStore(ql)); diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index a82995ffd99..c05a8917e8f 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -82,6 +82,7 @@ import { refuseUngrantedActivationWrite, refuseUngrantedActivationAuthoring, readActivationBody, + activationWriteContext, ACTION_ACTIVATION_SUBJECT, } from './activation-gate.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -255,12 +256,17 @@ async function handleActionActivationWrite( } try { // An action no package ships carries NO package key: absent is the - // ledger's one spelling for it, and the store writes `null`. - await ql.setActionActive({ - name: actionName, - ...(packageId !== undefined ? { packageId } : {}), - active: enabled, - }); + // ledger's one spelling for it, and the store writes `null`. The write + // runs for the caller, elevated, so the row and its audit row name the + // administrator who switched the action (`activationWriteContext`). + await ql.setActionActive( + { + name: actionName, + ...(packageId !== undefined ? { packageId } : {}), + active: enabled, + }, + { context: activationWriteContext(context) }, + ); } catch (err: unknown) { // A failed write must never read as a successful flip, and its code and // status must say the same thing (ADR-0112). The one shared rule diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index 0831d4b0807..651aa0c046f 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -82,7 +82,7 @@ // authorization RUNG off the execution context, which is where "platform // operator, NOT a tenant user role" (ADR-0068 D2) still means that. The // built-in identity NAME is deliberately no longer imported: see the doc block. -import { effectiveTenancyPosture, AuthzStoreUnavailableError } from '@objectstack/core'; +import { effectiveTenancyPosture, AuthzStoreUnavailableError, type MetadataActivationWriteContext } from '@objectstack/core'; import { postureEnforcesWall } from '@objectstack/spec/security'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps } from '../domain-handler-registry.js'; @@ -425,3 +425,24 @@ export function refuseUngrantedActivationAuthoring( ), }; } + +/** + * [ADR-0126 §4] The context an activation door's ledger WRITE runs under: the + * caller's own execution context, elevated — `{ ...callerContext, isSystem: true }`. + * + * Called only after both gates above admitted the caller, so the elevation + * grants nothing the door has not already judged: the ledger object is + * engine-owned and writable only elevated. What the caller's context adds is + * the NAME — the engine stamps `created_by` / `updated_by` from its `userId`, + * and the audit plugin records `user_id` / `actor` from the same session — so + * the record of an install-wide switch says which administrator threw it. A + * 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. + */ +export function activationWriteContext(context: HttpProtocolContext): MetadataActivationWriteContext { + const ec: unknown = context?.executionContext; + const caller = ec && typeof ec === 'object' ? (ec as Record) : {}; + return { ...caller, isSystem: true }; +} diff --git a/packages/runtime/src/domains/catalog-activation-door.test.ts b/packages/runtime/src/domains/catalog-activation-door.test.ts index 04bf5653bfe..5e0d5bfcd73 100644 --- a/packages/runtime/src/domains/catalog-activation-door.test.ts +++ b/packages/runtime/src/domains/catalog-activation-door.test.ts @@ -78,6 +78,8 @@ function makeEngine(opts: EngineOptions = {}) { [LEDGER]: [], }; const writes: Array<{ op: 'insert' | 'update'; data: Row }> = []; + /** The context each ledger write ran under, in write order — who the engine stamps the row with. */ + const writeContexts: unknown[] = []; const matches = (row: Row, where: Row | undefined): boolean => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); @@ -94,9 +96,10 @@ function makeEngine(opts: EngineOptions = {}) { const rows = (tables[object] ?? []).filter((r) => matches(r, options?.where)); return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows; }, - async insert(object: string, data: Row) { + async insert(object: string, data: Row, options?: any) { if (opts.writeError) throw opts.writeError; writes.push({ op: 'insert', data }); + writeContexts.push(options?.context); const row = { id: `act_${nextId++}`, ...data }; (tables[object] ??= []).push(row); return row; @@ -108,13 +111,14 @@ function makeEngine(opts: EngineOptions = {}) { if (dispatch.kind !== 'by-id') throw new Error('fake engine: only by-id updates are modelled'); if (opts.writeError) throw opts.writeError; writes.push({ op: 'update', data }); + writeContexts.push(options?.context); const row = (tables[object] ?? []).find((r) => r.id === dispatch.id); if (row) Object.assign(row, data); return row; }, }; if (!opts.unbound) bindTestSecurityCatalog(engine, CATALOG); - return { engine, tables, writes }; + return { engine, tables, writes, writeContexts }; } interface Harness { @@ -122,6 +126,7 @@ interface Harness { engine: any; tables: Record; writes: Array<{ op: 'insert' | 'update'; data: Row }>; + writeContexts: unknown[]; } function boot( @@ -129,7 +134,7 @@ function boot( opts: EngineOptions = {}, extraServices: Record = {}, ): Harness { - const { engine, tables, writes } = makeEngine(opts); + const { engine, tables, writes, writeContexts } = makeEngine(opts); const services: Record = { objectql: engine, data: engine, ...extraServices }; if (posture) services.tenancy = { posture }; const resolve = (name: string): unknown => services[name] ?? null; @@ -138,7 +143,7 @@ function boot( getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve }, }; - return { dispatcher: new HttpDispatcher(kernel as never), engine, tables, writes }; + return { dispatcher: new HttpDispatcher(kernel as never), engine, tables, writes, writeContexts }; } /** A tenant org admin holding `manage_metadata` — the §5 gate is then the only thing left. */ @@ -541,6 +546,27 @@ describe('the catalog activation door — an environment-authored item', () => { }); }); +// [#26474] The row is written ON BEHALF of the administrator who asked, so the +// door hands the store that caller's own envelope, elevated: the elevation keeps +// the engine-owned ledger writable, and the caller's `userId` is what the engine +// stamps on `created_by` / `updated_by` and the audit plugin records as +// `user_id` / `actor`. The store's bare system context names nobody. +describe('the catalog activation door — the write names the caller who asked for it', () => { + for (const [type, name] of [['position', POSITION], ['permission', SET]] as const) { + it(`\`${type}\`: the insert AND the later update run under the caller's envelope, elevated`, async () => { + const h = boot('isolated'); + const ctx = PLATFORM_OPERATOR(); + const expected = { ...(ctx.executionContext as object), isSystem: true }; + + expect(statusOf(await flip(h, ctx, `/_activation/${type}/${name}`, { enabled: false }))).toBe(200); + expect(statusOf(await flip(h, PLATFORM_OPERATOR(), `/_activation/${type}/${name}`, { enabled: true }))).toBe(200); + + expect(h.writes.map((w) => w.op)).toEqual(['insert', 'update']); + expect(h.writeContexts).toEqual([expected, expected]); + }); + } +}); + describe('the catalog activation door — the resolver honours what it writes', () => { it('CONTROL: before any flip the holder is granted through the position and the direct set', async () => { const h = boot('single'); diff --git a/packages/runtime/src/domains/catalog-activation.ts b/packages/runtime/src/domains/catalog-activation.ts index 35233444cb4..9722a7804b7 100644 --- a/packages/runtime/src/domains/catalog-activation.ts +++ b/packages/runtime/src/domains/catalog-activation.ts @@ -64,6 +64,7 @@ import { refuseUngrantedActivationWrite, refuseUngrantedActivationAuthoring, readActivationBody, + activationWriteContext, POSITION_ACTIVATION_SUBJECT, PERMISSION_SET_ACTIVATION_SUBJECT, SHARING_RULE_ACTIVATION_SUBJECT, @@ -392,12 +393,17 @@ export async function handleCatalogActivationWrite( // one, and NO package when it does not — a position or permission set // the environment authored through the metadata door. That item is // switched exactly like a packaged one: the resolver keys the row by - // type and name, never by package. - await new ObjectStoreMetadataActivationStore(ql, type).setActive({ - name: entry.name, - ...(entry.packageId !== undefined ? { packageId: entry.packageId } : {}), - active: enabled, - }); + // type and name, never by package. The write runs for the caller, + // elevated, so the row and its audit row name the administrator who + // switched the item (`activationWriteContext`). + await new ObjectStoreMetadataActivationStore(ql, type).setActive( + { + name: entry.name, + ...(entry.packageId !== undefined ? { packageId: entry.packageId } : {}), + active: enabled, + }, + { context: activationWriteContext(context) }, + ); } catch (err: unknown) { // A failed write must never read as a successful flip, and its code and // status must say the same thing (ADR-0112). The one shared rule diff --git a/packages/runtime/src/domains/scoped-service-miss-attribution.test.ts b/packages/runtime/src/domains/scoped-service-miss-attribution.test.ts index d5800e3c180..71e9bdc1b59 100644 --- a/packages/runtime/src/domains/scoped-service-miss-attribution.test.ts +++ b/packages/runtime/src/domains/scoped-service-miss-attribution.test.ts @@ -402,7 +402,7 @@ describe('[#16402] a scope with no instance manufactures no outage — all four const res = responseOf(await flip(dispatcher, tenantAdmin(SCOPE_EMPTY))); expect(res.status).toBe(200); - expect(setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }); + expect(setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }, expect.anything()); }); it('door 3 · POST /automation/:name/toggle — TOGGLES instead of answering 503', async () => { diff --git a/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts b/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts index 1d7e0526339..1eb6991c988 100644 --- a/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts +++ b/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts @@ -451,7 +451,7 @@ describe('[#15900] the install-wide activation write — a tenancy service that const res = responseOf(await flip(dispatcher, tenantAdmin())); expect(res.status).toBe(200); - expect(setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }); + expect(setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }, expect.anything()); }); /** @@ -482,7 +482,7 @@ describe('[#15900] the install-wide activation write — a tenancy service that const res = responseOf(await flip(dispatcher, operator())); expect(res.status).toBe(200); - expect(setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }); + expect(setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }, expect.anything()); }); });