@i4e/invest4edu-access-core 0.43.1 → 0.43.3

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.1",
3
+ "version": "0.43.3",
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": {
@@ -76,7 +76,9 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
76
76
  if (!isEvaluated(settings, "attach")) return { attached: false, kind: "none", reason: "mode_off" };
77
77
  const profile = await db().collection(CLIENTS).findOne(
78
78
  { _id: clientId },
79
- { session, projection: { distributor_id: 1, distributor_mappings: 1, linked_users: 1 } },
79
+ // profile_type is what the corporate silo in attachDecision reads — without it every profile
80
+ // looked like a main one and another firm could be attached to a corporate profile.
81
+ { session, projection: { distributor_id: 1, distributor_mappings: 1, linked_users: 1, profile_type: 1 } },
80
82
  );
81
83
  const decision = attachDecision({ profile, actor, via, at: now(), settings, ctx: getSideContext(), stamps });
82
84
  if (decision.kind === "none") return { attached: false, ...decision };
@@ -419,5 +421,56 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
419
421
  }
420
422
  }
421
423
 
422
- return Object.freeze({ findActiveProfilesByPan, findExisting, attach, attachUser, resolveActor, sideFilterFor, evaluatePersonalEdit, goalContextFor, noteClientUccEdit, clearUccPending, callerMayWriteClient });
424
+ /**
425
+ * SEC-97 — the caller's access to ONE client, FAIL-CLOSED: `full` | `linked` | `none`. Unlike the
426
+ * read helpers above (which keep screens working when a lookup breaks), this is for
427
+ * authorisation: no user, no client, an unknown actor or any error → `none`. A client-app
428
+ * session reaches only its own records (same master).
429
+ */
430
+ async function clientAccessForUser({ userId, clientId, session } = {}) {
431
+ try {
432
+ const actor = userId ? await resolveActor(userId, { session }) : null;
433
+ const cid = toId(clientId);
434
+ if (!actor || !cid) return { level: ACCESS_LEVELS.NONE, actor };
435
+ const client = await db().collection(CLIENTS).findOne({ _id: cid }, { session });
436
+ if (!client) return { level: ACCESS_LEVELS.NONE, actor };
437
+ if (actor.callerClass === CALLER_CLASSES.CLIENT) {
438
+ const user = await db().collection("users").findOne({ _id: toId(userId) }, { session, projection: { client_id: 1 } });
439
+ if (!user?.client_id) return { level: ACCESS_LEVELS.NONE, actor };
440
+ const master = await db().collection("masterchildclients").findOne({ child_client_ids: user.client_id }, { session, projection: { child_client_ids: 1 } });
441
+ const own = (master?.child_client_ids?.length ? master.child_client_ids : [user.client_id]).map(String);
442
+ return { level: own.includes(String(cid)) ? ACCESS_LEVELS.FULL : ACCESS_LEVELS.NONE, actor };
443
+ }
444
+ const { userIds = [], sourceDistributorIds = [] } = (getVisibilitySets ? await getVisibilitySets(actor.userId) : null) ?? {};
445
+ const { level } = clientAccessFor(client, {
446
+ userIds: [...userIds, actor.userId],
447
+ sourceDistributorIds,
448
+ firmDistributorIds: firmIdsOf(actor, sourceDistributorIds),
449
+ fullRecordAccess: actor.fullRecordAccess,
450
+ houseDistributorIds: getSideContext().houseDistributorIds,
451
+ }, getSettings());
452
+ return { level, actor };
453
+ } catch (err) {
454
+ onAudit({ kind: "client_access_refused", client_id: clientId, message: err?.message });
455
+ return { level: ACCESS_LEVELS.NONE, actor: null };
456
+ }
457
+ }
458
+
459
+ /**
460
+ * SEC-96 — the firm a PAN lookup runs for. A PARTNER looks only within their own firm (theirs +
461
+ * the firms they are RM of, `getVisibilitySets`): any other requested id is replaced by their own
462
+ * firm, so a corporate can never reach another corporate's profile by naming its id. Anyone else
463
+ * keeps the firm they name (internal staff act on a partner's behalf).
464
+ */
465
+ async function lookupFirmFor({ userId, requestedDistributorId = null, session } = {}) {
466
+ if (!userId) return requestedDistributorId ?? null;
467
+ const actor = await resolveActor(userId, { session });
468
+ if (actor?.callerClass !== CALLER_CLASSES.PARTNER) return requestedDistributorId ?? null;
469
+ if (!requestedDistributorId) return actor.distributorId ?? null;
470
+ const { sourceDistributorIds = [] } = (getVisibilitySets ? await getVisibilitySets(actor.userId) : null) ?? {};
471
+ const allowed = firmIdsOf(actor, sourceDistributorIds).map(String);
472
+ return allowed.includes(String(requestedDistributorId)) ? requestedDistributorId : actor.distributorId ?? null;
473
+ }
474
+
475
+ return Object.freeze({ findActiveProfilesByPan, findExisting, attach, attachUser, resolveActor, sideFilterFor, evaluatePersonalEdit, goalContextFor, noteClientUccEdit, clearUccPending, callerMayWriteClient, clientAccessForUser, lookupFirmFor });
423
476
  }