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
13 changes: 13 additions & 0 deletions .changeset/21624-flow-write-nodes-shared-prescription.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/service-automation': patch
---

fix(service-automation): a flow write node's refusal of a stored-metadata table ends on the same prescription sentence as the save-time refusal (#21624)

Clause-②: no

A flow `create_record`, `update_record` or `delete_record` node aimed at a stored-metadata table is refused twice: at save by `FlowSchema`, and at run time by the node itself, for a definition the parse never judged. Both refusals tell the author where a metadata change goes instead, and until now they said it in two spellings of one sentence: the run-time refusal named the elevation as `runAs: 'system'`, the save-time one as `runAs`, a system context.

**What changes.** The run-time refusal's message keeps its lead (the node type, what it would have done and the table, and that the write was not run) and now ends on `STORED_METADATA_BODY_PRESCRIPTION`, imported from `@objectstack/spec/kernel`: the one sentence the save-time refusal and the hook refusal also end on. Its elevation clause now reads "Elevation (`runAs`, a system context) does not change this."

**What does not change.** Which writes are refused, the refusal's `PERMISSION_DENIED` code, its guard classification (a `fault` edge does not route it) and every other object's writes are exactly as before.
12 changes: 8 additions & 4 deletions packages/services/service-automation/src/builtin/crud-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
import type { AutomationContext, IDataEngine } from '@objectstack/spec/contracts';
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
import { StandardErrorCode } from '@objectstack/spec/api';
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
import { isStoredMetadataBodyObject, STORED_METADATA_BODY_PRESCRIPTION } from '@objectstack/spec/kernel';
import {
collectStoredMetadataFilterFields,
ephemeralStoredHashDigest,
Expand Down Expand Up @@ -355,6 +355,11 @@ const STORED_METADATA_WRITE_VERB = {
* a non-platform principal's write to these tables with in a secured
* composition, and the code the body-write boundary for the same ruling
* carries. No code is minted. `undefined` for any other object.
*
* Its message names the node, the verb and the table, then ends on the
* family's ONE prescription, `STORED_METADATA_BODY_PRESCRIPTION`, imported from
* `@objectstack/spec/kernel`: the sentence `FlowSchema`'s save-time refusal of
* the same node ends on, so the save and the run tell an author the same thing.
*/
function storedMetadataWriteRefusal(
nodeType: keyof typeof STORED_METADATA_WRITE_VERB,
Expand All @@ -364,9 +369,8 @@ function storedMetadataWriteRefusal(
return {
...refuseNode(
`${nodeType}: refusing to ${STORED_METADATA_WRITE_VERB[nodeType]} '${objectName}': it holds stored `
+ 'metadata, and a flow may not write it directly, so the write was not run. Change metadata through the '
+ 'metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), where it is validated and its '
+ "provenance is recorded. Elevation (`runAs: 'system'`) does not change this.",
+ 'metadata, and a flow may not write it directly, so the write was not run. '
+ STORED_METADATA_BODY_PRESCRIPTION,
),
code: StandardErrorCode.enum.PERMISSION_DENIED,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
import { STORED_METADATA_BODY_PRESCRIPTION } from '@objectstack/spec/kernel';
import { AutomationServicePlugin } from '../plugin.js';
import type { AutomationEngine, NodeExecutor } from '../engine.js';

Expand All @@ -78,6 +79,16 @@ const BODY_FRAGMENT = '"label":"Pin body"';
*/
const STAND_IN_TARGET = 'pin_stand_in_target';

/**
* The closing sentence of `text`, measured against the family's ONE
* prescription, which both the save-time and the run-time refusal end on. Read
* from the imported constant, never restated, so a later rewording of that one
* sentence moves these pins with it.
*/
function closingPrescriptionOf(text: string): string {
return text.slice(-STORED_METADATA_BODY_PRESCRIPTION.length);
}

/** One write node in a flow definition aimed at a family table, and where it sits. */
interface FamilyTarget {
readonly path: string;
Expand Down Expand Up @@ -264,7 +275,10 @@ function harness(ql: ObjectQL, automation: AutomationEngine) {
(thrown!.issues ?? []).map((i) => ({ code: i.code, path: i.path.join('.') })),
`${def.name}: the save-time refusal's issues`,
).toEqual(targets.map((t) => ({ code: 'custom', path: t.path })));
for (const issue of thrown!.issues ?? []) expect(issue.message).toContain('the metadata protocol');
for (const issue of thrown!.issues ?? []) {
expect(closingPrescriptionOf(issue.message), `${def.name}: the save-time refusal ends on the family's prescription`)
.toBe(STORED_METADATA_BODY_PRESCRIPTION);
}
expect(await automation.getFlow(def.name), `${def.name}: a refused flow was registered`).toBeNull();
expect(await Promise.all(tables.map((object) => snapshot(object))), `${def.name}: the save-time refusal changed a table`)
.toEqual(before);
Expand Down Expand Up @@ -342,7 +356,8 @@ async function expectRefused(h: Harness, object: FamilyTable, nodeType: WriteNod
const where = `${nodeType} on ${object}, runAs '${runAs}'`;
expect(run.res.success, `${where}: the run must fail`).toBe(false);
expect(run.res.status, `${where}: the run's status`).toBe('failed');
expect(String(run.res.error), `${where}: the refusal names the metadata protocol`).toContain('the metadata protocol');
expect(closingPrescriptionOf(String(run.res.error)), `${where}: the run-time refusal ends on the family's prescription`)
.toBe(STORED_METADATA_BODY_PRESCRIPTION);
expect(run.downstreamRan, `${where}: the node downstream of the refusal ran`).toBe(false);
expect(run.familyWrites, `${where}: the engine's write verb was called on the family table`).toBe(0);
expect(await h.snapshot(object), `${where}: the family table changed`).toBe(before);
Expand Down
Loading