@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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* [ADR-0105 D9] Cross-organization approver targeting — resolving WHICH
|
|
5
|
+
* organization's directory an approver is looked up in.
|
|
6
|
+
*
|
|
7
|
+
* One organization id used to do three jobs at once in `openNodeRequest`:
|
|
8
|
+
* where the request row lives, where its inbox index rows live, and where its
|
|
9
|
+
* approvers are looked up. The first two are the request's own organization by
|
|
10
|
+
* definition. The third is not: a group CFO holds her `cfo` position in the
|
|
11
|
+
* GROUP organization while the purchase order she signs off lives in the PLANT
|
|
12
|
+
* organization. Binding all three together meant
|
|
13
|
+
* `expandPositionUsers('cfo', <plant>)` matched nobody and the slot fell into
|
|
14
|
+
* `onEmptyApprovers` — a group escalation could not be expressed at all.
|
|
15
|
+
*
|
|
16
|
+
* This module resolves only the third job. The request keeps living in its own
|
|
17
|
+
* organization, and D2's membership union is what lets a group-side approver
|
|
18
|
+
* READ it — see `assertApproversCanRead` below for why that is a precondition
|
|
19
|
+
* worth checking rather than assuming.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Cycle guard for the `parent_organization_id` walk (mirrors the BU subtree walk). */
|
|
23
|
+
const MAX_ORG_DEPTH = 32;
|
|
24
|
+
|
|
25
|
+
const SYSTEM_CTX = { isSystem: true } as const;
|
|
26
|
+
|
|
27
|
+
export interface ApproverOrgScopeEngine {
|
|
28
|
+
find(object: string, options: any): Promise<any[]>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApproverOrgScopeDeps {
|
|
32
|
+
engine: ApproverOrgScopeEngine;
|
|
33
|
+
/**
|
|
34
|
+
* The tenancy posture in force, when the host could resolve one. `undefined`
|
|
35
|
+
* means "unknown" — a stack booted without the tenancy service — and is
|
|
36
|
+
* treated as permissive so a minimal test/embedded stack is not broken by a
|
|
37
|
+
* guard it cannot answer.
|
|
38
|
+
*/
|
|
39
|
+
posture?: () => string | undefined;
|
|
40
|
+
logger?: { warn?: (msg: any, ...rest: any[]) => void; debug?: (msg: any, ...rest: any[]) => void };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every failure here THROWS `VALIDATION_FAILED`, matching how `expression`
|
|
45
|
+
* approvers already fail (#3447 P2): an approver declaration that cannot be
|
|
46
|
+
* resolved is a ROUTING BUG, never "condition not met". Silently falling back
|
|
47
|
+
* to the request's own organization would be the worst option available — a
|
|
48
|
+
* flow that reads as "escalate to group" would quietly approve inside the
|
|
49
|
+
* plant, and the deployment that changed posture would reroute its approvals
|
|
50
|
+
* with no signal at all.
|
|
51
|
+
*
|
|
52
|
+
* Messages name the offending value because their primary reader is the AI
|
|
53
|
+
* author fixing the flow on the next validate pass.
|
|
54
|
+
*/
|
|
55
|
+
function fail(message: string): never {
|
|
56
|
+
throw new Error(`VALIDATION_FAILED: ${message}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function findOrg(
|
|
60
|
+
engine: ApproverOrgScopeEngine,
|
|
61
|
+
where: Record<string, unknown>,
|
|
62
|
+
): Promise<any | null> {
|
|
63
|
+
try {
|
|
64
|
+
const rows = await engine.find('sys_organization', {
|
|
65
|
+
filter: where,
|
|
66
|
+
fields: ['id', 'slug', 'parent_organization_id'],
|
|
67
|
+
limit: 1,
|
|
68
|
+
context: SYSTEM_CTX,
|
|
69
|
+
} as any);
|
|
70
|
+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The id chain from `orgId` up to its root, inclusive. Fail-closed on cycles. */
|
|
77
|
+
async function ancestorChain(
|
|
78
|
+
engine: ApproverOrgScopeEngine,
|
|
79
|
+
orgId: string,
|
|
80
|
+
): Promise<string[]> {
|
|
81
|
+
const chain: string[] = [];
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
let cursor: string | null = orgId;
|
|
84
|
+
for (let depth = 0; cursor && depth < MAX_ORG_DEPTH; depth++) {
|
|
85
|
+
if (seen.has(cursor)) break; // cycle in the grouping metadata — stop, do not loop
|
|
86
|
+
seen.add(cursor);
|
|
87
|
+
chain.push(cursor);
|
|
88
|
+
const row = await findOrg(engine, { id: cursor });
|
|
89
|
+
const parent = row?.parent_organization_id;
|
|
90
|
+
cursor = parent ? String(parent) : null;
|
|
91
|
+
}
|
|
92
|
+
return chain;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve an approver's `organization` declaration to a concrete organization
|
|
97
|
+
* id for directory lookups. Returns the request's own organization when the
|
|
98
|
+
* declaration is absent — the unchanged default path, which does no reads.
|
|
99
|
+
*/
|
|
100
|
+
export async function resolveApproverDirectoryOrg(
|
|
101
|
+
deps: ApproverOrgScopeDeps,
|
|
102
|
+
declaration: string | null | undefined,
|
|
103
|
+
requestOrgId: string | null | undefined,
|
|
104
|
+
approverType: string,
|
|
105
|
+
isOrgScopedType: boolean,
|
|
106
|
+
): Promise<string | null | undefined> {
|
|
107
|
+
const declared = typeof declaration === 'string' ? declaration.trim() : '';
|
|
108
|
+
if (!declared) return requestOrgId;
|
|
109
|
+
|
|
110
|
+
// An `organization` on `user` / `field` / `manager` / `team` is not a
|
|
111
|
+
// narrower routing — those types never consult an org-scoped directory, so
|
|
112
|
+
// the declaration would have no effect. Refuse rather than ignore: a flow
|
|
113
|
+
// author who wrote it believed it did something.
|
|
114
|
+
if (!isOrgScopedType) {
|
|
115
|
+
fail(
|
|
116
|
+
`approver type '${approverType}' resolves people without an organization directory, `
|
|
117
|
+
+ `so 'organization: ${declared}' would have no effect — remove it `
|
|
118
|
+
+ `(ADR-0105 D9 applies to position / org_membership_level / department / expression)`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Posture guard. ADR-0105 D9 applies to `group` only: in `isolated`, cross-org
|
|
123
|
+
// approval remains system-context mirroring (cloud #2937 contract), and in
|
|
124
|
+
// `single` there is no second organization to target. Refusing here — rather
|
|
125
|
+
// than in a lint — is deliberate: posture is ENVIRONMENT configuration, so the
|
|
126
|
+
// same portable flow metadata may be deployed into any posture and no static
|
|
127
|
+
// check can see which. A deployment migrating group → isolated must fail
|
|
128
|
+
// loudly instead of silently rerouting its approvals.
|
|
129
|
+
const posture = deps.posture?.();
|
|
130
|
+
if (posture && posture !== 'group') {
|
|
131
|
+
fail(
|
|
132
|
+
`cross-organization approver targeting ('organization: ${declared}') requires the `
|
|
133
|
+
+ `'group' tenancy posture; this deployment resolves '${posture}' (ADR-0105 D9)`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const requestOrg = requestOrgId ? String(requestOrgId) : '';
|
|
138
|
+
if (!requestOrg) {
|
|
139
|
+
fail(
|
|
140
|
+
`'organization: ${declared}' cannot be resolved for a request that carries no `
|
|
141
|
+
+ `organization — cross-organization targeting needs an organization to resolve from`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const chain = await ancestorChain(deps.engine, requestOrg);
|
|
146
|
+
|
|
147
|
+
if (declared === '$root') {
|
|
148
|
+
const root = chain[chain.length - 1];
|
|
149
|
+
if (root === requestOrg && chain.length === 1) {
|
|
150
|
+
fail(
|
|
151
|
+
`'organization: $root' resolved to the request's own organization — this organization `
|
|
152
|
+
+ `has no 'parent_organization_id' lineage. Declare the group hierarchy (ADR-0105 D6) `
|
|
153
|
+
+ `or drop the targeting`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return root;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (declared === '$parent') {
|
|
160
|
+
const parent = chain[1];
|
|
161
|
+
if (!parent) {
|
|
162
|
+
fail(
|
|
163
|
+
`'organization: $parent' has no parent to resolve to — the request's organization is `
|
|
164
|
+
+ `already the root of its group (ADR-0105 D6 'parent_organization_id')`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return parent;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A slug names one specific organization — the shape the symbols cannot
|
|
171
|
+
// express (a SIBLING, e.g. a shared-services centre approving payables for
|
|
172
|
+
// every plant). Slugs rather than ids because flow metadata is portable
|
|
173
|
+
// across environments and organization ids are minted per deployment.
|
|
174
|
+
const target = await findOrg(deps.engine, { slug: declared });
|
|
175
|
+
if (!target?.id) {
|
|
176
|
+
fail(
|
|
177
|
+
`no organization with slug '${declared}' — 'organization' takes an organization SLUG `
|
|
178
|
+
+ `or a symbol ($root / $parent), never an id (ids are per-deployment, flows are portable)`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const targetId = String(target.id);
|
|
182
|
+
if (targetId === requestOrg) return targetId;
|
|
183
|
+
|
|
184
|
+
// Legality: the target must share a grouping root with the request's
|
|
185
|
+
// organization. "Shares a root" rather than "is an ancestor" because the
|
|
186
|
+
// sibling case is a first-class use. The rule depends only on the
|
|
187
|
+
// organization tree — never on who submitted — so one flow routes identically
|
|
188
|
+
// for every submitter, which is what makes a routing bug reproducible.
|
|
189
|
+
const targetChain = await ancestorChain(deps.engine, targetId);
|
|
190
|
+
const targetRoot = targetChain[targetChain.length - 1];
|
|
191
|
+
const requestRoot = chain[chain.length - 1];
|
|
192
|
+
if (!targetRoot || !requestRoot || targetRoot !== requestRoot) {
|
|
193
|
+
fail(
|
|
194
|
+
`organization '${declared}' is not in the same group as the request's organization `
|
|
195
|
+
+ `— cross-organization approval routes WITHIN one group (they must share a `
|
|
196
|
+
+ `'parent_organization_id' root, ADR-0105 D6/D9)`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return targetId;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Drop approvers who could not READ the request they were routed to, and say
|
|
204
|
+
* so.
|
|
205
|
+
*
|
|
206
|
+
* ADR-0105 D9 notes that reads by cross-org approvers "are covered by D2
|
|
207
|
+
* (membership union)". True — but D2's union is `organization_id IN
|
|
208
|
+
* accessible_org_ids`, and the request row is stamped with the REQUEST's
|
|
209
|
+
* organization. So a group-side approver reaches it only if she also holds a
|
|
210
|
+
* membership there. That is the intended group shape (group staff are members
|
|
211
|
+
* of every plant while holding their positions in the group org — the shape the
|
|
212
|
+
* ADR-0105 acceptance dogfood already models), but nothing enforces it.
|
|
213
|
+
*
|
|
214
|
+
* Left unchecked the failure is SILENT and expensive: routing succeeds, the
|
|
215
|
+
* request and inbox rows are written, and the approver then opens a task she
|
|
216
|
+
* cannot open — the wall hides the record. Filtering here converts that into
|
|
217
|
+
* the empty-slate path the node already has a policy for
|
|
218
|
+
* (`onEmptyApprovers`), and logs exactly who was dropped and why, so the fix
|
|
219
|
+
* ("grant the membership" or "retarget") is legible without a debugger.
|
|
220
|
+
*/
|
|
221
|
+
export async function filterApproversWhoCanRead(
|
|
222
|
+
deps: ApproverOrgScopeDeps,
|
|
223
|
+
userIds: string[],
|
|
224
|
+
requestOrgId: string | null | undefined,
|
|
225
|
+
context: { approverType: string; value?: string; directoryOrgId?: string | null },
|
|
226
|
+
): Promise<string[]> {
|
|
227
|
+
const requestOrg = requestOrgId ? String(requestOrgId) : '';
|
|
228
|
+
if (!requestOrg || userIds.length === 0) return userIds;
|
|
229
|
+
|
|
230
|
+
let members: any[] = [];
|
|
231
|
+
try {
|
|
232
|
+
members = await deps.engine.find('sys_member', {
|
|
233
|
+
filter: { organization_id: requestOrg, user_id: { $in: userIds } },
|
|
234
|
+
fields: ['user_id'],
|
|
235
|
+
limit: 10000,
|
|
236
|
+
context: SYSTEM_CTX,
|
|
237
|
+
} as any);
|
|
238
|
+
} catch {
|
|
239
|
+
// Membership unreadable — do NOT drop everyone on an infrastructure
|
|
240
|
+
// hiccup. Routing to someone who may not see the request is recoverable
|
|
241
|
+
// (an admin can grant the membership); silently emptying a live approval
|
|
242
|
+
// slate is not.
|
|
243
|
+
return userIds;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const canRead = new Set(
|
|
247
|
+
(members ?? []).map((m: any) => String(m?.user_id ?? '')).filter(Boolean),
|
|
248
|
+
);
|
|
249
|
+
const dropped = userIds.filter((u) => !canRead.has(u));
|
|
250
|
+
if (dropped.length === 0) return userIds;
|
|
251
|
+
|
|
252
|
+
deps.logger?.warn?.(
|
|
253
|
+
`[approvals] ADR-0105 D9: ${dropped.length} cross-organization approver(s) dropped — `
|
|
254
|
+
+ `they hold '${context.value ?? context.approverType}' in organization `
|
|
255
|
+
+ `'${context.directoryOrgId}' but no membership in the request's organization `
|
|
256
|
+
+ `'${requestOrg}', so the D2 union wall would hide the request from them. `
|
|
257
|
+
+ `Grant those users a membership in the request's organization, or retarget the approver.`,
|
|
258
|
+
{ dropped, requestOrganizationId: requestOrg, directoryOrganizationId: context.directoryOrgId },
|
|
259
|
+
);
|
|
260
|
+
return userIds.filter((u) => canRead.has(u));
|
|
261
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,9 @@ export {
|
|
|
20
20
|
type ApprovalClock,
|
|
21
21
|
type ApprovalServiceOptions,
|
|
22
22
|
type ApprovalResumeSurface,
|
|
23
|
+
// #3447 P2 — expression approvers + empty-slate auto-approve outcome.
|
|
24
|
+
type ApproverExpressionContext,
|
|
25
|
+
type ApprovalNodeAutoOutcome,
|
|
23
26
|
} from './approval-service.js';
|
|
24
27
|
export {
|
|
25
28
|
ApprovalsServicePlugin,
|
package/src/lifecycle-hooks.ts
CHANGED
|
@@ -93,6 +93,28 @@ export function bindApprovalLockHook(engine: MinimalEngine, logger?: MinimalLogg
|
|
|
93
93
|
const pending = await pendingRequestFor(engine, object, id);
|
|
94
94
|
if (!pending) return;
|
|
95
95
|
|
|
96
|
+
// The run that OPENED this approval may still write its own target record
|
|
97
|
+
// (#3456). Without this the lock cannot tell "the run that owns this pending
|
|
98
|
+
// request" from "an unrelated user edit", so a flow that touches the record
|
|
99
|
+
// between opening the approval and the decision — or a manual `resume` with
|
|
100
|
+
// no decision — dies on its own `RECORD_LOCKED` and leaves the record locked
|
|
101
|
+
// behind it.
|
|
102
|
+
//
|
|
103
|
+
// Keyed on run identity, NOT on elevation: a `runAs:'user'` run must stay
|
|
104
|
+
// RLS-scoped, so widening it to `isSystem` would be the wrong tool. The
|
|
105
|
+
// automation engine stamps `flowRunId` into the server-built ExecutionContext
|
|
106
|
+
// (never client-supplied, like `isSystem`) and it grants nothing by itself —
|
|
107
|
+
// the only write it permits is to the one record this very run already holds
|
|
108
|
+
// a pending request against.
|
|
109
|
+
//
|
|
110
|
+
// Read off `provenance`, not `session`: provenance says WHAT produced the
|
|
111
|
+
// write, and a run can own its writes while resolving no principal at all.
|
|
112
|
+
// A schedule-triggered run is exactly that — it reaches here with NO
|
|
113
|
+
// session, and that shape is the one that used to die on its own lock
|
|
114
|
+
// (#3712).
|
|
115
|
+
const writerRun = (ctx?.provenance as any)?.flowRunId;
|
|
116
|
+
if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
|
|
117
|
+
|
|
96
118
|
const config = parseJson<any>(pending.node_config_json, {});
|
|
97
119
|
if (config?.lockRecord === false) return;
|
|
98
120
|
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #3703 / #3712 / #3760 — the run that opened a pending approval may write its
|
|
5
|
+
* own locked record.
|
|
6
|
+
*
|
|
7
|
+
* #3703 exempted "the run that opened the pending request" from the approvals
|
|
8
|
+
* record lock, keyed on `flowRunId`. #3712 extended that to the run that
|
|
9
|
+
* resolved no identity — an effective `runAs:'user'` run with no trigger user —
|
|
10
|
+
* by giving it a provenance-only ObjectQL context carrying just the run id.
|
|
11
|
+
*
|
|
12
|
+
* #3760 then closed the fail-open that path depended on: a run with no trigger
|
|
13
|
+
* user may no longer perform a data operation at all, because presenting no
|
|
14
|
+
* principal is precisely what made the write UNSCOPED. So the user-less variant
|
|
15
|
+
* is now REFUSED rather than exempted, and a schedule reaches its own record the
|
|
16
|
+
* explicit way — `runAs:'system'`, which the hook exempts on its own
|
|
17
|
+
* `isSystem` branch. The `flowRunId` exemption remains live and load-bearing for
|
|
18
|
+
* what it was built for: a `runAs:'user'` run that DOES have a user.
|
|
19
|
+
*
|
|
20
|
+
* The original miss was a HAND-OFF gap, not a logic gap: every hop worked in
|
|
21
|
+
* isolation. So this test still refuses to stub any hop. It runs the real
|
|
22
|
+
* `resolveRunDataContext` from the automation runtime, feeds its output to a
|
|
23
|
+
* real {@link ObjectQL} engine, and lets the real lock hook decide — the same
|
|
24
|
+
* three layers, in the same order, as a live deployment.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
28
|
+
import { ObjectQL } from '@objectstack/objectql';
|
|
29
|
+
import { resolveRunDataContext } from '@objectstack/service-automation';
|
|
30
|
+
import { bindApprovalLockHook } from './lifecycle-hooks.js';
|
|
31
|
+
|
|
32
|
+
const opportunity = {
|
|
33
|
+
name: 'opportunity',
|
|
34
|
+
label: 'Opportunity',
|
|
35
|
+
fields: {
|
|
36
|
+
id: { name: 'id', type: 'text' as const, primaryKey: true },
|
|
37
|
+
name: { name: 'name', type: 'text' as const },
|
|
38
|
+
amount: { name: 'amount', type: 'number' as const },
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The lock hook reads pending requests off this object. */
|
|
43
|
+
const approvalRequest = {
|
|
44
|
+
name: 'sys_approval_request',
|
|
45
|
+
label: 'Approval Request',
|
|
46
|
+
fields: {
|
|
47
|
+
id: { name: 'id', type: 'text' as const, primaryKey: true },
|
|
48
|
+
object_name: { name: 'object_name', type: 'text' as const },
|
|
49
|
+
record_id: { name: 'record_id', type: 'text' as const },
|
|
50
|
+
status: { name: 'status', type: 'text' as const },
|
|
51
|
+
flow_run_id: { name: 'flow_run_id', type: 'text' as const },
|
|
52
|
+
node_config_json: { name: 'node_config_json', type: 'text' as const },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function makeMemoryDriver() {
|
|
57
|
+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
|
|
58
|
+
const storeFor = (o: string) => {
|
|
59
|
+
let s = stores.get(o);
|
|
60
|
+
if (!s) { s = new Map(); stores.set(o, s); }
|
|
61
|
+
return s;
|
|
62
|
+
};
|
|
63
|
+
let nextId = 0;
|
|
64
|
+
const matches = (row: Record<string, unknown>, where: any): boolean => {
|
|
65
|
+
if (!where || typeof where !== 'object') return true;
|
|
66
|
+
for (const [k, v] of Object.entries(where)) {
|
|
67
|
+
if (k.startsWith('$')) continue;
|
|
68
|
+
const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
|
|
69
|
+
if ((row[k] ?? null) !== (exp ?? null)) return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
};
|
|
73
|
+
const driver: any = {
|
|
74
|
+
name: 'memory', version: '0.0.0', supports: {},
|
|
75
|
+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
|
|
76
|
+
async find(o: string, ast: any) { return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); },
|
|
77
|
+
findStream() { throw new Error('ns'); },
|
|
78
|
+
async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; },
|
|
79
|
+
async create(o: string, data: Record<string, unknown>) {
|
|
80
|
+
nextId += 1;
|
|
81
|
+
const id = (data.id as string) ?? `r_${nextId}`;
|
|
82
|
+
const row = { ...data, id };
|
|
83
|
+
storeFor(o).set(id, row);
|
|
84
|
+
return row;
|
|
85
|
+
},
|
|
86
|
+
async update(o: string, id: string, data: Record<string, unknown>) {
|
|
87
|
+
const s = storeFor(o);
|
|
88
|
+
const cur = s.get(id);
|
|
89
|
+
if (!cur) throw new Error(`nf ${o}/${id}`);
|
|
90
|
+
const up = { ...cur, ...data, id };
|
|
91
|
+
s.set(id, up);
|
|
92
|
+
return up;
|
|
93
|
+
},
|
|
94
|
+
async upsert(o: string, data: Record<string, unknown>) {
|
|
95
|
+
const id = data.id as string | undefined;
|
|
96
|
+
return id && storeFor(o).has(id) ? this.update(o, id, data) : this.create(o, data);
|
|
97
|
+
},
|
|
98
|
+
async delete(o: string, id: string) { return storeFor(o).delete(id); },
|
|
99
|
+
async count(o: string, ast: any) { return (await this.find(o, ast)).length; },
|
|
100
|
+
async bulkCreate(o: string, rows: Record<string, unknown>[]) { return Promise.all(rows.map((r) => this.create(o, r))); },
|
|
101
|
+
async bulkUpdate() { return []; },
|
|
102
|
+
async bulkDelete() {},
|
|
103
|
+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
|
|
104
|
+
async commit() {}, async rollback() {},
|
|
105
|
+
};
|
|
106
|
+
return driver;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A `runAs:'user'` run that HAS a trigger user — the live consumer of the
|
|
111
|
+
* `flowRunId` exemption, and the shape #3703 built it for (a record-change or
|
|
112
|
+
* screen flow that opened the approval and now writes its own target record).
|
|
113
|
+
*/
|
|
114
|
+
const OWNING_RUN = (flowRunId: string) => ({ runAs: 'user' as const, userId: 'u1', flowRunId });
|
|
115
|
+
|
|
116
|
+
/** What a schedule trigger produces: an event, and NO user. Refused since #3760. */
|
|
117
|
+
const USER_LESS_RUN = (flowRunId: string) => ({ runAs: 'user' as const, flowRunId });
|
|
118
|
+
|
|
119
|
+
/** The supported shape for a schedule that must write records (ADR-0049). */
|
|
120
|
+
const SYSTEM_RUN = (flowRunId: string) => ({ runAs: 'system' as const, flowRunId });
|
|
121
|
+
|
|
122
|
+
describe('an owning run and the approvals record lock (#3703 / #3712 / #3760)', () => {
|
|
123
|
+
let engine: ObjectQL;
|
|
124
|
+
let oppId: string;
|
|
125
|
+
|
|
126
|
+
beforeEach(async () => {
|
|
127
|
+
engine = new ObjectQL();
|
|
128
|
+
engine.registerDriver(makeMemoryDriver(), true);
|
|
129
|
+
await engine.init();
|
|
130
|
+
for (const o of [opportunity, approvalRequest]) engine.registry.registerObject(o as any);
|
|
131
|
+
|
|
132
|
+
const opp = await engine.insert('opportunity', { name: 'Deal', amount: 100 });
|
|
133
|
+
oppId = String(opp.id);
|
|
134
|
+
await engine.insert('sys_approval_request', {
|
|
135
|
+
object_name: 'opportunity',
|
|
136
|
+
record_id: oppId,
|
|
137
|
+
status: 'pending',
|
|
138
|
+
flow_run_id: 'run_1',
|
|
139
|
+
node_config_json: JSON.stringify({ lockRecord: true }),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
bindApprovalLockHook(engine as any);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const writeAs = (context: unknown) =>
|
|
146
|
+
engine.update('opportunity', { id: oppId, amount: 200 }, { context } as any);
|
|
147
|
+
|
|
148
|
+
it('lets the OWNING run write its own target record', async () => {
|
|
149
|
+
// The full hand-off, unstubbed: the automation runtime resolves the run's
|
|
150
|
+
// ObjectQL context, the engine turns it into hook provenance, the lock hook
|
|
151
|
+
// matches it against the pending request it opened.
|
|
152
|
+
const dataCtx = resolveRunDataContext(OWNING_RUN('run_1'));
|
|
153
|
+
expect(dataCtx, 'nothing could carry the run id').toMatchObject({ flowRunId: 'run_1' });
|
|
154
|
+
|
|
155
|
+
await expect(writeAs(dataCtx)).resolves.toBeDefined();
|
|
156
|
+
const row = await engine.findOne('opportunity', { where: { id: oppId } });
|
|
157
|
+
expect(row.amount).toBe(200);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('still blocks a DIFFERENT run', async () => {
|
|
161
|
+
await expect(writeAs(resolveRunDataContext(OWNING_RUN('run_other'))))
|
|
162
|
+
.rejects.toThrow(/RECORD_LOCKED/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('still blocks an ordinary user edit', async () => {
|
|
166
|
+
await expect(writeAs({ isSystem: false, userId: 'u1', positions: [], permissions: [] }))
|
|
167
|
+
.rejects.toThrow(/RECORD_LOCKED/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('still blocks a context-less write', async () => {
|
|
171
|
+
await expect(writeAs(undefined)).rejects.toThrow(/RECORD_LOCKED/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('the exemption is PROVENANCE, not privilege — the exempted write is not elevated', async () => {
|
|
175
|
+
// The exemption must not have been bought with elevation: the run that
|
|
176
|
+
// writes its own locked record is still a plain `runAs:'user'` principal,
|
|
177
|
+
// subject to the same RLS as the user who triggered it. Only `flowRunId`
|
|
178
|
+
// distinguishes it, and `flowRunId` grants nothing on its own.
|
|
179
|
+
const dataCtx = resolveRunDataContext(OWNING_RUN('run_1')) as Record<string, unknown>;
|
|
180
|
+
expect(dataCtx.isSystem, 'the exemption rode in on isSystem').toBe(false);
|
|
181
|
+
expect(dataCtx.flowRunId).toBe('run_1');
|
|
182
|
+
expect(dataCtx.userId).toBe('u1');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// #3760 — the case #3712 solved by handing the lock a provenance-only context
|
|
186
|
+
// is gone at the root: such a run may not perform a data operation at all,
|
|
187
|
+
// because presenting no principal is exactly what made the write unscoped. It
|
|
188
|
+
// is refused BEFORE the lock is ever consulted.
|
|
189
|
+
it('a USER-LESS run never reaches the lock — it cannot perform a data op at all', async () => {
|
|
190
|
+
expect(() => resolveRunDataContext(USER_LESS_RUN('run_1')))
|
|
191
|
+
.toThrow(/no trigger user could be resolved/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ...and the capability itself survives, via the explicit route: a schedule
|
|
195
|
+
// that must write records declares `runAs:'system'`, which the lock hook
|
|
196
|
+
// exempts on its own isSystem branch. Elevation is now declared rather than
|
|
197
|
+
// acquired by having no identity.
|
|
198
|
+
it("a schedule that declares runAs:'system' still writes its own target record", async () => {
|
|
199
|
+
const dataCtx = resolveRunDataContext(SYSTEM_RUN('run_1'));
|
|
200
|
+
expect(dataCtx).toMatchObject({ isSystem: true, flowRunId: 'run_1' });
|
|
201
|
+
|
|
202
|
+
await expect(writeAs(dataCtx)).resolves.toBeDefined();
|
|
203
|
+
const row = await engine.findOne('opportunity', { where: { id: oppId } });
|
|
204
|
+
expect(row.amount).toBe(200);
|
|
205
|
+
});
|
|
206
|
+
});
|