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
20 changes: 20 additions & 0 deletions .changeset/26474-activation-ledger-actor.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions packages/core/src/utils/metadata-activation-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
72 changes: 61 additions & 11 deletions packages/core/src/utils/metadata-activation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

/** 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.
*
Expand Down Expand Up @@ -120,8 +157,11 @@ export interface MetadataActivationRow {
export interface MetadataActivationStore {
/** Every activation row for this type — the ledger is deployment-wide. */
list(): Promise<MetadataActivationRow[]>;
/** Insert or update the row for one packaged artifact. */
setActive(row: MetadataActivationRow): Promise<void>;
/**
* 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<void>;
}

/**
Expand Down Expand Up @@ -176,7 +216,8 @@ export class InMemoryMetadataActivationStore implements MetadataActivationStore
return [...this.rows.values()];
}

async setActive(row: MetadataActivationRow): Promise<void> {
/** The caller is not recorded: a map has no `created_by` to stamp. */
async setActive(row: MetadataActivationRow, _options?: MetadataActivationWriteOptions): Promise<void> {
refuseEmptyPackageId(row, `'${row.name}'`);
this.rows.set(row.name, { ...row });
}
Expand All @@ -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(
Expand Down Expand Up @@ -256,21 +300,27 @@ 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<void> {
async setActive(row: MetadataActivationRow, options?: MetadataActivationWriteOptions): Promise<void> {
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;

if (current && (current as { id?: unknown }).id != null) {
await this.engine.update(
METADATA_ACTIVATION_TABLE,
{ id: (current as { id: unknown }).id, active: row.active, package_id: packageId },
{ context: SYSTEM_CTX },
{ context },
);
return;
}
Expand All @@ -283,7 +333,7 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto
package_id: packageId,
active: row.active,
},
{ context: SYSTEM_CTX },
{ context },
);
}

Expand Down
17 changes: 17 additions & 0 deletions packages/objectql/src/action-activation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions packages/objectql/src/action-activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
type MetadataActivationRow,
type MetadataActivationStore,
type MetadataActivationStoreEngine,
type MetadataActivationWriteOptions,
} from '@objectstack/core';

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
async setActive(row: ActionActivationRow, options?: ActionActivationWriteOptions): Promise<void> {
if (!this.store) {
throw Object.assign(
new Error(
Expand All @@ -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);
}
Expand Down
15 changes: 11 additions & 4 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
await this.actionActivation.setActive(row);
async setActionActive(row: ActionActivationRow, options?: ActionActivationWriteOptions): Promise<void> {
await this.actionActivation.setActive(row, options);
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/objectql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type {
ActionActivationRow,
ActionActivationStore,
ActionActivationStoreEngine,
ActionActivationWriteOptions,
} from './action-activation.js';

// Export Registry
Expand Down
Loading
Loading