@i4e/invest4edu-access-core 0.32.0 → 0.34.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/README.md +126 -126
- package/package.json +58 -56
- package/src/access-config.js +68 -68
- package/src/access-resolver.js +269 -269
- package/src/access-schema.js +174 -174
- package/src/credits.js +128 -128
- package/src/entitlement-schema.js +194 -194
- package/src/entitlement-store.d.ts +99 -99
- package/src/entitlement-store.js +610 -610
- package/src/entitlement.js +300 -300
- package/src/grid-schema.js +231 -231
- package/src/index.js +84 -74
- package/src/proration.js +155 -155
- package/src/report-audience.js +226 -0
- package/src/reportee-tree.js +129 -129
- package/src/role-capabilities.js +93 -93
- package/src/route-features.js +292 -292
- package/src/route-screen.js +45 -0
- package/src/subscription-lifecycle.js +106 -106
- package/src/tenant-context.js +26 -26
- package/src/tenant-plugin.js +177 -177
- package/src/visible-when.js +108 -108
package/src/reportee-tree.js
CHANGED
|
@@ -1,129 +1,129 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reportee-tree DFS — @i4e/invest4edu-access-core (Track 3 G3 shared primitive).
|
|
3
|
-
*
|
|
4
|
-
* The one canonical "self + all reportees" traversal, shared by both backends so v1 and v2
|
|
5
|
-
* compose the SAME hierarchy scope. Pure + data-source agnostic: the caller passes a flat
|
|
6
|
-
* employees list `[{ _id, user_id, reporting_manager }]` (v1 feeds the Redis snapshot, v2 the
|
|
7
|
-
* `employees` collection) and one-or-more root user ids (delegation passes `[self, ...givers]`).
|
|
8
|
-
*
|
|
9
|
-
* ── `reporting_manager` is an employee `_id` (BRI-973) ────────────────────────────────
|
|
10
|
-
* It used to hold the manager's **user id**. It now holds the manager's **employee `_id`**,
|
|
11
|
-
* so the hierarchy no longer depends on `employees.user_id` and that field can be dropped.
|
|
12
|
-
*
|
|
13
|
-
* Through the rollout this accepted both shapes; that widening is gone. Only an `_id` that is
|
|
14
|
-
* present in the list resolves — an unrecognised reference detaches that manager's downline
|
|
15
|
-
* rather than being read as a user id, which is the correct reading now that no row should
|
|
16
|
-
* hold one.
|
|
17
|
-
*
|
|
18
|
-
* ── DEPLOY ORDER: migration first, then this version ──────────────────────────────────
|
|
19
|
-
* This is a BREAKING change to the data this function can read, on a 0.x minor. Against
|
|
20
|
-
* un-migrated rows it returns ZERO reportees — and since it feeds record visibility and the
|
|
21
|
-
* lead access scope, that is a silent narrowing of what people can see rather than an error
|
|
22
|
-
* anyone would notice.
|
|
23
|
-
*
|
|
24
|
-
* So: run nfd-api-node's migrations/verify-reporting-manager-to-employee-id.js and get a
|
|
25
|
-
* clean verdict FIRST, then ship this version. The window that matters is a rolling deploy,
|
|
26
|
-
* where an upgraded instance and un-migrated rows can coexist for real minutes — the
|
|
27
|
-
* migration has to be complete before the first instance carrying this code serves traffic,
|
|
28
|
-
* not merely started.
|
|
29
|
-
*
|
|
30
|
-
* Roots and the returned set stay **user ids** throughout, because that is what the callers
|
|
31
|
-
* compare against (`assigned_rm_id`, `rm_id`). Only the manager reference is translated.
|
|
32
|
-
*
|
|
33
|
-
* ── CALLER CONTRACT: pass the manager's own row ───────────────────────────────────────
|
|
34
|
-
* An employee-id reference can only be resolved if the row carrying that `_id` is in the
|
|
35
|
-
* list. A user-id reference never needed a lookup, so under the old shape an absent manager
|
|
36
|
-
* row cost nothing — under the new shape it silently detaches that manager's entire
|
|
37
|
-
* downline.
|
|
38
|
-
*
|
|
39
|
-
* So a caller must feed EVERY employee for linking, and filter unwanted people out of the
|
|
40
|
-
* RESULT. Feeding a pre-filtered list (`{ status: 1 }`, an account scope, anything) drops
|
|
41
|
-
* link rows, and what you lose is not the filtered person but everyone beneath them.
|
|
42
|
-
*
|
|
43
|
-
* The caller this bites is nfd-api-node's `src/cache/employeeCache.js`, which builds the
|
|
44
|
-
* hierarchy snapshot. It filtered `{ status: 1 }` and argued the filter was safe because it
|
|
45
|
-
* "already breaks the chain at a deactivated manager" — true under the old shape, where the
|
|
46
|
-
* manager's user id was the map key whether or not their row was present, and false under
|
|
47
|
-
* this one. It now selects every employee and filters the RESULT instead. Any new caller
|
|
48
|
-
* has to do the same.
|
|
49
|
-
*
|
|
50
|
-
* @param {string|string[]} roots root user id(s)
|
|
51
|
-
* @param {Array<{_id?:*, user_id:*, reporting_manager:*}>} employees flat employee list
|
|
52
|
-
* @param {Object} [options]
|
|
53
|
-
* @param {Function} [options.onUnresolvable] called once when the list carries no `_id` at
|
|
54
|
-
* all — see below. Defaults to `console.warn`; pass the app logger to route it properly.
|
|
55
|
-
* @returns {string[]} de-duplicated reachable set (roots + all reportees) as STRING ids
|
|
56
|
-
*/
|
|
57
|
-
export function resolveReporteeUserIds(roots, employees, options = {}) {
|
|
58
|
-
const rootIds = (Array.isArray(roots) ? roots : [roots]).filter((id) => id != null).map(String);
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* employee `_id` -> that employee's user id.
|
|
62
|
-
*
|
|
63
|
-
* Built first and over the whole list, because a manager may appear anywhere in it — a
|
|
64
|
-
* single pass that resolved as it went would miss any manager listed after their reportee.
|
|
65
|
-
*
|
|
66
|
-
* Employees with no `user_id` are still indexed: such a row cannot be a REPORTEE (nothing
|
|
67
|
-
* to return for them) but it can perfectly well be a MANAGER, and dropping it here would
|
|
68
|
-
* silently orphan their whole downline. `null` marks "known employee, no portal user".
|
|
69
|
-
*/
|
|
70
|
-
const userIdByEmployeeId = new Map();
|
|
71
|
-
for (const emp of employees || []) {
|
|
72
|
-
if (!emp || emp._id == null) continue;
|
|
73
|
-
userIdByEmployeeId.set(String(emp._id), emp.user_id == null ? null : String(emp.user_id));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* A list with no `_id` on any row is the pre-BRI-973 shape, and nothing in it can ever
|
|
78
|
-
* resolve — every caller gets `[roots]` back, which reads as "this manager has no
|
|
79
|
-
* reportees" rather than as "this list cannot be understood".
|
|
80
|
-
*
|
|
81
|
-
* That is the one failure this cannot leave silent. It is not a degraded answer, it is a
|
|
82
|
-
* confident wrong one, and it is cheap to tell apart: an employee list of any size with
|
|
83
|
-
* an empty index means the caller passed the old shape or dropped `_id` from a projection.
|
|
84
|
-
* A warn here costs nothing on a correct call and turns the worst failure mode into an
|
|
85
|
-
* obvious one. It does not throw — this sits on the login path, and refusing to resolve a
|
|
86
|
-
* scope is a harder failure than reporting one.
|
|
87
|
-
*/
|
|
88
|
-
if ((employees?.length || 0) > 0 && userIdByEmployeeId.size === 0) {
|
|
89
|
-
const warn = options.onUnresolvable || ((message) => console.warn(message));
|
|
90
|
-
warn(
|
|
91
|
-
`[reportee-tree] ${employees.length} employees but not one carries an \`_id\`, so no `
|
|
92
|
-
+ 'reporting_manager reference can resolve and every caller sees zero reportees. The list '
|
|
93
|
-
+ 'is in the pre-BRI-973 shape, or `_id` was dropped from the projection.',
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const byManager = new Map();
|
|
98
|
-
for (const emp of employees || []) {
|
|
99
|
-
if (!emp || !emp.user_id) continue;
|
|
100
|
-
if (emp.reporting_manager == null) continue;
|
|
101
|
-
|
|
102
|
-
const raw = String(emp.reporting_manager);
|
|
103
|
-
/**
|
|
104
|
-
* The reference is an employee `_id`; it is translated to that manager's user id.
|
|
105
|
-
*
|
|
106
|
-
* `get` rather than the old "fall back to reading it as a user id": an unknown `_id` now
|
|
107
|
-
* means either a dangling reference or a row the caller filtered out of the list, and
|
|
108
|
-
* both are conditions to drop the edge on, not to guess about. See the caller contract.
|
|
109
|
-
*/
|
|
110
|
-
const managerId = userIdByEmployeeId.get(raw);
|
|
111
|
-
// Unknown reference, or a manager with no portal user — no key to hang reportees on.
|
|
112
|
-
if (managerId == null) continue;
|
|
113
|
-
|
|
114
|
-
if (!byManager.has(managerId)) byManager.set(managerId, []);
|
|
115
|
-
byManager.get(managerId).push(String(emp.user_id));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const visited = new Set();
|
|
119
|
-
const result = new Set();
|
|
120
|
-
const stack = [...rootIds];
|
|
121
|
-
while (stack.length) {
|
|
122
|
-
const id = stack.pop();
|
|
123
|
-
if (!id || visited.has(id)) continue;
|
|
124
|
-
visited.add(id);
|
|
125
|
-
result.add(id);
|
|
126
|
-
for (const rep of byManager.get(id) || []) stack.push(rep);
|
|
127
|
-
}
|
|
128
|
-
return [...result];
|
|
129
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reportee-tree DFS — @i4e/invest4edu-access-core (Track 3 G3 shared primitive).
|
|
3
|
+
*
|
|
4
|
+
* The one canonical "self + all reportees" traversal, shared by both backends so v1 and v2
|
|
5
|
+
* compose the SAME hierarchy scope. Pure + data-source agnostic: the caller passes a flat
|
|
6
|
+
* employees list `[{ _id, user_id, reporting_manager }]` (v1 feeds the Redis snapshot, v2 the
|
|
7
|
+
* `employees` collection) and one-or-more root user ids (delegation passes `[self, ...givers]`).
|
|
8
|
+
*
|
|
9
|
+
* ── `reporting_manager` is an employee `_id` (BRI-973) ────────────────────────────────
|
|
10
|
+
* It used to hold the manager's **user id**. It now holds the manager's **employee `_id`**,
|
|
11
|
+
* so the hierarchy no longer depends on `employees.user_id` and that field can be dropped.
|
|
12
|
+
*
|
|
13
|
+
* Through the rollout this accepted both shapes; that widening is gone. Only an `_id` that is
|
|
14
|
+
* present in the list resolves — an unrecognised reference detaches that manager's downline
|
|
15
|
+
* rather than being read as a user id, which is the correct reading now that no row should
|
|
16
|
+
* hold one.
|
|
17
|
+
*
|
|
18
|
+
* ── DEPLOY ORDER: migration first, then this version ──────────────────────────────────
|
|
19
|
+
* This is a BREAKING change to the data this function can read, on a 0.x minor. Against
|
|
20
|
+
* un-migrated rows it returns ZERO reportees — and since it feeds record visibility and the
|
|
21
|
+
* lead access scope, that is a silent narrowing of what people can see rather than an error
|
|
22
|
+
* anyone would notice.
|
|
23
|
+
*
|
|
24
|
+
* So: run nfd-api-node's migrations/verify-reporting-manager-to-employee-id.js and get a
|
|
25
|
+
* clean verdict FIRST, then ship this version. The window that matters is a rolling deploy,
|
|
26
|
+
* where an upgraded instance and un-migrated rows can coexist for real minutes — the
|
|
27
|
+
* migration has to be complete before the first instance carrying this code serves traffic,
|
|
28
|
+
* not merely started.
|
|
29
|
+
*
|
|
30
|
+
* Roots and the returned set stay **user ids** throughout, because that is what the callers
|
|
31
|
+
* compare against (`assigned_rm_id`, `rm_id`). Only the manager reference is translated.
|
|
32
|
+
*
|
|
33
|
+
* ── CALLER CONTRACT: pass the manager's own row ───────────────────────────────────────
|
|
34
|
+
* An employee-id reference can only be resolved if the row carrying that `_id` is in the
|
|
35
|
+
* list. A user-id reference never needed a lookup, so under the old shape an absent manager
|
|
36
|
+
* row cost nothing — under the new shape it silently detaches that manager's entire
|
|
37
|
+
* downline.
|
|
38
|
+
*
|
|
39
|
+
* So a caller must feed EVERY employee for linking, and filter unwanted people out of the
|
|
40
|
+
* RESULT. Feeding a pre-filtered list (`{ status: 1 }`, an account scope, anything) drops
|
|
41
|
+
* link rows, and what you lose is not the filtered person but everyone beneath them.
|
|
42
|
+
*
|
|
43
|
+
* The caller this bites is nfd-api-node's `src/cache/employeeCache.js`, which builds the
|
|
44
|
+
* hierarchy snapshot. It filtered `{ status: 1 }` and argued the filter was safe because it
|
|
45
|
+
* "already breaks the chain at a deactivated manager" — true under the old shape, where the
|
|
46
|
+
* manager's user id was the map key whether or not their row was present, and false under
|
|
47
|
+
* this one. It now selects every employee and filters the RESULT instead. Any new caller
|
|
48
|
+
* has to do the same.
|
|
49
|
+
*
|
|
50
|
+
* @param {string|string[]} roots root user id(s)
|
|
51
|
+
* @param {Array<{_id?:*, user_id:*, reporting_manager:*}>} employees flat employee list
|
|
52
|
+
* @param {Object} [options]
|
|
53
|
+
* @param {Function} [options.onUnresolvable] called once when the list carries no `_id` at
|
|
54
|
+
* all — see below. Defaults to `console.warn`; pass the app logger to route it properly.
|
|
55
|
+
* @returns {string[]} de-duplicated reachable set (roots + all reportees) as STRING ids
|
|
56
|
+
*/
|
|
57
|
+
export function resolveReporteeUserIds(roots, employees, options = {}) {
|
|
58
|
+
const rootIds = (Array.isArray(roots) ? roots : [roots]).filter((id) => id != null).map(String);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* employee `_id` -> that employee's user id.
|
|
62
|
+
*
|
|
63
|
+
* Built first and over the whole list, because a manager may appear anywhere in it — a
|
|
64
|
+
* single pass that resolved as it went would miss any manager listed after their reportee.
|
|
65
|
+
*
|
|
66
|
+
* Employees with no `user_id` are still indexed: such a row cannot be a REPORTEE (nothing
|
|
67
|
+
* to return for them) but it can perfectly well be a MANAGER, and dropping it here would
|
|
68
|
+
* silently orphan their whole downline. `null` marks "known employee, no portal user".
|
|
69
|
+
*/
|
|
70
|
+
const userIdByEmployeeId = new Map();
|
|
71
|
+
for (const emp of employees || []) {
|
|
72
|
+
if (!emp || emp._id == null) continue;
|
|
73
|
+
userIdByEmployeeId.set(String(emp._id), emp.user_id == null ? null : String(emp.user_id));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A list with no `_id` on any row is the pre-BRI-973 shape, and nothing in it can ever
|
|
78
|
+
* resolve — every caller gets `[roots]` back, which reads as "this manager has no
|
|
79
|
+
* reportees" rather than as "this list cannot be understood".
|
|
80
|
+
*
|
|
81
|
+
* That is the one failure this cannot leave silent. It is not a degraded answer, it is a
|
|
82
|
+
* confident wrong one, and it is cheap to tell apart: an employee list of any size with
|
|
83
|
+
* an empty index means the caller passed the old shape or dropped `_id` from a projection.
|
|
84
|
+
* A warn here costs nothing on a correct call and turns the worst failure mode into an
|
|
85
|
+
* obvious one. It does not throw — this sits on the login path, and refusing to resolve a
|
|
86
|
+
* scope is a harder failure than reporting one.
|
|
87
|
+
*/
|
|
88
|
+
if ((employees?.length || 0) > 0 && userIdByEmployeeId.size === 0) {
|
|
89
|
+
const warn = options.onUnresolvable || ((message) => console.warn(message));
|
|
90
|
+
warn(
|
|
91
|
+
`[reportee-tree] ${employees.length} employees but not one carries an \`_id\`, so no `
|
|
92
|
+
+ 'reporting_manager reference can resolve and every caller sees zero reportees. The list '
|
|
93
|
+
+ 'is in the pre-BRI-973 shape, or `_id` was dropped from the projection.',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const byManager = new Map();
|
|
98
|
+
for (const emp of employees || []) {
|
|
99
|
+
if (!emp || !emp.user_id) continue;
|
|
100
|
+
if (emp.reporting_manager == null) continue;
|
|
101
|
+
|
|
102
|
+
const raw = String(emp.reporting_manager);
|
|
103
|
+
/**
|
|
104
|
+
* The reference is an employee `_id`; it is translated to that manager's user id.
|
|
105
|
+
*
|
|
106
|
+
* `get` rather than the old "fall back to reading it as a user id": an unknown `_id` now
|
|
107
|
+
* means either a dangling reference or a row the caller filtered out of the list, and
|
|
108
|
+
* both are conditions to drop the edge on, not to guess about. See the caller contract.
|
|
109
|
+
*/
|
|
110
|
+
const managerId = userIdByEmployeeId.get(raw);
|
|
111
|
+
// Unknown reference, or a manager with no portal user — no key to hang reportees on.
|
|
112
|
+
if (managerId == null) continue;
|
|
113
|
+
|
|
114
|
+
if (!byManager.has(managerId)) byManager.set(managerId, []);
|
|
115
|
+
byManager.get(managerId).push(String(emp.user_id));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const visited = new Set();
|
|
119
|
+
const result = new Set();
|
|
120
|
+
const stack = [...rootIds];
|
|
121
|
+
while (stack.length) {
|
|
122
|
+
const id = stack.pop();
|
|
123
|
+
if (!id || visited.has(id)) continue;
|
|
124
|
+
visited.add(id);
|
|
125
|
+
result.add(id);
|
|
126
|
+
for (const rep of byManager.get(id) || []) stack.push(rep);
|
|
127
|
+
}
|
|
128
|
+
return [...result];
|
|
129
|
+
}
|
package/src/role-capabilities.js
CHANGED
|
@@ -1,93 +1,93 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Role capabilities — @i4e/invest4edu-access-core (Track 3 C1 · G7).
|
|
3
|
-
*
|
|
4
|
-
* Turns role classification from hardcoded per-repo Sets into DATA. A `roleCapabilities`
|
|
5
|
-
* collection (one row per role) overrides these defaults; with no rows, `resolveCapabilities`
|
|
6
|
-
* reproduces the exact legacy behaviour, so wiring this in is behaviour-neutral until config
|
|
7
|
-
* is populated.
|
|
8
|
-
*
|
|
9
|
-
* Flags:
|
|
10
|
-
* isFullAccess — sees all data within the account (BROKER_ADMIN-style super-role)
|
|
11
|
-
* isEmployee — eligible for the reportee-tree / 5-field visibility scope
|
|
12
|
-
* canGovernDelegation — may grant / edit / reactivate delegations
|
|
13
|
-
* canCorrectE1 — may run the audited E1 correction
|
|
14
|
-
* canCrossAccount — audited sysadmin cross-account path (default: none)
|
|
15
|
-
* productScoped — pages + records restricted to the user's mapped products
|
|
16
|
-
* bypassAllGates — system-admin break-glass (D27): opens EVERY gate WITHIN the caller's
|
|
17
|
-
* own account (RBAC, nav, entitlement, product scope, grid visibleWhen,
|
|
18
|
-
* kill switches). Deliberately NOT cross-tenant — crossing accounts is
|
|
19
|
-
* the separate, stricter `canCrossAccount` path (D3/BRI-582: config data
|
|
20
|
-
* only, header-driven, logged before bypass). bypassAllGates ≠
|
|
21
|
-
* canCrossAccount. Exists so a broken access config can never lock
|
|
22
|
-
* everyone out of the screens needed to repair it.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
export const CAPABILITY_FLAGS = Object.freeze([
|
|
26
|
-
"isFullAccess",
|
|
27
|
-
"isEmployee",
|
|
28
|
-
"canGovernDelegation",
|
|
29
|
-
"canCorrectE1",
|
|
30
|
-
"canCrossAccount",
|
|
31
|
-
"productScoped",
|
|
32
|
-
"bypassAllGates",
|
|
33
|
-
// Edit the GLOBAL feature registry (modules/screens/actions + their flags). Deliberately
|
|
34
|
-
// narrower than canGovernDelegation: that includes per-account BROKER_ADMINs, and the registry
|
|
35
|
-
// is cross-account — one edit changes what every tenant sees.
|
|
36
|
-
"canManageRegistry",
|
|
37
|
-
]);
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Legacy-parity defaults. MUST mirror the hardcoded Sets they replace:
|
|
41
|
-
* FULL_ACCESS_ROLES = SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN, BACKOFFICE_USER, HR_USER
|
|
42
|
-
* EMPLOYEE_ROLES = SALES_USER, PAT_SALES
|
|
43
|
-
* GRANT_ROLES (deleg)= SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN
|
|
44
|
-
* ALLOWED_ROLES (e1) = SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN, BACKOFFICE_USER
|
|
45
|
-
*/
|
|
46
|
-
export const DEFAULT_ROLE_CAPABILITIES = Object.freeze({
|
|
47
|
-
// bypassAllGates (D27) defaults to the two platform-admin roles. Inert until the access
|
|
48
|
-
// engine reads it, and revocable from the `rolecapabilities` collection with no deploy.
|
|
49
|
-
SUPER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true, canManageRegistry: true },
|
|
50
|
-
SYSTEM_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true, canManageRegistry: true },
|
|
51
|
-
BROKER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true },
|
|
52
|
-
BACKOFFICE_USER: { isFullAccess: true, canCorrectE1: true },
|
|
53
|
-
HR_USER: { isFullAccess: true },
|
|
54
|
-
SALES_USER: { isEmployee: true },
|
|
55
|
-
PAT_SALES: { isEmployee: true },
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
function pickFlags(row) {
|
|
59
|
-
const out = {};
|
|
60
|
-
for (const f of CAPABILITY_FLAGS) if (row[f] != null) out[f] = !!row[f];
|
|
61
|
-
return out;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Merge DB rows over the parity defaults → a role→capabilities map.
|
|
66
|
-
* @param {Array<{role:string} & Record<string, boolean>>} dbRows
|
|
67
|
-
*/
|
|
68
|
-
export function buildCapabilityMap(dbRows = []) {
|
|
69
|
-
const map = {};
|
|
70
|
-
for (const [role, caps] of Object.entries(DEFAULT_ROLE_CAPABILITIES)) map[role] = { ...caps };
|
|
71
|
-
for (const row of dbRows) {
|
|
72
|
-
if (!row || !row.role) continue;
|
|
73
|
-
map[row.role] = { ...(map[row.role] || {}), ...pickFlags(row) };
|
|
74
|
-
}
|
|
75
|
-
return map;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* OR-combine the capability flags across all of a caller's roles (multi-role = union).
|
|
80
|
-
* @param {string[]} roles
|
|
81
|
-
* @param {Record<string, Record<string, boolean>>} [capabilityMap] defaults if omitted
|
|
82
|
-
* @returns {Record<string, boolean>} every CAPABILITY_FLAG resolved to a boolean
|
|
83
|
-
*/
|
|
84
|
-
export function resolveCapabilities(roles, capabilityMap = DEFAULT_ROLE_CAPABILITIES) {
|
|
85
|
-
const out = {};
|
|
86
|
-
for (const f of CAPABILITY_FLAGS) out[f] = false;
|
|
87
|
-
for (const role of Array.isArray(roles) ? roles : [roles].filter(Boolean)) {
|
|
88
|
-
const caps = capabilityMap[role];
|
|
89
|
-
if (!caps) continue;
|
|
90
|
-
for (const f of CAPABILITY_FLAGS) if (caps[f]) out[f] = true;
|
|
91
|
-
}
|
|
92
|
-
return out;
|
|
93
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Role capabilities — @i4e/invest4edu-access-core (Track 3 C1 · G7).
|
|
3
|
+
*
|
|
4
|
+
* Turns role classification from hardcoded per-repo Sets into DATA. A `roleCapabilities`
|
|
5
|
+
* collection (one row per role) overrides these defaults; with no rows, `resolveCapabilities`
|
|
6
|
+
* reproduces the exact legacy behaviour, so wiring this in is behaviour-neutral until config
|
|
7
|
+
* is populated.
|
|
8
|
+
*
|
|
9
|
+
* Flags:
|
|
10
|
+
* isFullAccess — sees all data within the account (BROKER_ADMIN-style super-role)
|
|
11
|
+
* isEmployee — eligible for the reportee-tree / 5-field visibility scope
|
|
12
|
+
* canGovernDelegation — may grant / edit / reactivate delegations
|
|
13
|
+
* canCorrectE1 — may run the audited E1 correction
|
|
14
|
+
* canCrossAccount — audited sysadmin cross-account path (default: none)
|
|
15
|
+
* productScoped — pages + records restricted to the user's mapped products
|
|
16
|
+
* bypassAllGates — system-admin break-glass (D27): opens EVERY gate WITHIN the caller's
|
|
17
|
+
* own account (RBAC, nav, entitlement, product scope, grid visibleWhen,
|
|
18
|
+
* kill switches). Deliberately NOT cross-tenant — crossing accounts is
|
|
19
|
+
* the separate, stricter `canCrossAccount` path (D3/BRI-582: config data
|
|
20
|
+
* only, header-driven, logged before bypass). bypassAllGates ≠
|
|
21
|
+
* canCrossAccount. Exists so a broken access config can never lock
|
|
22
|
+
* everyone out of the screens needed to repair it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const CAPABILITY_FLAGS = Object.freeze([
|
|
26
|
+
"isFullAccess",
|
|
27
|
+
"isEmployee",
|
|
28
|
+
"canGovernDelegation",
|
|
29
|
+
"canCorrectE1",
|
|
30
|
+
"canCrossAccount",
|
|
31
|
+
"productScoped",
|
|
32
|
+
"bypassAllGates",
|
|
33
|
+
// Edit the GLOBAL feature registry (modules/screens/actions + their flags). Deliberately
|
|
34
|
+
// narrower than canGovernDelegation: that includes per-account BROKER_ADMINs, and the registry
|
|
35
|
+
// is cross-account — one edit changes what every tenant sees.
|
|
36
|
+
"canManageRegistry",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Legacy-parity defaults. MUST mirror the hardcoded Sets they replace:
|
|
41
|
+
* FULL_ACCESS_ROLES = SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN, BACKOFFICE_USER, HR_USER
|
|
42
|
+
* EMPLOYEE_ROLES = SALES_USER, PAT_SALES
|
|
43
|
+
* GRANT_ROLES (deleg)= SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN
|
|
44
|
+
* ALLOWED_ROLES (e1) = SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN, BACKOFFICE_USER
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_ROLE_CAPABILITIES = Object.freeze({
|
|
47
|
+
// bypassAllGates (D27) defaults to the two platform-admin roles. Inert until the access
|
|
48
|
+
// engine reads it, and revocable from the `rolecapabilities` collection with no deploy.
|
|
49
|
+
SUPER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true, canManageRegistry: true },
|
|
50
|
+
SYSTEM_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true, canManageRegistry: true },
|
|
51
|
+
BROKER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true },
|
|
52
|
+
BACKOFFICE_USER: { isFullAccess: true, canCorrectE1: true },
|
|
53
|
+
HR_USER: { isFullAccess: true },
|
|
54
|
+
SALES_USER: { isEmployee: true },
|
|
55
|
+
PAT_SALES: { isEmployee: true },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function pickFlags(row) {
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const f of CAPABILITY_FLAGS) if (row[f] != null) out[f] = !!row[f];
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Merge DB rows over the parity defaults → a role→capabilities map.
|
|
66
|
+
* @param {Array<{role:string} & Record<string, boolean>>} dbRows
|
|
67
|
+
*/
|
|
68
|
+
export function buildCapabilityMap(dbRows = []) {
|
|
69
|
+
const map = {};
|
|
70
|
+
for (const [role, caps] of Object.entries(DEFAULT_ROLE_CAPABILITIES)) map[role] = { ...caps };
|
|
71
|
+
for (const row of dbRows) {
|
|
72
|
+
if (!row || !row.role) continue;
|
|
73
|
+
map[row.role] = { ...(map[row.role] || {}), ...pickFlags(row) };
|
|
74
|
+
}
|
|
75
|
+
return map;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* OR-combine the capability flags across all of a caller's roles (multi-role = union).
|
|
80
|
+
* @param {string[]} roles
|
|
81
|
+
* @param {Record<string, Record<string, boolean>>} [capabilityMap] defaults if omitted
|
|
82
|
+
* @returns {Record<string, boolean>} every CAPABILITY_FLAG resolved to a boolean
|
|
83
|
+
*/
|
|
84
|
+
export function resolveCapabilities(roles, capabilityMap = DEFAULT_ROLE_CAPABILITIES) {
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const f of CAPABILITY_FLAGS) out[f] = false;
|
|
87
|
+
for (const role of Array.isArray(roles) ? roles : [roles].filter(Boolean)) {
|
|
88
|
+
const caps = capabilityMap[role];
|
|
89
|
+
if (!caps) continue;
|
|
90
|
+
for (const f of CAPABILITY_FLAGS) if (caps[f]) out[f] = true;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|