diff --git a/.changeset/24321-resume-as-decider.md b/.changeset/24321-resume-as-decider.md new file mode 100644 index 00000000000..eb527329711 --- /dev/null +++ b/.changeset/24321-resume-as-decider.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/plugin-approvals': minor +--- + +feat(automation)!: an approval decision resumes a flow as the person who decided + +Clause-②: yes (narrowing). The narrowing: under `runAs: 'user'`, a post-decision node that the submitter's rights admitted and the decider's do not is now refused (ruling A item 3). The widening: three published interfaces each gain one optional member, `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`) and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). No schema shape moves: `flow.runAs` keeps its two values and only its describe changes. + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A resumed flow run kept its trigger user for its whole life. In an approval flow that meant the nodes after the manager's decision wrote as the rep who submitted: the record's Last Modified By (`updated_by`) and the audit log named someone who did not act, and under `runAs: 'user'` the writes ran under the rep's grants. + +`flow.runAs: 'user'` now means the human whose act is running the current segment: the trigger user until a human decision, the decider after it. `runAs: 'system'` flows keep system privileges; after the decision their attribution user is the decider. + +- **Now, `runAs: 'user'`:** after an approval decision (approve or reject), the nodes that follow run with the decider's identity and permissions together. Their positions, permission sets and organization peers are resolved for them through the same resolver that seeds the trigger user's run, never copied from the trigger user. `updated_by` and `sys_audit_log` name the decider for those writes. The nodes before the decision keep the submitter. A second decision moves the segment to its own decider, a later pause keeps the identity the segment had, and the run's history still names the user who triggered it. +- **Now, `runAs: 'system'`:** after an approval decision, the flow keeps system privileges and its nodes run as `{ isSystem: true, userId: DECIDER }`, the shape the approvals verdict mirror already writes with. No permission is resolved and elevation is unchanged, so a read-only column still lands. Their writes, `sys_audit_log`, and `ctx.user` in the hooks those writes fire name the decider; the nodes before the decision keep the submitter. +- **Refused now:** under `runAs: 'user'`, a node after the decision that the decider may not perform (an object or field they may not edit, a record outside their reach) is refused, the run stops at that node as a repairable strand, and the decision door answers `RESUME_FAILED` (`repairable: true`) naming the run, the node and the refusal, as for any other failed post-decision node. Before, the same node ran with the submitter's rights. A repair replay (`continueRestoredRun`) continues as the decider too. +- **Unchanged:** a flow with no human decision node; an SLA auto-decision, which names no human, so the run keeps the identity it had (on both postures); a subflow parent's own nodes; and the send-back, resubmit and recall doors. + +`@objectstack/plugin-approvals` hands the decider to the engine on the service-stamped resume signal: the person the verdict mirror already writes as. The engine honours a decider only on a signal that carries the owning service's `RESUME_AUTHORITY_SERVICE` marker, so a resume built from an HTTP body cannot name one. The published types gain one optional member each: `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`), and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). + +What changes for you: if a `runAs: 'user'` flow's nodes after an approval decision must write something the approver may not, such as a read-only field or a field the approver's permission set marks non-editable, the flow needs `runAs: 'system'`; its writes then keep system privileges and are attributed to the approver. A hook that reads `ctx.user` on a write a `runAs: 'system'` approval flow makes after the decision now sees the decider instead of the submitter. A run that was suspended before this release and is decided after it continues as the decider. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 2e548b906ad..045fec7b2a8 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -73,7 +73,7 @@ const approvalFlow = { | `version` | `number` | optional | Version number (defaults to `1`) | | `status` | `enum` | optional | `'draft'`, `'active'`, `'obsolete'`, `'invalid'` (defaults to `'draft'`) | | `type` | `FlowType` | ✅ | Flow trigger type (see below) | -| `runAs` | `enum` | optional | `'system'` (elevated, bypasses RLS) or `'user'` (the triggering user, RLS-respecting; the default). A `'user'` run that resolved **no** trigger user has nothing to scope to, so its data operations are refused — declare `'system'` for schedule / time-relative / API triggers and for record-change flows fired by a write that carried no user | +| `runAs` | `enum` | optional | `'system'` (elevated, bypasses RLS; after an approval decision its attribution user is the decider) or `'user'` (the default, RLS-respecting: the trigger user until a human decision, the decider after it, with the decider's permissions). A `'user'` run that resolved **no** trigger user has nothing to scope to, so its data operations are refused — declare `'system'` for schedule / time-relative / API triggers and for record-change flows fired by a write that carried no user | | `variables` | `FlowVariable[]` | optional | Input/output variables | | `nodes` | `FlowNode[]` | ✅ | Flow nodes | | `edges` | `FlowEdge[]` | ✅ | Connections between nodes | @@ -1088,8 +1088,8 @@ referent there. The shape is `ResumeFailureDetailsSchema` in The route asks two questions, in this order. Both must pass. **1. Who is resuming.** A resumed run continues under the identity stored on -the run, so its data nodes run as the user who **started** it, whoever sent the -values. The route therefore admits only: +the run — the user who **started** it or, after an approval decision, the +decider — whoever sent the values. The route therefore admits only: - the user who started the run (the run's `trigger.userId`) — no extra permission needed; diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 084d5c5b344..859088d9d8a 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -120,7 +120,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -244,7 +244,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -375,7 +375,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -683,7 +683,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -748,7 +748,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index 75652cf4ea4..bdc45c63ecb 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -41,7 +41,7 @@ const result = FlowSchema.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -262,7 +262,7 @@ const result = FlowSchema.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index b137eedb406..740b9c8cc0f 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -160,6 +160,16 @@ export interface ApprovalResumeSurface { resume?(runId: string, signal?: { output?: Record; branchLabel?: string; + /** + * The person whose approve / reject decision this resume carries + * (`ResumeSignal.resumedBy`, ruling A on `FlowSchema.runAs`): the same + * person the verdict mirror writes as ({@link actingUserId}). The engine + * runs a `runAs: 'user'` flow's nodes after the decision as them, + * resolving their own grants, and a `runAs: 'system'` flow's with them as + * the attribution user of its unchanged elevation; absent when no human + * decided (the SLA sweep), and then the run keeps the identity it had. + */ + resumedBy?: string; /** * #3801: the engine refuses a resume of an `approval` suspension unless * the signal carries this marker — the proof that the resume is the tail @@ -772,6 +782,13 @@ export interface StrandedContinuationSignal { decision: string; /** How the original door described the outcome in prose, e.g. `the reject decision`. */ what: string; + /** + * The decider the original resume carried ({@link ApprovalResumeSurface}'s + * `resumedBy`), so a replay continues the nodes after the decision as the + * person who decided, never as the run's trigger user. Absent when the + * original carried none. + */ + resumedBy?: string; } /** @@ -4023,7 +4040,7 @@ export class ApprovalService implements IApprovalService { */ private async serviceResume( runId: string, - signal: { output?: Record; branchLabel?: string }, + signal: { output?: Record; branchLabel?: string; resumedBy?: string }, ): Promise<{ runId: string; repairable: boolean; error: string } | undefined> { const result = await this.automation!.resume!(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true }); const reported = result as @@ -4219,7 +4236,7 @@ export class ApprovalService implements IApprovalService { runId: string, requestId: string, what: string, - signal: { output?: Record; branchLabel?: string }, + signal: { output?: Record; branchLabel?: string; resumedBy?: string }, decision: string, ): Promise<{ resumed: boolean; resumeError?: string; resumeFailure?: ResumeFailureReport }> { const missing = this.missingRunCapability(runId, requestId, what, 'resume'); @@ -4279,6 +4296,7 @@ export class ApprovalService implements IApprovalService { if (repairable) { await this.journalStrandedContinuation(requestId, { branchLabel: signal.branchLabel, output: signal.output, decision, what, + ...(signal.resumedBy ? { resumedBy: signal.resumedBy } : {}), }); } throw strandedDecisionFailure( @@ -4321,6 +4339,12 @@ export class ApprovalService implements IApprovalService { const branchLabel = result.decision === 'approve' ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject; + // Ruling A on `FlowSchema.runAs`: the decider rides the hand-off, so the + // nodes after the decision run as the person who decided — the person + // the verdict mirror above already wrote as. The SLA sweep's context + // vouches for nobody, so its auto-decision names no decider and the run + // keeps the identity it had. + const decider = actingUserId(context); const outcome = await this.resumeRecordedOutcome( result.runId, requestId, `the ${result.decision} decision`, { @@ -4331,6 +4355,7 @@ export class ApprovalService implements IApprovalService { // Reserved keys are spread LAST so no output can shadow them (the // whitelist already rejects them; this is defense in depth). output: { ...(result.outputs ?? {}), decision: result.decision, requestId }, + ...(decider ? { resumedBy: decider } : {}), }, result.decision, ); @@ -6410,7 +6435,12 @@ export class ApprovalService implements IApprovalService { // so a continuation that strands AGAIN is repairable exactly like the first. const outcome = await this.resumeRecordedOutcome( runId, requestId, signal.what, - { branchLabel: signal.branchLabel, output: signal.output }, + { + branchLabel: signal.branchLabel, output: signal.output, + // The journalled decider (ruling A): the replayed nodes after the + // decision run as the person who decided, not as the run's trigger. + ...(typeof signal.resumedBy === 'string' && signal.resumedBy ? { resumedBy: signal.resumedBy } : {}), + }, signal.decision, ); diff --git a/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts b/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts index 5fcbfa759de..d90533affac 100644 --- a/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts +++ b/packages/plugins/plugin-approvals/src/restored-approval-continuation.test.ts @@ -39,7 +39,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { AutomationEngine, InMemorySuspendedRunStore } from '@objectstack/service-automation'; +import { AutomationEngine, InMemorySuspendedRunStore, resolveRunDataContext } from '@objectstack/service-automation'; // [#4550] The engine double below routes its write verbs through ObjectQL's OWN // dispatch predicates rather than a hand-mirrored copy. import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; @@ -893,3 +893,138 @@ describe('#15389 — a restored approval suspension has an issuer again', () => expect(await second.hasSuspendedRun(amb.flow_run_id)).toBe(true); }); }); + +/** + * Ruling A on `FlowSchema.runAs` — the decider rides the decision hand-off. + * + * 「the human whose act is running this segment: the trigger user until a + * human decision, the decider after it」. `decide` hands the engine the person + * who decided (the same person the verdict mirror writes as) on the + * service-stamped resume signal, and the engine runs a `runAs: 'user'` flow's + * nodes after the decision as them, with their own resolved grants. The SLA + * sweep's auto-decision vouches for nobody, so it names no decider. And the + * decider is journalled with a stranded continuation, so the repair replay + * (#15389, this file's subject) continues as the person who decided — never + * falling back to the run's trigger user. + * + * Read on the run's own data context, `resolveRunDataContext(context)`: the + * envelope every CRUD node hands ObjectQL. The engine-side switch, and the + * attribution on a real stack, are pinned in `service-automation`'s + * `resume-decider-identity.integration.test.ts`. + */ +describe('ruling A — a decision resumes the run as the person who decided', () => { + const DECIDER_CTX = { userId: 'u1', isSystem: false, positions: [], permissions: [] } as any; + const GRANTS: Record = { + submitter: { positions: ['sales_rep'], permissions: ['deal_rep'] }, + u1: { positions: ['sales_manager'], permissions: ['deal_manager'] }, + }; + /** start → before → approval ─approve→ after_approve / ─reject→ after_reject → end */ + const WHO_FLOW = { + name: 'who_flow', label: 'Who Flow', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'before', type: 'whoami', label: 'Before' }, + { id: 'review', type: 'approval', label: 'Review', config: { approvers: [{ type: 'user', value: 'u1' }] } }, + { id: 'after_approve', type: 'whoami', label: 'After approve' }, + { id: 'after_reject', type: 'whoami', label: 'After reject' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'before' }, + { id: 'e2', source: 'before', target: 'review' }, + { id: 'e3', source: 'review', target: 'after_approve', label: 'approve' }, + { id: 'e4', source: 'review', target: 'after_reject', label: 'reject' }, + { id: 'e5', source: 'after_approve', target: 'end' }, + { id: 'e6', source: 'after_reject', target: 'end' }, + ], + }; + + let data: ReturnType; + let service: ApprovalService; + let seen: Record; + let rejectThrowsOnce: boolean; + + function boot() { + const automation = new AutomationEngine(noopLogger as any, new InMemorySuspendedRunStore()); + automation.setUserGrantsResolver(async (userId) => GRANTS[userId]); + registerApprovalNode(automation, service, noopLogger as any); + automation.registerNodeExecutor({ + type: 'whoami', + async execute(node: any, _vars: any, context: any) { + if (node.id === 'after_reject' && rejectThrowsOnce) { + rejectThrowsOnce = false; + throw new Error(DOWNSTREAM_FAILURE); + } + const ctx = resolveRunDataContext(context); + seen[node.id] = { userId: ctx?.userId, permissions: ctx?.permissions, ...(ctx?.isSystem ? { isSystem: true } : {}) }; + return { success: true }; + }, + } as never); + automation.registerFlow('who_flow', WHO_FLOW as never); + automation.registerFlow('who_flow_sys', { ...WHO_FLOW, name: 'who_flow_sys', runAs: 'system' } as never); + service.attachAutomation(automation); + return automation; + } + + beforeEach(() => { + seen = {}; rejectThrowsOnce = false; + data = makeFakeEngine(); + service = new ApprovalService({ engine: data as any, logger: noopLogger }); + }); + + const rowOf = async (id: string) => + (await data.find('sys_approval_request', { where: { id } }))[0]; + + async function parked(automation: AutomationEngine, flow = 'who_flow') { + await automation.execute(flow, { + object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter', + } as never); + expect(seen.before, 'the nodes before the decision run as the submitter').toEqual(flow === 'who_flow' + ? { userId: 'submitter', permissions: ['deal_rep'] } + : { userId: 'submitter', permissions: [], isSystem: true }); + return (await data.find('sys_approval_request', { where: { status: 'pending' } }))[0]; + } + + it.each(['approve', 'reject'] as const)('%s: the nodes after the decision run as the decider, with the decider\'s grants', async (decision) => { + const automation = boot(); + const req = await parked(automation); + const out = await service.decide(req.id, { decision, actorId: 'u1', comment: decision }, DECIDER_CTX); + expect(out.resumed, JSON.stringify(out)).toBe(true); + expect(seen[`after_${decision}`]).toEqual({ userId: 'u1', permissions: ['deal_manager'] }); + }); + + it.each(['approve', 'reject'] as const)("runAs:'system' %s: the nodes after the decision keep their elevation and carry the decider as their attribution user (ruling 6267719791 Q1 A)", async (decision) => { + const automation = boot(); + const req = await parked(automation, 'who_flow_sys'); + const out = await service.decide(req.id, { decision, actorId: 'u1', comment: decision }, DECIDER_CTX); + expect(out.resumed, JSON.stringify(out)).toBe(true); + expect(seen[`after_${decision}`]).toEqual({ userId: 'u1', permissions: [], isSystem: true }); + }); + + it('CONTROL: an SLA auto-decision names no decider, so the run keeps the identity it had', async () => { + const automation = boot(); + const req = await parked(automation); + // The sweep's shape: a system context that vouches for nobody. + const out = await service.decide(req.id, { decision: 'approve', actorId: 'u1', comment: 'SLA escalation' }, SYSTEM_CTX); + expect(out.resumed, JSON.stringify(out)).toBe(true); + expect(seen.after_approve).toEqual({ userId: 'submitter', permissions: ['deal_rep'] }); + }); + + it('a stranded decision is replayed as the person who decided, not as the trigger user', async () => { + const automation = boot(); + const req = await parked(automation); + rejectThrowsOnce = true; + const err = await service.decide(req.id, { decision: 'reject', actorId: 'u1', comment: 'no' }, DECIDER_CTX) + .then(() => null, (e: Error) => e); + expect(err?.message).toMatch(/^RESUME_FAILED/); + // The journal carries the decider beside the signal it re-issues. + const journal = JSON.parse((await rowOf(req.id)).node_config_json).__strandedContinuation; + expect(journal).toMatchObject({ decision: 'reject', resumedBy: 'u1' }); + + expect((await automation.restoreConsumedSuspension(req.flow_run_id)).restored).toBe(true); + const out = await service.continueRestoredRun(req.id, { requestedBy: 'ops', reason: 'fixed the record' }); + expect(out.resumed, JSON.stringify(out)).toBe(true); + expect(out.source).toBe('journal'); + expect(seen.after_reject, 'the replay fell back to the trigger user').toEqual({ userId: 'u1', permissions: ['deal_manager'] }); + }); +}); diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index a88c39adb3f..c1a9a206f64 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -37,8 +37,10 @@ "@objectstack/driver-sql": "workspace:*", "@objectstack/metadata-core": "workspace:*", "@objectstack/objectql": "workspace:*", + "@objectstack/plugin-audit": "workspace:*", "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-security": "workspace:*", + "@objectstack/plugin-sharing": "workspace:*", "@objectstack/refd-timer-testkit": "workspace:*", "@objectstack/service-job": "workspace:*", "@objectstack/service-messaging": "workspace:*", diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 7d06c7464a8..019ddb6c6fd 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -212,7 +212,7 @@ const FLOW_NODE_UNKNOWN_KEY_GUIDANCE: Record> = { '`required` field meant to stay hidden then blocks the screen from ever being submitted.', }, }; -import { runIsUnscopedUserMode, flowTouchesData, runOrgUserIds, type SeededRunContext } from './runtime-identity.js'; +import { runIsUnscopedUserMode, flowTouchesData, runOrgUserIds, runTriggerUserId, type SeededRunContext } from './runtime-identity.js'; import { isGuardRefusal, refuseNode } from './guard-refusal.js'; import { readPartialSteps } from './partial-steps.js'; import { isRegionSuspensionRefusal, refuseRegionSuspension } from './region-suspension-refusal.js'; @@ -1197,7 +1197,9 @@ function buildRunTrigger( : undefined; return { type: context?.event ?? 'manual', - userId: context?.userId, + // The run's TRIGGER user, which a decision switch (ruling A on + // `FlowSchema.runAs`) moves out of `userId` and keeps here. + userId: runTriggerUserId(context), object: context?.object, recordId, }; @@ -6437,6 +6439,51 @@ export class AutomationEngine implements IAutomationService { // human's peers in the run's organization, which is what the request // path's agent envelope carries as well. A context that already carries // one — a `subflow` / `map` child, a resumed leg — keeps it. + await this.seedRunGrants(flow.name, runContext, 'triggering'); + + if (runIsUnscopedUserMode(runContext) && flowTouchesData(flow)) { + this.logger.warn( + `[runAs] flow '${flow.name}' executes with runAs:'user' but its trigger resolved no user ` + + `— its data operations will be REFUSED. Without a user they would carry no principal: ` + + `refused by the security plugin where one is composed, unscoped where none is (the fail-open ADR-0049 forbids). ` + + `Declare runAs:'system' to make the elevation explicit and intended, or arrange ` + + `for the trigger to supply a user. Note a user-less trigger is NOT only a schedule: a ` + + `record-change flow fired by a system write carries no user either (ADR-0049).`, + ); + } + + // #3475 — opt-in single-hop lookup expansion, AFTER identity resolution so + // the re-read runs as this run's own principal (see helper). + await this.expandDeclaredLookups(flow, runContext); + return runContext; + } + + /** + * Seed a `runAs:'user'` run context's authorization facts — `positions`, + * `permissions` and the peer list `org_user_ids` — for its `userId`, through + * the wired grants resolver: the one resolver the request path seeds a + * request's envelope through (`resolveUserAuthzGrants`, bridged by the + * automation plugin). Mutates `runContext`; a fact it already carries is + * kept verbatim, so only the missing ones are resolved (see the setup + * comment in {@link resolveRunContext} for why carried positions and + * permissions are never re-broadened). + * + * Two callers, one resolution: run setup for the TRIGGER user + * ({@link resolveRunContext}), and the decision switch for the DECIDER + * ({@link resolveDecisionSegmentContext}), which hands this a context whose + * trigger-user facts it has already removed — so the decider's segment + * carries the decider's own positions, permission sets and peers, resolved + * exactly as the decider's own request resolves them, and never a copy of + * anybody else's. + * + * A `runAs:'system'` run, a user-less run, a context carrying every fact, + * and an engine with no resolver wired resolve nothing. + */ + private async seedRunGrants( + flowName: string, + runContext: AutomationContext, + whose: 'triggering' | 'deciding', + ): Promise { const needsGrants = !Array.isArray(runContext.permissions); const needsPeers = runOrgUserIds(runContext) === undefined; if ( @@ -6459,7 +6506,7 @@ export class AutomationEngine implements IAutomationService { } } catch (err) { // Fail-safe, never fail-open: on a resolution error the run keeps - // the trigger's (unresolved) identity — the data middleware applies + // the user's (unresolved) identity — the data middleware applies // its baseline member fallback, NOT elevation — and we warn loudly // so the degraded authorization is audible rather than silent. // @@ -6481,28 +6528,85 @@ export class AutomationEngine implements IAutomationService { : `its data ops keep the trigger's positions and permission sets, but every row ` + `policy reading current_user.org_user_ids drops out (narrower, not elevated)`; this.logger.warn( - `[runAs] flow '${flow.name}' could not resolve grants for triggering user ` + + `[runAs] flow '${flowName}' could not resolve grants for ${whose} user ` + `'${runContext.userId}' — ${consequence}. The resolver's failure is in this record's meta.`, describeThrownForLog(err), ); } } + } - if (runIsUnscopedUserMode(runContext) && flowTouchesData(flow)) { - this.logger.warn( - `[runAs] flow '${flow.name}' executes with runAs:'user' but its trigger resolved no user ` + - `— its data operations will be REFUSED. Without a user they would carry no principal: ` + - `refused by the security plugin where one is composed, unscoped where none is (the fail-open ADR-0049 forbids). ` + - `Declare runAs:'system' to make the elevation explicit and intended, or arrange ` + - `for the trigger to supply a user. Note a user-less trigger is NOT only a schedule: a ` + - `record-change flow fired by a system write carries no user either (ADR-0049).`, - ); - } - - // #3475 — opt-in single-hop lookup expansion, AFTER identity resolution so - // the re-read runs as this run's own principal (see helper). - await this.expandDeclaredLookups(flow, runContext); - return runContext; + /** + * The context the nodes after a resumed pause run with (ruling A on + * `FlowSchema.runAs`: 「the human whose act is running this segment: the + * trigger user until a human decision, the decider after it」). + * + * When the signal names the decider of an approval + * ({@link ResumeSignal.resumedBy}) AND carries the owning service's + * {@link RESUME_AUTHORITY_SERVICE} marker, a `runAs:'user'` run continues + * as that decider — identity and permissions together. The answer is a + * COPY of the persisted context with the trigger user's authorization facts + * REMOVED, `userId` set to the decider, and the decider's own positions, + * permission sets and peers resolved by {@link seedRunGrants}, the trigger + * setup's own resolver. Nothing of the trigger user's survives into the + * segment: copying the decision request's envelope by hand, or keeping the + * submitter's grants under the decider's name, would be two identities in + * one context — the reading ruling A rejected. + * + * The trigger is not forgotten: {@link SeededRunContext.triggerUserId} + * records who started the run (`null` when its trigger resolved no user), + * set on the first switch and kept by every later one, so the run's + * history still names its trigger ({@link buildRunTrigger}). The switched + * context is what a later pause in the same run persists, so a second + * decision, a cold resume or a repair replay continues from the decider's + * segment and never falls back to the submitter. + * + * A `runAs:'system'` run moves its ATTRIBUTION user and nothing else (ruling + * 6267719791 Q1 A: 「`'system'` flows keep system privileges; after the + * decision their attribution user is the decider」). Its nodes after the + * decision run as `{ isSystem: true, userId: DECIDER }` — the shape the + * approvals verdict mirror already writes with — so `updated_by`, the audit + * row and `ctx.user` in the hooks those writes fire name the decider, while + * elevation is untouched and no grant is resolved (a system run's data + * context carries none: {@link resolveRunDataContext}'s system arm). The + * trigger user's positions, permission sets and peers are removed here too, + * so `current_user` in the segment is the decider and never the decider's id + * over the submitter's grants. + * + * Unchanged: + * - a signal with no decider — an SLA auto-decision, a screen, a wait + * timer, every engine-built continuation (a subflow parent's own nodes + * included: the parent declares no decision node of its own); + * - a decider the signal names WITHOUT the service marker: only the + * service that authenticated and recorded the decision may name its + * decider, and an HTTP body cannot carry the symbol; + * - a decider who is already the run's acting user. + * + * On a resolution failure the decider's segment carries no positions or + * permission sets, so its data operations fall back to the baseline member + * set — narrower, never the trigger user's grants (see the warning in + * {@link seedRunGrants}). + */ + private async resolveDecisionSegmentContext( + run: SuspendedRun, + signal: ResumeSignal, + ): Promise { + const context = run.context; + const decider = signal[RESUME_AUTHORITY_SERVICE] === true ? signal.resumedBy : undefined; + if (typeof decider !== 'string' || decider === '') return context; + if (context?.userId === decider) return context; + const switched: SeededRunContext = { + ...(context ?? {}), + userId: decider, + triggerUserId: runTriggerUserId(context) ?? null, + }; + delete switched.positions; + delete switched.permissions; + delete switched.org_user_ids; + // A system run keeps its elevation: attribution moves, no grant is resolved. + if (switched.runAs === 'system') return switched; + await this.seedRunGrants(run.flowName, switched, 'deciding'); + return switched; } /** @@ -8127,6 +8231,17 @@ export class AutomationEngine implements IAutomationService { // where a throw is just a value we decline to copy. const variablesAtPause = this.cloneVariablesAtPause(run, runId); + // Ruling A on `FlowSchema.runAs`: who runs the nodes after this + // pause — the decider, when the owning service's signal names one + // (with their own grants on a `runAs:'user'` run, as the attribution + // user of a still-elevated `runAs:'system'` run); otherwise the + // identity the run paused with. + // Resolved BEFORE the claim, so nothing here can run after the + // suspension is consumed and leave it consumed with no continuation; + // the persisted `run.context` is left as it was, and the repair + // journal therefore still snapshots the pause exactly as it parked. + const segmentContext = await this.resolveDecisionSegmentContext(run, signal); + // Consume the suspension *before* running downstream work — a run // resumes exactly once per pause, and a duplicate resume after a // partial restart must not double-run side effects. (Folding the @@ -8203,7 +8318,7 @@ export class AutomationEngine implements IAutomationService { await this.forgetSuspendedRun(run, 'resumed', claim.kind === 'claimed'); const steps = run.steps; - const context = run.context; + const context = segmentContext; try { // [#18714] The child this run is parked on REFUSED — on either @@ -8426,6 +8541,13 @@ export class AutomationEngine implements IAutomationService { const variablesSnapshot = Object.fromEntries(variables); await this.persistSuspendedRun({ ...run, + // The segment's identity, not the one the run parked + // with last time: after a decision switch (ruling A on + // `FlowSchema.runAs`) the next pause must continue as + // the decider, whatever resumes it — never fall back to + // the trigger user. Identical to `run.context` when no + // switch happened. + context, nodeId: err.nodeId, nodeType: err.nodeType, variables: variablesSnapshot, diff --git a/packages/services/service-automation/src/resume-decider-identity.integration.test.ts b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts new file mode 100644 index 00000000000..40e0fc06a3c --- /dev/null +++ b/packages/services/service-automation/src/resume-decider-identity.integration.test.ts @@ -0,0 +1,537 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * After an approval decision, a `runAs:'user'` run continues as the DECIDER — + * identity and permissions together (ruling A on `FlowSchema.runAs`: + * 「the human whose act is running this segment: the trigger user until a + * human decision, the decider after it」). + * + * The defect: a resumed run kept its trigger user for its whole life, so the + * nodes after a manager's approval wrote as the rep who submitted — the + * record's `updated_by` and the audit trail named someone who did not act, and + * the writes ran under the rep's grants rather than the manager's. + * + * Two layers, each pinning what the other cannot see: + * + * 1. THE ENVELOPE (a bare engine, a recording node, a grants-resolver stub): + * the data context every post-decision node hands the data engine + * is the decider's — their id, positions, permission sets and peers, as + * the resolver answers for THEM — with nothing of the submitter's left in + * it; the nodes before the decision keep the submitter's; the run's + * history keeps naming its trigger; a later pause in the same run does not + * fall back to the submitter. A `runAs:'system'` run keeps its elevation + * and carries the decider as its attribution user, with no grant resolved + * (ruling 6267719791 Q1 A). And the controls: a signal naming a decider + * without the owning service's marker, and a signal with no decider (on + * both postures), all continue exactly as before. + * 2. THE REAL STACK (ObjectKernel, ObjectQLPlugin's audit stamps, driver-sql + * on better-sqlite3 `:memory:`, the identity objects, the real + * SecurityPlugin and record sharing, the grants resolver the automation + * plugin bridges): the + * stored `updated_by` and the `sys_audit_log` row (AuditPlugin's + * writers) name the decider after the decision and the submitter before + * it — on a `runAs:'system'` run too, whose readonly verdict column still + * lands under its unchanged elevation (ruling 6267719791 Q1 A); the decider's own grants are what admit the write; + * and a post-decision node the decider may not perform — a field their + * permission set marks non-editable, which the submitter's set allows — + * is refused loudly, the run reporting the node, while the same node + * resumed with no decider still lands as the submitter. + * + * The pause here is a stand-in for the `approval` node: a node type that + * declares `resumeAuthority: 'service'`, resumed with the + * `RESUME_AUTHORITY_SERVICE` marker and `resumedBy` exactly as + * `ApprovalService.decide` hands them over (that hand-off is pinned in + * `plugin-approvals`' `restored-approval-continuation.test.ts`). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel, resolveUserAuthzGrants, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { SharingServicePlugin } from '@objectstack/plugin-sharing'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { RESUME_AUTHORITY_SERVICE, type ResumeSignal } from '@objectstack/spec/contracts'; +import { AutomationEngine } from './engine.js'; +import { AutomationServicePlugin } from './plugin.js'; +import { InMemorySuspendedRunStore } from './suspended-run-store.js'; +import { resolveRunDataContext } from './runtime-identity.js'; + +const silentLogger = (): any => { + const l: any = { info() {}, warn() {}, error() {}, debug() {} }; + l.child = () => l; + return l; +}; + +/** The approval node's stand-in: a pause only its owning service may continue. */ +const GATED = defineActionDescriptor({ + type: 'decision_gate', version: '1.0.0', name: 'Decision Gate', + supportsPause: true, resumeAuthority: 'service', +}); +/** A pause anyone may continue (a screen's shape): the control for the marker. */ +const OPEN = defineActionDescriptor({ + type: 'open_gate', version: '1.0.0', name: 'Open Gate', + supportsPause: true, resumeAuthority: 'any', +}); + +function registerGates(engine: AutomationEngine): void { + engine.registerNodeExecutor({ type: 'decision_gate', descriptor: GATED, async execute() { return { success: true, suspend: true }; } }); + engine.registerNodeExecutor({ type: 'open_gate', descriptor: OPEN, async execute() { return { success: true, suspend: true }; } }); +} + +/** What `ApprovalService.decide` hands the engine for a human decision. */ +const decided = (branchLabel: 'approve' | 'reject', resumedBy?: string): ResumeSignal => ({ + branchLabel, + output: { decision: branchLabel }, + ...(resumedBy ? { resumedBy } : {}), + [RESUME_AUTHORITY_SERVICE]: true, +}); + +/** + * A post-decision write. On the real stack it is an `update_record`; in the + * envelope layer it is `record_identity`, a node that records exactly what a + * CRUD node hands ObjectQL as `options.context` — `resolveRunDataContext` of + * the run context it was given — so no data-engine double stands in. + */ +const write = (id: string, objectName: string, fields: Record, recorder = false) => (recorder + ? { id, type: 'record_identity', label: id } + : { + id, type: 'update_record', label: id, + config: { objectName, filter: { id: { dialect: 'cel', source: 'dealId' } }, fields }, + }); + +/** + * start → submit → gate1 ─approve→ after1 → gate2 ─approve→ after2 → end + * └reject→ rejected → end + * Every write is a node of its own, so each one's data context is read apart. + */ +function reviewFlow(name: string, opts: { runAs?: 'system' | 'user'; gate?: string; objectName?: string; approveFields?: Record; rejectFields?: Record; recorder?: boolean } = {}) { + const objectName = opts.objectName ?? 'deal'; + const r = opts.recorder === true; + return { + name, label: name, type: 'autolaunched', + ...(opts.runAs ? { runAs: opts.runAs } : {}), + variables: [{ name: 'dealId', type: 'text', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + write('submit', objectName, { stage: 'submitted' }, r), + { id: 'gate1', type: opts.gate ?? 'decision_gate', label: 'Manager review' }, + write('after1', objectName, opts.approveFields ?? { approved_note: 'approved' }, r), + { id: 'gate2', type: 'decision_gate', label: 'Second review' }, + write('after2', objectName, { stage: 'won' }, r), + write('rejected', objectName, opts.rejectFields ?? { stage: 'rejected' }, r), + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'submit' }, + { id: 'e2', source: 'submit', target: 'gate1' }, + { id: 'e3', source: 'gate1', target: 'after1', label: 'approve' }, + { id: 'e4', source: 'gate1', target: 'rejected', label: 'reject' }, + { id: 'e5', source: 'after1', target: 'gate2' }, + { id: 'e6', source: 'gate2', target: 'after2', label: 'approve' }, + { id: 'e7', source: 'gate2', target: 'rejected', label: 'reject' }, + { id: 'e8', source: 'after2', target: 'end' }, + { id: 'e9', source: 'rejected', target: 'end' }, + ], + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. The envelope +// ───────────────────────────────────────────────────────────────────────────── + +/** Each user's grants as the resolver answers them, deliberately disjoint. */ +const GRANTS: Record = { + usr_rep: { positions: ['sales_rep', 'everyone'], permissions: ['deal_rep'], org_user_ids: ['usr_rep', 'usr_mgr', 'usr_dir'] }, + usr_mgr: { positions: ['sales_manager', 'everyone'], permissions: ['deal_manager'], org_user_ids: ['usr_mgr', 'usr_rep'] }, + usr_dir: { positions: ['sales_director', 'everyone'], permissions: ['deal_director'], org_user_ids: ['usr_dir'] }, +}; + +function envelopeHarness(opts: { failFor?: string } = {}) { + const seen = new Map(); + const resolved: Array<[string, string | undefined]> = []; + const engine = new AutomationEngine(silentLogger(), new InMemorySuspendedRunStore()); + registerGates(engine); + engine.registerNodeExecutor({ + type: 'record_identity', + async execute(node, _variables, context) { + seen.set(node.id, { + ...resolveRunDataContext(context), + // The run context's own facts, beside the envelope: what `current_user` + // is built from in the segment's CEL. + raw: { userId: context?.userId, positions: context?.positions, permissions: context?.permissions }, + }); + return { success: true }; + }, + }); + engine.setUserGrantsResolver(async (userId, tenantId) => { + resolved.push([userId, tenantId]); + if (userId === opts.failFor) throw new Error(`grants store unreachable for ${userId}`); + const g = GRANTS[userId]; + return g ? { ...g, tenantId } : undefined; + }); + /** The data context the named post-decision node would have handed ObjectQL. */ + const ctxOf = (nodeId: string) => seen.get(nodeId); + return { engine, resolved, ctxOf }; +} + +const TRIGGER = { userId: 'usr_rep', tenantId: 'org_a', params: { dealId: 'd1' } }; + +describe("after an approval decision a runAs:'user' run's data operations carry the decider's identity and permissions", () => { + it('approve: the nodes after the decision run as the decider, with the decider\'s own resolved grants', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + expect(paused.status).toBe('paused'); + + // Before the decision: the submitter, as today. + const before = h.ctxOf('submit'); + expect(before).toMatchObject({ isSystem: false, userId: 'usr_rep', permissions: ['deal_rep'] }); + expect(before.positions).toContain('sales_rep'); + + const res = await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); + expect(res.status, JSON.stringify(res)).toBe('paused'); // parked again at gate2 + + const after = h.ctxOf('after1'); + expect(after, 'the post-decision write never reached the data engine').toBeTruthy(); + expect(after.isSystem).toBe(false); + expect(after.userId).toBe('usr_mgr'); + expect(after.tenantId).toBe('org_a'); + // Identity AND permissions together: every authorization fact is the + // decider's, resolved for them — none of the submitter's survives. + expect(after.positions).toEqual(GRANTS.usr_mgr.positions); + expect(after.permissions).toEqual(GRANTS.usr_mgr.permissions); + expect(after.org_user_ids).toEqual(GRANTS.usr_mgr.org_user_ids); + expect(after.positions).not.toContain('sales_rep'); + expect(after.permissions).not.toContain('deal_rep'); + // Through the same resolver the trigger was seeded through, for the decider. + expect(h.resolved).toContainEqual(['usr_mgr', 'org_a']); + }); + + it('reject: the reject branch runs as the decider too', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + const res = await h.engine.resume(paused.runId!, decided('reject', 'usr_mgr')); + expect(res.success, JSON.stringify(res)).toBe(true); + expect(h.ctxOf('rejected')).toMatchObject({ userId: 'usr_mgr', permissions: ['deal_manager'] }); + }); + + it('a later pause keeps the decider: a resume that names no decider does not fall back to the submitter', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); + // The second gate continued by its owning service with no human decider + // (an SLA auto-decision's shape). + const res = await h.engine.resume(paused.runId!, decided('approve')); + expect(res.success, JSON.stringify(res)).toBe(true); + expect(h.ctxOf('after2')).toMatchObject({ userId: 'usr_mgr', permissions: ['deal_manager'] }); + }); + + it('a second decision moves the segment to its own decider, and the run history still names the trigger', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); + const res = await h.engine.resume(paused.runId!, decided('approve', 'usr_dir')); + expect(res.success, JSON.stringify(res)).toBe(true); + expect(h.ctxOf('after2')).toMatchObject({ userId: 'usr_dir', permissions: ['deal_director'] }); + expect(h.ctxOf('after2').org_user_ids).toEqual(GRANTS.usr_dir.org_user_ids); + + const run = await h.engine.getRun(paused.runId!); + expect(run?.status).toBe('completed'); + expect(run?.trigger?.userId, 'the run history must keep naming who triggered the run').toBe('usr_rep'); + }); + + it("a decider whose grants cannot be resolved runs on the baseline, never on the submitter's grants", async () => { + const h = envelopeHarness({ failFor: 'usr_mgr' }); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + await h.engine.resume(paused.runId!, decided('approve', 'usr_mgr')); + const after = h.ctxOf('after1'); + expect(after.userId).toBe('usr_mgr'); + expect(after.positions).toEqual([]); + expect(after.permissions).toEqual([]); + expect(after.org_user_ids).toBeUndefined(); + }); + + it.each([['approve', 'after1'], ['reject', 'rejected']] as const)( + "runAs:'system' %s: the nodes after the decision keep their elevation and carry the decider as their attribution user, with no grant resolved", + async (decision, nodeId) => { + const h = envelopeHarness(); + h.engine.registerFlow('sys_review', reviewFlow('sys_review', { runAs: 'system', recorder: true }) as never); + // The REST / action doors' shape: the trigger forwards its own grants. + const paused = await h.engine.execute('sys_review', { ...TRIGGER, positions: ['sales_rep'], permissions: ['deal_rep'] } as never); + expect(h.ctxOf('submit')).toMatchObject({ isSystem: true, userId: 'usr_rep' }); + + await h.engine.resume(paused.runId!, decided(decision, 'usr_mgr')); + const after = h.ctxOf(nodeId); + // Ruling 6267719791 Q1 A: `{ isSystem: true, userId: }`. + expect(after).toMatchObject({ isSystem: true, userId: 'usr_mgr', positions: [], permissions: [], tenantId: 'org_a' }); + expect(after.org_user_ids).toBeUndefined(); + // No permission resolution for a system run, and none of the submitter's + // grants left under the decider's name. + expect(h.resolved.map(([u]) => u), 'a system run resolves no grant for the decider').not.toContain('usr_mgr'); + expect(after.raw).toEqual({ userId: 'usr_mgr', positions: undefined, permissions: undefined }); + }, + ); + + it("runAs:'system': the run history still names the trigger after the decider takes over", async () => { + const h = envelopeHarness(); + h.engine.registerFlow('sys_review', reviewFlow('sys_review', { runAs: 'system', recorder: true }) as never); + const paused = await h.engine.execute('sys_review', { ...TRIGGER } as never); + const res = await h.engine.resume(paused.runId!, decided('reject', 'usr_mgr')); + expect(res.success, JSON.stringify(res)).toBe(true); + expect((await h.engine.getRun(paused.runId!))?.trigger?.userId).toBe('usr_rep'); + }); + + describe('controls — unchanged', () => { + it("CONTROL: a runAs:'system' run resumed with no decider keeps the user it carried", async () => { + const h = envelopeHarness(); + h.engine.registerFlow('sys_review', reviewFlow('sys_review', { runAs: 'system', recorder: true }) as never); + const paused = await h.engine.execute('sys_review', { ...TRIGGER } as never); + await h.engine.resume(paused.runId!, decided('approve')); + expect(h.ctxOf('after1')).toMatchObject({ isSystem: true, userId: 'usr_rep', positions: [], permissions: [] }); + }); + + it('CONTROL: a decider named WITHOUT the owning service\'s marker changes nothing', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('open_review', reviewFlow('open_review', { gate: 'open_gate', recorder: true }) as never); + const paused = await h.engine.execute('open_review', { ...TRIGGER } as never); + // What an HTTP body can build: no symbol-keyed marker. + const res = await h.engine.resume(paused.runId!, { branchLabel: 'approve', resumedBy: 'usr_mgr' }); + expect(res.status, JSON.stringify(res)).toBe('paused'); + expect(h.ctxOf('after1')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); + }); + + it('CONTROL: a service resume that names no decider (an SLA auto-decision) keeps the run as it was', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('review', reviewFlow('review', { recorder: true }) as never); + const paused = await h.engine.execute('review', { ...TRIGGER } as never); + await h.engine.resume(paused.runId!, decided('approve')); + expect(h.ctxOf('after1')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); + }); + + it('CONTROL: a flow with no human decision node runs as its trigger user throughout', async () => { + const h = envelopeHarness(); + h.engine.registerFlow('straight', { + name: 'straight', label: 'straight', type: 'autolaunched', + variables: [{ name: 'dealId', type: 'text', isInput: true }], + nodes: [{ id: 'start', type: 'start', label: 'Start' }, write('w', 'deal', { stage: 'straight' }, true), { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'w' }, { id: 'e2', source: 'w', target: 'end' }], + } as never); + const res = await h.engine.execute('straight', { ...TRIGGER } as never); + expect(res.success, JSON.stringify(res)).toBe(true); + expect(h.ctxOf('w')).toMatchObject({ userId: 'usr_rep', permissions: ['deal_rep'] }); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. The real stack +// ───────────────────────────────────────────────────────────────────────────── + +const SYS = { isSystem: true } as const; + +/** + * `updated_by` / `created_by` / `owner_id` are injected by the registry, as on + * an app object. `private`, as HotCRM's `crm_opportunity` is: record sharing + * then owns the write verdict, so the manager's `modifyAllRecords` is what + * reaches a deal the rep owns (the platform's ownership floor defers to it). + */ +const dealObject = { + name: 'deal', + label: 'Deal', + sharingModel: 'private', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + stage: { name: 'stage', label: 'Stage', type: 'text' }, + approved_note: { name: 'approved_note', label: 'Approved Note', type: 'text' }, + restricted_note: { name: 'restricted_note', label: 'Restricted Note', type: 'text' }, + // HotCRM's verdict-column shape (`approval_status`, `approved_date`): a + // static `readonly` column objectql strips from every non-system UPDATE, + // so only an elevated write lands it. + verdict: { name: 'verdict', label: 'Verdict', type: 'text', readonly: true }, + }, +}; + +/** The rep edits their own deals, every field. */ +const dealRepSet = PermissionSetSchema.parse({ + name: 'deal_rep', label: 'Deal rep', + objects: { deal: { allowRead: true, allowCreate: true, allowEdit: true } }, +}); +/** The manager edits every deal — but may not edit `restricted_note`. */ +const dealManagerSet = PermissionSetSchema.parse({ + name: 'deal_manager', label: 'Deal manager', + objects: { deal: { allowRead: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: true } }, + fields: { 'deal.restricted_note': { readable: true, editable: false } }, +}); + +describe('on the real stack: attribution after the decision, and the refusal when the decider lacks a right', () => { + let kernel: ObjectKernel; + let ql: ObjectQL; + let automation: AutomationEngine; + let orgA = ''; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await kernel.use({ + name: 'test.driver.sqlite', type: 'standard', version: '1.0.0', + async init(ctx: PluginContext) { ctx.registerService('driver.default', driver); }, + }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + await kernel.use(createIdentityObjectsPlugin()); + await kernel.use(new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, dealRepSet, dealManagerSet] })); + await kernel.use(new SharingServicePlugin()); + await kernel.use(new AuditPlugin()); + await kernel.bootstrap(); + + ql = kernel.getService('objectql'); + automation = kernel.getService('automation'); + registerGates(automation); + ql.registry.registerObject(dealObject as any, 'decider-test', 'decider-test'); + await ql.syncSchemas(); + + const orgs = await ql.find('sys_organization', { context: SYS }); + orgA = String(orgs[0].id); + for (const [id, name] of [['usr_rep', 'Rep'], ['usr_mgr', 'Manager']]) { + await ql.insert('sys_user', { id, name, email: `${id}@example.test` }, { context: SYS }); + await ql.insert('sys_member', { id: `mem_${id}`, user_id: id, organization_id: orgA, role: 'member' }, { context: SYS }); + } + await ql.insert('sys_user_permission_set', { id: 'ups_rep', user_id: 'usr_rep', permission_set: 'deal_rep', organization_id: orgA }, { context: SYS }); + await ql.insert('sys_user_permission_set', { id: 'ups_mgr', user_id: 'usr_mgr', permission_set: 'deal_manager', organization_id: orgA }, { context: SYS }); + + automation.registerFlow('deal_review', reviewFlow('deal_review') as never); + automation.registerFlow('deal_review_restricted', reviewFlow('deal_review_restricted', { approveFields: { restricted_note: 'set after the decision' } }) as never); + automation.registerFlow('deal_review_system', reviewFlow('deal_review_system', { + runAs: 'system', + approveFields: { verdict: 'approved', approved_note: 'approved' }, + rejectFields: { verdict: 'rejected', stage: 'rejected' }, + }) as never); + }, 30_000); + + afterAll(async () => { + try { await kernel?.shutdown(); } catch { /* noop */ } + }); + + /** What the request path hands ObjectQL for `userId` acting in the org. */ + const requestContext = async (userId: string) => { + const g = await resolveUserAuthzGrants(ql, userId, { tenantId: orgA, bypassGrantsCache: true }); + return { userId, tenantId: orgA, isSystem: false, positions: g.positions, permissions: g.permissions, org_user_ids: g.org_user_ids }; + }; + const stored = async (id: string) => (await ql.findOne('deal', { where: { id }, context: SYS })) as any; + /** The `sys_audit_log` UPDATE rows `AuditPlugin`'s writers left for the deal, oldest first. */ + const auditUpdates = async (id: string) => + ((await ql.find('sys_audit_log', { where: { object_name: 'deal', record_id: id, action: 'update' }, context: SYS })) as any[]) + .map((r) => ({ user_id: r.user_id, new_value: JSON.parse(String(r.new_value ?? '{}')) })); + + /** The rep creates a deal through their own request, then submits it. */ + async function submitted(flow: string, title: string) { + const created: any = await ql.insert('deal', { title }, { context: await requestContext('usr_rep') }); + const dealId = String(created.id); + const paused = await automation.execute(flow, { userId: 'usr_rep', tenantId: orgA, params: { dealId } } as never); + expect(paused.status, JSON.stringify(paused)).toBe('paused'); + const row = await stored(dealId); + expect(row.stage).toBe('submitted'); + expect(row.updated_by, 'guard: the submitter wrote the record last before the decision').toBe('usr_rep'); + return { dealId, runId: paused.runId! }; + } + + it("GUARD: the manager's write rests on the manager's own grants — under the rep's sets it is refused", async () => { + // The fixture would be measuring nothing if any member could write a + // rep's deal: the default member scope keys writes on `created_by`, so it + // is `deal_manager`'s `modifyAllRecords` that admits the manager below. + const created: any = await ql.insert('deal', { title: 'guard' }, { context: await requestContext('usr_rep') }); + const repSets = (await requestContext('usr_rep')).permissions; + await expect( + ql.update('deal', { id: created.id, approved_note: 'x' }, { context: { userId: 'usr_mgr', tenantId: orgA, isSystem: false, positions: ['org_member', 'everyone'], permissions: repSets } }), + ).rejects.toThrow(); + }); + + it('approve: `updated_by` names the manager after the decision, and the submitter before it', async () => { + const { dealId, runId } = await submitted('deal_review', 'approve me'); + const res = await automation.resume(runId, decided('approve', 'usr_mgr')); + expect(res.status, JSON.stringify(res)).toBe('paused'); + const row = await stored(dealId); + expect(row.approved_note).toBe('approved'); + expect(row.updated_by, 'the post-decision write is attributed to the decider').toBe('usr_mgr'); + expect(row.created_by).toBe('usr_rep'); + const audit = await auditUpdates(dealId); + expect(audit.find((a) => a.new_value.approved_note === 'approved')?.user_id, 'the audit row names the decider').toBe('usr_mgr'); + expect(audit.find((a) => a.new_value.stage === 'submitted')?.user_id, 'the pre-decision write stays the submitter\'s').toBe('usr_rep'); + }); + + it('reject: the reject branch is attributed to the manager', async () => { + const { dealId, runId } = await submitted('deal_review', 'reject me'); + const res = await automation.resume(runId, decided('reject', 'usr_mgr')); + expect(res.success, JSON.stringify(res)).toBe(true); + const row = await stored(dealId); + expect(row.stage).toBe('rejected'); + expect(row.updated_by).toBe('usr_mgr'); + expect((await auditUpdates(dealId)).find((a) => a.new_value.stage === 'rejected')?.user_id).toBe('usr_mgr'); + }); + + it('a post-decision node the decider may not perform is refused loudly, and the run reports the node', async () => { + const { dealId, runId } = await submitted('deal_review_restricted', 'restricted'); + const res: any = await automation.resume(runId, decided('approve', 'usr_mgr')); + expect(res.success).toBe(false); + expect(res.status, 'the run stops at the refused node, repairable').toBe('stranded'); + expect(res.error, 'the report names the refused node').toContain('after1'); + expect(res.error, 'and the field the decider may not write').toContain('restricted_note'); + const row = await stored(dealId); + expect(row.restricted_note ?? null, 'nothing landed').toBeNull(); + expect(row.updated_by).toBe('usr_rep'); + + const run = await automation.getRun(runId); + expect(run?.status).toBe('failed'); + expect(run?.steps?.find((s: any) => s.nodeId === 'after1')?.status).toBe('failure'); + }); + + it("GUARD: `verdict` is a column only an elevated write lands — the manager's own request is stripped of it", async () => { + const created: any = await ql.insert('deal', { title: 'verdict guard' }, { context: await requestContext('usr_rep') }); + await ql.update('deal', { id: created.id, verdict: 'hand-set', approved_note: 'by hand' }, { context: await requestContext('usr_mgr') }); + const row = await stored(String(created.id)); + expect(row.approved_note, 'guard: the manager may write this deal').toBe('by hand'); + expect(row.verdict ?? null, 'a non-system write cannot land the readonly column').toBeNull(); + }); + + it.each([['approve', 'approved'], ['reject', 'rejected']] as const)( + "runAs:'system' %s: the readonly verdict lands under unchanged elevation, and `updated_by` and the audit row name the manager", + async (decision, verdict) => { + const { dealId, runId } = await submitted('deal_review_system', `system ${decision}`); + const res = await automation.resume(runId, decided(decision, 'usr_mgr')); + expect(res.success, JSON.stringify(res)).not.toBe(false); + const row = await stored(dealId); + expect(row.verdict, 'elevation unchanged: the readonly column landed').toBe(verdict); + expect(row.updated_by, 'the post-decision write is attributed to the decider').toBe('usr_mgr'); + const audit = await auditUpdates(dealId); + expect(audit.find((a) => a.new_value.verdict === verdict)?.user_id, 'the audit row names the decider').toBe('usr_mgr'); + expect(audit.find((a) => a.new_value.stage === 'submitted')?.user_id, "the pre-decision write stays the submitter's").toBe('usr_rep'); + }, + ); + + it("CONTROL: a runAs:'system' run resumed with no decider keeps the submitter", async () => { + const { dealId, runId } = await submitted('deal_review_system', 'system, no decider'); + const res = await automation.resume(runId, decided('approve')); + expect(res.success, JSON.stringify(res)).not.toBe(false); + const row = await stored(dealId); + expect(row.verdict).toBe('approved'); + expect(row.updated_by).toBe('usr_rep'); + expect((await auditUpdates(dealId)).find((a) => a.new_value.verdict === 'approved')?.user_id).toBe('usr_rep'); + }); + + it('CONTROL: the same node resumed with no decider lands as the submitter, whose set allows it', async () => { + const { dealId, runId } = await submitted('deal_review_restricted', 'restricted, no decider'); + const res = await automation.resume(runId, decided('approve')); + expect(res.status, JSON.stringify(res)).toBe('paused'); + const row = await stored(dealId); + expect(row.restricted_note).toBe('set after the decision'); + expect(row.updated_by).toBe('usr_rep'); + }); +}); diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index 9032572de01..f7d04696289 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -27,8 +27,9 @@ export interface RunIdentityContext { actor?: string; /** * Acting user id. On a `runAs:'user'` run it drives owner/role RLS. On a - * `runAs:'system'` run it is carried through UNCHANGED from the trigger when - * one resolved a user (#5494): elevation and anonymity are separate choices + * `runAs:'system'` run it is carried through from the trigger when one + * resolved a user (#5494), and after an approval decision it is the decider + * (ruling 6267719791 Q1 A): elevation and anonymity are separate choices * (ADR-0073's authorization-vs-attribution axes; the #3783 approvals-mirror * `{ isSystem: true, userId }` shape), and the security middleware's * `isSystem` short-circuit precedes every gate that reads `userId`, so under @@ -136,6 +137,32 @@ export interface RunDataContext extends RunIdentityContext {} */ export interface SeededRunContext extends AutomationContext { org_user_ids?: string[]; + /** + * Who TRIGGERED the run, recorded when a human decision moved the run's + * acting identity (`userId`) to the decider (ruling A on `FlowSchema.runAs`; + * `AutomationEngine.resolveDecisionSegmentContext` is the only writer). + * `null` when the trigger resolved no user. Set on the first switch and kept + * by every later one, and persisted with the rest of the context through a + * later pause, so the run's history names its trigger however many + * decisions it has passed. Absent until a switch: `userId` is then still the + * trigger user ({@link runTriggerUserId}). + */ + triggerUserId?: string | null; +} + +/** + * The run's trigger user: {@link SeededRunContext.triggerUserId} once a + * decision switch recorded it (`undefined` for a trigger that resolved no + * user), and `userId` before any switch — when the two are the same person by + * construction. Attribution of the run, not of its writes: the writes of the + * current segment are `userId`'s. + */ +export function runTriggerUserId(context: AutomationContext | undefined): string | undefined { + const seeded = context as SeededRunContext | undefined; + if (seeded && Object.prototype.hasOwnProperty.call(seeded, 'triggerUserId')) { + return typeof seeded.triggerUserId === 'string' && seeded.triggerUserId !== '' ? seeded.triggerUserId : undefined; + } + return context?.userId; } /** @@ -154,7 +181,8 @@ export function runOrgUserIds(context: AutomationContext | undefined): string[] * * - `runAs:'system'` → `{ isSystem: true, actor: 'svc:flow:' }`, * PLUS the trigger's `userId` / `tenantId` when the trigger resolved them - * (#5494) — the security middleware short-circuits on `isSystem`, so the + * (#5494; after an approval decision `userId` is the decider, the run's + * elevation unchanged) — the security middleware short-circuits on `isSystem`, so the * run reads/writes with full access, bypassing RLS; the `actor` label and * the carried-through acting user keep those writes attributable in the * audit log and in the rows' own `created_by`/`updated_by` stamps diff --git a/packages/services/service-automation/vitest.config.ts b/packages/services/service-automation/vitest.config.ts index 8d57372290b..e3be7f2489b 100644 --- a/packages/services/service-automation/vitest.config.ts +++ b/packages/services/service-automation/vitest.config.ts @@ -81,6 +81,23 @@ export default defineConfig({ find: /^@objectstack\/plugin-auth$/, replacement: path.resolve(__dirname, '../../plugins/plugin-auth/src/index.ts'), }, + { + // `resume-decider-identity.integration.test.ts` composes record sharing + // (`SharingServicePlugin`): a `private` object's write verdict is the + // sharing service's, which is what lets a manager's `modifyAllRecords` + // reach a deal the rep owns. Read from source for the reason the entries + // above give. Same anchored-regex rule. + find: /^@objectstack\/plugin-sharing$/, + replacement: path.resolve(__dirname, '../../plugins/plugin-sharing/src/index.ts'), + }, + { + // The same suite reads the `sys_audit_log` rows the post-decision writes + // leave (`AuditPlugin`'s writers), so the attribution it pins is the + // audit trail's own, not only the row's `updated_by`. Read from source + // for the reason the entries above give. Same anchored-regex rule. + find: /^@objectstack\/plugin-audit$/, + replacement: path.resolve(__dirname, '../../plugins/plugin-audit/src/index.ts'), + }, ], }, }); diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 1f0e23538bf..0b4978714ab 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -1123,8 +1123,24 @@ export const FlowSchema = lazySchema(() => strictObject( // ADR-0049 / #1888 — ENFORCED. The service-automation engine establishes the // declared identity for the run's data operations and restores the caller's // context afterward: `system` runs elevated (a full-access, RLS-bypassing - // system principal); `user` (default) runs as the triggering user, so CRUD - // nodes' ObjectQL reads/writes respect that user's row-level security. + // system principal); `user` (default) runs as the human whose act is running + // the current segment, so CRUD nodes' ObjectQL reads/writes respect that + // user's row-level security. + // + // That human is the trigger user until a human decision, and the decider + // after it: when an approval decision (approve or reject) resumes the run, + // the nodes after it run with the decider's identity and permissions + // together — their positions, permission sets and organisation peers, + // resolved the way the decider's own request resolves them — while the nodes + // before it keep the trigger user's. So a post-decision write is attributed + // to the decider (`updated_by`, the audit log), and a post-decision node the + // decider may not perform is refused, loudly, and the run reports it; a flow + // whose approval must write what the approver may not declares `system`. + // `system` flows keep system privileges; after the decision their attribution + // user is the decider (`{ isSystem: true, userId: DECIDER }`, the shape the + // approvals verdict mirror writes with), so their post-decision writes, and + // `ctx.user` in the hooks those writes fire, name the decider too. A flow + // with no human decision node runs as its trigger user throughout. // // A run under `user` that resolves NO trigger user has nothing to scope to, so // its data operations are REFUSED (#3760). This is NOT a schedule-only case — @@ -1145,7 +1161,11 @@ export const FlowSchema = lazySchema(() => strictObject( .enum(['system', 'user']) .default('user') .describe( - 'Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). ' + + 'Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after ' + + 'an approval decision their attribution user is the decider; user = the human whose act is running ' + + 'this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes ' + + "after an approval decision run with the decider's identity and permissions, so a node the decider may " + + 'not perform is refused). ' + 'A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — ' + 'declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any ' + 'record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses ' + diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 373b1f24337..30a2dc07821 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -73,7 +73,12 @@ export interface AutomationContext { object?: string; /** Trigger event type (e.g. 'on_create', 'on_update') */ event?: string; - /** User who triggered the automation */ + /** + * The acting user: who triggered the automation — or, on a run that an + * approval decision resumed, the decider, for the nodes after that + * decision, on both postures (`FlowSchema.runAs`; + * {@link ResumeSignal.resumedBy}). + */ userId?: string; /** * Position names of the triggering identity (ADR-0090 D3; formerly @@ -150,10 +155,15 @@ export interface AutomationContext { * the write. The label is a FALLBACK, not a replacement — the audit writer * records `session.userId ?? session.actor` on `sys_audit_log.actor` — and * elevation never costs the run its operator: a `runAs:'system'` run - * carries the triggering user through UNCHANGED whenever the trigger - * resolved one (#5494), so its writes still stamp `created_by` / - * `updated_by` and `sys_audit_log.user_id` with that human, exactly as the - * same trigger would under `runAs:'user'`. `runAs` declares the run's + * carries the triggering user through whenever the trigger resolved one + * (#5494), so its writes still stamp `created_by` / `updated_by` and + * `sys_audit_log.user_id` with that human, exactly as the same trigger + * would under `runAs:'user'` — and after a human decision that parity + * holds with the decider: an approval decision moves the acting user to + * the decider for the nodes after it on BOTH postures (`FlowSchema.runAs`). + * A `runAs:'user'` run takes the decider's permissions with them; a + * `runAs:'system'` run keeps its elevation and only its attribution user + * moves. `runAs` declares the run's * AUTHORIZATION posture and leaves ATTRIBUTION alone (ADR-0073 D2). * * The `svc:flow:` label is therefore what a genuinely USER-LESS run falls @@ -597,6 +607,25 @@ export interface ResumeSignal { * `{var}` interpolation and conditions read them directly. */ variables?: Record; + /** + * The user whose decision this resume is the tail of — the decider of an + * approval (approve or reject). On a `runAs: 'user'` run the nodes after + * the decision run as this user, with the identity and permissions the + * engine resolves for them through the same grants resolver a trigger + * user's run is seeded through (positions, permission sets, organisation + * peers), never with the trigger user's. On a `runAs: 'system'` run they + * keep the run's elevation and carry this user as their attribution user + * (`{ isSystem: true, userId }`), with no grant resolved; see + * `FlowSchema.runAs`. Absent + * when no human decided (an SLA auto-decision), and then the run keeps the + * identity it had. + * + * Honoured ONLY on a signal that also carries + * {@link RESUME_AUTHORITY_SERVICE}: the owning service names the decider it + * already authenticated and recorded. A signal built from an HTTP body + * cannot carry that marker, so a body naming a user here changes nothing. + */ + resumedBy?: string; /** * Set by the service that OWNS the suspension to clear the resume gate on * a `resumeAuthority: 'service'` node — or on one that declares no diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9049ea5cebe..5edd43cbf57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2523,12 +2523,18 @@ importers: '@objectstack/objectql': specifier: workspace:* version: link:../../objectql + '@objectstack/plugin-audit': + specifier: workspace:* + version: link:../../plugins/plugin-audit '@objectstack/plugin-auth': specifier: workspace:* version: link:../../plugins/plugin-auth '@objectstack/plugin-security': specifier: workspace:* version: link:../../plugins/plugin-security + '@objectstack/plugin-sharing': + specifier: workspace:* + version: link:../../plugins/plugin-sharing '@objectstack/refd-timer-testkit': specifier: workspace:* version: link:../../qa/refd-timer-testkit