@i4e/invest4edu-access-core 0.43.0 → 0.43.2
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.43.
|
|
3
|
+
"version": "0.43.2",
|
|
4
4
|
"description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, entity/role vocabulary, mobile app identity, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -37,7 +37,7 @@ const UCC_RULES_TTL_MS = 60_000;
|
|
|
37
37
|
* distributors); without it an employee is never treated as already seeing a client.
|
|
38
38
|
* `toId` converts ids to the stored type (ObjectId); identity by default.
|
|
39
39
|
*/
|
|
40
|
-
export function createClientProfileService({ getDb, getSettings, getSideContext = () => ({}), getVisibilitySets = null, getFieldPolicyIndex = () => new Map(), toId = (v) => v, onAudit = () => {}, emit = null, now = () => new Date() }) {
|
|
40
|
+
export function createClientProfileService({ getDb, getSettings, getSideContext = () => ({}), getVisibilitySets = null, getFieldPolicyIndex = () => new Map(), toId = (v) => v, onAudit = () => {}, emit = null, now = () => new Date(), getSessionContext = () => null }) {
|
|
41
41
|
const db = () => {
|
|
42
42
|
const handle = getDb?.();
|
|
43
43
|
if (!handle) throw new Error("client-profile service: no database handle");
|
|
@@ -144,7 +144,15 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
|
|
|
144
144
|
if (!id) return null;
|
|
145
145
|
const user = await db().collection("users").findOne({ _id: id }, { session, projection: { distributor_id: 1, client_id: 1, employee_id: 1, full_record_access: 1 } });
|
|
146
146
|
if (!user) return null;
|
|
147
|
-
|
|
147
|
+
// The SESSION decides, not the stored roles: a person who is both a partner and a client has one
|
|
148
|
+
// user record, and their app login is pinned to the client role in its token while their
|
|
149
|
+
// portal login carries the partner role. The backend injects `getSessionContext` (its request's
|
|
150
|
+
// tenant context: the verified token's userId + roles — getTenantStore from
|
|
151
|
+
// @i4e/invest4edu-access-core/tenant-context, kept out of this module so browser code that
|
|
152
|
+
// imports client-profile never pulls in async_hooks); used when it is the user being resolved.
|
|
153
|
+
const ctx = getSessionContext?.() ?? null;
|
|
154
|
+
const sessionRoles = ctx && ctx.userId != null && String(ctx.userId) === String(userId) && Array.isArray(ctx.roles) && ctx.roles.length ? ctx.roles : null;
|
|
155
|
+
const roles = sessionRoles ?? await roleNamesOf(id, session);
|
|
148
156
|
const capabilityMap = buildCapabilityMap(await db().collection("rolecapabilities").find({}, { session }).toArray());
|
|
149
157
|
const isClientApp = Boolean(user.client_id) && roles.length > 0 && roles.every((r) => CLIENT_APP_ROLE_NAMES.includes(r));
|
|
150
158
|
return {
|
|
@@ -411,5 +419,56 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
|
|
|
411
419
|
}
|
|
412
420
|
}
|
|
413
421
|
|
|
414
|
-
|
|
422
|
+
/**
|
|
423
|
+
* SEC-97 — the caller's access to ONE client, FAIL-CLOSED: `full` | `linked` | `none`. Unlike the
|
|
424
|
+
* read helpers above (which keep screens working when a lookup breaks), this is for
|
|
425
|
+
* authorisation: no user, no client, an unknown actor or any error → `none`. A client-app
|
|
426
|
+
* session reaches only its own records (same master).
|
|
427
|
+
*/
|
|
428
|
+
async function clientAccessForUser({ userId, clientId, session } = {}) {
|
|
429
|
+
try {
|
|
430
|
+
const actor = userId ? await resolveActor(userId, { session }) : null;
|
|
431
|
+
const cid = toId(clientId);
|
|
432
|
+
if (!actor || !cid) return { level: ACCESS_LEVELS.NONE, actor };
|
|
433
|
+
const client = await db().collection(CLIENTS).findOne({ _id: cid }, { session });
|
|
434
|
+
if (!client) return { level: ACCESS_LEVELS.NONE, actor };
|
|
435
|
+
if (actor.callerClass === CALLER_CLASSES.CLIENT) {
|
|
436
|
+
const user = await db().collection("users").findOne({ _id: toId(userId) }, { session, projection: { client_id: 1 } });
|
|
437
|
+
if (!user?.client_id) return { level: ACCESS_LEVELS.NONE, actor };
|
|
438
|
+
const master = await db().collection("masterchildclients").findOne({ child_client_ids: user.client_id }, { session, projection: { child_client_ids: 1 } });
|
|
439
|
+
const own = (master?.child_client_ids?.length ? master.child_client_ids : [user.client_id]).map(String);
|
|
440
|
+
return { level: own.includes(String(cid)) ? ACCESS_LEVELS.FULL : ACCESS_LEVELS.NONE, actor };
|
|
441
|
+
}
|
|
442
|
+
const { userIds = [], sourceDistributorIds = [] } = (getVisibilitySets ? await getVisibilitySets(actor.userId) : null) ?? {};
|
|
443
|
+
const { level } = clientAccessFor(client, {
|
|
444
|
+
userIds: [...userIds, actor.userId],
|
|
445
|
+
sourceDistributorIds,
|
|
446
|
+
firmDistributorIds: firmIdsOf(actor, sourceDistributorIds),
|
|
447
|
+
fullRecordAccess: actor.fullRecordAccess,
|
|
448
|
+
houseDistributorIds: getSideContext().houseDistributorIds,
|
|
449
|
+
}, getSettings());
|
|
450
|
+
return { level, actor };
|
|
451
|
+
} catch (err) {
|
|
452
|
+
onAudit({ kind: "client_access_refused", client_id: clientId, message: err?.message });
|
|
453
|
+
return { level: ACCESS_LEVELS.NONE, actor: null };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* SEC-96 — the firm a PAN lookup runs for. A PARTNER looks only within their own firm (theirs +
|
|
459
|
+
* the firms they are RM of, `getVisibilitySets`): any other requested id is replaced by their own
|
|
460
|
+
* firm, so a corporate can never reach another corporate's profile by naming its id. Anyone else
|
|
461
|
+
* keeps the firm they name (internal staff act on a partner's behalf).
|
|
462
|
+
*/
|
|
463
|
+
async function lookupFirmFor({ userId, requestedDistributorId = null, session } = {}) {
|
|
464
|
+
if (!userId) return requestedDistributorId ?? null;
|
|
465
|
+
const actor = await resolveActor(userId, { session });
|
|
466
|
+
if (actor?.callerClass !== CALLER_CLASSES.PARTNER) return requestedDistributorId ?? null;
|
|
467
|
+
if (!requestedDistributorId) return actor.distributorId ?? null;
|
|
468
|
+
const { sourceDistributorIds = [] } = (getVisibilitySets ? await getVisibilitySets(actor.userId) : null) ?? {};
|
|
469
|
+
const allowed = firmIdsOf(actor, sourceDistributorIds).map(String);
|
|
470
|
+
return allowed.includes(String(requestedDistributorId)) ? requestedDistributorId : actor.distributorId ?? null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return Object.freeze({ findActiveProfilesByPan, findExisting, attach, attachUser, resolveActor, sideFilterFor, evaluatePersonalEdit, goalContextFor, noteClientUccEdit, clearUccPending, callerMayWriteClient, clientAccessForUser, lookupFirmFor });
|
|
415
474
|
}
|