@i4e/invest4edu-access-core 0.41.0 → 0.42.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
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": {
@@ -363,6 +363,40 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
363
363
  }
364
364
  }
365
365
 
366
+ /**
367
+ * May this caller write to this client? A client-app session only to their own records — the
368
+ * same person's profiles under one master; portal callers are scoped by their own rules and
369
+ * always pass here. Gated by modes.own_records_only (audit logs, enforce refuses).
370
+ * @returns {Promise<{ allowed: boolean, reason?: string }>}
371
+ */
372
+ async function callerMayWriteClient({ userId, clientId } = {}) {
373
+ const settings = getSettings();
374
+ if (!isEvaluated(settings, "own_records_only") || !userId || !clientId) return { allowed: true };
375
+ let actor;
376
+ try {
377
+ actor = await resolveActor(userId);
378
+ } catch (err) {
379
+ onAudit({ kind: "own_records_error", client_id: clientId, message: err?.message });
380
+ return { allowed: true };
381
+ }
382
+ if (actor?.callerClass !== CALLER_CLASSES.CLIENT) return { allowed: true };
383
+ let own = [];
384
+ try {
385
+ const user = await db().collection("users").findOne({ _id: toId(userId) }, { projection: { client_id: 1 } });
386
+ if (user?.client_id) {
387
+ const master = await db().collection("masterchildclients").findOne({ child_client_ids: user.client_id }, { projection: { child_client_ids: 1 } });
388
+ own = (master?.child_client_ids?.length ? master.child_client_ids : [user.client_id]).map(String);
389
+ }
390
+ } catch (err) {
391
+ // A client session whose records cannot be read must not write anywhere.
392
+ onAudit({ kind: "own_records_error", client_id: clientId, message: err?.message });
393
+ return { allowed: !isEnforced(settings, "own_records_only"), reason: "unresolved" };
394
+ }
395
+ if (own.includes(String(clientId))) return { allowed: true };
396
+ onAudit({ kind: "own_records_only", client_id: clientId, user_id: String(userId) });
397
+ return { allowed: !isEnforced(settings, "own_records_only"), reason: "not_own_record" };
398
+ }
399
+
366
400
  /** The UCC was updated: nothing is pending any more. Never throws. */
367
401
  async function clearUccPending(clientId) {
368
402
  try {
@@ -374,5 +408,5 @@ export function createClientProfileService({ getDb, getSettings, getSideContext
374
408
  }
375
409
  }
376
410
 
377
- return Object.freeze({ findActiveProfilesByPan, findExisting, attach, attachUser, resolveActor, sideFilterFor, evaluatePersonalEdit, goalContextFor, noteClientUccEdit, clearUccPending });
411
+ return Object.freeze({ findActiveProfilesByPan, findExisting, attach, attachUser, resolveActor, sideFilterFor, evaluatePersonalEdit, goalContextFor, noteClientUccEdit, clearUccPending, callerMayWriteClient });
378
412
  }
@@ -73,6 +73,7 @@ export const DEFAULT_CLIENT_PROFILE_SETTINGS = deepFreeze({
73
73
  mf_one_investor: MODES.OFF,
74
74
  detail_scope: MODES.OFF,
75
75
  ucc_followup: MODES.OFF,
76
+ own_records_only: MODES.OFF,
76
77
  },
77
78
  corporate_distributor_types: [],
78
79
  house_distributor_codes: [],
@@ -186,6 +187,7 @@ export const CLIENT_PROFILE_SETTING_DESCRIPTIONS = Object.freeze({
186
187
  "modes.goal_approval": "Hold partner-added goals until the client approves them.",
187
188
  "modes.resolve_by_pan": "Show an existing client instead of a duplicate error when a PAN is found.",
188
189
  "modes.mf_one_investor": "Refuse a second MF investor for a person who already has one (D9).",
190
+ "modes.own_records_only": "A client-app session may write only its own records (one person under one master); anything else is refused.",
189
191
  "modes.ucc_followup": "Flag a profile for a UCC update when its client changes UCC details in the app (never pushed by the client).",
190
192
  "modes.detail_scope": "A client's full record by id only for callers whose client list includes them; everyone else gets the limited card (R15).",
191
193
  "modes.linked_visibility": "Clients reached only through a secondary mapping or a linked user appear in lists, as the limited card.",