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/21623-flow-read-node-evaluate-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/service-automation': patch
---

fix(service-automation): a flow's `get_record` node refuses a filter that evaluates the stored-metadata tables' body or content hash, as the generic data door does (#21623)

Clause-②: no

The two stored-metadata tables (the current metadata bodies and their version history) hold each body as stored, credential material included, and content-hash columns computed over it. A flow's `get_record` node now serves those rows projected and keyed, but it still ran its `filter` against the stored values as written, under either run identity (`runAs: 'system'` and `runAs: 'user'`). A filter over the body column or a content-hash column was evaluated row by row, so whether a row came back answered the filter: a predicate over the withheld values. The generic data door refuses those filters before its query runs.

**What changes.** When the node reads either table, it judges its filter the way the data door judges the same filter, before the data engine is asked, on both branches (one row, and a row list when `limit` is above 1). The columns the filter reads are collected after interpolation, so a condition that a `{token}` supplies is judged too. A filter that reads the body column, or a content-hash column (the history table's parent hash and change note included), refuses the node with the data door's own message and error code, `INVALID_FIELD`. The refusal is a guard failure: the run fails, nothing downstream of the node runs, and a `fault` edge does not route it. A `try_catch` catch region reads the code on `{$error.code}`. To read a stored-metadata row from a flow, filter by `name`, `type`, `state` or another scalar column.

**What does not change.** A filter over scalar columns is served as before: the body projected and the hash keyed. Every other object is filtered and read exactly as before, including columns that share these names. The write nodes are unchanged. The node consumes the data door's own functions from `@objectstack/metadata-protocol` (`collectStoredMetadataFilterFields`, `storedMetadataBodyPredicateRefusal`, `storedMetadataHashEvaluateRefusal`) and keeps no copy of them.
61 changes: 61 additions & 0 deletions packages/services/service-automation/src/builtin/crud-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ import type { DroppedFieldsEvent } from '@objectstack/spec/data';
import { StandardErrorCode } from '@objectstack/spec/api';
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
import {
collectStoredMetadataFilterFields,
ephemeralStoredHashDigest,
redactStoredMetadataRows,
serveStoredMetadataHashColumnRows,
storedMetadataBodyPredicateRefusal,
storedMetadataBodyProjection,
storedMetadataHashEvaluateRefusal,
type StoredHashDigest,
} from '@objectstack/metadata-protocol';
import type { AutomationEngine } from '../engine.js';
Expand Down Expand Up @@ -270,6 +273,56 @@ async function serveFamilyRead<A>(
return answer;
}

/**
* [#21623] Refuse a node whose filter EVALUATES the stored-metadata family's
* body or content hash, the way the generic data door refuses the same filter.
*
* {@link serveFamilyRead} closes the node's serve and copy exits; this closes
* its evaluate exit. A filter over the stored body column, or over a stored
* content-hash column, is evaluated against the stored values row by row, so
* whether a row comes back answers the predicate even though the row itself
* is served projected: a guessed prefix of withheld credential material, or a
* guessed hash, returns the row exactly when it is right (the predicate oracle
* the family's refusals name). Under `runAs: 'system'` the engine reads
* elevated and cannot tell this read from the platform's own internal
* readers, so the rule is applied here, before the engine is asked.
*
* Built only from the door's own functions (`@objectstack/metadata-protocol`),
* in the door's own order, never a copy:
* - the columns the filter reads come from the family's ONE filter-field
* collector (`collectStoredMetadataFilterFields`): every key's head and
* every cross-field `{ $field }` comparand, at any depth;
* - the body refusal (`storedMetadataBodyPredicateRefusal`) is asked first,
* then the content-hash refusal (`storedMetadataHashEvaluateRefusal`).
*
* `query` is the option bag the node hands the engine, so the collector reads
* exactly the filter the engine would run: the INTERPOLATED one, since a
* `{token}` can resolve to a whole condition (a `$and` list, a comparand) whose
* columns the authored template does not show. The node configs declare no
* sort and no grouping, so the refusals are fed filter fields only.
*
* The answer is a guard refusal ({@link refuseNode}: the metadata is wrong,
* and re-running it unchanged never succeeds) carrying the door's own error
* code, read off the door's refusal rather than spelled again. `undefined`
* outside the family ({@link isStoredMetadataBodyObject}) and for a filter that
* reads neither column.
*/
function storedMetadataFilterRefusal(
nodeType: string,
objectName: string,
query: { where: Record<string, unknown> },
): (ReturnType<typeof refuseNode> & { code: string }) | undefined {
if (!isStoredMetadataBodyObject(objectName)) return undefined;
const filterFields = collectStoredMetadataFilterFields(objectName, query);
const refuse = (refusal: Error) =>
({ ...refuseNode(`${nodeType}: ${refusal.message}`), code: (refusal as Error & { code: string }).code });
const bodyPredicateRefusal = storedMetadataBodyPredicateRefusal(objectName, { filterFields });
if (bodyPredicateRefusal) return refuse(bodyPredicateRefusal);
const hashEvaluateRefusal = storedMetadataHashEvaluateRefusal(objectName, { filterFields });
if (hashEvaluateRefusal) return refuse(hashEvaluateRefusal);
return undefined;
}

/**
* CRUD built-in nodes — `get_record` / `create_record` / `update_record` /
* `delete_record`, wired to the runtime data layer (ObjectQL / IDataEngine).
Expand Down Expand Up @@ -353,6 +406,14 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
const limit = cfg.limit;
const outputVariable = cfg.outputVariable;

// [#21623] A filter that evaluates the stored-metadata family's
// body or content hash is refused before the engine is asked,
// with the data door's own code, under either run identity. It
// reads the interpolated filter, in the `where` slot both
// engine reads below hand it in.
const familyRefusal = storedMetadataFilterRefusal('get_record', objectName, { where: filter });
if (familyRefusal) return familyRefusal;

const data = getData();
if (!data) {
ctx.logger.warn(`[get_record] no data engine; skipping ${objectName}`);
Expand Down
Loading
Loading