@i4e/invest4edu-access-core 0.4.1 → 0.5.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/package.json +9 -3
- package/src/access-resolver.js +256 -0
- package/src/access-schema.js +151 -0
- package/src/index.js +31 -1
- package/src/role-capabilities.js +12 -2
- package/src/tenant-plugin.js +21 -1
- package/src/visible-when.js +108 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Shared access-control primitives
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js",
|
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
"./tenant-plugin": "./src/tenant-plugin.js",
|
|
10
10
|
"./role-capabilities": "./src/role-capabilities.js",
|
|
11
11
|
"./reportee-tree": "./src/reportee-tree.js",
|
|
12
|
-
"./access-config": "./src/access-config.js"
|
|
12
|
+
"./access-config": "./src/access-config.js",
|
|
13
|
+
"./access-schema": "./src/access-schema.js",
|
|
14
|
+
"./access-resolver": "./src/access-resolver.js",
|
|
15
|
+
"./visible-when": "./src/visible-when.js"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/"
|
|
13
19
|
},
|
|
14
20
|
"files": [
|
|
15
21
|
"src",
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access snapshot resolver — @i4e/invest4edu-access-core (unified access framework, P1).
|
|
3
|
+
*
|
|
4
|
+
* ONE resolver produces the "access snapshot" for a request: the nav tree, the per-feature
|
|
5
|
+
* allow/actions/entitlement/tracking map, and the product scope. Everything downstream — nav,
|
|
6
|
+
* `hasFeature`, `requireFeature`, grid `visibleWhen`, entitlement, metering — reads this one
|
|
7
|
+
* object, so there is a single place where access is decided.
|
|
8
|
+
*
|
|
9
|
+
* PURE by design: callers inject already-loaded data (`registry`, `grants`, `mappedProducts`, …).
|
|
10
|
+
* No Mongoose, no I/O, no ALS — which is what makes allow/deny matrices testable without a DB.
|
|
11
|
+
*
|
|
12
|
+
* Design: TDD-unified-access-and-subscription.md §4
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { parentCodeOf } from "./access-schema.js";
|
|
16
|
+
|
|
17
|
+
/** Reasons a feature can be locked, surfaced so the UI can distinguish upsell from hard-deny. */
|
|
18
|
+
export const ENTITLEMENT_REASONS = Object.freeze({
|
|
19
|
+
OK: "ok",
|
|
20
|
+
ADMIN_BYPASS: "admin_bypass",
|
|
21
|
+
NO_SUBSCRIPTION: "no_subscription",
|
|
22
|
+
TRIAL_ACTIVE: "trial_active",
|
|
23
|
+
TRIAL_EXPIRED: "trial_expired",
|
|
24
|
+
LOCKED: "locked",
|
|
25
|
+
QUOTA_EXCEEDED: "quota_exceeded",
|
|
26
|
+
PAYMENT_OVERDUE: "payment_overdue",
|
|
27
|
+
SUBSCRIPTION_EXPIRED: "subscription_expired",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const isLive = (f) =>
|
|
31
|
+
!!f && f.status === "active" && f.kill_switch !== "on";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Merge grants into an effective allow-set per feature.
|
|
35
|
+
*
|
|
36
|
+
* Rules (design §3.3): union(role allows) − union(user denies) + union(user allows).
|
|
37
|
+
* Deny beats allow at the same subject level; the user level beats the role level.
|
|
38
|
+
*
|
|
39
|
+
* Product dimension: a grant with `product_code: null` is UNSCOPED and contributes the caller's
|
|
40
|
+
* whole product set (legacy-equivalent). A scoped grant contributes only that product, and only
|
|
41
|
+
* when the caller actually has it mapped (default-deny).
|
|
42
|
+
*
|
|
43
|
+
* @returns {Map<string, {products: Set<string>|null}>} null products = unscoped/all
|
|
44
|
+
*/
|
|
45
|
+
export function mergeGrants(grants = [], { mappedProducts = [], now = new Date() } = {}) {
|
|
46
|
+
const mapped = new Set(mappedProducts.map(String));
|
|
47
|
+
|
|
48
|
+
const roleAllow = new Map(); // code → Set|null
|
|
49
|
+
const userAllow = new Map();
|
|
50
|
+
const userDeny = new Set();
|
|
51
|
+
const roleDeny = new Set();
|
|
52
|
+
|
|
53
|
+
const addTo = (map, code, product) => {
|
|
54
|
+
if (!map.has(code)) map.set(code, { products: null, anyUnscoped: false });
|
|
55
|
+
const e = map.get(code);
|
|
56
|
+
if (product == null) {
|
|
57
|
+
e.anyUnscoped = true; // unscoped grant wins — covers everything the user has
|
|
58
|
+
e.products = null;
|
|
59
|
+
} else if (!e.anyUnscoped) {
|
|
60
|
+
if (!e.products) e.products = new Set();
|
|
61
|
+
e.products.add(String(product));
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
for (const g of grants) {
|
|
66
|
+
if (!g || !g.feature_code) continue;
|
|
67
|
+
if (g.expires_at && new Date(g.expires_at) <= now) continue; // expired grants are inert
|
|
68
|
+
|
|
69
|
+
// A product-scoped grant only counts when the caller actually has that product mapped.
|
|
70
|
+
if (g.product_code != null && !mapped.has(String(g.product_code))) continue;
|
|
71
|
+
|
|
72
|
+
const isUser = g.subject_type === "user";
|
|
73
|
+
if (g.effect === "deny") {
|
|
74
|
+
(isUser ? userDeny : roleDeny).add(g.feature_code);
|
|
75
|
+
} else {
|
|
76
|
+
addTo(isUser ? userAllow : roleAllow, g.feature_code, g.product_code ?? null);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const out = new Map();
|
|
81
|
+
const put = (code, entry) => {
|
|
82
|
+
const prev = out.get(code);
|
|
83
|
+
if (!prev) {
|
|
84
|
+
out.set(code, { products: entry.anyUnscoped ? null : new Set(entry.products || []) });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (prev.products === null || entry.anyUnscoped) prev.products = null;
|
|
88
|
+
else for (const p of entry.products || []) prev.products.add(p);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
for (const [code, entry] of roleAllow) {
|
|
92
|
+
if (roleDeny.has(code)) continue; // deny beats allow at the same level
|
|
93
|
+
put(code, entry);
|
|
94
|
+
}
|
|
95
|
+
for (const code of userDeny) out.delete(code); // user deny beats role allow
|
|
96
|
+
for (const [code, entry] of userAllow) put(code, entry); // user allow beats role deny
|
|
97
|
+
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the nav tree from the features the caller holds.
|
|
103
|
+
* Screens only, `nav.show_in_nav`, grouped by module and ordered by position.
|
|
104
|
+
*
|
|
105
|
+
* The nav IS the access model projected — not a parallel copy (requirement 2).
|
|
106
|
+
*/
|
|
107
|
+
export function buildNav(allowedScreenFeatures = [], modules = []) {
|
|
108
|
+
const modByCode = new Map(
|
|
109
|
+
modules.filter((m) => m && m.status !== "retired").map((m) => [m.module_code, m]),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const groups = new Map();
|
|
113
|
+
for (const f of allowedScreenFeatures) {
|
|
114
|
+
if (!f?.nav?.show_in_nav) continue;
|
|
115
|
+
const code = f.module_code;
|
|
116
|
+
const mod = modByCode.get(code);
|
|
117
|
+
if (!mod) continue; // a feature whose module is missing/retired is simply not navigable
|
|
118
|
+
if (!groups.has(code)) {
|
|
119
|
+
groups.set(code, {
|
|
120
|
+
module_code: code,
|
|
121
|
+
name: mod.name,
|
|
122
|
+
icon: mod.icon || null,
|
|
123
|
+
position: mod.position ?? 0,
|
|
124
|
+
items: [],
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
groups.get(code).items.push({
|
|
128
|
+
feature_code: f.feature_code,
|
|
129
|
+
name: f.name,
|
|
130
|
+
route: f.route || null,
|
|
131
|
+
position: f.nav.position ?? 0,
|
|
132
|
+
is_external_url: !!f.nav.is_external_url,
|
|
133
|
+
external_url: f.nav.external_url || null,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const nav = [...groups.values()].sort((a, b) => a.position - b.position);
|
|
138
|
+
for (const g of nav) g.items.sort((a, b) => a.position - b.position);
|
|
139
|
+
return nav;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolve the access snapshot.
|
|
144
|
+
*
|
|
145
|
+
* @param {Object} p
|
|
146
|
+
* @param {Object} p.identity { account_id, userId, roles[], actingAs? }
|
|
147
|
+
* @param {Object} p.registry { features: [], modules: [] } (already filtered to live rows)
|
|
148
|
+
* @param {Array} p.grants access_grants rows for {account, roles ∪ userId}
|
|
149
|
+
* @param {Array} p.mappedProducts the caller's product codes
|
|
150
|
+
* @param {Object} p.capabilities capsFor(roles) — reads `bypassAllGates` (D27)
|
|
151
|
+
* @param {Function} [p.entitlementFor] (feature) → entitlement block | null
|
|
152
|
+
*/
|
|
153
|
+
export function resolveAccessSnapshot({
|
|
154
|
+
identity = {},
|
|
155
|
+
registry = {},
|
|
156
|
+
grants = [],
|
|
157
|
+
mappedProducts = [],
|
|
158
|
+
capabilities = {},
|
|
159
|
+
entitlementFor = null,
|
|
160
|
+
now = new Date(),
|
|
161
|
+
} = {}) {
|
|
162
|
+
const features = (registry.features || []).filter(isLive);
|
|
163
|
+
const modules = registry.modules || [];
|
|
164
|
+
|
|
165
|
+
// ── D27: system-admin break-glass ───────────────────────────────────────────────────────────
|
|
166
|
+
// Resolved ONCE, here — never re-checked per gate, so no gate can forget to honour it. Opens
|
|
167
|
+
// every gate WITHIN the caller's own account. Deliberately NOT cross-tenant: crossing accounts
|
|
168
|
+
// is the separate, stricter canCrossAccount path (D3/BRI-582). bypassAllGates ≠ canCrossAccount.
|
|
169
|
+
const bypass = !!capabilities.bypassAllGates;
|
|
170
|
+
|
|
171
|
+
const allowedByCode = bypass
|
|
172
|
+
? new Map(features.map((f) => [f.feature_code, { products: null }]))
|
|
173
|
+
: mergeGrants(grants, { mappedProducts, now });
|
|
174
|
+
|
|
175
|
+
const byCode = new Map(features.map((f) => [f.feature_code, f]));
|
|
176
|
+
const productScope = bypass
|
|
177
|
+
? [...new Set(features.flatMap((f) => f.products || []))]
|
|
178
|
+
: [...new Set(mappedProducts.map(String))];
|
|
179
|
+
|
|
180
|
+
const featureMap = {};
|
|
181
|
+
const allowedScreens = [];
|
|
182
|
+
|
|
183
|
+
for (const f of features) {
|
|
184
|
+
const grant = allowedByCode.get(f.feature_code);
|
|
185
|
+
if (!grant) continue;
|
|
186
|
+
|
|
187
|
+
// Platform-level product constraint on the feature itself, intersected with the grant's.
|
|
188
|
+
let products = grant.products ? [...grant.products] : [...productScope];
|
|
189
|
+
if (Array.isArray(f.products) && f.products.length) {
|
|
190
|
+
const allowedForFeature = new Set(f.products.map(String));
|
|
191
|
+
products = products.filter((p) => allowedForFeature.has(p));
|
|
192
|
+
// A product-constrained feature with no overlap is not reachable at all.
|
|
193
|
+
if (products.length === 0 && !bypass) continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const entitlement = bypass
|
|
197
|
+
? {
|
|
198
|
+
unlocked: true,
|
|
199
|
+
quota: null,
|
|
200
|
+
used: 0,
|
|
201
|
+
remaining: null,
|
|
202
|
+
overage_policy: "unlimited",
|
|
203
|
+
reason: ENTITLEMENT_REASONS.ADMIN_BYPASS,
|
|
204
|
+
}
|
|
205
|
+
: entitlementFor
|
|
206
|
+
? entitlementFor(f)
|
|
207
|
+
: null;
|
|
208
|
+
|
|
209
|
+
featureMap[f.feature_code] = {
|
|
210
|
+
allowed: true,
|
|
211
|
+
products: grant.products === null && !Array.isArray(f.products) ? "*" : products,
|
|
212
|
+
actions: [],
|
|
213
|
+
entitlement,
|
|
214
|
+
tracking: !!f.tracking_enabled,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (f.feature_type === "screen") allowedScreens.push(f);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Attach each allowed action to its parent screen (actions are features, design §2).
|
|
221
|
+
for (const code of Object.keys(featureMap)) {
|
|
222
|
+
const f = byCode.get(code);
|
|
223
|
+
if (f?.feature_type !== "action") continue;
|
|
224
|
+
const parent = f.parent_feature_code || parentCodeOf(code);
|
|
225
|
+
if (parent && featureMap[parent]) featureMap[parent].actions.push(code);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
version: 1,
|
|
230
|
+
resolved_at: now.toISOString(),
|
|
231
|
+
resolved_for: {
|
|
232
|
+
userId: identity.userId ? String(identity.userId) : null,
|
|
233
|
+
account_id: identity.account_id ? String(identity.account_id) : null,
|
|
234
|
+
actingAs: identity.actingAs?.giver_id || null,
|
|
235
|
+
real_userId: identity.actingAs?.delegate_id || (identity.userId ? String(identity.userId) : null),
|
|
236
|
+
admin_bypass: bypass,
|
|
237
|
+
},
|
|
238
|
+
nav: buildNav(allowedScreens, modules),
|
|
239
|
+
features: featureMap,
|
|
240
|
+
productScope,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Snapshot-based `hasFeature` (the server-side twin of the client helper). */
|
|
245
|
+
export function hasFeature(snapshot, featureCode) {
|
|
246
|
+
return !!snapshot?.features?.[featureCode]?.allowed;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** True when the feature is allowed AND (no entitlement gate OR it is unlocked). */
|
|
250
|
+
export function isUsable(snapshot, featureCode) {
|
|
251
|
+
const f = snapshot?.features?.[featureCode];
|
|
252
|
+
if (!f?.allowed) return false;
|
|
253
|
+
return !f.entitlement || f.entitlement.unlocked !== false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default { resolveAccessSnapshot, mergeGrants, buildNav, hasFeature, isUsable, ENTITLEMENT_REASONS };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access-model schema DEFINITIONS — @i4e/invest4edu-access-core (unified access framework, P1).
|
|
3
|
+
*
|
|
4
|
+
* These are PLAIN OBJECTS, not Mongoose schemas (D4: no models in the package). Each backend
|
|
5
|
+
* registers its own models from these definitions, so there is one source of truth for field
|
|
6
|
+
* names without dragging a second Mongoose instance into the package.
|
|
7
|
+
*
|
|
8
|
+
* Design: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TDD-unified-access-and-subscription.md
|
|
9
|
+
* Status: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TRACKER-unified-access.md
|
|
10
|
+
*
|
|
11
|
+
* Taxonomy: Module → Feature → Action
|
|
12
|
+
* - a PAGE is a feature (feature_type: 'screen') — retires Page + page_features CSV
|
|
13
|
+
* - an ACTION is a feature (parent_feature_code) — so a plan can entitle an action
|
|
14
|
+
* One `feature_code` namespace serves RBAC, nav, grids, metering and entitlement.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Feature types. A page is a `screen`; an action is an `action` with a parent. */
|
|
18
|
+
export const FEATURE_TYPES = Object.freeze(["screen", "action", "data", "service", "tool"]);
|
|
19
|
+
|
|
20
|
+
/** Lifecycle status shared by registry collections. */
|
|
21
|
+
export const REGISTRY_STATUSES = Object.freeze(["active", "dormant", "retired"]);
|
|
22
|
+
|
|
23
|
+
/** Per-feature enforcement ladder (mirrors the platform's off→warn→enforce doctrine). */
|
|
24
|
+
export const ROLLOUT_MODES = Object.freeze(["off", "warn", "enforce"]);
|
|
25
|
+
|
|
26
|
+
/** Grant effect. `deny` at the user level replaces the old `revoked_pages`. */
|
|
27
|
+
export const GRANT_EFFECTS = Object.freeze(["allow", "deny"]);
|
|
28
|
+
|
|
29
|
+
/** Subject a grant is attached to. */
|
|
30
|
+
export const GRANT_SUBJECTS = Object.freeze(["role", "user"]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `feature_code` convention: MODULE.SCREEN or MODULE.SCREEN.ACTION.
|
|
34
|
+
* Uppercase segments, `_` within a segment, `.` between. Codes are IMMUTABLE once created —
|
|
35
|
+
* they appear in grants, plan entitlements, usage events and grid configs.
|
|
36
|
+
*/
|
|
37
|
+
export const FEATURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*){1,2}$/;
|
|
38
|
+
|
|
39
|
+
export function isValidFeatureCode(code) {
|
|
40
|
+
return typeof code === "string" && FEATURE_CODE_PATTERN.test(code);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Derive a screen's code from an action code (`A.B.C` → `A.B`); null if not an action. */
|
|
44
|
+
export function parentCodeOf(featureCode) {
|
|
45
|
+
if (!isValidFeatureCode(featureCode)) return null;
|
|
46
|
+
const parts = featureCode.split(".");
|
|
47
|
+
return parts.length === 3 ? `${parts[0]}.${parts[1]}` : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Module code of any feature code (`A.B[.C]` → `A`). */
|
|
51
|
+
export function moduleCodeOf(featureCode) {
|
|
52
|
+
if (!isValidFeatureCode(featureCode)) return null;
|
|
53
|
+
return featureCode.split(".")[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Collection definitions ────────────────────────────────────────────────────────────────────
|
|
57
|
+
// `collection` is the Mongo collection name each backend must use, so v1 and v2 agree.
|
|
58
|
+
|
|
59
|
+
/** Global: nav grouping + reporting dimension. Carries no grants. */
|
|
60
|
+
export const ACCESS_MODULE_DEF = Object.freeze({
|
|
61
|
+
collection: "access_modules",
|
|
62
|
+
fields: Object.freeze({
|
|
63
|
+
module_code: { type: "String", required: true, unique: true, index: true },
|
|
64
|
+
name: { type: "String", required: true },
|
|
65
|
+
icon: { type: "String" },
|
|
66
|
+
position: { type: "Number", default: 0 },
|
|
67
|
+
status: { type: "String", enum: REGISTRY_STATUSES, default: "active" },
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Global: THE feature registry. Absorbs Page, Page.page_features, the flat Feature model, and
|
|
73
|
+
* the subscription feature registry (BRI-674/707 — one vocabulary, structurally).
|
|
74
|
+
*/
|
|
75
|
+
export const ACCESS_FEATURE_DEF = Object.freeze({
|
|
76
|
+
collection: "access_features",
|
|
77
|
+
fields: Object.freeze({
|
|
78
|
+
feature_code: { type: "String", required: true, unique: true, index: true, immutable: true },
|
|
79
|
+
name: { type: "String", required: true },
|
|
80
|
+
description: { type: "String" },
|
|
81
|
+
feature_type: { type: "String", enum: FEATURE_TYPES, required: true, index: true },
|
|
82
|
+
module_code: { type: "String", index: true },
|
|
83
|
+
parent_feature_code: { type: "String", default: null, index: true }, // set for actions
|
|
84
|
+
|
|
85
|
+
route: { type: "String", default: null }, // screens only (absorbs page_route_for_web)
|
|
86
|
+
nav: {
|
|
87
|
+
show_in_nav: { type: "Boolean", default: false },
|
|
88
|
+
position: { type: "Number", default: 0 },
|
|
89
|
+
icon: { type: "String" },
|
|
90
|
+
is_external_url: { type: "Boolean", default: false },
|
|
91
|
+
external_url: { type: "String" },
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
products: { type: "[String]", default: [] }, // empty = product-agnostic
|
|
95
|
+
|
|
96
|
+
status: { type: "String", enum: REGISTRY_STATUSES, default: "active", index: true },
|
|
97
|
+
kill_switch: { type: "String", enum: ["on", "off"], default: "off" },
|
|
98
|
+
rollout_mode: { type: "String", enum: ROLLOUT_MODES, default: "off" },
|
|
99
|
+
|
|
100
|
+
// Survives an entitlement block (login, billing/checkout, profile) — see design §7.4.
|
|
101
|
+
always_available: { type: "Boolean", default: false },
|
|
102
|
+
|
|
103
|
+
tracking_enabled: { type: "Boolean", default: false }, // analytics (side DB)
|
|
104
|
+
is_meterable: { type: "Boolean", default: false }, // quota (app DB counters)
|
|
105
|
+
single_usage_purchasable: { type: "Boolean", default: false },
|
|
106
|
+
single_usage_price: { type: "Number", default: null },
|
|
107
|
+
|
|
108
|
+
legacy_page_id: { type: "ObjectId", default: null }, // migration traceability
|
|
109
|
+
created_by: { type: "ObjectId" },
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Tenant: one row per grant. Replaces RolePrivileges.accessible_pages, UserPrivileges and
|
|
115
|
+
* revoked_pages. Rows (not embedded arrays) so grants are indexable, diffable and expirable.
|
|
116
|
+
*/
|
|
117
|
+
export const ACCESS_GRANT_DEF = Object.freeze({
|
|
118
|
+
collection: "access_grants",
|
|
119
|
+
fields: Object.freeze({
|
|
120
|
+
account_id: { type: "ObjectId", required: true, index: true },
|
|
121
|
+
subject_type: { type: "String", enum: GRANT_SUBJECTS, required: true },
|
|
122
|
+
subject_id: { type: "String", required: true }, // role_code | userId
|
|
123
|
+
feature_code: { type: "String", required: true, index: true },
|
|
124
|
+
// null = unscoped (applies across the user's mapped_products) — the legacy-equivalent default.
|
|
125
|
+
// set = applies only when that product is in the user's mapped_products (default-deny).
|
|
126
|
+
product_code: { type: "String", default: null },
|
|
127
|
+
effect: { type: "String", enum: GRANT_EFFECTS, default: "allow" },
|
|
128
|
+
conditions: { type: "Mixed", default: null },
|
|
129
|
+
expires_at: { type: "Date", default: null },
|
|
130
|
+
granted_by: { type: "ObjectId" },
|
|
131
|
+
}),
|
|
132
|
+
indexes: Object.freeze([
|
|
133
|
+
{ keys: { account_id: 1, subject_type: 1, subject_id: 1 } },
|
|
134
|
+
{ keys: { account_id: 1, feature_code: 1 } },
|
|
135
|
+
]),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export default {
|
|
139
|
+
FEATURE_TYPES,
|
|
140
|
+
REGISTRY_STATUSES,
|
|
141
|
+
ROLLOUT_MODES,
|
|
142
|
+
GRANT_EFFECTS,
|
|
143
|
+
GRANT_SUBJECTS,
|
|
144
|
+
FEATURE_CODE_PATTERN,
|
|
145
|
+
isValidFeatureCode,
|
|
146
|
+
parentCodeOf,
|
|
147
|
+
moduleCodeOf,
|
|
148
|
+
ACCESS_MODULE_DEF,
|
|
149
|
+
ACCESS_FEATURE_DEF,
|
|
150
|
+
ACCESS_GRANT_DEF,
|
|
151
|
+
};
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Tenant isolation (D1 keystone) + role capabilities (G7).
|
|
4
4
|
*/
|
|
5
5
|
export { default as als, runWithTenant, runAsSystem, getTenantStore } from "./tenant-context.js";
|
|
6
|
-
export { default as tenantPlugin } from "./tenant-plugin.js";
|
|
6
|
+
export { default as tenantPlugin, setTenantMode, getTenantMode } from "./tenant-plugin.js";
|
|
7
7
|
export {
|
|
8
8
|
CAPABILITY_FLAGS,
|
|
9
9
|
DEFAULT_ROLE_CAPABILITIES,
|
|
@@ -18,3 +18,33 @@ export {
|
|
|
18
18
|
readEnvFlags,
|
|
19
19
|
resolveFlags,
|
|
20
20
|
} from "./access-config.js";
|
|
21
|
+
|
|
22
|
+
// ── Unified access framework (P1) ─────────────────────────────────────────────────────────────
|
|
23
|
+
export {
|
|
24
|
+
FEATURE_TYPES,
|
|
25
|
+
REGISTRY_STATUSES,
|
|
26
|
+
ROLLOUT_MODES,
|
|
27
|
+
GRANT_EFFECTS,
|
|
28
|
+
GRANT_SUBJECTS,
|
|
29
|
+
FEATURE_CODE_PATTERN,
|
|
30
|
+
isValidFeatureCode,
|
|
31
|
+
parentCodeOf,
|
|
32
|
+
moduleCodeOf,
|
|
33
|
+
ACCESS_MODULE_DEF,
|
|
34
|
+
ACCESS_FEATURE_DEF,
|
|
35
|
+
ACCESS_GRANT_DEF,
|
|
36
|
+
} from "./access-schema.js";
|
|
37
|
+
export {
|
|
38
|
+
resolveAccessSnapshot,
|
|
39
|
+
mergeGrants,
|
|
40
|
+
buildNav,
|
|
41
|
+
hasFeature,
|
|
42
|
+
isUsable,
|
|
43
|
+
ENTITLEMENT_REASONS,
|
|
44
|
+
} from "./access-resolver.js";
|
|
45
|
+
export {
|
|
46
|
+
evaluateVisibleWhen,
|
|
47
|
+
filterVisible,
|
|
48
|
+
applyRelabel,
|
|
49
|
+
resolveColumns,
|
|
50
|
+
} from "./visible-when.js";
|
package/src/role-capabilities.js
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* canCorrectE1 — may run the audited E1 correction
|
|
14
14
|
* canCrossAccount — audited sysadmin cross-account path (default: none)
|
|
15
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.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
export const CAPABILITY_FLAGS = Object.freeze([
|
|
@@ -22,6 +29,7 @@ export const CAPABILITY_FLAGS = Object.freeze([
|
|
|
22
29
|
"canCorrectE1",
|
|
23
30
|
"canCrossAccount",
|
|
24
31
|
"productScoped",
|
|
32
|
+
"bypassAllGates",
|
|
25
33
|
]);
|
|
26
34
|
|
|
27
35
|
/**
|
|
@@ -32,8 +40,10 @@ export const CAPABILITY_FLAGS = Object.freeze([
|
|
|
32
40
|
* ALLOWED_ROLES (e1) = SUPER_ADMIN, SYSTEM_ADMIN, BROKER_ADMIN, BACKOFFICE_USER
|
|
33
41
|
*/
|
|
34
42
|
export const DEFAULT_ROLE_CAPABILITIES = Object.freeze({
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
// bypassAllGates (D27) defaults to the two platform-admin roles. Inert until the access
|
|
44
|
+
// engine reads it, and revocable from the `rolecapabilities` collection with no deploy.
|
|
45
|
+
SUPER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true },
|
|
46
|
+
SYSTEM_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true, bypassAllGates: true },
|
|
37
47
|
BROKER_ADMIN: { isFullAccess: true, canGovernDelegation: true, canCorrectE1: true },
|
|
38
48
|
BACKOFFICE_USER: { isFullAccess: true, canCorrectE1: true },
|
|
39
49
|
HR_USER: { isFullAccess: true },
|
package/src/tenant-plugin.js
CHANGED
|
@@ -20,7 +20,27 @@
|
|
|
20
20
|
import als from "./tenant-context.js";
|
|
21
21
|
|
|
22
22
|
const READ_OPS = ["find", "findOne", "countDocuments"];
|
|
23
|
-
const
|
|
23
|
+
const VALID = new Set(["off", "warn", "enforce"]);
|
|
24
|
+
|
|
25
|
+
// Enforcement mode is resolvable from TWO places, so it can be flipped from the admin UI
|
|
26
|
+
// (DB) WITHOUT a restart, while the env var stays the infra-level emergency override.
|
|
27
|
+
// Precedence: env TENANT_ENFORCEMENT (if valid) > DB value (setTenantMode) > "off"
|
|
28
|
+
let dbMode = null;
|
|
29
|
+
|
|
30
|
+
/** Called by the backend from the `accessconfig` DB doc so a UI toggle takes effect (~refresh). */
|
|
31
|
+
export function setTenantMode(m) {
|
|
32
|
+
const v = m ? String(m).toLowerCase() : "";
|
|
33
|
+
dbMode = VALID.has(v) ? v : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Effective mode. */
|
|
37
|
+
export function getTenantMode() {
|
|
38
|
+
const env = (process.env.TENANT_ENFORCEMENT || "").toLowerCase();
|
|
39
|
+
if (VALID.has(env)) return env; // explicit env wins (emergency / infra)
|
|
40
|
+
return dbMode || "off";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const mode = getTenantMode;
|
|
24
44
|
|
|
25
45
|
function tenantHook() {
|
|
26
46
|
if (mode() === "off") return; // literal no-op — identical to pre-Track-3
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `visibleWhen` — ONE predicate language, ONE evaluator.
|
|
3
|
+
* @i4e/invest4edu-access-core (unified access framework, P1)
|
|
4
|
+
*
|
|
5
|
+
* The same function gates nav items, screen actions, grid columns, filters, row/bulk actions,
|
|
6
|
+
* exports and summary metrics. That is what makes "driven by product and roles" true across the
|
|
7
|
+
* whole app instead of per-screen — and it means a rule is written once, in data, not in 139
|
|
8
|
+
* hardcoded predicates.
|
|
9
|
+
*
|
|
10
|
+
* Client-side evaluation is UX ONLY. Servers MUST re-evaluate before returning data, projecting
|
|
11
|
+
* columns, or building an export/summary — never trust a client-supplied column list.
|
|
12
|
+
*
|
|
13
|
+
* Design: TDD-unified-access-and-subscription.md §8.3
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { hasFeature, isUsable } from "./access-resolver.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Shape (every key optional; all present keys must pass — AND):
|
|
20
|
+
* {
|
|
21
|
+
* features: ["ORDERS.LIST"] // caller must hold all of these
|
|
22
|
+
* actions: ["ORDERS.LIST.CANCEL"] // alias of features, reads better on actions
|
|
23
|
+
* entitled: "TOOLS.AI_REPORT" // must be allowed AND entitlement-unlocked
|
|
24
|
+
* products: ["IVP003"] // caller's product scope must intersect
|
|
25
|
+
* distributorLevel: ["D1","D2"]
|
|
26
|
+
* screenType: "openorders" | ["a","b"]
|
|
27
|
+
* roles: ["BROKER_ADMIN"] // LEGACY escape hatch — lint-flagged, being removed
|
|
28
|
+
* anyOf: [ {...}, {...} ] // OR of nested predicates
|
|
29
|
+
* not: { ... } // negation
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* @param {Object|null|undefined} rule absent/empty ⇒ visible (opt-in gating, not opt-out)
|
|
33
|
+
* @param {Object} ctx { snapshot, productScope?, distributorLevel?, screenType?, roles? }
|
|
34
|
+
*/
|
|
35
|
+
export function evaluateVisibleWhen(rule, ctx = {}) {
|
|
36
|
+
if (!rule || typeof rule !== "object") return true;
|
|
37
|
+
|
|
38
|
+
const snapshot = ctx.snapshot || null;
|
|
39
|
+
// A system admin (D27) resolves a fully-open snapshot, so feature/entitlement checks below
|
|
40
|
+
// pass naturally. Product/level predicates still evaluate — an admin seeing a D1-only column
|
|
41
|
+
// labelled for D1 would be misleading, not permissive.
|
|
42
|
+
const productScope = new Set(
|
|
43
|
+
(ctx.productScope || snapshot?.productScope || []).map(String),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
|
|
47
|
+
|
|
48
|
+
if (rule.features && !asArray(rule.features).every((c) => hasFeature(snapshot, c))) return false;
|
|
49
|
+
if (rule.actions && !asArray(rule.actions).every((c) => hasFeature(snapshot, c))) return false;
|
|
50
|
+
if (rule.entitled && !asArray(rule.entitled).every((c) => isUsable(snapshot, c))) return false;
|
|
51
|
+
|
|
52
|
+
if (rule.products) {
|
|
53
|
+
const want = asArray(rule.products).map(String);
|
|
54
|
+
if (want.length && !want.some((p) => productScope.has(p))) return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (rule.distributorLevel) {
|
|
58
|
+
const lvl = ctx.distributorLevel ? String(ctx.distributorLevel) : null;
|
|
59
|
+
if (!lvl || !asArray(rule.distributorLevel).map(String).includes(lvl)) return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (rule.screenType) {
|
|
63
|
+
const st = ctx.screenType ? String(ctx.screenType) : null;
|
|
64
|
+
if (!st || !asArray(rule.screenType).map(String).includes(st)) return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// LEGACY: kept only so migration can proceed screen-by-screen. New configs must not use it.
|
|
68
|
+
if (rule.roles) {
|
|
69
|
+
const held = new Set(asArray(ctx.roles || snapshot?.resolved_for?.roles).map(String));
|
|
70
|
+
if (!asArray(rule.roles).map(String).some((r) => held.has(r))) return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (rule.anyOf) {
|
|
74
|
+
const branches = asArray(rule.anyOf);
|
|
75
|
+
if (branches.length && !branches.some((b) => evaluateVisibleWhen(b, ctx))) return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (rule.not && evaluateVisibleWhen(rule.not, ctx)) return false;
|
|
79
|
+
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Filter a list of `{visibleWhen}`-bearing config items (columns, metrics, actions, filters). */
|
|
84
|
+
export function filterVisible(items = [], ctx = {}) {
|
|
85
|
+
return items.filter((i) => evaluateVisibleWhen(i?.visibleWhen, ctx));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Apply `relabelWhen` overrides to a grid column — absorbs the imperative relabelling in
|
|
90
|
+
* order-constants (e.g. d1/d2/d3_payout → "Net Income" for the matching distributor level).
|
|
91
|
+
* First matching rule wins; the column is returned unchanged when nothing matches.
|
|
92
|
+
*/
|
|
93
|
+
export function applyRelabel(column, ctx = {}) {
|
|
94
|
+
if (!column?.relabelWhen?.length) return column;
|
|
95
|
+
for (const r of column.relabelWhen) {
|
|
96
|
+
if (evaluateVisibleWhen(r?.when, ctx)) {
|
|
97
|
+
return { ...column, header: r.header ?? column.header, label: r.label ?? column.label };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return column;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolve a full column list: drop hidden ones, apply relabelling, preserve order. */
|
|
104
|
+
export function resolveColumns(columns = [], ctx = {}) {
|
|
105
|
+
return filterVisible(columns, ctx).map((c) => applyRelabel(c, ctx));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default { evaluateVisibleWhen, filterVisible, applyRelabel, resolveColumns };
|