@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +1023 -0
- package/dist/index.d.mts +650 -535
- package/dist/index.d.ts +650 -535
- package/dist/index.js +1713 -207
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1711 -197
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -7
- package/scripts/i18n-extract.config.ts +6 -1
- package/src/approval-actor-impersonation.test.ts +330 -0
- package/src/approval-node.test.ts +160 -0
- package/src/approval-node.ts +57 -0
- package/src/approval-revise.test.ts +41 -34
- package/src/approval-service.test.ts +1408 -40
- package/src/approval-service.ts +1364 -107
- package/src/approvals-plugin.ts +36 -5
- package/src/approver-cross-org.integration.test.ts +206 -0
- package/src/approver-org-scope.test.ts +201 -0
- package/src/approver-org-scope.ts +261 -0
- package/src/index.ts +3 -0
- package/src/lifecycle-hooks.ts +22 -0
- package/src/record-lock-schedule-run.integration.test.ts +206 -0
- package/src/status-mirror-cascade.integration.test.ts +224 -0
- package/src/sys-approval-action.object.ts +9 -0
- package/src/sys-approval-delegation.object.test.ts +42 -0
- package/src/sys-approval-delegation.object.ts +3 -3
- package/src/sys-approval-request.object.test.ts +13 -0
- package/src/sys-approval-request.object.ts +17 -5
- package/src/translations/bundle-ownership.test.ts +48 -0
- package/src/translations/en.objects.generated.ts +111 -5
- package/src/translations/es-ES.objects.generated.ts +111 -5
- package/src/translations/ja-JP.objects.generated.ts +111 -5
- package/src/translations/zh-CN.objects.generated.ts +110 -4
package/src/approval-service.ts
CHANGED
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
import { createHash, randomBytes } from 'node:crypto';
|
|
4
4
|
import {
|
|
5
5
|
APPROVAL_BRANCH_LABELS,
|
|
6
|
+
approverTypeIsOrgScoped,
|
|
6
7
|
canonicalApproverType,
|
|
8
|
+
normalizeDecisionOutputs,
|
|
7
9
|
type ApprovalNodeConfig,
|
|
8
10
|
} from '@objectstack/spec/automation';
|
|
11
|
+
import { ExpressionEngine, collectCelRootIdentifiers } from '@objectstack/formula';
|
|
12
|
+
import {
|
|
13
|
+
ADMIN_FULL_ACCESS,
|
|
14
|
+
ORGANIZATION_ADMIN_GRANTS,
|
|
15
|
+
BUILTIN_IDENTITY_PLATFORM_ADMIN,
|
|
16
|
+
BUILTIN_IDENTITY_ORG_OWNER,
|
|
17
|
+
BUILTIN_IDENTITY_ORG_ADMIN,
|
|
18
|
+
} from '@objectstack/spec/identity';
|
|
9
19
|
import type {
|
|
10
20
|
IApprovalService,
|
|
11
21
|
ApprovalRequestRow,
|
|
12
22
|
ApprovalActionRow,
|
|
23
|
+
ApprovalActionAttachment,
|
|
13
24
|
ApprovalDecisionInput,
|
|
14
25
|
ApprovalDecisionResult,
|
|
15
26
|
ApprovalRecallInput,
|
|
@@ -21,7 +32,15 @@ import type {
|
|
|
21
32
|
ApprovalStatus,
|
|
22
33
|
SharingExecutionContext,
|
|
23
34
|
} from '@objectstack/spec/contracts';
|
|
35
|
+
import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts';
|
|
36
|
+
import { isFileIdToken } from '@objectstack/spec/data';
|
|
24
37
|
import { isGrantActive } from '@objectstack/core';
|
|
38
|
+
import {
|
|
39
|
+
filterApproversWhoCanRead,
|
|
40
|
+
resolveApproverDirectoryOrg,
|
|
41
|
+
type ApproverOrgScopeDeps,
|
|
42
|
+
type ApproverOrgScopeEngine,
|
|
43
|
+
} from './approver-org-scope.js';
|
|
25
44
|
|
|
26
45
|
/**
|
|
27
46
|
* Node-era approval runtime (ADR-0019).
|
|
@@ -53,7 +72,18 @@ export interface ApprovalClock { now(): Date }
|
|
|
53
72
|
* plugin when an automation engine is present (see `approval-node.ts`).
|
|
54
73
|
*/
|
|
55
74
|
export interface ApprovalResumeSurface {
|
|
56
|
-
resume?(runId: string, signal?: {
|
|
75
|
+
resume?(runId: string, signal?: {
|
|
76
|
+
output?: Record<string, unknown>;
|
|
77
|
+
branchLabel?: string;
|
|
78
|
+
/**
|
|
79
|
+
* #3801: the engine refuses a resume of an `approval` suspension unless
|
|
80
|
+
* the signal carries this marker — the proof that the resume is the tail
|
|
81
|
+
* of a decision THIS service already authorized and recorded, not a raw
|
|
82
|
+
* `POST …/runs/:runId/resume` around it. Every resume below stamps it via
|
|
83
|
+
* {@link ApprovalService.serviceResume}.
|
|
84
|
+
*/
|
|
85
|
+
[RESUME_AUTHORITY_SERVICE]?: true;
|
|
86
|
+
}): Promise<unknown>;
|
|
57
87
|
/** Flow definition lookup, used to derive step-progress display data. */
|
|
58
88
|
getFlow?(name: string): Promise<any | null>;
|
|
59
89
|
/**
|
|
@@ -62,6 +92,19 @@ export interface ApprovalResumeSurface {
|
|
|
62
92
|
* which has no reject edge to resume down.
|
|
63
93
|
*/
|
|
64
94
|
cancelRun?(runId: string, reason?: string): Promise<unknown>;
|
|
95
|
+
/**
|
|
96
|
+
* Look up a run's recorded outcome (#3456). Used by the dead-run sweep to ask
|
|
97
|
+
* "is the run behind this pending request still alive?".
|
|
98
|
+
*
|
|
99
|
+
* The contract that makes the sweep safe is the answer for a run that is
|
|
100
|
+
* merely SUSPENDED (the normal state of a run waiting on an approval): the
|
|
101
|
+
* engine writes no execution-log entry until a run reaches a terminal state,
|
|
102
|
+
* so a suspended run resolves to `null`, never to a status. The sweep
|
|
103
|
+
* therefore acts only on an explicit terminal-failure status and treats
|
|
104
|
+
* `null` — unknown run, evicted log, no durable store, no automation engine —
|
|
105
|
+
* as "still alive".
|
|
106
|
+
*/
|
|
107
|
+
getRun?(runId: string): Promise<{ status?: string } | null>;
|
|
65
108
|
}
|
|
66
109
|
|
|
67
110
|
/**
|
|
@@ -90,6 +133,24 @@ export const ESCALATION_JOB_NAME = 'approvals-sla-escalation';
|
|
|
90
133
|
export const ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1000;
|
|
91
134
|
/** Reserved actor id for machine decisions made by the SLA scanner. */
|
|
92
135
|
export const SLA_ACTOR_ID = 'system:sla';
|
|
136
|
+
/** Reserved actor id for requests abandoned because their run died (#3456). */
|
|
137
|
+
export const DEAD_RUN_ACTOR_ID = 'system:dead-run';
|
|
138
|
+
/**
|
|
139
|
+
* Run statuses that mean "this run will never resume", so a request still
|
|
140
|
+
* pending on it is orphaned (#3456). A CLOSED set, deliberately: the dead-run
|
|
141
|
+
* sweep treats every other answer — `paused` (a run waiting on its approval,
|
|
142
|
+
* the normal case), `running`, an unknown status, or no answer at all — as
|
|
143
|
+
* alive, so an unrecognised state can never cost someone a live approval.
|
|
144
|
+
*
|
|
145
|
+
* `completed` belongs here with the failure states. The approval node only
|
|
146
|
+
* writes a request row on the path where it also suspends the run, and every
|
|
147
|
+
* in-band transition (decide / recall / send-back / resubmit) finalises the
|
|
148
|
+
* request *before* it resumes the run — so a completed run with a still-pending
|
|
149
|
+
* request means the run was resumed out of band and left the request behind.
|
|
150
|
+
*/
|
|
151
|
+
const TERMINAL_RUN_STATUSES: ReadonlySet<string> = new Set([
|
|
152
|
+
'completed', 'failed', 'cancelled', 'timed_out',
|
|
153
|
+
]);
|
|
93
154
|
|
|
94
155
|
/** Default lifetime of an actionable-link token (ADR-0043). */
|
|
95
156
|
export const ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1000;
|
|
@@ -101,6 +162,33 @@ export type ActionTokenOutcome =
|
|
|
101
162
|
|
|
102
163
|
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
|
|
103
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Who is acting, for the purpose of a data write made on their behalf (#3783).
|
|
167
|
+
*
|
|
168
|
+
* Reads the AUTHENTICATED principal off the execution context — deliberately not
|
|
169
|
+
* `input.actorId`. When this was written the two could still disagree: every
|
|
170
|
+
* public entrypoint took `actorId` from the request body (`body.actorId ??
|
|
171
|
+
* context.userId`, see the REST approval routes) and the service only checked
|
|
172
|
+
* that it named a pending approver, never that it was the caller. That was
|
|
173
|
+
* called tolerable on an audit row — but the same unchecked value was the
|
|
174
|
+
* authorization key, so it was in fact impersonation, and #3800 closed it:
|
|
175
|
+
* {@link ApprovalService.resolveActor} now pins the actor to an identity the
|
|
176
|
+
* server can prove belongs to the caller. This helper stays the separate,
|
|
177
|
+
* stricter answer for a DATA WRITE, which wants the bare human id and never a
|
|
178
|
+
* `type:value` slot literal or a machine sentinel.
|
|
179
|
+
*
|
|
180
|
+
* A caller holding a trustworthy actor with no session behind it — the ADR-0043
|
|
181
|
+
* action link, whose token cryptographically binds exactly one approver — puts
|
|
182
|
+
* that actor ON the context instead of relying on this.
|
|
183
|
+
*
|
|
184
|
+
* `null` for a machine caller (the SLA sweep passes {@link SYSTEM_CTX}), so a
|
|
185
|
+
* reserved sentinel like {@link SLA_ACTOR_ID} can never surface as a `userId`.
|
|
186
|
+
*/
|
|
187
|
+
function actingUserId(context: SharingExecutionContext | undefined): string | null {
|
|
188
|
+
const userId = (context as { userId?: unknown } | undefined)?.userId;
|
|
189
|
+
return typeof userId === 'string' && userId ? userId : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
104
192
|
/**
|
|
105
193
|
* Max hops when following an OOO delegation chain (#1322 M1): A out → B, B out
|
|
106
194
|
* → C, … Bounds the walk so a mis-configured chain can't loop or resolve
|
|
@@ -108,6 +196,21 @@ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
|
|
|
108
196
|
*/
|
|
109
197
|
const OOO_MAX_CHAIN = 8;
|
|
110
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Approver types resolved by QUERYING a graph rather than by taking `value`
|
|
201
|
+
* literally (#3807). Each can legitimately come back empty — an unstaffed
|
|
202
|
+
* position, an emptied team, a mis-pointed unit — and the caller then falls
|
|
203
|
+
* back to a `type:value` literal that no user can act on. They are listed here
|
|
204
|
+
* so that dead end gets one warning instead of passing in silence.
|
|
205
|
+
*
|
|
206
|
+
* `user` / `field` are deliberately absent: they resolve to the id they were
|
|
207
|
+
* given without a lookup, so there is no "expanded to nobody" state to report.
|
|
208
|
+
* `business_unit` / `bu` are the accepted dialects of `department`.
|
|
209
|
+
*/
|
|
210
|
+
const GRAPH_APPROVER_TYPES: ReadonlySet<string> = new Set([
|
|
211
|
+
'team', 'department', 'business_unit', 'bu', 'position', 'org_membership_level', 'manager',
|
|
212
|
+
]);
|
|
213
|
+
|
|
111
214
|
/** One OOO delegation hop applied while resolving an approver (#1322 M1/M4). */
|
|
112
215
|
interface OooSubstitution {
|
|
113
216
|
/** The approver who was skipped (out of office). */
|
|
@@ -118,6 +221,43 @@ interface OooSubstitution {
|
|
|
118
221
|
reason: string | null;
|
|
119
222
|
}
|
|
120
223
|
|
|
224
|
+
/**
|
|
225
|
+
* The CLOSED set of namespace roots an `expression` approver may reference
|
|
226
|
+
* (#3447 P2). Three explicit times/sources, no `record`, no bare field names:
|
|
227
|
+
* `record` means "the record at event time" everywhere else on the platform
|
|
228
|
+
* (flow conditions: trigger snapshot; hooks: the write payload), so binding it
|
|
229
|
+
* here — to either time — would silently alias one meaning to the other. The
|
|
230
|
+
* runtime CEL env treats unknown roots as `dyn` (→ `null` → an empty slate),
|
|
231
|
+
* so out-of-contract roots MUST be rejected before evaluation; both this
|
|
232
|
+
* pre-check and the lint rule read the roots via
|
|
233
|
+
* {@link collectCelRootIdentifiers} so they can never drift.
|
|
234
|
+
*/
|
|
235
|
+
const APPROVER_EXPRESSION_ROOTS = new Set(['current', 'trigger', 'vars']);
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Evaluation context an approval node hands to `expression` approvers
|
|
239
|
+
* (#3447 P2). `current` (the live record) is supplied by openNodeRequest's
|
|
240
|
+
* re-read; these two carry the other roots.
|
|
241
|
+
*/
|
|
242
|
+
export interface ApproverExpressionContext {
|
|
243
|
+
/** Submit-time snapshot (the flow's `$record`) — bound as `trigger.*`. */
|
|
244
|
+
trigger?: Record<string, unknown> | null;
|
|
245
|
+
/** Flow variables at node entry (nested by dotted key) — bound as `vars.*`. */
|
|
246
|
+
vars?: Record<string, unknown> | null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Non-request outcome of {@link ApprovalService.openNodeRequest}: the node
|
|
251
|
+
* resolved an empty approver slate and its `onEmptyApprovers: 'auto_approve'`
|
|
252
|
+
* policy waved it through (#3447 P2). No `sys_approval_request` row exists —
|
|
253
|
+
* nobody was ever asked — so the node must complete down its `approve` edge
|
|
254
|
+
* instead of suspending.
|
|
255
|
+
*/
|
|
256
|
+
export interface ApprovalNodeAutoOutcome {
|
|
257
|
+
autoApproved: true;
|
|
258
|
+
reason: 'empty_approvers';
|
|
259
|
+
}
|
|
260
|
+
|
|
121
261
|
function uid(prefix: string): string {
|
|
122
262
|
const g: any = globalThis as any;
|
|
123
263
|
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
@@ -184,6 +324,27 @@ function rowFromRequest(row: any): ApprovalRequestRow {
|
|
|
184
324
|
sla_due_at: slaDueAt(row.created_at, cfg),
|
|
185
325
|
// ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
|
|
186
326
|
round: typeof cfg?.__round === 'number' ? cfg.__round : undefined,
|
|
327
|
+
// objectui#2902: the node's record-lock policy. The lock is enforced
|
|
328
|
+
// server-side in `lifecycle-hooks.ts` off THIS SAME snapshot with the
|
|
329
|
+
// same `!== false` default, so the flag a client renders and the rule the
|
|
330
|
+
// server applies can never drift. Without it a console can only see
|
|
331
|
+
// "a pending request exists" and has to assume the record is locked —
|
|
332
|
+
// which mislabels every `lockRecord: false` node as locked and hides an
|
|
333
|
+
// edit the server would have accepted.
|
|
334
|
+
lock_record: cfg?.lockRecord !== false,
|
|
335
|
+
// #3447 P2: the node's author-declared decision outputs, surfaced so a
|
|
336
|
+
// decision UI can render input fields for them and POST `outputs` on
|
|
337
|
+
// approve/reject. Per-request (each node declares its own), which is why
|
|
338
|
+
// this rides the row instead of the static action params. Two shapes for
|
|
339
|
+
// version skew: `decision_outputs` stays the bare KEY list an older
|
|
340
|
+
// console renders as text inputs; `decision_output_defs` carries the
|
|
341
|
+
// normalized typed declarations a picker-aware console prefers.
|
|
342
|
+
...(() => {
|
|
343
|
+
const defs = normalizeDecisionOutputs(cfg?.decisionOutputs);
|
|
344
|
+
return defs.length
|
|
345
|
+
? { decision_outputs: defs.map(d => d.key), decision_output_defs: defs }
|
|
346
|
+
: {};
|
|
347
|
+
})(),
|
|
187
348
|
} as any;
|
|
188
349
|
}
|
|
189
350
|
|
|
@@ -196,7 +357,58 @@ function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
|
|
|
196
357
|
return new Date(t + hours * 3600_000).toISOString();
|
|
197
358
|
}
|
|
198
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Normalize one raw `attachments` entry into an {@link ApprovalActionAttachment}.
|
|
362
|
+
*
|
|
363
|
+
* `sys_approval_action.attachments` is a `Field.file` (multiple), so the column
|
|
364
|
+
* **stores opaque `sys_file` ids** — that is the stored form of every media
|
|
365
|
+
* field (ADR-0104 D3). What arrives here is whichever of three forms the read
|
|
366
|
+
* path produced:
|
|
367
|
+
*
|
|
368
|
+
* 1. the **expanded** `{ id, name, size, mimeType, url }` the ObjectQL read
|
|
369
|
+
* path resolves a stored id into — the normal case;
|
|
370
|
+
* 2. a **bare id**, when there was nothing to expand it into (storage service
|
|
371
|
+
* absent, file not committed);
|
|
372
|
+
* 3. a **legacy inline blob** (`{ file_id, name, mime_type, url, … }`) written
|
|
373
|
+
* before file-as-reference, until the backfill converts it.
|
|
374
|
+
*
|
|
375
|
+
* The original mapping did `String(entry)`, which turned form 1 into the
|
|
376
|
+
* literal `"[object Object]"` — so the inbox timeline showed a nameless,
|
|
377
|
+
* un-openable attachment chip (#3266 follow-up; caught by browser verification).
|
|
378
|
+
*
|
|
379
|
+
* Note the casing: the expanded form carries `mimeType`, the legacy blob
|
|
380
|
+
* `mime_type`. Both are accepted for the duration of the migration window.
|
|
381
|
+
*/
|
|
382
|
+
function normalizeActionAttachment(entry: any): ApprovalActionAttachment | undefined {
|
|
383
|
+
if (entry == null) return undefined;
|
|
384
|
+
// Form 2 — a bare reference. `isFileIdToken` is the platform's single arbiter
|
|
385
|
+
// of "is this string an opaque file id, or a URL?", shared with the engine's
|
|
386
|
+
// read resolver, so the two cannot disagree about what counts as an id.
|
|
387
|
+
if (typeof entry === 'string') {
|
|
388
|
+
const id = entry.trim();
|
|
389
|
+
if (!id) return undefined;
|
|
390
|
+
return isFileIdToken(id) ? { id } : { id, url: id };
|
|
391
|
+
}
|
|
392
|
+
if (typeof entry === 'object') {
|
|
393
|
+
// Forms 1 and 3 — `file_id` is the legacy blob's key for the same thing.
|
|
394
|
+
const id = entry.id ?? entry.file_id;
|
|
395
|
+
if (id == null || String(id) === '') return undefined;
|
|
396
|
+
const mimeType = entry.mimeType ?? entry.mime_type;
|
|
397
|
+
return {
|
|
398
|
+
id: String(id),
|
|
399
|
+
name: typeof entry.name === 'string' ? entry.name : undefined,
|
|
400
|
+
url: typeof entry.url === 'string' ? entry.url : undefined,
|
|
401
|
+
mimeType: typeof mimeType === 'string' ? mimeType : undefined,
|
|
402
|
+
size: typeof entry.size === 'number' ? entry.size : undefined,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
199
408
|
function rowFromAction(row: any): ApprovalActionRow {
|
|
409
|
+
const attachments = Array.isArray(row.attachments)
|
|
410
|
+
? row.attachments.map(normalizeActionAttachment).filter((a: ApprovalActionAttachment | undefined): a is ApprovalActionAttachment => !!a)
|
|
411
|
+
: [];
|
|
200
412
|
return {
|
|
201
413
|
id: String(row.id),
|
|
202
414
|
request_id: String(row.request_id),
|
|
@@ -205,10 +417,9 @@ function rowFromAction(row: any): ApprovalActionRow {
|
|
|
205
417
|
action: row.action,
|
|
206
418
|
actor_id: row.actor_id ?? undefined,
|
|
207
419
|
comment: row.comment ?? undefined,
|
|
208
|
-
// Decision attachments (#3266)
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : undefined,
|
|
420
|
+
// Decision attachments (#3266): rich descriptors carrying the display name +
|
|
421
|
+
// download URL, so consumers label/open them without reading `sys_file`.
|
|
422
|
+
attachments: attachments.length ? attachments : undefined,
|
|
212
423
|
created_at: row.created_at ?? undefined,
|
|
213
424
|
};
|
|
214
425
|
}
|
|
@@ -232,6 +443,14 @@ export interface ApprovalServiceOptions {
|
|
|
232
443
|
* the Console and IM webviews; outbound email needs the absolute form.
|
|
233
444
|
*/
|
|
234
445
|
publicBaseUrl?: string;
|
|
446
|
+
/**
|
|
447
|
+
* [ADR-0105 D9] The tenancy posture in force. Cross-organization approver
|
|
448
|
+
* targeting is a `group`-posture capability; the resolver refuses the
|
|
449
|
+
* declaration under any other posture rather than silently ignoring it.
|
|
450
|
+
* Absent (a stack booted with no tenancy service) reads as "unknown" and the
|
|
451
|
+
* guard stands down.
|
|
452
|
+
*/
|
|
453
|
+
tenancyPosture?: () => string | undefined;
|
|
235
454
|
}
|
|
236
455
|
|
|
237
456
|
export class ApprovalService implements IApprovalService {
|
|
@@ -241,6 +460,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
241
460
|
private automation?: ApprovalResumeSurface;
|
|
242
461
|
private messaging?: ApprovalMessagingSurface;
|
|
243
462
|
private publicBaseUrl: string;
|
|
463
|
+
private tenancyPosture?: () => string | undefined;
|
|
244
464
|
|
|
245
465
|
constructor(opts: ApprovalServiceOptions) {
|
|
246
466
|
this.engine = opts.engine;
|
|
@@ -249,6 +469,36 @@ export class ApprovalService implements IApprovalService {
|
|
|
249
469
|
this.automation = opts.automation;
|
|
250
470
|
this.messaging = opts.messaging;
|
|
251
471
|
this.publicBaseUrl = (opts.publicBaseUrl ?? '').replace(/\/$/, '');
|
|
472
|
+
this.tenancyPosture = opts.tenancyPosture;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Attach (or replace) the ADR-0105 D9 posture provider. */
|
|
476
|
+
attachTenancyPosture(provider: () => string | undefined): void {
|
|
477
|
+
this.tenancyPosture = provider;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Deps bundle for the ADR-0105 D9 org-scope helpers. */
|
|
481
|
+
private get orgScopeDeps(): ApproverOrgScopeDeps {
|
|
482
|
+
return {
|
|
483
|
+
engine: this.engine as unknown as ApproverOrgScopeEngine,
|
|
484
|
+
posture: this.tenancyPosture,
|
|
485
|
+
logger: this.logger,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* [ADR-0105 D9] Which organization's directory resolves ONE approver spec.
|
|
491
|
+
* Absent declaration ⇒ the request's own organization (unchanged, no reads).
|
|
492
|
+
*/
|
|
493
|
+
private async directoryOrgFor(a: any, requestOrgId: string | null | undefined): Promise<string | null | undefined> {
|
|
494
|
+
const rawType = String(a?.type ?? '');
|
|
495
|
+
return resolveApproverDirectoryOrg(
|
|
496
|
+
this.orgScopeDeps,
|
|
497
|
+
a?.organization,
|
|
498
|
+
requestOrgId,
|
|
499
|
+
rawType,
|
|
500
|
+
approverTypeIsOrgScoped(rawType),
|
|
501
|
+
);
|
|
252
502
|
}
|
|
253
503
|
|
|
254
504
|
/** Attach (or replace) the automation surface used to resume flow runs. */
|
|
@@ -307,6 +557,120 @@ export class ApprovalService implements IApprovalService {
|
|
|
307
557
|
return raw;
|
|
308
558
|
}
|
|
309
559
|
|
|
560
|
+
/**
|
|
561
|
+
* Privileged-override gate (#3424). A stuck approval — one routed to a
|
|
562
|
+
* position/team with no holders (so its `pending_approvers` is only an
|
|
563
|
+
* unresolvable `type:value` literal) or to approvers who have all since left —
|
|
564
|
+
* is otherwise undecidable: no concrete user is in the slate, so every normal
|
|
565
|
+
* `decide` / `reassign` / `recall` is `FORBIDDEN` and (with `lockRecord`) the
|
|
566
|
+
* record stays locked forever with no in-product recovery. A platform or
|
|
567
|
+
* tenant admin — the same posture the engine's superuser bypass already
|
|
568
|
+
* trusts — may always act on a PENDING request to release it: approve, reject,
|
|
569
|
+
* reassign it to a real approver, or recall it.
|
|
570
|
+
*
|
|
571
|
+
* A platform admin crosses the tenant wall (matching the unscoped
|
|
572
|
+
* `admin_full_access` evidence); a tenant admin may override only within their
|
|
573
|
+
* own org (or an org-less request). A system context always passes. Signals are
|
|
574
|
+
* read defensively off the resolved exec context (`permissions` / `positions` /
|
|
575
|
+
* the derived `posture`, ADR-0095) so any transport that resolves through the
|
|
576
|
+
* shared authz resolver lights this up without extra wiring.
|
|
577
|
+
*/
|
|
578
|
+
private isOverrideActor(context: SharingExecutionContext, requestOrg?: string | null): boolean {
|
|
579
|
+
if (!context) return false;
|
|
580
|
+
if (context.isSystem) return true;
|
|
581
|
+
const perms = Array.isArray(context.permissions) ? context.permissions : [];
|
|
582
|
+
const positions = Array.isArray(context.positions) ? context.positions : [];
|
|
583
|
+
const posture = (context as any).posture;
|
|
584
|
+
const isPlatformAdmin = posture === 'PLATFORM_ADMIN'
|
|
585
|
+
|| perms.includes(ADMIN_FULL_ACCESS)
|
|
586
|
+
|| positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN);
|
|
587
|
+
if (isPlatformAdmin) return true;
|
|
588
|
+
const isTenantAdmin = posture === 'TENANT_ADMIN'
|
|
589
|
+
|| ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n))
|
|
590
|
+
|| positions.includes(BUILTIN_IDENTITY_ORG_OWNER)
|
|
591
|
+
|| positions.includes(BUILTIN_IDENTITY_ORG_ADMIN);
|
|
592
|
+
if (!isTenantAdmin) return false;
|
|
593
|
+
// A tenant admin's authority stops at their own org; a null-org request is
|
|
594
|
+
// global and any admin may release it.
|
|
595
|
+
const actorTenant = (context as any).tenantId ?? (context as any).organizationId ?? null;
|
|
596
|
+
return requestOrg == null || (actorTenant != null && String(requestOrg) === String(actorTenant));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Pin the acting identity to the AUTHENTICATED CALLER (#3800).
|
|
601
|
+
*
|
|
602
|
+
* Every public entrypoint accepts an `actorId`, and the REST routes fill it
|
|
603
|
+
* from `body.actorId ?? body.actor_id ?? context.userId` — so before this
|
|
604
|
+
* gate the body won. The authorization checks downstream all read that value
|
|
605
|
+
* (`pending_approvers.includes(input.actorId)`, `submitter_id === actorId`),
|
|
606
|
+
* which made the body-supplied string not merely the audit label but the key
|
|
607
|
+
* that opens the door: any authenticated user could name a pending approver
|
|
608
|
+
* and have that approver's decision recorded and the owning flow resumed.
|
|
609
|
+
* #3783 drew this line for the data-write identity ({@link actingUserId});
|
|
610
|
+
* this closes the authorization half.
|
|
611
|
+
*
|
|
612
|
+
* A caller may still name an identity OTHER than their bare user id, because
|
|
613
|
+
* a slot legitimately can be keyed by one: `resolveApproverSpec` stores the
|
|
614
|
+
* `type:value` literal when a graph lookup yields nothing, and an author may
|
|
615
|
+
* write an email as a `user` approver. So the rule is not "actorId must equal
|
|
616
|
+
* userId" — it is **"actorId must be an identity the SERVER can prove belongs
|
|
617
|
+
* to the caller"**. Anything else is `FORBIDDEN`.
|
|
618
|
+
*
|
|
619
|
+
* A system context is exempt and keeps its explicit actor: the SLA sweep
|
|
620
|
+
* passes the reserved {@link SLA_ACTOR_ID} sentinel, and the ADR-0043 action
|
|
621
|
+
* link passes the approver its single-use token is cryptographically bound to
|
|
622
|
+
* (having also put them on the context). Those are the only two callers that
|
|
623
|
+
* hold a trustworthy actor with no session behind them.
|
|
624
|
+
*
|
|
625
|
+
* A caller with NO identity at all cannot act. That case is reachable: the
|
|
626
|
+
* REST anonymous-deny only fires when `api.requireAuth` is set, so without it
|
|
627
|
+
* an anonymous request previously decided approvals outright by naming one.
|
|
628
|
+
*/
|
|
629
|
+
private async resolveActor(
|
|
630
|
+
actorId: string | undefined,
|
|
631
|
+
context: SharingExecutionContext,
|
|
632
|
+
): Promise<string> {
|
|
633
|
+
// The machine callers — their actor is server-minted, not caller-supplied.
|
|
634
|
+
if (context?.isSystem) {
|
|
635
|
+
if (!actorId) throw new Error('VALIDATION_FAILED: actorId is required');
|
|
636
|
+
return actorId;
|
|
637
|
+
}
|
|
638
|
+
const uid = actingUserId(context);
|
|
639
|
+
if (!uid) {
|
|
640
|
+
throw new Error('FORBIDDEN: an approval action requires an authenticated caller');
|
|
641
|
+
}
|
|
642
|
+
// The common case: no actor named, or the caller named themselves.
|
|
643
|
+
if (!actorId || String(actorId) === uid) return uid;
|
|
644
|
+
|
|
645
|
+
// Named something else — allow it ONLY if the server can prove the caller
|
|
646
|
+
// holds that identity. `positions` is resolved by the shared authz resolver
|
|
647
|
+
// (never client-supplied); `role:` is the ADR-0090 D3 deprecated spelling
|
|
648
|
+
// that 15.x-era slots and the Console's own identity list still carry.
|
|
649
|
+
const named = String(actorId);
|
|
650
|
+
for (const position of context.positions ?? []) {
|
|
651
|
+
if (named === `position:${position}` || named === `role:${position}`) return named;
|
|
652
|
+
}
|
|
653
|
+
// Email last — it costs a read, so only when nothing cheaper matched.
|
|
654
|
+
if (named.includes('@') && await this.callerHasEmail(uid, named)) return named;
|
|
655
|
+
|
|
656
|
+
throw new Error(
|
|
657
|
+
`FORBIDDEN: cannot act as '${named}' — an approval action is recorded against the authenticated caller`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Does `userId`'s own account carry `email`? (Slots keyed by email, #3800.) */
|
|
662
|
+
private async callerHasEmail(userId: string, email: string): Promise<boolean> {
|
|
663
|
+
try {
|
|
664
|
+
const rows = await this.engine.find('sys_user', {
|
|
665
|
+
where: { id: userId }, limit: 1, context: SYSTEM_CTX,
|
|
666
|
+
});
|
|
667
|
+
const row: any = Array.isArray(rows) ? rows[0] : null;
|
|
668
|
+
return !!row?.email && String(row.email).toLowerCase() === email.toLowerCase();
|
|
669
|
+
} catch {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
310
674
|
/**
|
|
311
675
|
* Expand the approvers on an Approval node into user IDs by querying the
|
|
312
676
|
* graph tables for `team:` / `department:` / `position:` /
|
|
@@ -344,7 +708,19 @@ export class ApprovalService implements IApprovalService {
|
|
|
344
708
|
step: any,
|
|
345
709
|
record?: any,
|
|
346
710
|
organizationId?: string | null,
|
|
347
|
-
opts?: {
|
|
711
|
+
opts?: {
|
|
712
|
+
now?: number;
|
|
713
|
+
substitutions?: OooSubstitution[];
|
|
714
|
+
groups?: Record<string, string[]>;
|
|
715
|
+
/** #3447 P2: `trigger`/`vars` roots for `expression` approvers. */
|
|
716
|
+
exprCtx?: ApproverExpressionContext;
|
|
717
|
+
/**
|
|
718
|
+
* #3447 P2 audit collector: what each dynamic spec resolved FROM (the
|
|
719
|
+
* live field value / the expression's intermediate values), snapshotted
|
|
720
|
+
* as `__resolvedFrom` so "why these people" stays answerable later.
|
|
721
|
+
*/
|
|
722
|
+
resolvedFrom?: Record<string, unknown>;
|
|
723
|
+
},
|
|
348
724
|
): Promise<string[]> {
|
|
349
725
|
if (!step || !Array.isArray(step.approvers)) return [];
|
|
350
726
|
const now = opts?.now ?? this.clock.now().getTime();
|
|
@@ -353,11 +729,34 @@ export class ApprovalService implements IApprovalService {
|
|
|
353
729
|
for (let idx = 0; idx < specs.length; idx++) {
|
|
354
730
|
const a = specs[idx];
|
|
355
731
|
if (!a) continue;
|
|
356
|
-
|
|
357
|
-
//
|
|
358
|
-
// approver without an explicit `group` forms its own group keyed by
|
|
359
|
-
// position, so a plain per-approver list still behaves predictably.
|
|
732
|
+
// Approvers without an explicit `group` each form their own group keyed
|
|
733
|
+
// by position (#3266), so a plain per-approver list behaves predictably.
|
|
360
734
|
const groupKey = a.group != null && String(a.group) !== '' ? String(a.group) : `#${idx}`;
|
|
735
|
+
|
|
736
|
+
// #3447 P2: `expression` approvers resolve OUTSIDE resolveApproverSpec —
|
|
737
|
+
// a graph-expanded expression (resolveAs: department/…) must key each
|
|
738
|
+
// intermediate value as its own per_group group, which the flat string[]
|
|
739
|
+
// contract of resolveApproverSpec cannot carry.
|
|
740
|
+
if (canonicalApproverType(String(a.type)) === 'expression') {
|
|
741
|
+
const resolved = await this.resolveExpressionApprovers(
|
|
742
|
+
a, record, organizationId, now, opts?.substitutions, opts?.exprCtx,
|
|
743
|
+
);
|
|
744
|
+
if (opts?.resolvedFrom) opts.resolvedFrom[`expression#${idx}`] = resolved.raw;
|
|
745
|
+
for (const entry of resolved.slots) {
|
|
746
|
+
if (!entry.id) continue;
|
|
747
|
+
out.push(entry.id);
|
|
748
|
+
if (opts?.groups) {
|
|
749
|
+
(opts.groups[entry.id] ??= []).push(entry.subGroup ? `${groupKey}:${entry.subGroup}` : groupKey);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (opts?.resolvedFrom && canonicalApproverType(String(a.type)) === 'field' && a.value != null) {
|
|
756
|
+
opts.resolvedFrom[`field:${a.value}`] = (record as any)?.[a.value] ?? null;
|
|
757
|
+
}
|
|
758
|
+
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
759
|
+
// per_group (#3266): tag each resolved id with this spec's group.
|
|
361
760
|
for (const u of ids) {
|
|
362
761
|
if (!u) continue;
|
|
363
762
|
out.push(u);
|
|
@@ -393,24 +792,65 @@ export class ApprovalService implements IApprovalService {
|
|
|
393
792
|
{ deprecated: a.type, canonical: type },
|
|
394
793
|
);
|
|
395
794
|
}
|
|
795
|
+
// [ADR-0105 D9] WHERE this approver is looked up — the request's own
|
|
796
|
+
// organization unless the spec targets another one in the same group.
|
|
797
|
+
//
|
|
798
|
+
// Resolved HERE, above the `user` / `field` / `manager` early returns,
|
|
799
|
+
// because refusing a declaration on a directory-less type is one of the
|
|
800
|
+
// things this resolution DOES (those types name a person outright, so
|
|
801
|
+
// `organization` on them cannot narrow anything and an author who wrote it
|
|
802
|
+
// misunderstood the field). Resolving it after those returns made the
|
|
803
|
+
// refusal unreachable and the declaration silently inert — exactly the
|
|
804
|
+
// "ignored, not refused" behaviour ADR-0105 D9 rules out, and what the
|
|
805
|
+
// cloud group-posture dogfood caught.
|
|
806
|
+
//
|
|
807
|
+
// Costs nothing on the overwhelmingly common path: with no `organization`
|
|
808
|
+
// declared, the resolver returns the request org without reading anything.
|
|
809
|
+
const directoryOrg = await this.directoryOrgFor(a, organizationId);
|
|
810
|
+
const crossOrg = directoryOrg !== organizationId;
|
|
811
|
+
|
|
396
812
|
if (type === 'user') {
|
|
397
813
|
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
|
|
398
814
|
}
|
|
399
815
|
if (type === 'field' && record) {
|
|
400
|
-
|
|
816
|
+
// #3447: a record field can name MANY approvers — a multi-select user
|
|
817
|
+
// field arrives as an array (or a legacy CSV string). Fan each out into
|
|
818
|
+
// its own slot and OOO-substitute per person; collapsing to `String(...)`
|
|
819
|
+
// (→ `'u1,u2'`) would mint one bogus approver id and skip every delegate.
|
|
820
|
+
const out: string[] = [];
|
|
821
|
+
for (const id of csvSplit((record as any)[a.value])) {
|
|
822
|
+
out.push(...await this.applyOooDelegation(id, now, organizationId, substitutions));
|
|
823
|
+
}
|
|
824
|
+
return out;
|
|
401
825
|
}
|
|
826
|
+
// `directoryOrg` / `crossOrg` were resolved at the TOP of this method, so
|
|
827
|
+
// the refusal reaches directory-less types too. Resolution failures
|
|
828
|
+
// propagate: they are routing bugs, and that call sits OUTSIDE the
|
|
829
|
+
// swallowing try below on purpose (see the catch's comment).
|
|
830
|
+
//
|
|
831
|
+
// A cross-org slate is filtered to the people who can actually READ the
|
|
832
|
+
// request (D2 union); same-org routing is untouched and does no extra read.
|
|
833
|
+
const bounded = async (users: string[]): Promise<string[]> => (
|
|
834
|
+
crossOrg
|
|
835
|
+
? filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
|
|
836
|
+
approverType: type, value: a.value != null ? String(a.value) : undefined,
|
|
837
|
+
directoryOrgId: directoryOrg,
|
|
838
|
+
})
|
|
839
|
+
: users
|
|
840
|
+
);
|
|
841
|
+
|
|
402
842
|
try {
|
|
403
843
|
if (type === 'team') {
|
|
404
844
|
const users = await this.expandTeamUsers(String(a.value));
|
|
405
845
|
if (users.length) return users;
|
|
406
846
|
} else if (type === 'department' || type === 'business_unit' || type === 'bu') {
|
|
407
|
-
const users = await this.expandBusinessUnitUsers(String(a.value),
|
|
847
|
+
const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
|
|
408
848
|
if (users.length) return users;
|
|
409
849
|
} else if (type === 'position') {
|
|
410
|
-
const users = await this.expandPositionUsers(String(a.value),
|
|
850
|
+
const users = await bounded(await this.expandPositionUsers(String(a.value), directoryOrg));
|
|
411
851
|
if (users.length) return users;
|
|
412
852
|
} else if (type === 'org_membership_level') {
|
|
413
|
-
const users = await this.expandMembershipTierUsers(String(a.value),
|
|
853
|
+
const users = await bounded(await this.expandMembershipTierUsers(String(a.value), directoryOrg));
|
|
414
854
|
if (users.length) return users;
|
|
415
855
|
} else if (type === 'manager' && record) {
|
|
416
856
|
const subject = (record as any)[a.value] ?? (record as any).owner_id;
|
|
@@ -419,10 +859,176 @@ export class ApprovalService implements IApprovalService {
|
|
|
419
859
|
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
|
|
420
860
|
}
|
|
421
861
|
}
|
|
422
|
-
} catch { /* fall through */ }
|
|
862
|
+
} catch { /* a directory lookup failed → fall through to the literal slot */ }
|
|
863
|
+
// #3508: `queue` is declared-but-unenforced — there is no queue branch
|
|
864
|
+
// above, so a queue approver always lands here and the `queue:<id>` slot
|
|
865
|
+
// routes to nobody. The spec marks it non-authorable
|
|
866
|
+
// (NON_AUTHORABLE_APPROVER_TYPES) so designers stop offering it; warn for
|
|
867
|
+
// the stored flows that still carry one, so the silent dead slot is at
|
|
868
|
+
// least visible to operators.
|
|
869
|
+
if (type === 'queue') {
|
|
870
|
+
this.logger?.warn?.(
|
|
871
|
+
`[approvals] approver type 'queue' is not implemented — the slot resolves to nobody (#3508)`,
|
|
872
|
+
{ value: a.value },
|
|
873
|
+
);
|
|
874
|
+
} else if (GRAPH_APPROVER_TYPES.has(type)) {
|
|
875
|
+
// #3807 follow-up — every OTHER way to land here is a graph type whose
|
|
876
|
+
// lookup produced nobody, and the literal below is a slot no user can
|
|
877
|
+
// ever act on. That silence is what let #3807 hide: a `department`
|
|
878
|
+
// approver pointing at a seeded (env-wide) unit resolved to
|
|
879
|
+
// `department:<id>` on every request, the request opened with an empty
|
|
880
|
+
// slate, and nothing in the logs said so — the first symptom was a
|
|
881
|
+
// permanently stuck approval (#3424). The fallback itself stays (a
|
|
882
|
+
// literal keeps 15.x slots and substring fixtures working); it just
|
|
883
|
+
// stops being invisible.
|
|
884
|
+
this.logger?.warn?.(
|
|
885
|
+
`[approvals] approver '${type}:${a.value}' expanded to nobody — the slot routes to no one `
|
|
886
|
+
+ `and the request cannot advance until someone is added or the approver is re-pointed (#3807)`,
|
|
887
|
+
{ type, value: a.value, organizationId: organizationId ?? null },
|
|
888
|
+
);
|
|
889
|
+
}
|
|
423
890
|
return [`${a.type}:${a.value}`];
|
|
424
891
|
}
|
|
425
892
|
|
|
893
|
+
/**
|
|
894
|
+
* Resolve an `expression` approver (#3447 P2): evaluate its CEL source at
|
|
895
|
+
* node entry against the three explicit roots — `current` (live record),
|
|
896
|
+
* `trigger` (submit snapshot), `vars` (flow variables) — then expand the
|
|
897
|
+
* result into people per `resolveAs`.
|
|
898
|
+
*
|
|
899
|
+
* Every failure here THROWS (config/parse errors as `VALIDATION_FAILED`,
|
|
900
|
+
* evaluation faults as `EXPRESSION_FAILED`) so the approval node fails
|
|
901
|
+
* loudly instead of opening a request routed to nobody — an approver
|
|
902
|
+
* expression that cannot run is a routing bug, never "condition not met".
|
|
903
|
+
* Error messages carry the correct spelling because their primary reader is
|
|
904
|
+
* the AI author fixing the flow on the next validate pass.
|
|
905
|
+
*
|
|
906
|
+
* Returns `slots` (approver id + optional per_group sub-key) and `raw` (the
|
|
907
|
+
* expression's own values, pre-expansion) for the `__resolvedFrom` audit.
|
|
908
|
+
*/
|
|
909
|
+
private async resolveExpressionApprovers(
|
|
910
|
+
a: any,
|
|
911
|
+
liveRecord: any,
|
|
912
|
+
organizationId: string | null | undefined,
|
|
913
|
+
now: number,
|
|
914
|
+
substitutions?: OooSubstitution[],
|
|
915
|
+
exprCtx?: ApproverExpressionContext,
|
|
916
|
+
): Promise<{ slots: Array<{ id: string; subGroup?: string }>; raw: string[] }> {
|
|
917
|
+
const source = String(a.value ?? '').trim();
|
|
918
|
+
if (!source) {
|
|
919
|
+
throw new Error('VALIDATION_FAILED: expression approver has an empty expression');
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Closed-root pre-check. The runtime env resolves ANY unknown root as dyn →
|
|
923
|
+
// null, so `record.x` / a bare field would silently yield an empty slate;
|
|
924
|
+
// reject it here with the correct spelling instead.
|
|
925
|
+
const parsed = collectCelRootIdentifiers(source);
|
|
926
|
+
if (!parsed.ok) {
|
|
927
|
+
throw new Error(`VALIDATION_FAILED: expression approver does not parse: ${parsed.error} — source: \`${source}\``);
|
|
928
|
+
}
|
|
929
|
+
const illegal = parsed.roots.filter(r => !APPROVER_EXPRESSION_ROOTS.has(r));
|
|
930
|
+
if (illegal.length) {
|
|
931
|
+
const hint = illegal.includes('record') || illegal.includes('previous')
|
|
932
|
+
? `\`record\`/\`previous\` are not bound here — write \`current.<field>\` for the record's live state `
|
|
933
|
+
+ `at node entry, or \`trigger.<field>\` for the submit-time snapshot (\`vars.previous\` carries the pre-update row)`
|
|
934
|
+
: `did you mean \`current.<field>\` (live record), \`trigger.<field>\` (submit snapshot), or \`vars.<name>\` (flow variable)?`;
|
|
935
|
+
throw new Error(
|
|
936
|
+
`VALIDATION_FAILED: expression approver references \`${illegal.join('`, `')}\` — `
|
|
937
|
+
+ `only \`current.*\`, \`trigger.*\` and \`vars.*\` are available; ${hint}. Source: \`${source}\``,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const result = ExpressionEngine.evaluate(
|
|
942
|
+
{ dialect: 'cel', source },
|
|
943
|
+
{ extra: { current: liveRecord ?? {}, trigger: exprCtx?.trigger ?? {}, vars: exprCtx?.vars ?? {} } },
|
|
944
|
+
);
|
|
945
|
+
if (!result.ok) {
|
|
946
|
+
throw new Error(
|
|
947
|
+
`EXPRESSION_FAILED: expression approver failed to evaluate (${result.error.kind}): `
|
|
948
|
+
+ `${result.error.message} — source: \`${source}\``,
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Normalize to a string list: a user-id/CSV string, an array of ids, or
|
|
953
|
+
// null/empty (an EMPTY slate — legal, handled by onEmptyApprovers). Any
|
|
954
|
+
// other shape is a config bug, rejected loudly.
|
|
955
|
+
const value = result.value as unknown;
|
|
956
|
+
let raw: string[];
|
|
957
|
+
if (value == null || value === '') {
|
|
958
|
+
raw = [];
|
|
959
|
+
} else if (typeof value === 'string') {
|
|
960
|
+
raw = csvSplit(value);
|
|
961
|
+
} else if (Array.isArray(value)) {
|
|
962
|
+
const bad = value.find(v => v != null && typeof v !== 'string' && typeof v !== 'number');
|
|
963
|
+
if (bad !== undefined) {
|
|
964
|
+
throw new Error(
|
|
965
|
+
`EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), `
|
|
966
|
+
+ `got an array containing ${typeof bad} — source: \`${source}\``,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
raw = value.map(v => String(v ?? '').trim()).filter(Boolean);
|
|
970
|
+
} else {
|
|
971
|
+
throw new Error(
|
|
972
|
+
`EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), `
|
|
973
|
+
+ `got ${typeof value} — source: \`${source}\``,
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// `resolveAs` expansion. `user` (default): each value IS a person —
|
|
978
|
+
// individually routed, so OOO delegation applies (#1322). Graph kinds
|
|
979
|
+
// re-expand each value through the same lookups the static types use; a
|
|
980
|
+
// group still has its other members, so like the static graph types they
|
|
981
|
+
// are NOT OOO-substituted, and with per_group each intermediate value
|
|
982
|
+
// forms its own sub-group (one sign-off per returned department). A value
|
|
983
|
+
// whose expansion is empty keeps a `<kind>:<value>` literal slot — same
|
|
984
|
+
// unstaffed-target behaviour (and #3424 admin rescue) as the static types.
|
|
985
|
+
const resolveAs = String(a.resolveAs ?? 'user');
|
|
986
|
+
if (resolveAs === 'user') {
|
|
987
|
+
const slots: Array<{ id: string }> = [];
|
|
988
|
+
for (const id of raw) {
|
|
989
|
+
for (const routed of await this.applyOooDelegation(id, now, organizationId, substitutions)) {
|
|
990
|
+
slots.push({ id: routed });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return { slots, raw };
|
|
994
|
+
}
|
|
995
|
+
// [ADR-0105 D9] An expression that re-expands into a graph kind consults the
|
|
996
|
+
// same org-scoped directories the static types do, so it honours the same
|
|
997
|
+
// targeting. Resolved once for the whole slate, before the per-value loop —
|
|
998
|
+
// the declaration is a property of the spec, not of what the CEL returned.
|
|
999
|
+
const directoryOrg = await this.directoryOrgFor(a, organizationId);
|
|
1000
|
+
const crossOrg = directoryOrg !== organizationId;
|
|
1001
|
+
const slots: Array<{ id: string; subGroup: string }> = [];
|
|
1002
|
+
for (const key of raw) {
|
|
1003
|
+
let users: string[] = [];
|
|
1004
|
+
try {
|
|
1005
|
+
if (resolveAs === 'department') users = await this.expandBusinessUnitUsers(key, directoryOrg);
|
|
1006
|
+
else if (resolveAs === 'position') users = await this.expandPositionUsers(key, directoryOrg);
|
|
1007
|
+
else if (resolveAs === 'team') users = await this.expandTeamUsers(key);
|
|
1008
|
+
else {
|
|
1009
|
+
throw new Error(
|
|
1010
|
+
`VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' — `
|
|
1011
|
+
+ `use 'user', 'department', 'position', or 'team'`,
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
} catch (err: any) {
|
|
1015
|
+
if (String(err?.message ?? '').startsWith('VALIDATION_FAILED')) throw err;
|
|
1016
|
+
users = [];
|
|
1017
|
+
}
|
|
1018
|
+
if (crossOrg && users.length) {
|
|
1019
|
+
users = await filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
|
|
1020
|
+
approverType: resolveAs, value: key, directoryOrgId: directoryOrg,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
if (!users.length) {
|
|
1024
|
+
slots.push({ id: `${resolveAs}:${key}`, subGroup: key });
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
for (const u of users) slots.push({ id: u, subGroup: key });
|
|
1028
|
+
}
|
|
1029
|
+
return { slots, raw };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
426
1032
|
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
|
|
427
1033
|
private async expandTeamUsers(teamId: string): Promise<string[]> {
|
|
428
1034
|
if (!teamId) return [];
|
|
@@ -438,15 +1044,41 @@ export class ApprovalService implements IApprovalService {
|
|
|
438
1044
|
return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
|
|
439
1045
|
}
|
|
440
1046
|
|
|
1047
|
+
/**
|
|
1048
|
+
* Tenant scope for a `sys_business_unit` read that may legitimately be
|
|
1049
|
+
* env-wide (#3807).
|
|
1050
|
+
*
|
|
1051
|
+
* `organization_id = null` on a platform object means "owned by no
|
|
1052
|
+
* organization" — a row written by a seed, the file layer, or bootstrap,
|
|
1053
|
+
* i.e. before (or outside) any org exists. A strict
|
|
1054
|
+
* `organization_id = <request org>` equality made every such row invisible:
|
|
1055
|
+
* the seed check below found nothing, the whole expansion returned `[]`, and
|
|
1056
|
+
* the approver fell back to the dead `department:<id>` literal that routes to
|
|
1057
|
+
* nobody. That is not an edge case — an app's org tree is normally seeded
|
|
1058
|
+
* (a seed cannot know the org id the runtime mints at boot) while the
|
|
1059
|
+
* approval request always carries one, so EVERY department approver a
|
|
1060
|
+
* designer could pick resolved to nobody.
|
|
1061
|
+
*
|
|
1062
|
+
* Widen to "this org ∪ env-wide", the same predicate `sys_metadata`'s
|
|
1063
|
+
* pending-draft listing settled on for the identical reason. Another org's
|
|
1064
|
+
* unit still fails the match, so the wall between two organizations is
|
|
1065
|
+
* unchanged — only rows belonging to no org at all become visible.
|
|
1066
|
+
*/
|
|
1067
|
+
private businessUnitOrgScope(
|
|
1068
|
+
filter: Record<string, unknown>,
|
|
1069
|
+
organizationId?: string | null,
|
|
1070
|
+
): Record<string, unknown> {
|
|
1071
|
+
if (!organizationId) return filter;
|
|
1072
|
+
return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
441
1075
|
/** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
|
|
442
1076
|
private async expandBusinessUnitUsers(businessUnitId: string, organizationId?: string | null): Promise<string[]> {
|
|
443
1077
|
if (!businessUnitId) return [];
|
|
444
1078
|
// Seed sanity check: skip if dept doesn't exist or is inactive within tenant.
|
|
445
1079
|
try {
|
|
446
1080
|
const seed = await this.engine.find('sys_business_unit', {
|
|
447
|
-
filter: organizationId
|
|
448
|
-
? { id: businessUnitId, organization_id: organizationId }
|
|
449
|
-
: { id: businessUnitId },
|
|
1081
|
+
filter: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
|
|
450
1082
|
fields: ['id', 'active'],
|
|
451
1083
|
limit: 1,
|
|
452
1084
|
context: SYSTEM_CTX,
|
|
@@ -461,8 +1093,10 @@ export class ApprovalService implements IApprovalService {
|
|
|
461
1093
|
const parent = queue.shift()!;
|
|
462
1094
|
let kids: any[] = [];
|
|
463
1095
|
try {
|
|
464
|
-
const filter
|
|
465
|
-
|
|
1096
|
+
const filter = this.businessUnitOrgScope(
|
|
1097
|
+
{ parent_business_unit_id: parent, active: { $ne: false } },
|
|
1098
|
+
organizationId,
|
|
1099
|
+
);
|
|
466
1100
|
kids = await this.engine.find('sys_business_unit', { filter, fields: ['id'], limit: 1000, context: SYSTEM_CTX } as any);
|
|
467
1101
|
} catch { kids = []; }
|
|
468
1102
|
for (const k of kids ?? []) {
|
|
@@ -607,15 +1241,94 @@ export class ApprovalService implements IApprovalService {
|
|
|
607
1241
|
return active[0];
|
|
608
1242
|
}
|
|
609
1243
|
|
|
610
|
-
/**
|
|
611
|
-
|
|
1244
|
+
/**
|
|
1245
|
+
* Mirror a request status onto a business-object field, if configured.
|
|
1246
|
+
*
|
|
1247
|
+
* **Elevated, but not anonymous (#3783).** The write stays `isSystem`: the
|
|
1248
|
+
* record is normally LOCKED while its approval is live and the submitter
|
|
1249
|
+
* cannot edit it, so only a platform write can land the status — that is what
|
|
1250
|
+
* the lock hook's system exemption (`lifecycle-hooks.ts`) is for. What it must
|
|
1251
|
+
* NOT do is throw away *who* caused the transition. Every status below is
|
|
1252
|
+
* something a specific human just did — a submitter submitting or recalling,
|
|
1253
|
+
* an approver deciding or sending back — and this write is what fires the
|
|
1254
|
+
* target object's record-change flows. With no `userId` on it those cascades
|
|
1255
|
+
* inherit no trigger user, and since #3760 a `runAs:'user'` run with no trigger
|
|
1256
|
+
* user has its data ops REFUSED — so "when the invoice is approved, do X", the
|
|
1257
|
+
* most natural approvals automation there is, had to declare `runAs:'system'`
|
|
1258
|
+
* and take blanket elevation for a case where a perfectly good scoped identity
|
|
1259
|
+
* existed. Re-attaching the actor lets those cascades run as the deciding user
|
|
1260
|
+
* with RLS enforced. Same shape the approval node already uses when it calls
|
|
1261
|
+
* into this service (`approval-node.ts`).
|
|
1262
|
+
*
|
|
1263
|
+
* `actorId` is `null` for the genuinely machine-driven transitions (the SLA
|
|
1264
|
+
* escalation's auto-decision, the dead-run sweep). There is no human to name
|
|
1265
|
+
* there, and naming a sentinel would put a non-user in `updated_by` and in
|
|
1266
|
+
* every downstream flow's identity. Those cascades stay user-less — a flow
|
|
1267
|
+
* that wants to react to them still has to declare `runAs:'system'`, which is
|
|
1268
|
+
* the honest answer rather than an oversight.
|
|
1269
|
+
*
|
|
1270
|
+
* Deliberately carries `userId` ONLY, not the request's org. On an
|
|
1271
|
+
* ExecutionContext `tenantId` is a driver-scoping knob, not attribution
|
|
1272
|
+
* (`buildDriverOptions` turns it into a tenant predicate on the update), so
|
|
1273
|
+
* passing it would newly org-scope this write and silently no-op the mirror on
|
|
1274
|
+
* a record whose org differs from the request's — while buying nothing: the
|
|
1275
|
+
* automation engine back-fills the run's `tenantId` from the resolved user's
|
|
1276
|
+
* own grants.
|
|
1277
|
+
*/
|
|
1278
|
+
private async mirrorStatusField(
|
|
1279
|
+
object: string,
|
|
1280
|
+
recordId: string,
|
|
1281
|
+
field: string,
|
|
1282
|
+
status: string,
|
|
1283
|
+
actorId: string | null,
|
|
1284
|
+
): Promise<void> {
|
|
612
1285
|
try {
|
|
613
|
-
|
|
1286
|
+
const context = actorId ? { ...SYSTEM_CTX, userId: actorId } : SYSTEM_CTX;
|
|
1287
|
+
await this.engine.update(object, { id: recordId, [field]: status }, { context });
|
|
614
1288
|
} catch (err: any) {
|
|
615
1289
|
this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
|
|
616
1290
|
}
|
|
617
1291
|
}
|
|
618
1292
|
|
|
1293
|
+
/**
|
|
1294
|
+
* Re-read a business record's CURRENT state by id so approver resolution binds
|
|
1295
|
+
* to live data at node entry, not the trigger snapshot the flow froze into
|
|
1296
|
+
* `$record` at submit time (#3447). A `field` / `manager` approver names *who*
|
|
1297
|
+
* decides, and an earlier node — or the approver of an earlier step — may have
|
|
1298
|
+
* written that routing field after submit (e.g. a lead reviewer picking which
|
|
1299
|
+
* departments co-review). Graph approvers (team / position / …) already query
|
|
1300
|
+
* live; this brings the in-record types into line.
|
|
1301
|
+
*
|
|
1302
|
+
* Read under system identity: approver routing is a platform concern and the
|
|
1303
|
+
* record is the flow's own subject, so the submitter's RLS/FLS must not narrow
|
|
1304
|
+
* it. Degrades to `fallback` (the snapshot) when the record can't be re-read —
|
|
1305
|
+
* hard-deleted between submit and node entry, or an object whose backend can't
|
|
1306
|
+
* serve a point read — warning rather than throwing so a transient miss can't
|
|
1307
|
+
* wedge an approval. That "warn but proceed" stance matches the
|
|
1308
|
+
* no-concrete-approver guard (#3424) and the "record is gone" enrichment path
|
|
1309
|
+
* that already falls back to the payload snapshot.
|
|
1310
|
+
*/
|
|
1311
|
+
private async loadLiveRecord(object: string, recordId: string, fallback?: any): Promise<any> {
|
|
1312
|
+
try {
|
|
1313
|
+
const rows = await this.engine.find(object, {
|
|
1314
|
+
where: { id: recordId }, limit: 1, context: SYSTEM_CTX,
|
|
1315
|
+
} as any);
|
|
1316
|
+
const live = Array.isArray(rows) ? rows[0] : rows;
|
|
1317
|
+
if (live) return live;
|
|
1318
|
+
this.logger?.warn?.(
|
|
1319
|
+
`[approvals] live record ${object}/${recordId} not found at node entry — `
|
|
1320
|
+
+ 'resolving approvers against the trigger snapshot (#3447 fallback).',
|
|
1321
|
+
{ object, recordId },
|
|
1322
|
+
);
|
|
1323
|
+
} catch (err: any) {
|
|
1324
|
+
this.logger?.warn?.(
|
|
1325
|
+
`[approvals] live record re-read failed for ${object}/${recordId}: ${err?.message ?? err} — `
|
|
1326
|
+
+ 'resolving approvers against the trigger snapshot (#3447 fallback).',
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
return fallback ?? {};
|
|
1330
|
+
}
|
|
1331
|
+
|
|
619
1332
|
// ── ADR-0019: Approval-as-flow-node ──────────────────────────
|
|
620
1333
|
//
|
|
621
1334
|
// A flow's Approval node opens a request via `openNodeRequest` (carrying its
|
|
@@ -628,6 +1341,10 @@ export class ApprovalService implements IApprovalService {
|
|
|
628
1341
|
* Open a pending approval request on behalf of a flow's Approval node. The
|
|
629
1342
|
* node config (approvers / behavior / status field) is snapshotted on the row
|
|
630
1343
|
* so a decision can be made without any process to resolve against.
|
|
1344
|
+
*
|
|
1345
|
+
* #3447 P2: may instead return an {@link ApprovalNodeAutoOutcome} — no
|
|
1346
|
+
* request opened — when the slate resolves empty and the node's
|
|
1347
|
+
* `onEmptyApprovers` policy is `auto_approve`.
|
|
631
1348
|
*/
|
|
632
1349
|
async openNodeRequest(
|
|
633
1350
|
input: {
|
|
@@ -644,9 +1361,15 @@ export class ApprovalService implements IApprovalService {
|
|
|
644
1361
|
submitterId?: string | null;
|
|
645
1362
|
record?: any;
|
|
646
1363
|
organizationId?: string | null;
|
|
1364
|
+
/**
|
|
1365
|
+
* #3447 P2: flow variables at node entry (nested by dotted key, as the
|
|
1366
|
+
* engine's CEL conditions see them) — the `vars.*` root for `expression`
|
|
1367
|
+
* approvers. `input.record` doubles as their `trigger.*` root.
|
|
1368
|
+
*/
|
|
1369
|
+
variables?: Record<string, unknown> | null;
|
|
647
1370
|
},
|
|
648
1371
|
context: SharingExecutionContext,
|
|
649
|
-
): Promise<ApprovalRequestRow> {
|
|
1372
|
+
): Promise<ApprovalRequestRow | ApprovalNodeAutoOutcome> {
|
|
650
1373
|
if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
|
|
651
1374
|
if (!input.recordId) throw new Error('VALIDATION_FAILED: recordId is required');
|
|
652
1375
|
if (!input.runId) throw new Error('VALIDATION_FAILED: runId is required');
|
|
@@ -669,10 +1392,51 @@ export class ApprovalService implements IApprovalService {
|
|
|
669
1392
|
// per_group finalization is decided against the slate resolved at OPEN time
|
|
670
1393
|
// (OOO-substituted), not re-resolved live at each decision.
|
|
671
1394
|
const groups: Record<string, string[]> = {};
|
|
1395
|
+
// #3447: resolve approvers against the record's LIVE state at node entry, not
|
|
1396
|
+
// the trigger snapshot carried in `input.record`. This is the whole fix — an
|
|
1397
|
+
// earlier step may have written the field this node routes on.
|
|
1398
|
+
const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
|
|
1399
|
+
const resolvedFrom: Record<string, unknown> = {};
|
|
672
1400
|
const approvers = await this.expandApprovers(
|
|
673
|
-
{ approvers: input.config.approvers },
|
|
1401
|
+
{ approvers: input.config.approvers }, liveRecord, ctxOrg, {
|
|
1402
|
+
now: nowDate.getTime(), substitutions, groups,
|
|
1403
|
+
exprCtx: { trigger: input.record ?? null, vars: input.variables ?? null },
|
|
1404
|
+
resolvedFrom,
|
|
1405
|
+
},
|
|
674
1406
|
);
|
|
675
1407
|
|
|
1408
|
+
// Empty-slate policy (#3447 P2). "Empty" = no CONCRETE person — an
|
|
1409
|
+
// unstaffed position / empty expression result leaves only `type:value`
|
|
1410
|
+
// literal slots, decidable by nobody.
|
|
1411
|
+
if (!approvers.some(a => a && !a.includes(':'))) {
|
|
1412
|
+
const emptyPolicy = (input.config as any).onEmptyApprovers ?? 'admin_rescue';
|
|
1413
|
+
if (emptyPolicy === 'fail') {
|
|
1414
|
+
throw new Error(
|
|
1415
|
+
`NO_APPROVERS: approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no `
|
|
1416
|
+
+ `concrete approver and its onEmptyApprovers policy is 'fail'. Check that the approver target(s) `
|
|
1417
|
+
+ `are staffed / the routing field or expression yields user ids at node entry.`,
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
if (emptyPolicy === 'auto_approve') {
|
|
1421
|
+
this.logger?.warn?.(
|
|
1422
|
+
`[approvals] approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no `
|
|
1423
|
+
+ `concrete approver — auto-approving per onEmptyApprovers: 'auto_approve' (no request opened).`,
|
|
1424
|
+
{ object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers },
|
|
1425
|
+
);
|
|
1426
|
+
return { autoApproved: true, reason: 'empty_approvers' };
|
|
1427
|
+
}
|
|
1428
|
+
// #3424 admin_rescue (default): the request is still opened (a privileged
|
|
1429
|
+
// admin can override it, and legacy 15.x literal slots stay queryable) —
|
|
1430
|
+
// the only option that neither waves the record through nor kills the
|
|
1431
|
+
// run — but warn loudly so the misconfiguration surfaces instead of
|
|
1432
|
+
// silently locking the record with no obvious cause.
|
|
1433
|
+
this.logger?.warn?.(
|
|
1434
|
+
`[approvals] approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no concrete approver`
|
|
1435
|
+
+ ' — the request is decidable only by a privileged admin. Check that the approver target(s) are staffed.',
|
|
1436
|
+
{ object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers },
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
676
1440
|
const now = nowDate.toISOString();
|
|
677
1441
|
const id = uid('areq');
|
|
678
1442
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
@@ -681,10 +1445,23 @@ export class ApprovalService implements IApprovalService {
|
|
|
681
1445
|
const configSnapshot: any = { ...input.config };
|
|
682
1446
|
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
683
1447
|
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
684
|
-
// Snapshot the resolved approver→group map for
|
|
685
|
-
|
|
1448
|
+
// Snapshot the resolved approver→group map for EVERY multi-approver
|
|
1449
|
+
// behavior (was quorum/per_group only). #3447 P2 makes this load-bearing
|
|
1450
|
+
// for unanimous too: an `expression` approver can only resolve at OPEN
|
|
1451
|
+
// time (decide has no flow variables to evaluate against), so the tally
|
|
1452
|
+
// must read the open-time slate — which also pins unanimous+field to the
|
|
1453
|
+
// slate the approvers actually saw, instead of re-reading a field that may
|
|
1454
|
+
// have changed again since.
|
|
1455
|
+
if (input.config.behavior && input.config.behavior !== 'first_response') {
|
|
686
1456
|
configSnapshot.__approverGroups = groups;
|
|
687
1457
|
}
|
|
1458
|
+
// #3447 P2: snapshot what the dynamic approver sources resolved FROM (the
|
|
1459
|
+
// live routing-field value / the expression's intermediate values) so the
|
|
1460
|
+
// audit trail answers "why these people" — the resolution INPUT, pairing
|
|
1461
|
+
// the resolution RESULT already persisted as `pending_approvers`.
|
|
1462
|
+
if (Object.keys(resolvedFrom).length) {
|
|
1463
|
+
configSnapshot.__resolvedFrom = resolvedFrom;
|
|
1464
|
+
}
|
|
688
1465
|
// ADR-0044 round numbering: rounds of a revise loop share the run — count
|
|
689
1466
|
// this (run, node)'s prior requests; the new one is round N+1. Stamped on
|
|
690
1467
|
// the snapshot (precedent: __flowLabel), so no schema migration.
|
|
@@ -760,7 +1537,17 @@ export class ApprovalService implements IApprovalService {
|
|
|
760
1537
|
// Record lock (when `lockRecord !== false`) is enforced by the beforeUpdate
|
|
761
1538
|
// hook keyed on the now-pending request; no extra write needed here.
|
|
762
1539
|
if (input.config.approvalStatusField) {
|
|
763
|
-
|
|
1540
|
+
// Attributed to whoever the row itself calls the submitter (#3783), so
|
|
1541
|
+
// there is exactly one answer to "who submitted this". Not the
|
|
1542
|
+
// {@link actingUserId} route: `submitterId` is server-supplied here (the
|
|
1543
|
+
// approval node passes the run's own trigger user) and unreachable from a
|
|
1544
|
+
// request body, so it carries none of the caller-controlled risk that rule
|
|
1545
|
+
// exists for — and it already resolves to `context.userId` in every
|
|
1546
|
+
// first-party path.
|
|
1547
|
+
await this.mirrorStatusField(
|
|
1548
|
+
input.object, input.recordId, input.config.approvalStatusField, 'pending',
|
|
1549
|
+
row.submitter_id ?? null,
|
|
1550
|
+
);
|
|
764
1551
|
}
|
|
765
1552
|
|
|
766
1553
|
return rowFromRequest(row);
|
|
@@ -814,11 +1601,11 @@ export class ApprovalService implements IApprovalService {
|
|
|
814
1601
|
*/
|
|
815
1602
|
async decideNode(
|
|
816
1603
|
requestId: string,
|
|
817
|
-
input: { decision: 'approve' | 'reject'; actorId: string; comment?: string; attachments?: string[] },
|
|
1604
|
+
input: { decision: 'approve' | 'reject'; actorId: string; comment?: string; attachments?: string[]; outputs?: Record<string, unknown> },
|
|
818
1605
|
context: SharingExecutionContext,
|
|
819
|
-
): Promise<{ request: ApprovalRequestRow; runId: string | null; nodeId: string | null; finalized: boolean; decision: 'approve' | 'reject' }> {
|
|
1606
|
+
): Promise<{ request: ApprovalRequestRow; runId: string | null; nodeId: string | null; finalized: boolean; decision: 'approve' | 'reject'; outputs?: Record<string, unknown> }> {
|
|
820
1607
|
if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
|
|
821
|
-
|
|
1608
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
822
1609
|
if (input.decision !== 'approve' && input.decision !== 'reject') {
|
|
823
1610
|
throw new Error('VALIDATION_FAILED: decision must be approve|reject');
|
|
824
1611
|
}
|
|
@@ -832,8 +1619,13 @@ export class ApprovalService implements IApprovalService {
|
|
|
832
1619
|
if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
833
1620
|
|
|
834
1621
|
const pendingApprovers = csvSplit(raw.pending_approvers);
|
|
835
|
-
|
|
836
|
-
|
|
1622
|
+
// A privileged admin may override a stuck request (#3424) even when they
|
|
1623
|
+
// hold no slot — the escape hatch for an approval routed to an unstaffed
|
|
1624
|
+
// position or to approvers who have all left.
|
|
1625
|
+
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
1626
|
+
const isSlotHolder = pendingApprovers.includes(actorId);
|
|
1627
|
+
if (!isSlotHolder && !isOverride) {
|
|
1628
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
837
1629
|
}
|
|
838
1630
|
|
|
839
1631
|
const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
|
|
@@ -842,11 +1634,46 @@ export class ApprovalService implements IApprovalService {
|
|
|
842
1634
|
const runId: string | null = raw.flow_run_id ?? null;
|
|
843
1635
|
const now = this.clock.now().toISOString();
|
|
844
1636
|
|
|
1637
|
+
// #3447 P2: decision outputs — validated BEFORE any write (audit included)
|
|
1638
|
+
// so an out-of-contract payload rejects atomically. The trust model is a
|
|
1639
|
+
// `screen` node's: the AUTHOR declares the keys (`config.decisionOutputs`),
|
|
1640
|
+
// the approver only fills values. A decision carrying undeclared keys is a
|
|
1641
|
+
// caller bug; `decision`/`requestId` are reserved by the resume envelope.
|
|
1642
|
+
const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
|
|
1643
|
+
let acceptedOutputs: Record<string, unknown> | undefined;
|
|
1644
|
+
if (outputKeys.length) {
|
|
1645
|
+
// Typed declarations and bare keys whitelist identically — one
|
|
1646
|
+
// normalizer (spec) is the single reader of the union shape.
|
|
1647
|
+
const declared = normalizeDecisionOutputs((config as any).decisionOutputs).map(d => d.key);
|
|
1648
|
+
if (!declared.length) {
|
|
1649
|
+
throw new Error(
|
|
1650
|
+
`VALIDATION_FAILED: this approval node declares no decisionOutputs — outputs are not accepted. `
|
|
1651
|
+
+ `Declare the keys on the node config (decisionOutputs: [${outputKeys.map(k => `'${k}'`).join(', ')}]) `
|
|
1652
|
+
+ `to let approvers hand them to the flow.`,
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
const reserved = outputKeys.filter(k => k === 'decision' || k === 'requestId');
|
|
1656
|
+
if (reserved.length) {
|
|
1657
|
+
throw new Error(
|
|
1658
|
+
`VALIDATION_FAILED: decision output key(s) \`${reserved.join('`, `')}\` are reserved by the resume `
|
|
1659
|
+
+ `envelope — pick different names.`,
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
const undeclared = outputKeys.filter(k => !declared.includes(k));
|
|
1663
|
+
if (undeclared.length) {
|
|
1664
|
+
throw new Error(
|
|
1665
|
+
`VALIDATION_FAILED: decision output key(s) \`${undeclared.join('`, `')}\` are not declared on this `
|
|
1666
|
+
+ `node — declared keys: ${declared.map(k => `'${k}'`).join(', ') || '(none)'}.`,
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
acceptedOutputs = { ...input.outputs };
|
|
1670
|
+
}
|
|
1671
|
+
|
|
845
1672
|
// Audit the decision first so the quorum/per_group tally below sees it.
|
|
846
1673
|
await this.engine.insert('sys_approval_action', {
|
|
847
1674
|
id: uid('aact'), request_id: requestId, organization_id: org,
|
|
848
1675
|
step_name: nodeId, step_index: 0, action: input.decision,
|
|
849
|
-
actor_id:
|
|
1676
|
+
actor_id: actorId, comment: input.comment ?? null,
|
|
850
1677
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
851
1678
|
created_at: now,
|
|
852
1679
|
}, { context: SYSTEM_CTX });
|
|
@@ -855,19 +1682,27 @@ export class ApprovalService implements IApprovalService {
|
|
|
855
1682
|
// finalizes the node (one veto), so only the approve path can hold it open.
|
|
856
1683
|
// `first_response` finalizes on the first approval (falls straight through).
|
|
857
1684
|
const behavior = config.behavior ?? 'first_response';
|
|
858
|
-
|
|
1685
|
+
// A privileged override (an admin rescuing a stuck request, #3424) is an
|
|
1686
|
+
// authoritative decision, not one vote among the resolved slate — it
|
|
1687
|
+
// finalizes the node immediately, regardless of `unanimous`/`quorum`/
|
|
1688
|
+
// `per_group`. Only a real slot holder's approval feeds the multi-approver
|
|
1689
|
+
// tally below.
|
|
1690
|
+
if (input.decision === 'approve' && behavior !== 'first_response' && isSlotHolder) {
|
|
859
1691
|
const acts = await this.engine.find('sys_approval_action', {
|
|
860
1692
|
where: { request_id: requestId, step_index: 0, action: 'approve' }, limit: 1000, context: SYSTEM_CTX,
|
|
861
1693
|
});
|
|
862
1694
|
const approved = new Set<string>((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean));
|
|
863
1695
|
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
// opened before the snapshot existed
|
|
1696
|
+
// Tally against the OPEN-time snapshot (already OOO-substituted) for
|
|
1697
|
+
// every behavior that carries one. Re-resolution survives ONLY as the
|
|
1698
|
+
// back-compat path for requests opened before the snapshot existed —
|
|
1699
|
+
// it cannot ever run for an `expression` approver (#3447 P2: decide has
|
|
1700
|
+
// no flow variables to evaluate against; open time is the only
|
|
1701
|
+
// resolution point), and those always have a snapshot.
|
|
867
1702
|
const snapshotGroups = (config as any).__approverGroups as Record<string, string[]> | undefined;
|
|
868
1703
|
let original: string[];
|
|
869
1704
|
let groupMap: Record<string, string[]>;
|
|
870
|
-
if (snapshotGroups
|
|
1705
|
+
if (snapshotGroups) {
|
|
871
1706
|
groupMap = snapshotGroups;
|
|
872
1707
|
original = Object.keys(snapshotGroups);
|
|
873
1708
|
} else {
|
|
@@ -881,23 +1716,68 @@ export class ApprovalService implements IApprovalService {
|
|
|
881
1716
|
const stillPending = original.filter(a => !approved.has(a));
|
|
882
1717
|
await this.engine.update('sys_approval_request', {
|
|
883
1718
|
id: requestId, pending_approvers: stillPending.join(','), updated_at: now,
|
|
1719
|
+
// #3447 P2: a mid-tally approval may carry outputs too (unanimous /
|
|
1720
|
+
// per_group co-sign, each approver contributing their declared keys)
|
|
1721
|
+
// — accumulate them on the snapshot so the FINALIZING decision hands
|
|
1722
|
+
// the merged set to the flow.
|
|
1723
|
+
...(acceptedOutputs ? {
|
|
1724
|
+
node_config_json: JSON.stringify({
|
|
1725
|
+
...config,
|
|
1726
|
+
__decisionOutputs: { ...((config as any).__decisionOutputs ?? {}), ...acceptedOutputs },
|
|
1727
|
+
}),
|
|
1728
|
+
} : {}),
|
|
884
1729
|
}, { context: SYSTEM_CTX });
|
|
885
1730
|
await this.syncApproverIndex(requestId, stillPending, org, now);
|
|
886
|
-
const fresh = await this.
|
|
1731
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
887
1732
|
return { request: fresh!, runId, nodeId, finalized: false, decision: input.decision };
|
|
888
1733
|
}
|
|
889
1734
|
}
|
|
890
1735
|
|
|
891
1736
|
const finalStatus = input.decision === 'approve' ? 'approved' : 'rejected';
|
|
1737
|
+
// #3447 P2: the full accumulated output set — earlier co-sign votes' plus
|
|
1738
|
+
// this finalizing decision's — resumes the run and stays snapshotted for
|
|
1739
|
+
// the audit trail ("what did the approvers hand the flow").
|
|
1740
|
+
const mergedOutputs: Record<string, unknown> | undefined =
|
|
1741
|
+
acceptedOutputs || (config as any).__decisionOutputs
|
|
1742
|
+
? { ...((config as any).__decisionOutputs ?? {}), ...(acceptedOutputs ?? {}) }
|
|
1743
|
+
: undefined;
|
|
892
1744
|
await this.engine.update('sys_approval_request', {
|
|
893
1745
|
id: requestId, status: finalStatus, pending_approvers: null, completed_at: now, updated_at: now,
|
|
1746
|
+
...(mergedOutputs ? {
|
|
1747
|
+
node_config_json: JSON.stringify({ ...config, __decisionOutputs: mergedOutputs }),
|
|
1748
|
+
} : {}),
|
|
894
1749
|
}, { context: SYSTEM_CTX });
|
|
895
1750
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
896
1751
|
if (config.approvalStatusField) {
|
|
897
|
-
await this.mirrorStatusField(
|
|
1752
|
+
await this.mirrorStatusField(
|
|
1753
|
+
raw.object_name, raw.record_id, config.approvalStatusField, finalStatus,
|
|
1754
|
+
actingUserId(context),
|
|
1755
|
+
);
|
|
898
1756
|
}
|
|
899
|
-
const fresh = await this.
|
|
900
|
-
return { request: fresh!, runId, nodeId, finalized: true, decision: input.decision };
|
|
1757
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
1758
|
+
return { request: fresh!, runId, nodeId, finalized: true, decision: input.decision, outputs: mergedOutputs };
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* Continue the owning flow run after an outcome this service has already
|
|
1763
|
+
* authorized and written down (#3801).
|
|
1764
|
+
*
|
|
1765
|
+
* The `approval` node declares `resumeAuthority: 'service'`, so the engine
|
|
1766
|
+
* refuses any resume of an approval suspension that does not carry
|
|
1767
|
+
* {@link RESUME_AUTHORITY_SERVICE}. Every approvals-side resume goes through
|
|
1768
|
+
* here so the marker is stamped in ONE place — a new outcome path cannot
|
|
1769
|
+
* quietly ship a resume that the gate then rejects at runtime, and nothing
|
|
1770
|
+
* in this file hands the marker to a caller-supplied signal.
|
|
1771
|
+
*
|
|
1772
|
+
* Callers still guard on `typeof this.automation?.resume === 'function'`
|
|
1773
|
+
* (approvals runs fine with no automation attached) and keep their own
|
|
1774
|
+
* try/catch, because what a failed resume means differs per path.
|
|
1775
|
+
*/
|
|
1776
|
+
private async serviceResume(
|
|
1777
|
+
runId: string,
|
|
1778
|
+
signal: { output?: Record<string, unknown>; branchLabel?: string },
|
|
1779
|
+
): Promise<void> {
|
|
1780
|
+
await this.automation!.resume!(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
|
|
901
1781
|
}
|
|
902
1782
|
|
|
903
1783
|
/**
|
|
@@ -918,9 +1798,14 @@ export class ApprovalService implements IApprovalService {
|
|
|
918
1798
|
? APPROVAL_BRANCH_LABELS.approve
|
|
919
1799
|
: APPROVAL_BRANCH_LABELS.reject;
|
|
920
1800
|
try {
|
|
921
|
-
await this.
|
|
1801
|
+
await this.serviceResume(result.runId, {
|
|
922
1802
|
branchLabel,
|
|
923
|
-
|
|
1803
|
+
// #3447 P2: accepted decision outputs ride the resume envelope and
|
|
1804
|
+
// land as `<nodeId>.<key>` flow variables — a later approval node's
|
|
1805
|
+
// `expression` approver reads them as `vars.<nodeId>.<key>`.
|
|
1806
|
+
// Reserved keys are spread LAST so no output can shadow them (the
|
|
1807
|
+
// whitelist already rejects them; this is defense in depth).
|
|
1808
|
+
output: { ...(result.outputs ?? {}), decision: result.decision, requestId },
|
|
924
1809
|
});
|
|
925
1810
|
resumed = true;
|
|
926
1811
|
} catch (err: any) {
|
|
@@ -958,7 +1843,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
958
1843
|
context: SharingExecutionContext,
|
|
959
1844
|
): Promise<ApprovalRecallResult> {
|
|
960
1845
|
if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
|
|
961
|
-
|
|
1846
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
962
1847
|
|
|
963
1848
|
const rawRows = await this.engine.find('sys_approval_request', {
|
|
964
1849
|
where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
|
|
@@ -969,7 +1854,10 @@ export class ApprovalService implements IApprovalService {
|
|
|
969
1854
|
if (raw.status !== 'pending' && !inReviseWindow) {
|
|
970
1855
|
throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
971
1856
|
}
|
|
972
|
-
|
|
1857
|
+
// The submitter withdraws their own request; a privileged admin may recall
|
|
1858
|
+
// any pending request to release a stuck record (#3424).
|
|
1859
|
+
if (!this.isOverrideActor(context, raw.organization_id ?? null)
|
|
1860
|
+
&& raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
973
1861
|
throw new Error(`FORBIDDEN: only the submitter may recall this request`);
|
|
974
1862
|
}
|
|
975
1863
|
// A returned request is only recallable while it is still the run's live
|
|
@@ -985,7 +1873,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
985
1873
|
await this.engine.insert('sys_approval_action', {
|
|
986
1874
|
id: uid('aact'), request_id: requestId, organization_id: org,
|
|
987
1875
|
step_name: nodeId, step_index: 0, action: 'recall',
|
|
988
|
-
actor_id:
|
|
1876
|
+
actor_id: actorId, comment: input.comment ?? null, created_at: now,
|
|
989
1877
|
}, { context: SYSTEM_CTX });
|
|
990
1878
|
|
|
991
1879
|
await this.engine.update('sys_approval_request', {
|
|
@@ -993,7 +1881,10 @@ export class ApprovalService implements IApprovalService {
|
|
|
993
1881
|
}, { context: SYSTEM_CTX });
|
|
994
1882
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
995
1883
|
if (config.approvalStatusField) {
|
|
996
|
-
await this.mirrorStatusField(
|
|
1884
|
+
await this.mirrorStatusField(
|
|
1885
|
+
raw.object_name, raw.record_id, config.approvalStatusField, 'recalled',
|
|
1886
|
+
actingUserId(context),
|
|
1887
|
+
);
|
|
997
1888
|
}
|
|
998
1889
|
|
|
999
1890
|
let resumed = false;
|
|
@@ -1011,7 +1902,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1011
1902
|
}
|
|
1012
1903
|
} else if (runId && typeof this.automation?.resume === 'function') {
|
|
1013
1904
|
try {
|
|
1014
|
-
await this.
|
|
1905
|
+
await this.serviceResume(runId, {
|
|
1015
1906
|
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
1016
1907
|
output: { decision: 'recall', requestId },
|
|
1017
1908
|
});
|
|
@@ -1023,7 +1914,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1023
1914
|
}
|
|
1024
1915
|
}
|
|
1025
1916
|
|
|
1026
|
-
const fresh = await this.
|
|
1917
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
1027
1918
|
return { request: fresh!, runId, resumed };
|
|
1028
1919
|
}
|
|
1029
1920
|
|
|
@@ -1047,11 +1938,11 @@ export class ApprovalService implements IApprovalService {
|
|
|
1047
1938
|
input: ApprovalSendBackInput,
|
|
1048
1939
|
context: SharingExecutionContext,
|
|
1049
1940
|
): Promise<ApprovalSendBackResult> {
|
|
1050
|
-
|
|
1941
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
1051
1942
|
const raw = await this.loadPendingRow(requestId);
|
|
1052
1943
|
const pending = csvSplit(raw.pending_approvers);
|
|
1053
|
-
if (!context.isSystem && !pending.includes(
|
|
1054
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
1944
|
+
if (!context.isSystem && !pending.includes(actorId)) {
|
|
1945
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
1055
1946
|
}
|
|
1056
1947
|
|
|
1057
1948
|
const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
|
|
@@ -1077,7 +1968,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1077
1968
|
await this.engine.insert('sys_approval_action', {
|
|
1078
1969
|
id: uid('aact'), request_id: requestId, organization_id: org,
|
|
1079
1970
|
step_name: nodeId, step_index: 0, action: 'revise',
|
|
1080
|
-
actor_id:
|
|
1971
|
+
actor_id: actorId, comment: input.comment ?? null, created_at: now,
|
|
1081
1972
|
}, { context: SYSTEM_CTX });
|
|
1082
1973
|
|
|
1083
1974
|
if (priorSendBacks >= maxRevisions) {
|
|
@@ -1085,7 +1976,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1085
1976
|
await this.engine.insert('sys_approval_action', {
|
|
1086
1977
|
id: uid('aact'), request_id: requestId, organization_id: org,
|
|
1087
1978
|
step_name: nodeId, step_index: 0, action: 'reject',
|
|
1088
|
-
actor_id:
|
|
1979
|
+
actor_id: actorId,
|
|
1089
1980
|
comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`, created_at: now,
|
|
1090
1981
|
}, { context: SYSTEM_CTX });
|
|
1091
1982
|
await this.engine.update('sys_approval_request', {
|
|
@@ -1093,12 +1984,15 @@ export class ApprovalService implements IApprovalService {
|
|
|
1093
1984
|
}, { context: SYSTEM_CTX });
|
|
1094
1985
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
1095
1986
|
if (config.approvalStatusField) {
|
|
1096
|
-
await this.mirrorStatusField(
|
|
1987
|
+
await this.mirrorStatusField(
|
|
1988
|
+
raw.object_name, raw.record_id, config.approvalStatusField, 'rejected',
|
|
1989
|
+
actingUserId(context),
|
|
1990
|
+
);
|
|
1097
1991
|
}
|
|
1098
1992
|
let resumed = false;
|
|
1099
1993
|
if (runId && typeof this.automation?.resume === 'function') {
|
|
1100
1994
|
try {
|
|
1101
|
-
await this.
|
|
1995
|
+
await this.serviceResume(runId, {
|
|
1102
1996
|
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
1103
1997
|
output: { decision: 'reject', autoRejected: true, requestId },
|
|
1104
1998
|
});
|
|
@@ -1113,7 +2007,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1113
2007
|
await this.notify({
|
|
1114
2008
|
topic: 'approval.returned',
|
|
1115
2009
|
audience: [String(raw.submitter_id)],
|
|
1116
|
-
actorId:
|
|
2010
|
+
actorId: actorId,
|
|
1117
2011
|
source: { object: 'sys_approval_request', id: requestId },
|
|
1118
2012
|
payload: {
|
|
1119
2013
|
title: 'Approval auto-rejected',
|
|
@@ -1122,7 +2016,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1122
2016
|
},
|
|
1123
2017
|
});
|
|
1124
2018
|
}
|
|
1125
|
-
const fresh = await this.
|
|
2019
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
1126
2020
|
return { request: fresh!, runId, resumed, autoRejected: true };
|
|
1127
2021
|
}
|
|
1128
2022
|
|
|
@@ -1131,13 +2025,16 @@ export class ApprovalService implements IApprovalService {
|
|
|
1131
2025
|
}, { context: SYSTEM_CTX });
|
|
1132
2026
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
1133
2027
|
if (config.approvalStatusField) {
|
|
1134
|
-
await this.mirrorStatusField(
|
|
2028
|
+
await this.mirrorStatusField(
|
|
2029
|
+
raw.object_name, raw.record_id, config.approvalStatusField, 'returned',
|
|
2030
|
+
actingUserId(context),
|
|
2031
|
+
);
|
|
1135
2032
|
}
|
|
1136
2033
|
|
|
1137
2034
|
let resumed = false;
|
|
1138
2035
|
if (runId && typeof this.automation?.resume === 'function') {
|
|
1139
2036
|
try {
|
|
1140
|
-
await this.
|
|
2037
|
+
await this.serviceResume(runId, {
|
|
1141
2038
|
branchLabel: APPROVAL_BRANCH_LABELS.revise,
|
|
1142
2039
|
output: { decision: 'revise', requestId },
|
|
1143
2040
|
});
|
|
@@ -1153,7 +2050,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1153
2050
|
await this.notify({
|
|
1154
2051
|
topic: 'approval.returned',
|
|
1155
2052
|
audience: [String(raw.submitter_id)],
|
|
1156
|
-
actorId:
|
|
2053
|
+
actorId: actorId,
|
|
1157
2054
|
source: { object: 'sys_approval_request', id: requestId },
|
|
1158
2055
|
payload: {
|
|
1159
2056
|
title: 'Sent back for revision',
|
|
@@ -1163,7 +2060,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1163
2060
|
});
|
|
1164
2061
|
}
|
|
1165
2062
|
|
|
1166
|
-
const fresh = await this.
|
|
2063
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
1167
2064
|
return { request: fresh!, runId, resumed };
|
|
1168
2065
|
}
|
|
1169
2066
|
|
|
@@ -1179,7 +2076,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1179
2076
|
input: ApprovalResubmitInput,
|
|
1180
2077
|
context: SharingExecutionContext,
|
|
1181
2078
|
): Promise<ApprovalResubmitResult> {
|
|
1182
|
-
|
|
2079
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
1183
2080
|
const rawRows = await this.engine.find('sys_approval_request', {
|
|
1184
2081
|
where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
|
|
1185
2082
|
});
|
|
@@ -1188,7 +2085,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1188
2085
|
if (raw.status !== 'returned') {
|
|
1189
2086
|
throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
|
|
1190
2087
|
}
|
|
1191
|
-
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(
|
|
2088
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
1192
2089
|
throw new Error('FORBIDDEN: only the submitter may resubmit');
|
|
1193
2090
|
}
|
|
1194
2091
|
await this.assertLatestForRun(raw);
|
|
@@ -1216,7 +2113,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1216
2113
|
await this.engine.insert('sys_approval_action', {
|
|
1217
2114
|
id: uid('aact'), request_id: requestId, organization_id: org,
|
|
1218
2115
|
step_name: nodeId, step_index: 0, action: 'resubmit',
|
|
1219
|
-
actor_id:
|
|
2116
|
+
actor_id: actorId, comment: input.comment ?? null, created_at: now,
|
|
1220
2117
|
}, { context: SYSTEM_CTX });
|
|
1221
2118
|
|
|
1222
2119
|
// The next round only exists if this resume lands — surface `resumed`
|
|
@@ -1224,7 +2121,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1224
2121
|
let resumed = false;
|
|
1225
2122
|
if (runId && typeof this.automation?.resume === 'function') {
|
|
1226
2123
|
try {
|
|
1227
|
-
await this.
|
|
2124
|
+
await this.serviceResume(runId, {
|
|
1228
2125
|
branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
|
|
1229
2126
|
output: { resubmitted: true, requestId },
|
|
1230
2127
|
});
|
|
@@ -1236,7 +2133,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1236
2133
|
}
|
|
1237
2134
|
}
|
|
1238
2135
|
|
|
1239
|
-
const fresh = await this.
|
|
2136
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
1240
2137
|
return { request: fresh!, runId, resumed };
|
|
1241
2138
|
}
|
|
1242
2139
|
|
|
@@ -1288,38 +2185,50 @@ export class ApprovalService implements IApprovalService {
|
|
|
1288
2185
|
/**
|
|
1289
2186
|
* Hand a pending-approver slot to someone else. `from` defaults to the
|
|
1290
2187
|
* actor itself; the actor must hold the slot being handed over (or be a
|
|
1291
|
-
* system caller).
|
|
2188
|
+
* system caller). A privileged admin (#3424) may reassign a request whose
|
|
2189
|
+
* slate holds no real user — an unstaffed-position literal — by handing the
|
|
2190
|
+
* whole request to a real approver, rescuing it from the locked dead-end.
|
|
2191
|
+
* Audits `reassign` and notifies the new approver.
|
|
1292
2192
|
*/
|
|
1293
2193
|
async reassign(
|
|
1294
2194
|
requestId: string,
|
|
1295
2195
|
input: { actorId: string; to: string; from?: string; comment?: string },
|
|
1296
2196
|
context: SharingExecutionContext,
|
|
1297
2197
|
): Promise<{ request: ApprovalRequestRow }> {
|
|
1298
|
-
|
|
2198
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
1299
2199
|
const to = String(input?.to ?? '').trim();
|
|
1300
2200
|
if (!to) throw new Error('VALIDATION_FAILED: `to` (new approver) is required');
|
|
1301
2201
|
const raw = await this.loadPendingRow(requestId);
|
|
1302
2202
|
|
|
1303
2203
|
const pending = csvSplit(raw.pending_approvers);
|
|
1304
|
-
const from = String(input.from ?? input.actorId).trim();
|
|
1305
|
-
if (!pending.includes(from)) {
|
|
1306
|
-
throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
|
|
1307
|
-
}
|
|
1308
|
-
if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
|
|
1309
|
-
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
1310
|
-
}
|
|
1311
2204
|
if (pending.includes(to)) {
|
|
1312
2205
|
throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
|
|
1313
2206
|
}
|
|
1314
|
-
|
|
1315
|
-
const
|
|
2207
|
+
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
2208
|
+
const from = String(input.from ?? actorId).trim();
|
|
2209
|
+
let next: string[];
|
|
2210
|
+
if (pending.includes(from)) {
|
|
2211
|
+
// Normal hand-off: the actor holds the slot being moved (or is a
|
|
2212
|
+
// system/admin caller acting on a real holder's slot).
|
|
2213
|
+
if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
|
|
2214
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2215
|
+
}
|
|
2216
|
+
next = pending.map(a => (a === from ? to : a));
|
|
2217
|
+
} else if (isOverride) {
|
|
2218
|
+
// Admin rescue (#3424): the caller holds no slot — the slate is an
|
|
2219
|
+
// unstaffed-position literal or a set of departed approvers. Reassign the
|
|
2220
|
+
// whole request to a real approver so the normal decision flow can resume.
|
|
2221
|
+
next = [to];
|
|
2222
|
+
} else {
|
|
2223
|
+
throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
|
|
2224
|
+
}
|
|
1316
2225
|
const now = this.clock.now().toISOString();
|
|
1317
2226
|
// Audit first, then mutate — mirrors decideNode(), so a failed audit
|
|
1318
2227
|
// write can never leave a moved slot without a trail.
|
|
1319
2228
|
await this.engine.insert('sys_approval_action', {
|
|
1320
2229
|
id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
|
|
1321
2230
|
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'reassign',
|
|
1322
|
-
actor_id:
|
|
2231
|
+
actor_id: actorId, comment: input.comment ?? `${from} → ${to}`, created_at: now,
|
|
1323
2232
|
}, { context: SYSTEM_CTX });
|
|
1324
2233
|
// per_group / quorum (#3266): carry the delegated slot's group membership to
|
|
1325
2234
|
// the new approver in the snapshot, so their approval still counts for the
|
|
@@ -1342,7 +2251,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1342
2251
|
await this.notify({
|
|
1343
2252
|
topic: 'approval.reassigned',
|
|
1344
2253
|
audience: [to],
|
|
1345
|
-
actorId:
|
|
2254
|
+
actorId: actorId,
|
|
1346
2255
|
source: { object: 'sys_approval_request', id: requestId },
|
|
1347
2256
|
dedupKey: `approval-reassign-${requestId}-${to}`,
|
|
1348
2257
|
payload: {
|
|
@@ -1352,7 +2261,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1352
2261
|
},
|
|
1353
2262
|
});
|
|
1354
2263
|
|
|
1355
|
-
const fresh = await this.
|
|
2264
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
1356
2265
|
return { request: fresh! };
|
|
1357
2266
|
}
|
|
1358
2267
|
|
|
@@ -1365,9 +2274,9 @@ export class ApprovalService implements IApprovalService {
|
|
|
1365
2274
|
input: { actorId: string; comment?: string },
|
|
1366
2275
|
context: SharingExecutionContext,
|
|
1367
2276
|
): Promise<{ request: ApprovalRequestRow; notified: number }> {
|
|
1368
|
-
|
|
2277
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
1369
2278
|
const raw = await this.loadPendingRow(requestId);
|
|
1370
|
-
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(
|
|
2279
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
1371
2280
|
throw new Error('FORBIDDEN: only the submitter may send reminders');
|
|
1372
2281
|
}
|
|
1373
2282
|
|
|
@@ -1386,7 +2295,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1386
2295
|
await this.engine.insert('sys_approval_action', {
|
|
1387
2296
|
id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
|
|
1388
2297
|
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'remind',
|
|
1389
|
-
actor_id:
|
|
2298
|
+
actor_id: actorId, comment: input.comment ?? null, created_at: nowIso,
|
|
1390
2299
|
}, { context: SYSTEM_CTX });
|
|
1391
2300
|
|
|
1392
2301
|
// Per-approver fan-out: concrete identities (user ids / emails) each get
|
|
@@ -1401,7 +2310,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1401
2310
|
notified += await this.notify({
|
|
1402
2311
|
topic: 'approval.reminder',
|
|
1403
2312
|
audience: [approver],
|
|
1404
|
-
actorId:
|
|
2313
|
+
actorId: actorId,
|
|
1405
2314
|
source: { object: 'sys_approval_request', id: requestId },
|
|
1406
2315
|
dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
|
|
1407
2316
|
payload: {
|
|
@@ -1424,7 +2333,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1424
2333
|
notified += await this.notify({
|
|
1425
2334
|
topic: 'approval.reminder',
|
|
1426
2335
|
audience: literals,
|
|
1427
|
-
actorId:
|
|
2336
|
+
actorId: actorId,
|
|
1428
2337
|
source: { object: 'sys_approval_request', id: requestId },
|
|
1429
2338
|
dedupKey: `approval-remind-${requestId}-${nowIso}`,
|
|
1430
2339
|
payload: {
|
|
@@ -1435,7 +2344,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1435
2344
|
});
|
|
1436
2345
|
}
|
|
1437
2346
|
|
|
1438
|
-
const fresh = await this.
|
|
2347
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
1439
2348
|
return { request: fresh!, notified };
|
|
1440
2349
|
}
|
|
1441
2350
|
|
|
@@ -1532,7 +2441,14 @@ export class ApprovalService implements IApprovalService {
|
|
|
1532
2441
|
decision: res.token.action,
|
|
1533
2442
|
actorId: res.token.approver_id,
|
|
1534
2443
|
comment: 'Via action link',
|
|
1535
|
-
|
|
2444
|
+
// The token IS the authentication (#3783): it is single-use, hashed at
|
|
2445
|
+
// rest and bound to one approver, who `resolveActionToken` has just
|
|
2446
|
+
// re-checked still holds a pending slot. So this decision has a real
|
|
2447
|
+
// acting user even though no session carried it — name them on the
|
|
2448
|
+
// context, so the status mirror and every flow it cascades into are
|
|
2449
|
+
// attributed exactly like a decision made through the UI. Elevation is
|
|
2450
|
+
// unchanged: `isSystem` still stands in for the missing session.
|
|
2451
|
+
}, { ...SYSTEM_CTX, userId: res.token.approver_id } as unknown as SharingExecutionContext);
|
|
1536
2452
|
return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
|
|
1537
2453
|
}
|
|
1538
2454
|
|
|
@@ -1545,26 +2461,26 @@ export class ApprovalService implements IApprovalService {
|
|
|
1545
2461
|
input: { actorId: string; comment: string },
|
|
1546
2462
|
context: SharingExecutionContext,
|
|
1547
2463
|
): Promise<{ request: ApprovalRequestRow }> {
|
|
1548
|
-
|
|
2464
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
1549
2465
|
if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required');
|
|
1550
2466
|
const raw = await this.loadPendingRow(requestId);
|
|
1551
2467
|
const pending = csvSplit(raw.pending_approvers);
|
|
1552
|
-
if (!context.isSystem && !pending.includes(
|
|
1553
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
2468
|
+
if (!context.isSystem && !pending.includes(actorId)) {
|
|
2469
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
1554
2470
|
}
|
|
1555
2471
|
|
|
1556
2472
|
const now = this.clock.now().toISOString();
|
|
1557
2473
|
await this.engine.insert('sys_approval_action', {
|
|
1558
2474
|
id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
|
|
1559
2475
|
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'request_info',
|
|
1560
|
-
actor_id:
|
|
2476
|
+
actor_id: actorId, comment: input.comment.trim(), created_at: now,
|
|
1561
2477
|
}, { context: SYSTEM_CTX });
|
|
1562
2478
|
|
|
1563
2479
|
if (raw.submitter_id) {
|
|
1564
2480
|
await this.notify({
|
|
1565
2481
|
topic: 'approval.request_info',
|
|
1566
2482
|
audience: [String(raw.submitter_id)],
|
|
1567
|
-
actorId:
|
|
2483
|
+
actorId: actorId,
|
|
1568
2484
|
source: { object: 'sys_approval_request', id: requestId },
|
|
1569
2485
|
payload: {
|
|
1570
2486
|
title: 'More information requested',
|
|
@@ -1574,7 +2490,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1574
2490
|
});
|
|
1575
2491
|
}
|
|
1576
2492
|
|
|
1577
|
-
const fresh = await this.
|
|
2493
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
1578
2494
|
return { request: fresh! };
|
|
1579
2495
|
}
|
|
1580
2496
|
|
|
@@ -1584,20 +2500,20 @@ export class ApprovalService implements IApprovalService {
|
|
|
1584
2500
|
input: { actorId: string; comment: string; attachments?: string[] },
|
|
1585
2501
|
context: SharingExecutionContext,
|
|
1586
2502
|
): Promise<{ request: ApprovalRequestRow }> {
|
|
1587
|
-
|
|
2503
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
1588
2504
|
if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required');
|
|
1589
2505
|
const raw = await this.loadPendingRow(requestId);
|
|
1590
2506
|
const pending = csvSplit(raw.pending_approvers);
|
|
1591
|
-
const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(
|
|
1592
|
-
if (!context.isSystem && !isSubmitter && !pending.includes(
|
|
1593
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
2507
|
+
const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(actorId);
|
|
2508
|
+
if (!context.isSystem && !isSubmitter && !pending.includes(actorId)) {
|
|
2509
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not on this request`);
|
|
1594
2510
|
}
|
|
1595
2511
|
|
|
1596
2512
|
const now = this.clock.now().toISOString();
|
|
1597
2513
|
await this.engine.insert('sys_approval_action', {
|
|
1598
2514
|
id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
|
|
1599
2515
|
step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'comment',
|
|
1600
|
-
actor_id:
|
|
2516
|
+
actor_id: actorId, comment: input.comment.trim(),
|
|
1601
2517
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
1602
2518
|
created_at: now,
|
|
1603
2519
|
}, { context: SYSTEM_CTX });
|
|
@@ -1607,7 +2523,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1607
2523
|
await this.notify({
|
|
1608
2524
|
topic: 'approval.comment',
|
|
1609
2525
|
audience,
|
|
1610
|
-
actorId:
|
|
2526
|
+
actorId: actorId,
|
|
1611
2527
|
source: { object: 'sys_approval_request', id: requestId },
|
|
1612
2528
|
payload: {
|
|
1613
2529
|
title: 'New comment on an approval',
|
|
@@ -1616,7 +2532,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
1616
2532
|
},
|
|
1617
2533
|
});
|
|
1618
2534
|
|
|
1619
|
-
const fresh = await this.
|
|
2535
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
1620
2536
|
return { request: fresh! };
|
|
1621
2537
|
}
|
|
1622
2538
|
|
|
@@ -1671,6 +2587,124 @@ export class ApprovalService implements IApprovalService {
|
|
|
1671
2587
|
return { scanned: rows.length, escalated };
|
|
1672
2588
|
}
|
|
1673
2589
|
|
|
2590
|
+
// ── Dead-run release (#3456) ──────────────────────────────────
|
|
2591
|
+
|
|
2592
|
+
/**
|
|
2593
|
+
* One dead-run sweep: a pending request whose owning flow run has reached a
|
|
2594
|
+
* TERMINAL state can never be decided — nothing is left to resume — so the
|
|
2595
|
+
* request is finalised as `recalled` and, with `lockRecord`, the record it was
|
|
2596
|
+
* holding is released.
|
|
2597
|
+
*
|
|
2598
|
+
* This is the recovery half of #3456. The prevention half is the record lock's
|
|
2599
|
+
* owning-run exemption (`lifecycle-hooks.ts`), which stops a run from killing
|
|
2600
|
+
* itself on its own lock in the first place; this sweep cleans up the runs that
|
|
2601
|
+
* still die — for any reason, including a process crash, which no in-band
|
|
2602
|
+
* handler can catch because the process that would have run it is gone.
|
|
2603
|
+
*
|
|
2604
|
+
* **Fail-safe by construction.** It acts only on an explicit terminal status
|
|
2605
|
+
* from a closed set. Every other answer — `paused` (the normal state of a run
|
|
2606
|
+
* waiting on its approval), `running`, an unrecognised status, `null` (unknown
|
|
2607
|
+
* run, evicted log, no durable store), a `getRun` that throws, or no automation
|
|
2608
|
+
* engine at all — is read as "still alive" and left strictly alone. The failure
|
|
2609
|
+
* mode is therefore "a dead run's lock survives until an admin recalls it"
|
|
2610
|
+
* (today's behaviour, #3424), never "a live approval is destroyed".
|
|
2611
|
+
*
|
|
2612
|
+
* `recalled` is the finalisation because it is the platform's existing terminal
|
|
2613
|
+
* state for *a live request that ended without a decision*; the audit row names
|
|
2614
|
+
* the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
|
|
2615
|
+
* release is never mistaken for a submitter's withdrawal.
|
|
2616
|
+
*/
|
|
2617
|
+
async releaseDeadRunRequests(): Promise<{ scanned: number; released: number }> {
|
|
2618
|
+
// No liveness oracle → no basis to declare anything dead.
|
|
2619
|
+
if (typeof this.automation?.getRun !== 'function') return { scanned: 0, released: 0 };
|
|
2620
|
+
|
|
2621
|
+
let rows: any[] = [];
|
|
2622
|
+
try {
|
|
2623
|
+
rows = await this.engine.find('sys_approval_request', {
|
|
2624
|
+
where: { status: 'pending' }, limit: 500, context: SYSTEM_CTX,
|
|
2625
|
+
}) ?? [];
|
|
2626
|
+
} catch (err: any) {
|
|
2627
|
+
this.logger?.warn?.('[approvals] dead-run sweep failed to list requests', {
|
|
2628
|
+
error: err?.message ?? String(err),
|
|
2629
|
+
});
|
|
2630
|
+
return { scanned: 0, released: 0 };
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
let released = 0;
|
|
2634
|
+
for (const raw of rows) {
|
|
2635
|
+
try {
|
|
2636
|
+
const runId = raw?.flow_run_id ? String(raw.flow_run_id) : '';
|
|
2637
|
+
if (!runId) continue; // not node-driven — no run owns it, nothing to check
|
|
2638
|
+
|
|
2639
|
+
let status: string | undefined;
|
|
2640
|
+
try {
|
|
2641
|
+
const run = await this.automation.getRun!(runId);
|
|
2642
|
+
status = typeof run?.status === 'string' ? run.status : undefined;
|
|
2643
|
+
} catch (err: any) {
|
|
2644
|
+
// Unknown liveness is NOT death — leave the request pending.
|
|
2645
|
+
this.logger?.warn?.('[approvals] dead-run sweep could not read run status', {
|
|
2646
|
+
request: raw?.id, run: runId, error: err?.message ?? String(err),
|
|
2647
|
+
});
|
|
2648
|
+
continue;
|
|
2649
|
+
}
|
|
2650
|
+
if (!status || !TERMINAL_RUN_STATUSES.has(status)) continue;
|
|
2651
|
+
|
|
2652
|
+
await this.abandonForDeadRun(raw, runId, status);
|
|
2653
|
+
released++;
|
|
2654
|
+
} catch (err: any) {
|
|
2655
|
+
// One bad row never stops the sweep (mirrors runEscalations).
|
|
2656
|
+
this.logger?.warn?.('[approvals] dead-run release failed for request', {
|
|
2657
|
+
request: raw?.id, error: err?.message ?? String(err),
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
if (released > 0) {
|
|
2662
|
+
this.logger?.info?.('[approvals] dead-run sweep', { scanned: rows.length, released });
|
|
2663
|
+
}
|
|
2664
|
+
return { scanned: rows.length, released };
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
/**
|
|
2668
|
+
* Finalise one pending request whose owning run is terminal. Mirrors the
|
|
2669
|
+
* shape of {@link recall} — audit row first (so a crash mid-release leaves a
|
|
2670
|
+
* trace of the intent), then the status transition, approver-index sync and
|
|
2671
|
+
* the optional status-field mirror. No resume/cancel of the run: it is already
|
|
2672
|
+
* terminal, which is precisely why we are here.
|
|
2673
|
+
*/
|
|
2674
|
+
private async abandonForDeadRun(raw: any, runId: string, runStatus: string): Promise<void> {
|
|
2675
|
+
const org = raw.organization_id ?? null;
|
|
2676
|
+
const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
|
|
2677
|
+
const now = this.clock.now().toISOString();
|
|
2678
|
+
|
|
2679
|
+
await this.engine.insert('sys_approval_action', {
|
|
2680
|
+
id: uid('aact'), request_id: raw.id, organization_id: org,
|
|
2681
|
+
step_name: nodeId, step_index: 0, action: 'recall',
|
|
2682
|
+
actor_id: DEAD_RUN_ACTOR_ID,
|
|
2683
|
+
comment: `owning flow run ${runId} is ${runStatus} — request abandoned and record lock released`,
|
|
2684
|
+
created_at: now,
|
|
2685
|
+
}, { context: SYSTEM_CTX });
|
|
2686
|
+
|
|
2687
|
+
await this.engine.update('sys_approval_request', {
|
|
2688
|
+
id: raw.id, status: 'recalled', pending_approvers: null, completed_at: now, updated_at: now,
|
|
2689
|
+
}, { context: SYSTEM_CTX });
|
|
2690
|
+
await this.syncApproverIndex(raw.id, [], org, now);
|
|
2691
|
+
|
|
2692
|
+
const config = parseJson<ApprovalNodeConfig>(
|
|
2693
|
+
raw.node_config_json, { approvers: [], behavior: 'first_response' } as any,
|
|
2694
|
+
);
|
|
2695
|
+
if (config.approvalStatusField) {
|
|
2696
|
+
// No human did this — a sweep did. Left user-less on purpose (#3783): a
|
|
2697
|
+
// flow that wants to react to a dead-run release declares runAs:'system'.
|
|
2698
|
+
await this.mirrorStatusField(
|
|
2699
|
+
raw.object_name, raw.record_id, config.approvalStatusField, 'recalled', null,
|
|
2700
|
+
);
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
this.logger?.warn?.('[approvals] released a record held by a dead approval run', {
|
|
2704
|
+
request: raw.id, run: runId, runStatus, object: raw.object_name, record: raw.record_id,
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
|
|
1674
2708
|
/** Execute the configured escalation action for one overdue request. */
|
|
1675
2709
|
private async escalateRequest(raw: any, esc: any): Promise<void> {
|
|
1676
2710
|
const action: string = esc.action ?? 'notify';
|
|
@@ -1832,11 +2866,31 @@ export class ApprovalService implements IApprovalService {
|
|
|
1832
2866
|
} catch { return []; }
|
|
1833
2867
|
}
|
|
1834
2868
|
|
|
2869
|
+
/**
|
|
2870
|
+
* Field key → display label for an object's schema. Lets the inbox summary
|
|
2871
|
+
* show a human field name ("考核状态") instead of a title-cased machine key
|
|
2872
|
+
* ("Assessment Status"). For a single-locale project the schema label already
|
|
2873
|
+
* IS the localized string; symmetric with `resolveDisplayField`/lookup
|
|
2874
|
+
* resolution that power `payload_display`.
|
|
2875
|
+
*/
|
|
2876
|
+
private resolveFieldLabels(object: string): Record<string, string> {
|
|
2877
|
+
try {
|
|
2878
|
+
const schema: any = (this.engine as any).getSchema?.(object);
|
|
2879
|
+
const fields = schema?.fields ?? {};
|
|
2880
|
+
const out: Record<string, string> = {};
|
|
2881
|
+
for (const [key, f] of Object.entries<any>(fields)) {
|
|
2882
|
+
if (f?.label) out[key] = String(f.label);
|
|
2883
|
+
}
|
|
2884
|
+
return out;
|
|
2885
|
+
} catch { return {}; }
|
|
2886
|
+
}
|
|
2887
|
+
|
|
1835
2888
|
/**
|
|
1836
2889
|
* Attach inbox display fields to rows so clients never render a raw
|
|
1837
2890
|
* identifier: `record_title`, `submitter_name`, `object_label`,
|
|
1838
|
-
* `pending_approver_names` (user-id approvers),
|
|
1839
|
-
* (lookup foreign keys in the snapshot → referenced record titles)
|
|
2891
|
+
* `pending_approver_names` (user-id approvers), `payload_display`
|
|
2892
|
+
* (lookup foreign keys in the snapshot → referenced record titles), and
|
|
2893
|
+
* `payload_labels` (snapshot field keys → the target object's field labels).
|
|
1840
2894
|
* Batched: one query per distinct object (target + referenced) plus one
|
|
1841
2895
|
* `sys_user` lookup. Best-effort — a deleted record falls back to the
|
|
1842
2896
|
* payload snapshot, and any failure leaves the field unset rather than
|
|
@@ -1875,9 +2929,13 @@ export class ApprovalService implements IApprovalService {
|
|
|
1875
2929
|
|
|
1876
2930
|
// Lookup foreign keys inside payload snapshots → referenced record titles.
|
|
1877
2931
|
const lookupFieldsByObject = new Map<string, Array<{ key: string; reference: string }>>();
|
|
2932
|
+
// Field key → label per object, for the snapshot summary's field names.
|
|
2933
|
+
const fieldLabelsByObject = new Map<string, Record<string, string>>();
|
|
1878
2934
|
for (const object of byObject.keys()) {
|
|
1879
2935
|
const lookups = this.resolveLookupFields(object);
|
|
1880
2936
|
if (lookups.length) lookupFieldsByObject.set(object, lookups);
|
|
2937
|
+
const labels = this.resolveFieldLabels(object);
|
|
2938
|
+
if (Object.keys(labels).length) fieldLabelsByObject.set(object, labels);
|
|
1881
2939
|
}
|
|
1882
2940
|
const refIds = new Map<string, Set<string>>();
|
|
1883
2941
|
for (const r of rows) {
|
|
@@ -1945,6 +3003,18 @@ export class ApprovalService implements IApprovalService {
|
|
|
1945
3003
|
}
|
|
1946
3004
|
if (Object.keys(display).length) r.payload_display = display;
|
|
1947
3005
|
}
|
|
3006
|
+
|
|
3007
|
+
// Field labels for the snapshot keys the summary renders (only keys
|
|
3008
|
+
// actually present in the payload — a deleted field's label is noise).
|
|
3009
|
+
const fieldLabels = fieldLabelsByObject.get(r.object_name);
|
|
3010
|
+
if (fieldLabels && r.payload && typeof r.payload === 'object') {
|
|
3011
|
+
const labels: Record<string, string> = {};
|
|
3012
|
+
for (const key of Object.keys(r.payload as Record<string, unknown>)) {
|
|
3013
|
+
const l = fieldLabels[key];
|
|
3014
|
+
if (l) labels[key] = l;
|
|
3015
|
+
}
|
|
3016
|
+
if (Object.keys(labels).length) r.payload_labels = labels;
|
|
3017
|
+
}
|
|
1948
3018
|
}
|
|
1949
3019
|
}
|
|
1950
3020
|
|
|
@@ -2131,6 +3201,98 @@ export class ApprovalService implements IApprovalService {
|
|
|
2131
3201
|
return [...new Set<string>(list.map(r => String(r.request_id)))];
|
|
2132
3202
|
}
|
|
2133
3203
|
|
|
3204
|
+
/**
|
|
3205
|
+
* The request ids this caller is a PARTICIPANT of, or `null` for a caller
|
|
3206
|
+
* who may see everything in scope (#3590).
|
|
3207
|
+
*
|
|
3208
|
+
* These reads deliberately run with `SYSTEM_CTX` to bypass RLS — the
|
|
3209
|
+
* approver-visibility rule spans several identity forms that RLS cannot model
|
|
3210
|
+
* cleanly, which is why it has to be expressed here. Until now only the
|
|
3211
|
+
* TENANT half of that rule was applied, so any authenticated user could read
|
|
3212
|
+
* any request in their tenant (and, once attachments derived their access
|
|
3213
|
+
* from the request, its files too). This adds the participant half.
|
|
3214
|
+
*
|
|
3215
|
+
* A participant is the submitter, a current approver, or someone who has
|
|
3216
|
+
* already acted on the request (a past approver whose slot has moved on, a
|
|
3217
|
+
* commenter). Admins with override authority keep the unrestricted view the
|
|
3218
|
+
* "all requests" console surface depends on.
|
|
3219
|
+
*
|
|
3220
|
+
* Keying on the concrete user id is sufficient rather than an approximation:
|
|
3221
|
+
* position/team/manager/field approvers are resolved to concrete user ids at
|
|
3222
|
+
* open time, and the `type:value` literal is only the fallback for a spec
|
|
3223
|
+
* that resolved to NOBODY — a slot no one can act on either way (`can_act`
|
|
3224
|
+
* is a plain membership test over the resolved ids). So this cannot hide a
|
|
3225
|
+
* request from someone who could actually act on it.
|
|
3226
|
+
*/
|
|
3227
|
+
private async visibleRequestIds(
|
|
3228
|
+
context: SharingExecutionContext,
|
|
3229
|
+
tenantOrg: string | null,
|
|
3230
|
+
): Promise<Set<string> | null> {
|
|
3231
|
+
if (this.isOverrideActor(context, tenantOrg)) return null;
|
|
3232
|
+
const uid = (context as any)?.userId != null ? String((context as any).userId) : '';
|
|
3233
|
+
// A tokenless/anonymous caller participates in nothing. Fail closed.
|
|
3234
|
+
if (!uid) return new Set<string>();
|
|
3235
|
+
|
|
3236
|
+
const ids = new Set<string>();
|
|
3237
|
+
const cap = ApprovalService.APPROVER_INDEX_CAP;
|
|
3238
|
+
const add = (rows: unknown, key: string) => {
|
|
3239
|
+
const list: any[] = Array.isArray(rows) ? rows : [];
|
|
3240
|
+
for (const r of list) if (r?.[key] != null) ids.add(String(r[key]));
|
|
3241
|
+
if (list.length >= cap) {
|
|
3242
|
+
this.logger?.warn?.(
|
|
3243
|
+
'[approvals] participant-visibility probe hit its window — some requests may be hidden from a legitimate participant',
|
|
3244
|
+
{ cap, key },
|
|
3245
|
+
);
|
|
3246
|
+
}
|
|
3247
|
+
};
|
|
3248
|
+
|
|
3249
|
+
try {
|
|
3250
|
+
// Current approver — via the normalized index, so every identity form
|
|
3251
|
+
// the write path recorded is covered.
|
|
3252
|
+
for (const id of (await this.approverRequestIds([uid], tenantOrg)) ?? []) ids.add(id);
|
|
3253
|
+
|
|
3254
|
+
const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
|
|
3255
|
+
add(
|
|
3256
|
+
await this.engine.find('sys_approval_request', {
|
|
3257
|
+
where: { submitter_id: uid, ...orgWhere },
|
|
3258
|
+
fields: ['id'], limit: cap, context: SYSTEM_CTX,
|
|
3259
|
+
}),
|
|
3260
|
+
'id',
|
|
3261
|
+
);
|
|
3262
|
+
// Already acted on it: a past approver whose slot has moved on, or a
|
|
3263
|
+
// commenter. They saw it legitimately; keep it that way.
|
|
3264
|
+
add(
|
|
3265
|
+
await this.engine.find('sys_approval_action', {
|
|
3266
|
+
where: { actor_id: uid },
|
|
3267
|
+
fields: ['request_id'], limit: cap, context: SYSTEM_CTX,
|
|
3268
|
+
}),
|
|
3269
|
+
'request_id',
|
|
3270
|
+
);
|
|
3271
|
+
} catch (err) {
|
|
3272
|
+
// Never widen on error: a failed probe yields whatever was collected.
|
|
3273
|
+
this.logger?.warn?.('[approvals] participant-visibility probe failed', {
|
|
3274
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3275
|
+
});
|
|
3276
|
+
}
|
|
3277
|
+
return ids;
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
/** Intersect an existing `where.id` constraint with the participant set. */
|
|
3281
|
+
private applyVisibility(where: any, visible: Set<string> | null): boolean {
|
|
3282
|
+
if (!visible) return true;
|
|
3283
|
+
if (visible.size === 0) return false;
|
|
3284
|
+
let allowed = [...visible];
|
|
3285
|
+
const current = where.id;
|
|
3286
|
+
if (typeof current === 'string') allowed = allowed.filter((x) => x === current);
|
|
3287
|
+
else if (current && typeof current === 'object' && Array.isArray(current.$in)) {
|
|
3288
|
+
const set = new Set(current.$in.map((v: unknown) => String(v)));
|
|
3289
|
+
allowed = allowed.filter((x) => set.has(x));
|
|
3290
|
+
}
|
|
3291
|
+
if (allowed.length === 0) return false;
|
|
3292
|
+
where.id = allowed.length === 1 ? allowed[0] : { $in: allowed };
|
|
3293
|
+
return true;
|
|
3294
|
+
}
|
|
3295
|
+
|
|
2134
3296
|
async listRequests(
|
|
2135
3297
|
filter: {
|
|
2136
3298
|
object?: string;
|
|
@@ -2158,6 +3320,11 @@ export class ApprovalService implements IApprovalService {
|
|
|
2158
3320
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
2159
3321
|
}
|
|
2160
3322
|
|
|
3323
|
+
// #3590: the caller-supplied `approverId` is a FILTER, not authorization —
|
|
3324
|
+
// omitting it used to return every request in the tenant. Intersect with
|
|
3325
|
+
// what this caller actually participates in.
|
|
3326
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
|
|
3327
|
+
|
|
2161
3328
|
const findOpts: any = {
|
|
2162
3329
|
where,
|
|
2163
3330
|
orderBy: [{ field: 'created_at', order: 'desc' }],
|
|
@@ -2193,6 +3360,9 @@ export class ApprovalService implements IApprovalService {
|
|
|
2193
3360
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
2194
3361
|
}
|
|
2195
3362
|
|
|
3363
|
+
// #3590 — the count must agree with the list it paginates.
|
|
3364
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
|
|
3365
|
+
|
|
2196
3366
|
const countFn = (this.engine as any).count;
|
|
2197
3367
|
if (typeof countFn === 'function') {
|
|
2198
3368
|
try {
|
|
@@ -2209,7 +3379,33 @@ export class ApprovalService implements IApprovalService {
|
|
|
2209
3379
|
return Array.isArray(rows) ? rows.length : 0;
|
|
2210
3380
|
}
|
|
2211
3381
|
|
|
3382
|
+
/**
|
|
3383
|
+
* Read the request a write path just changed, to echo back as its result.
|
|
3384
|
+
*
|
|
3385
|
+
* NOT participant-gated (#3590), deliberately: the operation authorized
|
|
3386
|
+
* itself by its own rule before writing, so re-asking "may you see this?"
|
|
3387
|
+
* for the echo answers a question that has already been settled — and would
|
|
3388
|
+
* answer it WRONG for a caller context that carries no `userId` (a
|
|
3389
|
+
* flow-driven resume, a service-to-service call), turning a successful write
|
|
3390
|
+
* into a `null` result. Gating belongs on the read API, not on an
|
|
3391
|
+
* operation's own return value.
|
|
3392
|
+
*/
|
|
3393
|
+
private async readBackRequest(
|
|
3394
|
+
requestId: string,
|
|
3395
|
+
context: SharingExecutionContext,
|
|
3396
|
+
): Promise<ApprovalRequestRow | null> {
|
|
3397
|
+
return this.loadRequest(requestId, context, false);
|
|
3398
|
+
}
|
|
3399
|
+
|
|
2212
3400
|
async getRequest(requestId: string, context: SharingExecutionContext): Promise<ApprovalRequestRow | null> {
|
|
3401
|
+
return this.loadRequest(requestId, context, true);
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
private async loadRequest(
|
|
3405
|
+
requestId: string,
|
|
3406
|
+
context: SharingExecutionContext,
|
|
3407
|
+
enforceVisibility: boolean,
|
|
3408
|
+
): Promise<ApprovalRequestRow | null> {
|
|
2213
3409
|
if (!requestId) return null;
|
|
2214
3410
|
const where: any = { id: requestId };
|
|
2215
3411
|
const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId;
|
|
@@ -2218,6 +3414,13 @@ export class ApprovalService implements IApprovalService {
|
|
|
2218
3414
|
where, limit: 1, context: SYSTEM_CTX,
|
|
2219
3415
|
});
|
|
2220
3416
|
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
3417
|
+
// #3590: tenant scoping alone let any authenticated user read any request
|
|
3418
|
+
// — and, once decision attachments derived their access from the request
|
|
3419
|
+
// (#3580), its files too. Participation is the rest of the rule.
|
|
3420
|
+
if (enforceVisibility) {
|
|
3421
|
+
const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
|
|
3422
|
+
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
3423
|
+
}
|
|
2221
3424
|
const row = rowFromRequest(rows[0]);
|
|
2222
3425
|
await this.enrichRows([row]);
|
|
2223
3426
|
await this.attachFlowSteps(row);
|
|
@@ -2267,6 +3470,21 @@ export class ApprovalService implements IApprovalService {
|
|
|
2267
3470
|
});
|
|
2268
3471
|
progress.got = progress.groups.filter((g: any) => g.satisfied).length;
|
|
2269
3472
|
progress.need = progress.groups.length;
|
|
3473
|
+
|
|
3474
|
+
// Approver→group(s) for the STILL-PENDING slots (objectui#2807), so the
|
|
3475
|
+
// console can label each "waiting on" chip with the group it represents
|
|
3476
|
+
// rather than showing duplicate, context-free names. Only pending slots
|
|
3477
|
+
// matter — a resolved approver has dropped out of `pending_approvers`.
|
|
3478
|
+
// Synthetic (unnamed, `#N`) group keys are dropped: a `· #0` sub-tag is
|
|
3479
|
+
// noise, and the client would have to filter it anyway.
|
|
3480
|
+
const pendingGroups: Record<string, string[]> = {};
|
|
3481
|
+
for (const a of (row.pending_approvers ?? [])) {
|
|
3482
|
+
const named = (snapshot[a] ?? []).filter((g) => !/^#\d+$/.test(g));
|
|
3483
|
+
if (named.length) pendingGroups[a] = named;
|
|
3484
|
+
}
|
|
3485
|
+
if (Object.keys(pendingGroups).length) {
|
|
3486
|
+
(row as any).pending_approver_groups = pendingGroups;
|
|
3487
|
+
}
|
|
2270
3488
|
}
|
|
2271
3489
|
(row as any).decision_progress = progress;
|
|
2272
3490
|
} catch { /* display-only enrichment */ }
|
|
@@ -2278,8 +3496,13 @@ export class ApprovalService implements IApprovalService {
|
|
|
2278
3496
|
* caller's user id is in the resolved `pending_approvers` while the request is
|
|
2279
3497
|
* still `pending` (position/team/manager approvers are already resolved to
|
|
2280
3498
|
* concrete user ids at open time, so a plain membership test is faithful).
|
|
2281
|
-
* `is_submitter` is a straight owner check.
|
|
2282
|
-
*
|
|
3499
|
+
* `is_submitter` is a straight owner check. `can_override` (#3424) is true for
|
|
3500
|
+
* a platform/tenant admin on a PENDING request — the recovery path for an
|
|
3501
|
+
* approval routed to an unstaffed position or to approvers who have all left;
|
|
3502
|
+
* clients OR it into the decision actions' `visible` gate so an admin can act
|
|
3503
|
+
* even when they hold no slot. System/tokenless contexts get a both-false
|
|
3504
|
+
* `can_act`/`is_submitter` block (system gets `can_override` too — it may act
|
|
3505
|
+
* on anything). Cheap + synchronous — safe on list reads.
|
|
2283
3506
|
*/
|
|
2284
3507
|
private attachViewers(rows: ApprovalRequestRow[], context: SharingExecutionContext): void {
|
|
2285
3508
|
const uid = (context as any)?.userId != null ? String((context as any).userId) : null;
|
|
@@ -2288,6 +3511,8 @@ export class ApprovalService implements IApprovalService {
|
|
|
2288
3511
|
(row as any).viewer = {
|
|
2289
3512
|
can_act: row.status === 'pending' && !!uid && pending.includes(uid),
|
|
2290
3513
|
is_submitter: !!uid && row.submitter_id != null && String(row.submitter_id) === uid,
|
|
3514
|
+
can_override: row.status === 'pending'
|
|
3515
|
+
&& this.isOverrideActor(context, (row as any).organization_id ?? null),
|
|
2291
3516
|
};
|
|
2292
3517
|
}
|
|
2293
3518
|
}
|
|
@@ -2357,4 +3582,36 @@ export class ApprovalService implements IApprovalService {
|
|
|
2357
3582
|
}
|
|
2358
3583
|
return actions;
|
|
2359
3584
|
}
|
|
3585
|
+
|
|
3586
|
+
/**
|
|
3587
|
+
* `IFileAccessDelegate` — may this caller download a decision attachment?
|
|
3588
|
+
* (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.)
|
|
3589
|
+
*
|
|
3590
|
+
* A file referenced by `sys_approval_action.attachments` is owned by that
|
|
3591
|
+
* audit row, so the storage service would otherwise authorize the download by
|
|
3592
|
+
* testing whether the caller can READ the row. It cannot: `sys_approval_action`
|
|
3593
|
+
* is deliberately closed to ordinary approver positions, so that test denies
|
|
3594
|
+
* the very approver the attachment was filed for.
|
|
3595
|
+
*
|
|
3596
|
+
* The rule that actually governs seeing a decision is the one `listActions`
|
|
3597
|
+
* applies — can the caller see the PARENT REQUEST? — so this reuses it
|
|
3598
|
+
* exactly, rather than inventing a second, looser rule for the bytes. Fails
|
|
3599
|
+
* closed on any error.
|
|
3600
|
+
*/
|
|
3601
|
+
async authorizeFileRead(actionId: string, context: SharingExecutionContext): Promise<boolean> {
|
|
3602
|
+
if (!actionId) return false;
|
|
3603
|
+
try {
|
|
3604
|
+
const rows = await this.engine.find('sys_approval_action', {
|
|
3605
|
+
where: { id: actionId },
|
|
3606
|
+
limit: 1,
|
|
3607
|
+
context: SYSTEM_CTX,
|
|
3608
|
+
});
|
|
3609
|
+
const requestId = (Array.isArray(rows) ? rows[0] : undefined)?.request_id;
|
|
3610
|
+
if (!requestId) return false;
|
|
3611
|
+
// Same gate as listActions: visibility of the decision's parent request.
|
|
3612
|
+
return !!(await this.getRequest(String(requestId), context));
|
|
3613
|
+
} catch {
|
|
3614
|
+
return false;
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
2360
3617
|
}
|