@objectstack/plugin-approvals 17.1.0 → 17.2.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/CHANGELOG.md +397 -0
- package/dist/index.d.mts +365 -3
- package/dist/index.d.ts +365 -3
- package/dist/index.js +525 -17
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +525 -17
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -12
package/dist/index.mjs
CHANGED
|
@@ -2220,6 +2220,38 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
|
|
|
2220
2220
|
return userIds.filter((u) => canRead.has(u));
|
|
2221
2221
|
}
|
|
2222
2222
|
|
|
2223
|
+
// src/payload-redaction.ts
|
|
2224
|
+
function redactSnapshot(payload, readable) {
|
|
2225
|
+
if (readable === void 0) return { payload, redactedKeys: [] };
|
|
2226
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
2227
|
+
return { payload, redactedKeys: [] };
|
|
2228
|
+
}
|
|
2229
|
+
const allowed = new Set(readable.map((f) => String(f)));
|
|
2230
|
+
const source = payload;
|
|
2231
|
+
const redactedKeys = [];
|
|
2232
|
+
const kept = {};
|
|
2233
|
+
for (const key of Object.keys(source)) {
|
|
2234
|
+
if (allowed.has(key)) kept[key] = source[key];
|
|
2235
|
+
else redactedKeys.push(key);
|
|
2236
|
+
}
|
|
2237
|
+
if (redactedKeys.length === 0) return { payload, redactedKeys: [] };
|
|
2238
|
+
return { payload: kept, redactedKeys: redactedKeys.sort() };
|
|
2239
|
+
}
|
|
2240
|
+
async function resolveReadableSnapshotFields(security, objectName, context, logger) {
|
|
2241
|
+
if (!security || typeof security.getReadableFields !== "function") return void 0;
|
|
2242
|
+
const object = String(objectName ?? "").trim();
|
|
2243
|
+
if (!object) return void 0;
|
|
2244
|
+
try {
|
|
2245
|
+
return await security.getReadableFields(object, context);
|
|
2246
|
+
} catch (err) {
|
|
2247
|
+
logger?.warn?.("[approvals] payload redaction could not resolve readable fields \u2014 serving the snapshot unredacted", {
|
|
2248
|
+
object,
|
|
2249
|
+
error: err?.message ?? String(err)
|
|
2250
|
+
});
|
|
2251
|
+
return void 0;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2223
2255
|
// src/approval-service.ts
|
|
2224
2256
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
2225
2257
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
@@ -2240,6 +2272,7 @@ function actingUserId(context) {
|
|
|
2240
2272
|
return typeof userId === "string" && userId ? userId : null;
|
|
2241
2273
|
}
|
|
2242
2274
|
var OOO_MAX_CHAIN = 8;
|
|
2275
|
+
var MEMBER_SCREEN_READ_LIMIT = 5e4;
|
|
2243
2276
|
var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
|
|
2244
2277
|
"team",
|
|
2245
2278
|
"department",
|
|
@@ -2392,6 +2425,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2392
2425
|
this.messaging = opts.messaging;
|
|
2393
2426
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2394
2427
|
this.tenancyPosture = opts.tenancyPosture;
|
|
2428
|
+
this.fieldVisibility = opts.fieldVisibility;
|
|
2395
2429
|
this.recordReaderVisibleObjects = new Set(
|
|
2396
2430
|
(Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
|
|
2397
2431
|
);
|
|
@@ -2400,6 +2434,57 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2400
2434
|
attachTenancyPosture(provider) {
|
|
2401
2435
|
this.tenancyPosture = provider;
|
|
2402
2436
|
}
|
|
2437
|
+
/**
|
|
2438
|
+
* [#10749] Attach (or replace) the field-visibility authority the payload
|
|
2439
|
+
* redaction seam reads. Late-bound: plugin load order does not guarantee the
|
|
2440
|
+
* security service exists when this one is constructed.
|
|
2441
|
+
*/
|
|
2442
|
+
attachFieldVisibility(source) {
|
|
2443
|
+
this.fieldVisibility = source;
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* [#10749] Redact each row's payload snapshot down to the fields the READING
|
|
2447
|
+
* caller may see on that row's subject object.
|
|
2448
|
+
*
|
|
2449
|
+
* Runs BEFORE {@link ApprovalService.enrichRows}, and that ordering is
|
|
2450
|
+
* load-bearing rather than incidental: `enrichRows` derives `payload_display`
|
|
2451
|
+
* (lookup foreign keys inside the snapshot resolved to referenced record
|
|
2452
|
+
* titles) and `payload_labels` (a label per snapshot key) by WALKING THE
|
|
2453
|
+
* SNAPSHOT'S OWN KEYS. Redact first and both derived maps are clean for free;
|
|
2454
|
+
* redact after and a restricted field's name, its authored label and the
|
|
2455
|
+
* title of the record it points at all still ship — the value would be gone
|
|
2456
|
+
* and the disclosure would not.
|
|
2457
|
+
*
|
|
2458
|
+
* Rows are grouped by subject object so one `getReadableFields` call covers a
|
|
2459
|
+
* whole page of same-object requests.
|
|
2460
|
+
*/
|
|
2461
|
+
async redactPayloads(rows, context) {
|
|
2462
|
+
const withPayload = rows.filter((r) => r?.payload != null);
|
|
2463
|
+
if (withPayload.length === 0) return;
|
|
2464
|
+
const byObject = /* @__PURE__ */ new Map();
|
|
2465
|
+
for (const r of withPayload) {
|
|
2466
|
+
const key = String(r.object_name ?? "");
|
|
2467
|
+
let list = byObject.get(key);
|
|
2468
|
+
if (!list) {
|
|
2469
|
+
list = [];
|
|
2470
|
+
byObject.set(key, list);
|
|
2471
|
+
}
|
|
2472
|
+
list.push(r);
|
|
2473
|
+
}
|
|
2474
|
+
for (const [object, group] of byObject) {
|
|
2475
|
+
const readable = await resolveReadableSnapshotFields(
|
|
2476
|
+
this.fieldVisibility,
|
|
2477
|
+
object,
|
|
2478
|
+
context,
|
|
2479
|
+
this.logger
|
|
2480
|
+
);
|
|
2481
|
+
if (readable === void 0) continue;
|
|
2482
|
+
for (const r of group) {
|
|
2483
|
+
const { payload } = redactSnapshot(r.payload, readable);
|
|
2484
|
+
r.payload = payload;
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2403
2488
|
/** Deps bundle for the ADR-0105 D9 org-scope helpers. */
|
|
2404
2489
|
get orgScopeDeps() {
|
|
2405
2490
|
return {
|
|
@@ -2666,7 +2751,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2666
2751
|
}) : users;
|
|
2667
2752
|
try {
|
|
2668
2753
|
if (type === "team") {
|
|
2669
|
-
const users = await this.expandTeamUsers(String(a.value));
|
|
2754
|
+
const users = await this.expandTeamUsers(String(a.value), organizationId);
|
|
2670
2755
|
if (users.length) return users;
|
|
2671
2756
|
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
2672
2757
|
const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
|
|
@@ -2680,7 +2765,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2680
2765
|
} else if (type === "manager" && record) {
|
|
2681
2766
|
const subject = record[a.value] ?? record.owner_id;
|
|
2682
2767
|
if (subject) {
|
|
2683
|
-
const mgr = await this.lookupManager(String(subject));
|
|
2768
|
+
const mgr = await this.lookupManager(String(subject), organizationId);
|
|
2684
2769
|
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
|
|
2685
2770
|
}
|
|
2686
2771
|
}
|
|
@@ -2777,7 +2862,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2777
2862
|
try {
|
|
2778
2863
|
if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
|
|
2779
2864
|
else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
|
|
2780
|
-
else if (resolveAs === "team") users = await this.expandTeamUsers(key);
|
|
2865
|
+
else if (resolveAs === "team") users = await this.expandTeamUsers(key, directoryOrg);
|
|
2781
2866
|
else {
|
|
2782
2867
|
throw new Error(
|
|
2783
2868
|
`VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
|
|
@@ -2802,9 +2887,50 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2802
2887
|
}
|
|
2803
2888
|
return { slots, raw };
|
|
2804
2889
|
}
|
|
2805
|
-
/**
|
|
2806
|
-
|
|
2890
|
+
/**
|
|
2891
|
+
* Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy).
|
|
2892
|
+
*
|
|
2893
|
+
* Takes an organization for the reason every sibling expansion does
|
|
2894
|
+
* ({@link expandBusinessUnitUsers}, {@link expandPositionUsers},
|
|
2895
|
+
* {@link expandMembershipTierUsers}): an approver expansion answers "who, in
|
|
2896
|
+
* THIS organization". Before #10230 this one did not ask, and it was the last
|
|
2897
|
+
* expansion that did not — a `team` approver naming ANOTHER organization's
|
|
2898
|
+
* team routed that organization's people an approval over a record they are
|
|
2899
|
+
* not a tenant of.
|
|
2900
|
+
*
|
|
2901
|
+
* TWO screens run here, and they assert different things (#10230, #10547):
|
|
2902
|
+
*
|
|
2903
|
+
* 1. the TEAM must not provably belong to another organization
|
|
2904
|
+
* ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries
|
|
2905
|
+
* `organization_id` outright
|
|
2906
|
+
* (`packages/platform-objects/src/identity/sys-team.object.ts`), so a
|
|
2907
|
+
* team id transitively names exactly one organization and ONE row
|
|
2908
|
+
* answers the question;
|
|
2909
|
+
* 2. each expanded MEMBER must not provably hold membership only in other
|
|
2910
|
+
* organizations ({@link dropMembersProvablyOutsideOrg}) —
|
|
2911
|
+
* `sys_team_member` carries `team_id` and `user_id` and NO tenancy
|
|
2912
|
+
* column at all, so passing (1) says nothing whatever about the people
|
|
2913
|
+
* it lists.
|
|
2914
|
+
*
|
|
2915
|
+
* #10230 landed (1) alone and deferred (2) on purpose. What closed the
|
|
2916
|
+
* deferral is that (1) does not imply (2) even a little: a member removed
|
|
2917
|
+
* from the organization but left on the team, a team re-parented across
|
|
2918
|
+
* organizations (`/organization/update-team` accepts `organizationId` in its
|
|
2919
|
+
* partial body), or a `sys_team_member` row written by a seed rather than
|
|
2920
|
+
* through better-auth all produce a team that passes (1) carrying a user who
|
|
2921
|
+
* is provably a tenant of somewhere else. Measured on this tree, not read off
|
|
2922
|
+
* the schema — the probe is quoted in `team-member-org-screen.test.ts`.
|
|
2923
|
+
*
|
|
2924
|
+
* (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop
|
|
2925
|
+
* further out, and it is asserted the same way: `sys_user` carries no tenancy
|
|
2926
|
+
* fact, so `sys_member` rows are the only evidence that a person is placed
|
|
2927
|
+
* anywhere. Like that screen, this one grants no reads and applies no read
|
|
2928
|
+
* screen to any approver type that lacks one today, so it decides nothing
|
|
2929
|
+
* #7497 (does approver routing imply record read visibility?) asks.
|
|
2930
|
+
*/
|
|
2931
|
+
async expandTeamUsers(teamId, organizationId) {
|
|
2807
2932
|
if (!teamId) return [];
|
|
2933
|
+
if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
|
|
2808
2934
|
let rows = [];
|
|
2809
2935
|
try {
|
|
2810
2936
|
rows = await this.engine.find("sys_team_member", {
|
|
@@ -2816,7 +2942,149 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2816
2942
|
} catch {
|
|
2817
2943
|
rows = [];
|
|
2818
2944
|
}
|
|
2819
|
-
|
|
2945
|
+
const users = Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
2946
|
+
return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId);
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230)
|
|
2950
|
+
*
|
|
2951
|
+
* "Provably" carries the same posture the sibling screen states at length in
|
|
2952
|
+
* {@link managerIsProvablyOutsideOrg}, for the same reasons:
|
|
2953
|
+
*
|
|
2954
|
+
* - the team row carries an `organization_id` and it is not the request's
|
|
2955
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out;
|
|
2956
|
+
* - the row carries no `organization_id`, does not exist, or the read failed
|
|
2957
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
2958
|
+
*
|
|
2959
|
+
* The `organization_id = null` limb is not timidity — it is the reading
|
|
2960
|
+
* {@link businessUnitOrgScope} settled on one screen below, for the identical
|
|
2961
|
+
* shape: null on a platform object means "owned by no organization", which is
|
|
2962
|
+
* what a seed writes because a seed cannot know the organization id the
|
|
2963
|
+
* runtime mints at boot. Treating null as "not mine" would delete every
|
|
2964
|
+
* seeded team approver at once — a larger behaviour change than the hole
|
|
2965
|
+
* being closed. Measured, and not hypothetically: this package's own
|
|
2966
|
+
* `team_ok` expansion fixture is exactly such a stack (it has
|
|
2967
|
+
* `sys_team_member` rows, a request carrying an organization, and no
|
|
2968
|
+
* `sys_team` row at all).
|
|
2969
|
+
*
|
|
2970
|
+
* Screening the TEAM before reading its members is also what keeps the cost
|
|
2971
|
+
* at one row: a team that fails the screen never fans out.
|
|
2972
|
+
*/
|
|
2973
|
+
async teamIsProvablyOutsideOrg(teamId, organizationId) {
|
|
2974
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
2975
|
+
if (!requestOrg) return false;
|
|
2976
|
+
let rows = [];
|
|
2977
|
+
try {
|
|
2978
|
+
rows = await this.engine.find("sys_team", {
|
|
2979
|
+
where: { id: teamId },
|
|
2980
|
+
fields: ["id", "organization_id"],
|
|
2981
|
+
limit: 1,
|
|
2982
|
+
context: SYSTEM_CTX2
|
|
2983
|
+
});
|
|
2984
|
+
} catch {
|
|
2985
|
+
return false;
|
|
2986
|
+
}
|
|
2987
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
2988
|
+
const teamOrg = row?.organization_id ? String(row.organization_id) : "";
|
|
2989
|
+
if (!teamOrg) return false;
|
|
2990
|
+
if (teamOrg === requestOrg) return false;
|
|
2991
|
+
this.logger?.warn?.(
|
|
2992
|
+
`[approvals] #10230: team '${teamId}' was dropped from the approver slate \u2014 'sys_team.organization_id' is '${teamOrg}', not the request's organization '${requestOrg}', so routing this approval to its members would put approval authority over the record outside its tenant. Point the approver at a team in this organization, or route this step with an approver type that names someone in it.`,
|
|
2993
|
+
{ teamId, teamOrganizationId: teamOrg, requestOrganizationId: requestOrg }
|
|
2994
|
+
);
|
|
2995
|
+
return true;
|
|
2996
|
+
}
|
|
2997
|
+
/**
|
|
2998
|
+
* Drop the expanded team members who are PROVABLY tenants of other
|
|
2999
|
+
* organizations and not of `organizationId`. (#10547)
|
|
3000
|
+
*
|
|
3001
|
+
* Returns the survivors, in the order they were expanded.
|
|
3002
|
+
*
|
|
3003
|
+
* Posture — identical to {@link managerIsProvablyOutsideOrg} and
|
|
3004
|
+
* {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same
|
|
3005
|
+
* assertion about the same table:
|
|
3006
|
+
*
|
|
3007
|
+
* - membership rows exist for this user, none in `organizationId`
|
|
3008
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him;
|
|
3009
|
+
* - no membership rows at all for him, the read failed, or the request
|
|
3010
|
+
* carries no organization
|
|
3011
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3012
|
+
*
|
|
3013
|
+
* The absent limb is load-bearing rather than timid, and #3807 is the recorded
|
|
3014
|
+
* cost of getting it wrong: a stack that stamps an organization on requests
|
|
3015
|
+
* but never materializes `sys_member` rows would otherwise lose EVERY team
|
|
3016
|
+
* approver at once. This package's own `team_ok` expansion fixture and
|
|
3017
|
+
* #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and
|
|
3018
|
+
* a request organization and no `sys_member` table at all — so the absent
|
|
3019
|
+
* limb is exercised by neighbours on every run of this suite.
|
|
3020
|
+
*
|
|
3021
|
+
* ONE read for the whole slate, never one per person: the expansion is capped
|
|
3022
|
+
* at 10000 members and a per-user query would turn a single team approver
|
|
3023
|
+
* into 10000 round trips.
|
|
3024
|
+
*
|
|
3025
|
+
* ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is
|
|
3026
|
+
* the only evidence that a member IS a tenant here, so a result cut off at
|
|
3027
|
+
* the limit could be missing the very row that keeps a legitimate approver on
|
|
3028
|
+
* the slate — screening him out on missing evidence, which inverts the
|
|
3029
|
+
* posture into fail-CLOSED precisely where it must not. When the read comes
|
|
3030
|
+
* back at the cap it is treated as no evidence at all.
|
|
3031
|
+
*/
|
|
3032
|
+
async dropMembersProvablyOutsideOrg(teamId, userIds, organizationId) {
|
|
3033
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3034
|
+
if (!requestOrg || !userIds.length) return userIds;
|
|
3035
|
+
let rows = [];
|
|
3036
|
+
try {
|
|
3037
|
+
rows = await this.engine.find("sys_member", {
|
|
3038
|
+
where: { user_id: { $in: userIds } },
|
|
3039
|
+
fields: ["user_id", "organization_id"],
|
|
3040
|
+
limit: MEMBER_SCREEN_READ_LIMIT,
|
|
3041
|
+
context: SYSTEM_CTX2
|
|
3042
|
+
});
|
|
3043
|
+
} catch {
|
|
3044
|
+
return userIds;
|
|
3045
|
+
}
|
|
3046
|
+
if ((rows?.length ?? 0) >= MEMBER_SCREEN_READ_LIMIT) {
|
|
3047
|
+
this.logger?.warn?.(
|
|
3048
|
+
`[approvals] #10547: the membership screen for team '${teamId}' read ${rows.length} 'sys_member' rows, at or above its ${MEMBER_SCREEN_READ_LIMIT}-row cap, so the result may be truncated. Routing is left unchanged rather than risk dropping a member whose proof of membership fell outside the read.`,
|
|
3049
|
+
{ teamId, requestOrganizationId: requestOrg, rowsRead: rows.length }
|
|
3050
|
+
);
|
|
3051
|
+
return userIds;
|
|
3052
|
+
}
|
|
3053
|
+
const orgsByUser = /* @__PURE__ */ new Map();
|
|
3054
|
+
for (const r of rows ?? []) {
|
|
3055
|
+
const uid2 = String(r?.user_id ?? "");
|
|
3056
|
+
const org = String(r?.organization_id ?? "");
|
|
3057
|
+
if (!uid2 || !org) continue;
|
|
3058
|
+
const seen = orgsByUser.get(uid2);
|
|
3059
|
+
if (seen) seen.push(org);
|
|
3060
|
+
else orgsByUser.set(uid2, [org]);
|
|
3061
|
+
}
|
|
3062
|
+
const kept = [];
|
|
3063
|
+
const dropped = [];
|
|
3064
|
+
for (const uid2 of userIds) {
|
|
3065
|
+
const orgs = orgsByUser.get(uid2);
|
|
3066
|
+
if (!orgs?.length) {
|
|
3067
|
+
kept.push(uid2);
|
|
3068
|
+
continue;
|
|
3069
|
+
}
|
|
3070
|
+
if (orgs.includes(requestOrg)) {
|
|
3071
|
+
kept.push(uid2);
|
|
3072
|
+
continue;
|
|
3073
|
+
}
|
|
3074
|
+
dropped.push({ userId: uid2, organizationIds: orgs });
|
|
3075
|
+
}
|
|
3076
|
+
if (dropped.length) {
|
|
3077
|
+
this.logger?.warn?.(
|
|
3078
|
+
`[approvals] #10547: ${dropped.length} member(s) of team '${teamId}' were dropped from the approver slate \u2014 ${dropped.map((d) => `'${d.userId}'`).join(", ")} hold membership in other organization(s), none of them the request's organization '${requestOrg}', so routing this approval to them would put approval authority over the record outside its tenant. The TEAM itself belongs to this organization; its 'sys_team_member' rows carry no organization of their own. Remove them from the team, grant them a membership in this organization, or route this step with an approver type that names someone in it.`,
|
|
3079
|
+
{
|
|
3080
|
+
teamId,
|
|
3081
|
+
requestOrganizationId: requestOrg,
|
|
3082
|
+
droppedUserIds: dropped.map((d) => d.userId),
|
|
3083
|
+
droppedMemberOrganizationIds: dropped.map((d) => d.organizationIds)
|
|
3084
|
+
}
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
return kept;
|
|
2820
3088
|
}
|
|
2821
3089
|
/**
|
|
2822
3090
|
* Tenant scope for a `sys_business_unit` read that may legitimately be
|
|
@@ -2985,7 +3253,36 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2985
3253
|
}
|
|
2986
3254
|
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
2987
3255
|
}
|
|
2988
|
-
|
|
3256
|
+
/**
|
|
3257
|
+
* `sys_user.manager_id`, screened to the request's organization (#10153).
|
|
3258
|
+
*
|
|
3259
|
+
* Takes an organization argument for the same reason its siblings do
|
|
3260
|
+
* ({@link expandPositionUsers}, {@link expandMembershipTierUsers}): an
|
|
3261
|
+
* approver expansion answers "who, in THIS organization". Before #10153 this
|
|
3262
|
+
* one did not ask, and it was the only expansion that did not — a
|
|
3263
|
+
* `manager_id` pointing at a person in another organization routed that
|
|
3264
|
+
* person an approval over a record they are not a tenant of.
|
|
3265
|
+
*
|
|
3266
|
+
* ⚠️ The screen reads `sys_member`, which LOOKS like the D2 read-visibility
|
|
3267
|
+
* filter next to it ({@link filterApproversWhoCanRead}). It is not, and this
|
|
3268
|
+
* comment exists so the next reader does not conclude that #7497 (does
|
|
3269
|
+
* approver routing imply record read visibility?) was settled here. It was
|
|
3270
|
+
* not. Two facts make this the SIBLING treatment rather than a
|
|
3271
|
+
* read-visibility ruling:
|
|
3272
|
+
*
|
|
3273
|
+
* 1. Two of the three org-scoped expansions already screen on exactly this
|
|
3274
|
+
* column — `expandMembershipTierUsers` filters `sys_member.organization_id`
|
|
3275
|
+
* outright, and it is also the second limb of `expandPositionUsers`. So
|
|
3276
|
+
* `sys_member.organization_id` is already this file's answer to "which
|
|
3277
|
+
* organization is this person in", independent of what they may read.
|
|
3278
|
+
* 2. `sys_user` carries no `organization_id` at all. It is a GLOBAL identity
|
|
3279
|
+
* table, so a membership row is the only tenancy fact that exists for a
|
|
3280
|
+
* user — there is no other read this screen could have been written with.
|
|
3281
|
+
*
|
|
3282
|
+
* This change grants no reads and applies no read screen to any type that
|
|
3283
|
+
* lacks one today, so it decides nothing #7497 asks.
|
|
3284
|
+
*/
|
|
3285
|
+
async lookupManager(userId, organizationId) {
|
|
2989
3286
|
try {
|
|
2990
3287
|
const rows = await this.engine.find("sys_user", {
|
|
2991
3288
|
where: { id: userId },
|
|
@@ -2994,11 +3291,64 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2994
3291
|
context: SYSTEM_CTX2
|
|
2995
3292
|
});
|
|
2996
3293
|
const row = Array.isArray(rows) ? rows[0] : null;
|
|
2997
|
-
|
|
3294
|
+
const managerId = row?.manager_id ? String(row.manager_id) : null;
|
|
3295
|
+
if (!managerId) return null;
|
|
3296
|
+
if (await this.managerIsProvablyOutsideOrg(managerId, organizationId)) return null;
|
|
3297
|
+
return managerId;
|
|
2998
3298
|
} catch {
|
|
2999
3299
|
return null;
|
|
3000
3300
|
}
|
|
3001
3301
|
}
|
|
3302
|
+
/**
|
|
3303
|
+
* Is `managerId` PROVABLY a member of other organizations and not of
|
|
3304
|
+
* `organizationId`? (#10153)
|
|
3305
|
+
*
|
|
3306
|
+
* "Provably" is the whole shape of this screen, and it is deliberate rather
|
|
3307
|
+
* than a weaker version of "must prove membership":
|
|
3308
|
+
*
|
|
3309
|
+
* - membership rows exist for this user, none in the request's org
|
|
3310
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ screen him out;
|
|
3311
|
+
* - no membership rows at all, or the read failed
|
|
3312
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3313
|
+
*
|
|
3314
|
+
* The fail-open half is not timidity, it is this file's ruled posture on
|
|
3315
|
+
* addressing paths, stated twice already: {@link filterApproversWhoCanRead}
|
|
3316
|
+
* refuses to empty a live slate on an infrastructure hiccup, and
|
|
3317
|
+
* {@link expandPositionUsers} carries "a step routing to nobody is worse than
|
|
3318
|
+
* one routing to a lapsed holder". It is also load-bearing in practice — a
|
|
3319
|
+
* stack that stamps an organization on its requests but does not materialize
|
|
3320
|
+
* `sys_member` rows would otherwise lose every manager approver at once,
|
|
3321
|
+
* which is a bigger behaviour change than the hole being closed. Measured:
|
|
3322
|
+
* this repo's own `type:manager` out-of-office fixture is such a stack.
|
|
3323
|
+
*
|
|
3324
|
+
* Screening the MANAGER only, before OOO delegation, is deliberate too: the
|
|
3325
|
+
* delegate arrives from `sys_approval_delegation`, whose rows already carry
|
|
3326
|
+
* (and are already filtered by) an `organization_id` in
|
|
3327
|
+
* {@link lookupActiveDelegation}. This card is about `sys_user.manager_id`.
|
|
3328
|
+
*/
|
|
3329
|
+
async managerIsProvablyOutsideOrg(managerId, organizationId) {
|
|
3330
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3331
|
+
if (!requestOrg) return false;
|
|
3332
|
+
let rows = [];
|
|
3333
|
+
try {
|
|
3334
|
+
rows = await this.engine.find("sys_member", {
|
|
3335
|
+
where: { user_id: managerId },
|
|
3336
|
+
fields: ["user_id", "organization_id"],
|
|
3337
|
+
limit: 1e3,
|
|
3338
|
+
context: SYSTEM_CTX2
|
|
3339
|
+
});
|
|
3340
|
+
} catch {
|
|
3341
|
+
return false;
|
|
3342
|
+
}
|
|
3343
|
+
const orgs = (rows ?? []).map((r) => String(r?.organization_id ?? "")).filter(Boolean);
|
|
3344
|
+
if (!orgs.length) return false;
|
|
3345
|
+
if (orgs.includes(requestOrg)) return false;
|
|
3346
|
+
this.logger?.warn?.(
|
|
3347
|
+
`[approvals] #10153: manager '${managerId}' was dropped from the approver slate \u2014 'sys_user.manager_id' points across an organization boundary. He holds membership in ${orgs.length} organization(s), none of them the request's organization '${requestOrg}', so routing this approval to him would put approval authority over the record outside its tenant. Fix the 'manager_id' link, grant him a membership in this organization, or route this step with an approver type that names someone in it.`,
|
|
3348
|
+
{ managerId, requestOrganizationId: requestOrg, managerOrganizationIds: orgs }
|
|
3349
|
+
);
|
|
3350
|
+
return true;
|
|
3351
|
+
}
|
|
3002
3352
|
/**
|
|
3003
3353
|
* Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
|
|
3004
3354
|
* id, follow any active `sys_approval_delegation` chain and return the id the
|
|
@@ -5124,8 +5474,70 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5124
5474
|
return { requests: desired.size, inserted, deleted };
|
|
5125
5475
|
}
|
|
5126
5476
|
// ── Read API ─────────────────────────────────────────────────
|
|
5477
|
+
/**
|
|
5478
|
+
* [#11040] May the free-text pushdown carry an arm on the SNAPSHOT column,
|
|
5479
|
+
* for THIS caller over THIS query's scope?
|
|
5480
|
+
*
|
|
5481
|
+
* `payload_json` is the one searched column whose contents the serve path
|
|
5482
|
+
* masks per reader (`redactPayloads`, #10749). A predicate over it is
|
|
5483
|
+
* evaluated by the driver against the column AT REST — unmasked, before
|
|
5484
|
+
* anything is served — so for a caller whose view of the snapshot is masked,
|
|
5485
|
+
* row membership answers questions about contents that caller may not read.
|
|
5486
|
+
* The other four arms are columns of `sys_approval_request` itself, which
|
|
5487
|
+
* every caller who can see the row reads whole; they are untouched.
|
|
5488
|
+
*
|
|
5489
|
+
* ## The invariant this method exists to hold
|
|
5490
|
+
*
|
|
5491
|
+
* "This caller's view is masked" is read from **the same authority and the
|
|
5492
|
+
* same per-caller call as the serve path** — `resolveReadableSnapshotFields`,
|
|
5493
|
+
* asked as the CALLER (never `SYSTEM_CTX`). A second, independently derived
|
|
5494
|
+
* notion of "redacted" — comparing the readable set against the object's
|
|
5495
|
+
* schema, say — would be a fresh source of drift, and drift between the
|
|
5496
|
+
* serve rule and the filter rule IS the defect this closes, reconstituted one
|
|
5497
|
+
* layer down. So the only "not masked" answer accepted here is the one serve
|
|
5498
|
+
* itself acts on: `undefined`, the seam's documented do-not-narrow branch.
|
|
5499
|
+
* When the seam holds a concrete list the mask is IN FORCE, whether or not it
|
|
5500
|
+
* happens to remove a key from any particular row — a row-dependent question
|
|
5501
|
+
* no predicate can answer before rows exist.
|
|
5502
|
+
*
|
|
5503
|
+
* ## The two predicate-time cases
|
|
5504
|
+
*
|
|
5505
|
+
* Redaction is decided per ROW (each row names its own subject object, hence
|
|
5506
|
+
* its own readable set), but a filter is built before any row exists:
|
|
5507
|
+
*
|
|
5508
|
+
* - **authority absent** — `resolveReadableSnapshotFields` answers
|
|
5509
|
+
* `undefined` for EVERY object, so serve hands over every snapshot whole.
|
|
5510
|
+
* Keeping the arm leaks nothing serve does not already hand over, and this
|
|
5511
|
+
* is the shape every deployment that has not wired the security plugin
|
|
5512
|
+
* gets: search is byte-for-byte unchanged. Checked first, and that order
|
|
5513
|
+
* is load-bearing — see the object-scope note below.
|
|
5514
|
+
* - **authority wired** — the readable set is per object, so the arm is
|
|
5515
|
+
* admissible only for a scope of exactly one KNOWN object. With
|
|
5516
|
+
* `filter.object` present that object is known at predicate time and the
|
|
5517
|
+
* seam is asked about it directly. Absent, the query spans every object
|
|
5518
|
+
* and there is nothing sound to ask, so the arm is dropped.
|
|
5519
|
+
*
|
|
5520
|
+
* Dropping an arm is strictly NARROWING: it never refuses a query and never
|
|
5521
|
+
* widens what comes back, so the fail-closed direction is cheap here and is
|
|
5522
|
+
* taken rather than reaching for a per-object predicate machine to avoid it.
|
|
5523
|
+
* Refusing the query outright would be the louder behaviour change, and is
|
|
5524
|
+
* deliberately not what this does.
|
|
5525
|
+
*/
|
|
5526
|
+
async freeTextMayMatchSnapshot(objectInScope, context) {
|
|
5527
|
+
const authority = this.fieldVisibility;
|
|
5528
|
+
if (!authority || typeof authority.getReadableFields !== "function") return true;
|
|
5529
|
+
const object = String(objectInScope ?? "").trim();
|
|
5530
|
+
if (!object) return false;
|
|
5531
|
+
const readable = await resolveReadableSnapshotFields(
|
|
5532
|
+
authority,
|
|
5533
|
+
object,
|
|
5534
|
+
context,
|
|
5535
|
+
this.logger
|
|
5536
|
+
);
|
|
5537
|
+
return readable === void 0;
|
|
5538
|
+
}
|
|
5127
5539
|
/** Filter type accepted by {@link listRequests} / {@link countRequests}. */
|
|
5128
|
-
buildRequestWhere(filter, context) {
|
|
5540
|
+
async buildRequestWhere(filter, context) {
|
|
5129
5541
|
const f = {};
|
|
5130
5542
|
if (filter?.object) f.object_name = filter.object;
|
|
5131
5543
|
if (filter?.recordId) f.record_id = filter.recordId;
|
|
@@ -5134,13 +5546,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5134
5546
|
if (tenantOrg) f.organization_id = tenantOrg;
|
|
5135
5547
|
const q = filter?.q?.trim();
|
|
5136
5548
|
if (q) {
|
|
5137
|
-
|
|
5549
|
+
const arms = [
|
|
5138
5550
|
{ process_name: { $contains: q } },
|
|
5139
5551
|
{ object_name: { $contains: q } },
|
|
5140
5552
|
{ record_id: { $contains: q } },
|
|
5141
|
-
{ submitter_id: { $contains: q } }
|
|
5142
|
-
{ payload_json: { $contains: q } }
|
|
5553
|
+
{ submitter_id: { $contains: q } }
|
|
5143
5554
|
];
|
|
5555
|
+
if (await this.freeTextMayMatchSnapshot(filter?.object, context)) {
|
|
5556
|
+
arms.push({ payload_json: { $contains: q } });
|
|
5557
|
+
}
|
|
5558
|
+
f.$or = arms;
|
|
5144
5559
|
}
|
|
5145
5560
|
if (Array.isArray(filter?.status)) {
|
|
5146
5561
|
const statuses = filter.status.filter(Boolean);
|
|
@@ -5343,7 +5758,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5343
5758
|
return true;
|
|
5344
5759
|
}
|
|
5345
5760
|
async listRequests(filter, context) {
|
|
5346
|
-
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
5761
|
+
const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
|
|
5347
5762
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
5348
5763
|
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
5349
5764
|
if (ids) {
|
|
@@ -5367,12 +5782,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5367
5782
|
}
|
|
5368
5783
|
const rows = await this.engine.find("sys_approval_request", findOpts);
|
|
5369
5784
|
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
|
|
5785
|
+
await this.redactPayloads(list, context);
|
|
5370
5786
|
await this.enrichRows(list);
|
|
5371
5787
|
this.attachViewers(list, context);
|
|
5372
5788
|
return list;
|
|
5373
5789
|
}
|
|
5374
5790
|
async countRequests(filter, context) {
|
|
5375
|
-
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
5791
|
+
const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
|
|
5376
5792
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
5377
5793
|
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
5378
5794
|
if (ids) {
|
|
@@ -5435,6 +5851,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5435
5851
|
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
5436
5852
|
}
|
|
5437
5853
|
const row = rowFromRequest(rows[0]);
|
|
5854
|
+
await this.redactPayloads([row], context);
|
|
5438
5855
|
await this.enrichRows([row]);
|
|
5439
5856
|
await this.attachFlowSteps(row);
|
|
5440
5857
|
await this.attachDecisionProgress(row, rows[0]);
|
|
@@ -5938,6 +6355,52 @@ function unbindAllHooks(engine) {
|
|
|
5938
6355
|
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
5939
6356
|
}
|
|
5940
6357
|
|
|
6358
|
+
// src/payload-redaction-middleware.ts
|
|
6359
|
+
var APPROVAL_REQUEST_OBJECT = "sys_approval_request";
|
|
6360
|
+
function parseSnapshot(raw) {
|
|
6361
|
+
if (typeof raw !== "string" || raw.trim() === "") return { ok: false, value: void 0 };
|
|
6362
|
+
try {
|
|
6363
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
6364
|
+
} catch {
|
|
6365
|
+
return { ok: false, value: void 0 };
|
|
6366
|
+
}
|
|
6367
|
+
}
|
|
6368
|
+
async function redactRowsInPlace(rows, security, context, logger) {
|
|
6369
|
+
const list = Array.isArray(rows) ? rows : rows ? [rows] : [];
|
|
6370
|
+
if (list.length === 0) return;
|
|
6371
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6372
|
+
for (const row of list) {
|
|
6373
|
+
if (!row || typeof row !== "object") continue;
|
|
6374
|
+
const raw = row.payload_json;
|
|
6375
|
+
const parsed = parseSnapshot(raw);
|
|
6376
|
+
if (!parsed.ok) continue;
|
|
6377
|
+
const object = String(row.object_name ?? "").trim();
|
|
6378
|
+
if (!object) continue;
|
|
6379
|
+
if (!cache.has(object)) {
|
|
6380
|
+
cache.set(object, await resolveReadableSnapshotFields(security, object, context, logger));
|
|
6381
|
+
}
|
|
6382
|
+
const readable = cache.get(object);
|
|
6383
|
+
if (readable === void 0) continue;
|
|
6384
|
+
const { payload, redactedKeys } = redactSnapshot(parsed.value, readable);
|
|
6385
|
+
if (redactedKeys.length === 0) continue;
|
|
6386
|
+
row.payload_json = JSON.stringify(payload);
|
|
6387
|
+
}
|
|
6388
|
+
}
|
|
6389
|
+
function bindSnapshotRedactionMiddleware(engine, getSecurity, logger) {
|
|
6390
|
+
engine.registerMiddleware(async (opCtx, next) => {
|
|
6391
|
+
await next();
|
|
6392
|
+
if (opCtx?.operation !== "find" && opCtx?.operation !== "findOne") return;
|
|
6393
|
+
if (opCtx?.context?.isSystem) return;
|
|
6394
|
+
try {
|
|
6395
|
+
await redactRowsInPlace(opCtx.result, getSecurity(), opCtx.context, logger);
|
|
6396
|
+
} catch (err) {
|
|
6397
|
+
logger?.warn?.("[approvals] snapshot redaction middleware failed", {
|
|
6398
|
+
error: err?.message ?? String(err)
|
|
6399
|
+
});
|
|
6400
|
+
}
|
|
6401
|
+
}, { object: APPROVAL_REQUEST_OBJECT });
|
|
6402
|
+
}
|
|
6403
|
+
|
|
5941
6404
|
// src/approval-node.ts
|
|
5942
6405
|
import {
|
|
5943
6406
|
defineActionDescriptor as defineActionDescriptor2,
|
|
@@ -6209,11 +6672,26 @@ var ApprovalsServicePlugin = class {
|
|
|
6209
6672
|
}
|
|
6210
6673
|
}
|
|
6211
6674
|
});
|
|
6675
|
+
const fieldVisibility = () => {
|
|
6676
|
+
try {
|
|
6677
|
+
const sec = ctx.getService("security");
|
|
6678
|
+
return sec && typeof sec.getReadableFields === "function" ? sec : void 0;
|
|
6679
|
+
} catch {
|
|
6680
|
+
return void 0;
|
|
6681
|
+
}
|
|
6682
|
+
};
|
|
6683
|
+
this.service.attachFieldVisibility({
|
|
6684
|
+
getReadableFields: (object, context) => {
|
|
6685
|
+
const sec = fieldVisibility();
|
|
6686
|
+
return sec ? sec.getReadableFields(object, context) : Promise.resolve(void 0);
|
|
6687
|
+
}
|
|
6688
|
+
});
|
|
6212
6689
|
if (!this.options.disableAutoHooks) {
|
|
6213
6690
|
try {
|
|
6214
6691
|
unbindAllHooks(engine);
|
|
6215
6692
|
bindApprovalLockHook(engine, ctx.logger);
|
|
6216
6693
|
bindDelegationWriteGuard(engine, ctx.logger);
|
|
6694
|
+
bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
|
|
6217
6695
|
} catch (err) {
|
|
6218
6696
|
ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
|
|
6219
6697
|
}
|
|
@@ -6253,6 +6731,7 @@ var ApprovalsServicePlugin = class {
|
|
|
6253
6731
|
}
|
|
6254
6732
|
};
|
|
6255
6733
|
await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
|
|
6734
|
+
this.jobService = jobs;
|
|
6256
6735
|
this.escalationJobScheduled = true;
|
|
6257
6736
|
void sweep().catch((err) => {
|
|
6258
6737
|
ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
|
|
@@ -6339,14 +6818,32 @@ var ApprovalsServicePlugin = class {
|
|
|
6339
6818
|
);
|
|
6340
6819
|
}
|
|
6341
6820
|
}
|
|
6342
|
-
|
|
6821
|
+
/**
|
|
6822
|
+
* The kernel's teardown hook (`Plugin.destroy?()`, core `types.ts`) — the
|
|
6823
|
+
* ONLY teardown entry point `ObjectKernel.performShutdown()` and
|
|
6824
|
+
* `LiteKernel.destroy()` invoke.
|
|
6825
|
+
*
|
|
6826
|
+
* [#10371] IT USED TO BE `stop()`, WHICH NOTHING CALLED. `Plugin` declares
|
|
6827
|
+
* `init()`, `start?()` and `destroy?()` and no `stop()`, so the kernel walked
|
|
6828
|
+
* past this plugin at shutdown: the SLA escalation job stayed scheduled and
|
|
6829
|
+
* this plugin's ObjectQL hooks stayed bound to an engine the kernel had
|
|
6830
|
+
* finished with. `start()` IS on the interface, so the pair read as symmetric
|
|
6831
|
+
* in review — that asymmetry is what let the same shape survive in six
|
|
6832
|
+
* packages at once.
|
|
6833
|
+
*
|
|
6834
|
+
* This member owns no timer of its own (the escalation clock belongs to
|
|
6835
|
+
* `service-job`), so it never cost a merge-queue eviction the way the
|
|
6836
|
+
* `plugin-reports` / `service-messaging` members did (#9371). The class is
|
|
6837
|
+
* the same one either way: a teardown the kernel does not reach.
|
|
6838
|
+
*/
|
|
6839
|
+
async destroy() {
|
|
6343
6840
|
if (this.escalationJobScheduled) {
|
|
6344
6841
|
try {
|
|
6345
|
-
|
|
6346
|
-
await jobs?.cancel?.(ESCALATION_JOB_NAME);
|
|
6842
|
+
await this.jobService?.cancel?.(ESCALATION_JOB_NAME);
|
|
6347
6843
|
} catch {
|
|
6348
6844
|
}
|
|
6349
6845
|
this.escalationJobScheduled = false;
|
|
6846
|
+
this.jobService = void 0;
|
|
6350
6847
|
}
|
|
6351
6848
|
if (this.engine) {
|
|
6352
6849
|
try {
|
|
@@ -6355,6 +6852,17 @@ var ApprovalsServicePlugin = class {
|
|
|
6355
6852
|
}
|
|
6356
6853
|
}
|
|
6357
6854
|
}
|
|
6855
|
+
/**
|
|
6856
|
+
* Retained alias for {@link destroy}. Kept because it is public API of an
|
|
6857
|
+
* exported class, and removing it would break an embedder who learned to call
|
|
6858
|
+
* it directly precisely BECAUSE the kernel never did. The parameter is now
|
|
6859
|
+
* optional and ignored: `destroy()` takes no context, so teardown uses the
|
|
6860
|
+
* job service captured when the escalation clock was wired. Prefer kernel
|
|
6861
|
+
* shutdown; direct callers keep working unchanged.
|
|
6862
|
+
*/
|
|
6863
|
+
async stop(_ctx) {
|
|
6864
|
+
await this.destroy();
|
|
6865
|
+
}
|
|
6358
6866
|
};
|
|
6359
6867
|
export {
|
|
6360
6868
|
APPROVAL_REVISE_CORRELATION_PREFIX,
|