@memberjunction/core-entities-server 6.1.0-edge.6 → 6.1.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.
Files changed (33) hide show
  1. package/dist/custom/MJAIBridgeProviderEntityServer.server.d.ts +1 -5
  2. package/dist/custom/MJAIBridgeProviderEntityServer.server.d.ts.map +1 -1
  3. package/dist/custom/MJAIBridgeProviderEntityServer.server.js +13 -12
  4. package/dist/custom/MJAIBridgeProviderEntityServer.server.js.map +1 -1
  5. package/dist/custom/MJAIRemoteBrowserProviderEntityServer.server.d.ts +2 -6
  6. package/dist/custom/MJAIRemoteBrowserProviderEntityServer.server.d.ts.map +1 -1
  7. package/dist/custom/MJAIRemoteBrowserProviderEntityServer.server.js +16 -14
  8. package/dist/custom/MJAIRemoteBrowserProviderEntityServer.server.js.map +1 -1
  9. package/dist/custom/MJEntityEntityServer.server.d.ts +36 -0
  10. package/dist/custom/MJEntityEntityServer.server.d.ts.map +1 -0
  11. package/dist/custom/MJEntityEntityServer.server.js +77 -0
  12. package/dist/custom/MJEntityEntityServer.server.js.map +1 -0
  13. package/dist/custom/MJEntityFieldPermissionEntityServer.server.d.ts +167 -0
  14. package/dist/custom/MJEntityFieldPermissionEntityServer.server.d.ts.map +1 -0
  15. package/dist/custom/MJEntityFieldPermissionEntityServer.server.js +347 -0
  16. package/dist/custom/MJEntityFieldPermissionEntityServer.server.js.map +1 -0
  17. package/dist/custom/MJUserRoleEntityServer.server.d.ts +105 -8
  18. package/dist/custom/MJUserRoleEntityServer.server.d.ts.map +1 -1
  19. package/dist/custom/MJUserRoleEntityServer.server.js +170 -10
  20. package/dist/custom/MJUserRoleEntityServer.server.js.map +1 -1
  21. package/dist/custom/fieldPermissionDelta.d.ts +57 -0
  22. package/dist/custom/fieldPermissionDelta.d.ts.map +1 -0
  23. package/dist/custom/fieldPermissionDelta.js +196 -0
  24. package/dist/custom/fieldPermissionDelta.js.map +1 -0
  25. package/dist/custom/fieldPermissionReconciler.d.ts +34 -0
  26. package/dist/custom/fieldPermissionReconciler.d.ts.map +1 -0
  27. package/dist/custom/fieldPermissionReconciler.js +96 -0
  28. package/dist/custom/fieldPermissionReconciler.js.map +1 -0
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +4 -0
  32. package/dist/index.js.map +1 -1
  33. package/package.json +28 -28
@@ -4,11 +4,27 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { BaseEntity, BaseEntityResult, ValidationErrorInfo, ValidationErrorType } from '@memberjunction/core';
7
+ var MJUserRoleEntityServer_1;
8
+ import { BaseEntity, BaseEntityResult, IsRestrictingFieldRule, Metadata, ValidationErrorInfo, ValidationErrorType, } from '@memberjunction/core';
8
9
  import { RegisterClass, UUIDsEqual } from '@memberjunction/global';
9
10
  import { MJUserRoleEntity } from '@memberjunction/core-entities';
11
+ import { FindSystemUserFieldAccessViolations, UserCache } from '@memberjunction/generic-database-provider';
10
12
  /**
11
- * Server-side `MJ: User Roles` entity enforcing MJ's role-elevation invariant (issue #4282).
13
+ * Server-side `MJ: User Roles` entity. It carries TWO independent guards that happen to protect the
14
+ * same table from opposite directions. They share no state and no helpers, and every write path
15
+ * must satisfy both:
16
+ *
17
+ * A. **Role elevation** (issue #4282) — bounds what the CALLER may do, by their own roles.
18
+ * B. **System-user field access** — bounds what may be done TO the system user's role set.
19
+ *
20
+ * Guard A and guard B are orthogonal: A asks "is this caller entitled to make this change", B asks
21
+ * "does this change leave the system user field-restricted". A change must clear both, so the
22
+ * checks compose as a conjunction and are evaluated A-then-B (A is an in-memory role-list test; B
23
+ * walks cached metadata and only ever runs when the system user is the target).
24
+ *
25
+ * ---------------------------------------------------------------------------------------------
26
+ * GUARD A — ROLE ELEVATION (issue #4282)
27
+ * ---------------------------------------------------------------------------------------------
12
28
  *
13
29
  * WHY THIS EXISTS AT ALL. Issue #4260 (`MJUserEntityServer`) closed the `User.Type` route to
14
30
  * elevated capability. Role assignment is the platform's OTHER authority mechanism and was
@@ -81,13 +97,48 @@ import { MJUserRoleEntity } from '@memberjunction/core-entities';
81
97
  * deployment whose system user is NOT an Owner will see those sync paths fail closed — loudly,
82
98
  * at the save — exactly as #4260 documented for `MJ: Users`.
83
99
  *
84
- * Pure: reads only this record's own field state and the caller's cached roles. No `RunView`, no
85
- * provider, no engine, no I/O — so it costs nothing per save/delete and is unit-testable without a
86
- * database.
100
+ * Guard A is pure: it reads only this record's own field state and the caller's cached roles. No
101
+ * `RunView`, no provider, no engine, no I/O — so it costs nothing per save/delete and is
102
+ * unit-testable without a database. (Guard B is NOT pure in that sense; see below.)
103
+ *
104
+ * ---------------------------------------------------------------------------------------------
105
+ * GUARD B — SYSTEM-USER FIELD ACCESS
106
+ * ---------------------------------------------------------------------------------------------
107
+ *
108
+ * This is the other half of the system-user guard for field-level security.
109
+ * `MJEntityFieldPermissionEntityServer` guards the RULES; this guards the account's ROLE SET, from
110
+ * both directions. Without both halves an administrator reaches the forbidden state simply by
111
+ * doing the steps in a different order.
112
+ *
113
+ * - **Assignment** ({@link SystemUserRejectionReason}) — refuses giving the system user a role that
114
+ * already denies a field. Only DENYING rules count here, because adding a role can only add rules
115
+ * to the aggregate: its `Allow` rows grant, its `No Access` rows are inert, and only a `Deny`
116
+ * can take something away. A guard that counted every rule would refuse to reassemble the
117
+ * account's own role set the moment field security was enabled anywhere.
118
+ * - **Removal** ({@link Delete}) — refuses taking a role away when that would leave the account
119
+ * short of its entity-level access. Removal is the opposite shape: it drops rules OUT of the
120
+ * aggregate, so what matters is not what the departing role said but whether an `Allow` survives
121
+ * without it.
122
+ *
123
+ * Why the system user must stay unrestricted: the server runs background work as that account.
124
+ * It pre-warms the shared engine caches at startup, and in task mode — which job and agent
125
+ * runners use — engines instead load on first touch, so whichever caller gets there first
126
+ * configures the engine for the entire process. Engine caches are process-wide and shared
127
+ * across users. A restricted system user could therefore leave partially loaded records in a
128
+ * cache that everyone reads afterward, with nothing at the point of failure pointing back at
129
+ * the role assignment that caused it.
130
+ *
131
+ * Unlike guard A, guard B reads cached metadata (`Metadata.Entities`, the provider, `UserCache`).
132
+ * It still performs no database I/O, but it is not state-free, and it needs those caches populated
133
+ * to have an opinion — a cold `UserCache` skips the check rather than blocking an administrator.
134
+ *
135
+ * This restricts CONFIGURATION only. There is no user who is exempt from a Deny at runtime —
136
+ * not even the system user, whose access comes from the same rows as everyone else's.
87
137
  */
88
- let MJUserRoleEntityServer = class MJUserRoleEntityServer extends MJUserRoleEntity {
138
+ let MJUserRoleEntityServer = MJUserRoleEntityServer_1 = class MJUserRoleEntityServer extends MJUserRoleEntity {
89
139
  Validate() {
90
140
  const result = super.Validate();
141
+ // Guard A — the caller may only assign/move a role they hold themselves.
91
142
  if (!this.callerIsOwner()) {
92
143
  this.validateRoleHeld(result, this.RoleID, 'You may only assign a role that you hold yourself. Assigning a role you do not hold would ' +
93
144
  'grant authority you were not given. Ask an Owner to make this assignment.');
@@ -95,6 +146,13 @@ let MJUserRoleEntityServer = class MJUserRoleEntityServer extends MJUserRoleEnti
95
146
  this.validatePriorRoleHeld(result);
96
147
  }
97
148
  }
149
+ // Guard B — the system user may not be given a role that denies it a field. Applies to every
150
+ // caller, Owner included: an Owner is entitled to make the assignment but the resulting state
151
+ // is one the server cannot run in, so this is not an authority question.
152
+ const rejection = MJUserRoleEntityServer_1.SystemUserRejectionReason(this.UserID, this.RoleID, this.ProviderToUse);
153
+ if (rejection) {
154
+ result.Errors.push(new ValidationErrorInfo('RoleID', rejection, this.RoleID, ValidationErrorType.Failure));
155
+ }
98
156
  result.Success = result.Success && result.Errors.length === 0;
99
157
  return result;
100
158
  }
@@ -113,20 +171,36 @@ let MJUserRoleEntityServer = class MJUserRoleEntityServer extends MJUserRoleEnti
113
171
  return super.Save(options);
114
172
  }
115
173
  /**
116
- * Invariant 3 — a non-Owner may only revoke a role they hold themselves.
174
+ * Enforces both guards on the delete path.
175
+ *
176
+ * Guard A, invariant 3 — a non-Owner may only revoke a role they hold themselves.
117
177
  *
118
- * Reports the refusal the way `MJUserEntityServer.Delete` does — a `BaseEntityResult` on the
178
+ * Guard B — refuses REMOVING a role from the system user when that would cost the account its
179
+ * field access. The mirror of the assignment guard, and needed for the same reason the
180
+ * field-permission subclass guards its own delete path: the system user's access is ordinary
181
+ * `Allow` rows, and taking a role away drops that role's rows out of the aggregate. If the
182
+ * remaining roles have `No Access` on a field, the last removal denies it — with no `Deny`
183
+ * written anywhere and no field-permission row touched. It permits the removal when it also
184
+ * costs the account its entity-level read, since it is then denied one level up and field rules
185
+ * decide nothing — which is why the projection re-evaluates the entity ceiling too, rather than
186
+ * only the field rules.
187
+ *
188
+ * Reports refusals the way `MJUserEntityServer.Delete` does — a `BaseEntityResult` on the
119
189
  * result history, so `LatestResult.CompleteMessage` carries the reason, `Delete()` returning
120
190
  * `false` on a logical rejection rather than throwing (the CLAUDE.md Save/Delete error-handling
121
191
  * contract). Note that `SyncRolesUsersResolver.SyncUserRoles` treats a `false` from this method
122
192
  * as a hard error and throws, rolling its transaction back; that mutation runs as the system
123
- * Owner, so it is exempt and never sees this refusal on a default install.
193
+ * Owner, so it is exempt from guard A and never sees that refusal on a default install.
124
194
  */
125
195
  async Delete(options) {
126
196
  if (!this.callerIsOwner() && !this.callerHoldsRole(this.RoleID)) {
127
197
  return this.refuse('delete', 'You may only revoke a role that you hold yourself. Revoking a role you do not hold would let you ' +
128
198
  'strip authority you were never granted. Ask an Owner to make this change.');
129
199
  }
200
+ const rejection = this.systemUserRoleRemovalReason();
201
+ if (rejection) {
202
+ return this.refuse('delete', rejection);
203
+ }
130
204
  return super.Delete(options);
131
205
  }
132
206
  /**
@@ -200,6 +274,8 @@ let MJUserRoleEntityServer = class MJUserRoleEntityServer extends MJUserRoleEnti
200
274
  *
201
275
  * Reads `ActiveUser` rather than `ContextCurrentUser` directly so a per-request provider's
202
276
  * `CurrentUser` is honored on multi-provider servers.
277
+ *
278
+ * Applies to guard A only. Guard B has no Owner exemption — see `Validate()`.
203
279
  */
204
280
  callerIsOwner() {
205
281
  const caller = this.ActiveUser;
@@ -208,9 +284,93 @@ let MJUserRoleEntityServer = class MJUserRoleEntityServer extends MJUserRoleEnti
208
284
  }
209
285
  return caller.Type?.trim().toLowerCase() === 'owner';
210
286
  }
287
+ /**
288
+ * Why this role may not be taken off this user, or null when it may.
289
+ * Only ever rejects for the system user; every other user is unaffected.
290
+ */
291
+ systemUserRoleRemovalReason() {
292
+ const systemUser = UserCache.Instance?.GetSystemUser?.();
293
+ if (!systemUser || !this.UserID || !this.RoleID || !UUIDsEqual(systemUser.ID, this.UserID)) {
294
+ return null; // not the system user — nothing to guard
295
+ }
296
+ const provider = this.ProviderToUse;
297
+ const lost = FindSystemUserFieldAccessViolations(provider, systemUser, { WithoutRoleID: this.RoleID });
298
+ if (lost.length === 0) {
299
+ return null;
300
+ }
301
+ const shown = lost.slice(0, 3).map(v => `${v.EntityName}.${v.FieldName}`).join(', ');
302
+ const more = lost.length > 3 ? `, and ${lost.length - 3} more` : '';
303
+ return (`Removing this role from the MJ system user would leave it unable to use ${lost.length} field(s) ` +
304
+ `(${shown}${more}). The server runs background work as that account and shares one engine cache across ` +
305
+ `all users, so restricting it would let partially loaded records reach everyone. Field security has no ` +
306
+ `exempt user — grant those fields to another role the system user holds first.`);
307
+ }
308
+ /**
309
+ * Why this role may not be given to this user, or null when it may.
310
+ * Only ever rejects for the system user; every other user is unaffected.
311
+ */
312
+ static SystemUserRejectionReason(userID, roleID, provider) {
313
+ if (!userID || !roleID) {
314
+ return null;
315
+ }
316
+ // UserCache is populated on the server; a cold cache skips the check rather than
317
+ // blocking an administrator on missing state.
318
+ const systemUser = UserCache.Instance?.GetSystemUser?.();
319
+ if (!systemUser || !UUIDsEqual(systemUser.ID, userID)) {
320
+ return null; // not the system user — nothing to guard
321
+ }
322
+ const restricted = MJUserRoleEntityServer_1.EntitiesWithRestrictingFieldRulesForRole(roleID, provider);
323
+ if (restricted.length === 0) {
324
+ return null;
325
+ }
326
+ const shown = restricted.slice(0, 3).join(', ');
327
+ const more = restricted.length > 3 ? `, and ${restricted.length - 3} more` : '';
328
+ return (`This role denies access to at least one field (on ${shown}${more}), so it cannot be assigned to the MJ system user. ` +
329
+ `The server runs background work as that account and shares one engine cache across all users — ` +
330
+ `restricting it would let partially loaded records reach everyone. ` +
331
+ `Assign this role to a regular user, or remove its field permissions first.`);
332
+ }
333
+ /**
334
+ * Names of entities where this role carries at least one RESTRICTING field rule — a `Deny`
335
+ * on any verb. Walks cached metadata only — no database access. Runs only when the system
336
+ * user is the save target, which is rare.
337
+ *
338
+ * Grants and neutrals are ignored, and must be: the system user holds the standard roles
339
+ * (UI, Developer, Integration), snapshot initialization writes those roles `Allow` rows on
340
+ * every entity they can read, and field security has no runtime exemption to fall back on.
341
+ * A guard that counted any rule at all would refuse to reassemble the system user's own role
342
+ * set the moment field security was enabled anywhere.
343
+ *
344
+ * Deliberately does NOT gate on {@link EntityInfo.EnableFieldLevelSecurity}, even though
345
+ * rules on a disabled entity are inactive and gating would be the cheaper walk. Gating
346
+ * would leave the two halves of this guard unable to compose, and the gap is reachable in
347
+ * three ordinary steps: disable field security on an entity, assign the role (now carrying
348
+ * no active rules) to the system user, re-enable. Each step is permitted and the end state
349
+ * is the one both guards exist to prevent. Disabling preserves rules so re-enabling does
350
+ * not lose them, so a rule on a disabled entity is dormant rather than gone.
351
+ */
352
+ static EntitiesWithRestrictingFieldRulesForRole(roleID, provider) {
353
+ const md = provider ?? new Metadata();
354
+ const names = [];
355
+ for (const entity of md.Entities) {
356
+ const hit = entity.Fields.some(f => f.HasFieldPermissions &&
357
+ f.FieldPermissions.some(fp => UUIDsEqual(fp.RoleID, roleID) && IsRestrictingFieldRule(fp)));
358
+ if (hit) {
359
+ names.push(entity.Name);
360
+ }
361
+ }
362
+ return names;
363
+ }
211
364
  };
212
- MJUserRoleEntityServer = __decorate([
365
+ MJUserRoleEntityServer = MJUserRoleEntityServer_1 = __decorate([
213
366
  RegisterClass(BaseEntity, 'MJ: User Roles')
214
367
  ], MJUserRoleEntityServer);
215
368
  export { MJUserRoleEntityServer };
369
+ /**
370
+ * Loader stub — prevents the class from being tree-shaken out of the bundle. Mirrors the
371
+ * pattern used by the other server-side entity subclasses in this package.
372
+ */
373
+ export function LoadMJUserRoleEntityServer() {
374
+ // no-op
375
+ }
216
376
  //# sourceMappingURL=MJUserRoleEntityServer.server.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"MJUserRoleEntityServer.server.js","sourceRoot":"","sources":["../../src/custom/MJUserRoleEntityServer.server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"MJUserRoleEntityServer.server.js","sourceRoot":"","sources":["../../src/custom/MJUserRoleEntityServer.server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,57 @@
1
+ import { EntityInfo, FieldPermissionAccess } from '@memberjunction/core';
2
+ /**
3
+ * A permission row that should exist but does not, at its snapshot defaults.
4
+ */
5
+ export type FieldPermissionSnapshotRow = {
6
+ EntityFieldID: string;
7
+ RoleID: string;
8
+ ReadAccess: FieldPermissionAccess;
9
+ UpdateAccess: FieldPermissionAccess;
10
+ CreateAccess: FieldPermissionAccess;
11
+ };
12
+ /**
13
+ * What reconciliation must write to bring an entity's field-permission rows back in line with
14
+ * its fields and its entity-level permissions.
15
+ *
16
+ * There is no `ToUpdate`. An existing row is never touched, because an administrator's
17
+ * tightening has to survive every reconciliation — including disable → schema change →
18
+ * re-enable. That single rule is what makes "keep the rows, functionally inactive" on disable
19
+ * and "graceful reconciliation" on re-enable the same code path.
20
+ */
21
+ export type FieldPermissionDelta = {
22
+ /** (field, role) pairs that should have a row and do not. */
23
+ ToInsert: FieldPermissionSnapshotRow[];
24
+ /** IDs of rows that should no longer exist. */
25
+ ToDelete: string[];
26
+ };
27
+ /**
28
+ * Computes the field-permission rows an entity is missing and the ones it should no longer
29
+ * have. Pure — no I/O, no entity objects, no provider. Everything comes off `EntityInfo`.
30
+ *
31
+ * **Snapshot defaults mirror entity-level permissions**, so enabling field security on an
32
+ * entity changes no behavior until an administrator tightens a specific field. A role that can
33
+ * read the entity gets `ReadAccess = 'Allow'`; its Update and Create follow the entity-level
34
+ * grants, defaulting to `No Access`.
35
+ *
36
+ * **A role without entity-level read gets no rows at all.** The entity-level gate already
37
+ * excludes it, so rows would be noise — and a row granting Update without Read would violate
38
+ * the Read-required CHECK constraint anyway.
39
+ *
40
+ * **Unrestrictable fields (primary keys, `__mj_` columns) get no rows.** They are forced open
41
+ * in the aggregation regardless, so rows for them are clutter that would also trip the
42
+ * save-time guard.
43
+ *
44
+ * **No role is excluded — including the ones the MJ system user holds.** Those rows are what
45
+ * keeps the system user working now that the aggregation has no bypass for it: it holds the
46
+ * standard roles (UI, Developer, Integration), which carry entity read on essentially
47
+ * everything, so the snapshot grants them `Allow` here and the save-time guards refuse any
48
+ * later `Deny` aimed at them. Access the server relies on is therefore visible in the data
49
+ * rather than implied by an exemption in code.
50
+ */
51
+ export declare function ComputeFieldPermissionDelta(entity: EntityInfo): FieldPermissionDelta;
52
+ /**
53
+ * True when the delta would change nothing, so callers can skip opening a transaction at all.
54
+ * Reconciliation runs on ordinary saves, and the overwhelmingly common answer is "nothing to do".
55
+ */
56
+ export declare function IsEmptyFieldPermissionDelta(delta: FieldPermissionDelta): boolean;
57
+ //# sourceMappingURL=fieldPermissionDelta.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fieldPermissionDelta.d.ts","sourceRoot":"","sources":["../../src/custom/fieldPermissionDelta.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,UAAU,EAAwB,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAEhH;;GAEG;AACH,MAAM,MAAM,0BAA0B,GAAG;IACrC,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,qBAAqB,CAAC;IAClC,YAAY,EAAE,qBAAqB,CAAC;IACpC,YAAY,EAAE,qBAAqB,CAAC;CACvC,CAAC;AAEF;;;;;;;;GAQG;AACH,MAAM,MAAM,oBAAoB,GAAG;IAC/B,6DAA6D;IAC7D,QAAQ,EAAE,0BAA0B,EAAE,CAAC;IACvC,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,EAAE,CAAC;CACtB,CAAC;AAqBF;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,UAAU,GAAG,oBAAoB,CAcpF;AAoKD;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAEhF"}
@@ -0,0 +1,196 @@
1
+ import { FieldPermissionAccess } from '@memberjunction/core';
2
+ /**
3
+ * Computes the field-permission rows an entity is missing and the ones it should no longer
4
+ * have. Pure — no I/O, no entity objects, no provider. Everything comes off `EntityInfo`.
5
+ *
6
+ * **Snapshot defaults mirror entity-level permissions**, so enabling field security on an
7
+ * entity changes no behavior until an administrator tightens a specific field. A role that can
8
+ * read the entity gets `ReadAccess = 'Allow'`; its Update and Create follow the entity-level
9
+ * grants, defaulting to `No Access`.
10
+ *
11
+ * **A role without entity-level read gets no rows at all.** The entity-level gate already
12
+ * excludes it, so rows would be noise — and a row granting Update without Read would violate
13
+ * the Read-required CHECK constraint anyway.
14
+ *
15
+ * **Unrestrictable fields (primary keys, `__mj_` columns) get no rows.** They are forced open
16
+ * in the aggregation regardless, so rows for them are clutter that would also trip the
17
+ * save-time guard.
18
+ *
19
+ * **No role is excluded — including the ones the MJ system user holds.** Those rows are what
20
+ * keeps the system user working now that the aggregation has no bypass for it: it holds the
21
+ * standard roles (UI, Developer, Integration), which carry entity read on essentially
22
+ * everything, so the snapshot grants them `Allow` here and the save-time guards refuse any
23
+ * later `Deny` aimed at them. Access the server relies on is therefore visible in the data
24
+ * rather than implied by an exemption in code.
25
+ */
26
+ export function ComputeFieldPermissionDelta(entity) {
27
+ const accessByRoleID = buildRoleEntityAccessMap(entity);
28
+ const rolesWithEntityPermission = buildRolesWithAnyEntityPermission(entity);
29
+ const restrictableFields = entity.Fields.filter(isRestrictable);
30
+ // The two halves ask DIFFERENT questions of the same permission rows, and must not share an
31
+ // answer. Creating a snapshot row is a grant, so it is offered only to roles that can actually
32
+ // READ the entity (`accessByRoleID`). Deleting a row destroys an administrator's configuration,
33
+ // so it is justified only when the row can no longer influence any decision — which turns on
34
+ // whether the role relates to the entity at all, never on the strength of that relationship.
35
+ return {
36
+ ToInsert: computeMissingRows(restrictableFields, accessByRoleID),
37
+ ToDelete: computeOrphanRowIDs(entity, rolesWithEntityPermission),
38
+ };
39
+ }
40
+ /**
41
+ * A field is restrictable when field security could meaningfully apply to it. Primary keys and
42
+ * `__mj_` system columns are not, and neither is any field on the security-configuration or
43
+ * identity entities.
44
+ */
45
+ function isRestrictable(field) {
46
+ return !field.IsUnrestrictableField && !field.IsOnUnrestrictableEntity;
47
+ }
48
+ /**
49
+ * Every role holding ANY entity-permission row for this entity, whatever it grants and whichever
50
+ * `Type` it carries.
51
+ *
52
+ * This is the orphan test's criterion, and it is deliberately weaker than
53
+ * {@link buildRoleEntityAccessMap}. A field rule bound to a role is live at runtime purely on ROLE
54
+ * MEMBERSHIP — `EntityFieldInfo.AggregateFieldRulesForUser` matches on
55
+ * `user.UserRoles.find(...)` and never consults that role's entity-level access. So a role that
56
+ * grants nothing at entity level still contributes its field rules to the aggregate of any user who
57
+ * holds it AND gets entity access from a different role. Judging such a row inert because its own
58
+ * role cannot read the entity confuses a per-role fact with a per-user one, and deleting it on that
59
+ * basis destroys a `Deny` that was fully in force — turning "a Deny always wins" into
60
+ * "a Deny wins until reconciliation runs".
61
+ *
62
+ * Membership of this set is what `MJEntityFieldPermissionEntityServer.Validate()` requires before a
63
+ * field rule may be written at all, so the rule reconciliation enforces on cleanup is the same one
64
+ * authoring enforces on write.
65
+ */
66
+ function buildRolesWithAnyEntityPermission(entity) {
67
+ const roleIDs = new Set();
68
+ for (const permission of entity.Permissions) {
69
+ const roleID = normalizeID(permission.RoleID);
70
+ if (roleID) {
71
+ roleIDs.add(roleID);
72
+ }
73
+ }
74
+ return roleIDs;
75
+ }
76
+ /**
77
+ * Each role's effective entity-level access, aggregating its own Allow/Deny rows the same way
78
+ * `EntityInfo.GetUserPermisions` does one level up: a Deny beats an Allow for the same action.
79
+ *
80
+ * Roles resolving to no read access are omitted entirely, so callers can treat presence in the
81
+ * map as "this role should have rows."
82
+ */
83
+ function buildRoleEntityAccessMap(entity) {
84
+ const allow = new Map();
85
+ const deny = new Map();
86
+ for (const permission of entity.Permissions) {
87
+ const roleID = normalizeID(permission.RoleID);
88
+ if (!roleID) {
89
+ continue;
90
+ }
91
+ const bucket = isDenyPermission(permission) ? deny : allow;
92
+ foldPermissionInto(bucket, roleID, permission);
93
+ }
94
+ const effective = new Map();
95
+ for (const [roleID, granted] of allow) {
96
+ const blocked = deny.get(roleID) ?? { CanRead: false, CanUpdate: false, CanCreate: false };
97
+ const access = {
98
+ CanRead: granted.CanRead && !blocked.CanRead,
99
+ CanUpdate: granted.CanUpdate && !blocked.CanUpdate,
100
+ CanCreate: granted.CanCreate && !blocked.CanCreate,
101
+ };
102
+ if (access.CanRead) {
103
+ effective.set(roleID, access);
104
+ }
105
+ }
106
+ return effective;
107
+ }
108
+ function isDenyPermission(permission) {
109
+ return (permission.Type ?? 'Allow').trim().toLowerCase() === 'deny';
110
+ }
111
+ function foldPermissionInto(bucket, roleID, permission) {
112
+ const current = bucket.get(roleID) ?? { CanRead: false, CanUpdate: false, CanCreate: false };
113
+ current.CanRead = current.CanRead || !!permission.CanRead;
114
+ current.CanUpdate = current.CanUpdate || !!permission.CanUpdate;
115
+ current.CanCreate = current.CanCreate || !!permission.CanCreate;
116
+ bucket.set(roleID, current);
117
+ }
118
+ /**
119
+ * Every (restrictable field × qualifying role) pair that has no row yet, at snapshot defaults.
120
+ */
121
+ function computeMissingRows(fields, accessByRoleID) {
122
+ const missing = [];
123
+ for (const field of fields) {
124
+ const existingRoleIDs = new Set(field.FieldPermissions.map(fp => normalizeID(fp.RoleID)).filter(Boolean));
125
+ for (const [roleID, access] of accessByRoleID) {
126
+ if (existingRoleIDs.has(roleID)) {
127
+ continue;
128
+ }
129
+ // A read-only field cannot be written through the API by anyone — it is excluded from
130
+ // the generated create input and from the update SET list — so an Update or Create
131
+ // verb on it can never decide anything. Writing `Allow` there would author a rule
132
+ // that reads as a granted permission and is in fact inert, on ~1,000 fields x every
133
+ // qualifying role. Read stays meaningful and is untouched: restricting a foreign-key
134
+ // display column ("who does this record belong to") is a legitimate and common use.
135
+ const writable = !field.ReadOnly;
136
+ missing.push({
137
+ EntityFieldID: field.ID,
138
+ RoleID: roleID,
139
+ // Read is Allow by construction — buildRoleEntityAccessMap omits roles without
140
+ // it — which is what keeps these rows on the right side of the Read-required
141
+ // CHECK constraint no matter what Update and Create resolve to.
142
+ ReadAccess: FieldPermissionAccess.Allow,
143
+ UpdateAccess: writable && access.CanUpdate ? FieldPermissionAccess.Allow : FieldPermissionAccess.NoAccess,
144
+ CreateAccess: writable && access.CanCreate ? FieldPermissionAccess.Allow : FieldPermissionAccess.NoAccess,
145
+ });
146
+ }
147
+ }
148
+ return missing;
149
+ }
150
+ /**
151
+ * Rows that should no longer exist. Two ways a row becomes an orphan:
152
+ *
153
+ * - its field is no longer restrictable (a column was made a primary key, or the entity joined
154
+ * the unrestrictable list), so the aggregation returns fully-open for it regardless of any rule;
155
+ * - its role holds no entity-permission row for this entity at all, so no user can carry the rule
156
+ * into an aggregate through this entity — which also covers a role that no longer exists.
157
+ *
158
+ * A row whose FIELD was dropped disappears with the field's cascade, so it never reaches this
159
+ * walk — `entity.Fields` is the live set.
160
+ *
161
+ * **A role's entity-level ACCESS is deliberately not part of this test**, and an earlier version of
162
+ * this function that used it was wrong. It reasoned that a role without entity read is excluded by
163
+ * the entity gate, so its field rules cannot matter. But `EntityInfo.GetUserPermisions` aggregates
164
+ * the entity gate across ALL of a user's roles, exactly as the field aggregation does — so a user
165
+ * reading the entity through role S still carries role R's field rules, whatever R grants on its
166
+ * own. Deleting them silently restored access an administrator had explicitly denied, and
167
+ * `computeMissingRows` only ever writes `Allow`, so it never came back.
168
+ */
169
+ function computeOrphanRowIDs(entity, rolesWithEntityPermission) {
170
+ const orphans = [];
171
+ for (const field of entity.Fields) {
172
+ const fieldIsRestrictable = isRestrictable(field);
173
+ for (const permission of field.FieldPermissions) {
174
+ const roleID = normalizeID(permission.RoleID);
175
+ if (!fieldIsRestrictable || !roleID || !rolesWithEntityPermission.has(roleID)) {
176
+ orphans.push(permission.ID);
177
+ }
178
+ }
179
+ }
180
+ return orphans;
181
+ }
182
+ /**
183
+ * Lowercased ID for map keys. UUIDs arrive with different casing depending on their source, so
184
+ * normalize once here rather than scanning with a comparator on every lookup.
185
+ */
186
+ function normalizeID(id) {
187
+ return (id ?? '').trim().toLowerCase();
188
+ }
189
+ /**
190
+ * True when the delta would change nothing, so callers can skip opening a transaction at all.
191
+ * Reconciliation runs on ordinary saves, and the overwhelmingly common answer is "nothing to do".
192
+ */
193
+ export function IsEmptyFieldPermissionDelta(delta) {
194
+ return delta.ToInsert.length === 0 && delta.ToDelete.length === 0;
195
+ }
196
+ //# sourceMappingURL=fieldPermissionDelta.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fieldPermissionDelta.js","sourceRoot":"","sources":["../../src/custom/fieldPermissionDelta.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ import { EntityInfo, IMetadataProvider, UserInfo } from '@memberjunction/core';
2
+ /** What a reconciliation run actually wrote. */
3
+ export type FieldPermissionReconcileResult = {
4
+ Inserted: number;
5
+ Deleted: number;
6
+ };
7
+ /**
8
+ * Brings an entity's field-permission rows in line with its fields and entity-level
9
+ * permissions, writing the whole delta in one transaction.
10
+ *
11
+ * Every row goes through `BaseEntity.Save()` / `.Delete()` rather than direct SQL, so the
12
+ * save-time target guard, validation and entity actions all run — the delta function and the
13
+ * guard agree instead of one bypassing the other. `AllowDirectSQLInsert` stays off.
14
+ *
15
+ * Returns immediately when there is nothing to do, which is the overwhelmingly common case:
16
+ * reconciliation is triggered from ordinary saves, and a transaction opened to write zero rows
17
+ * is pure cost.
18
+ *
19
+ * @param entity the entity to reconcile — its `Fields` and `Permissions` are the desired state
20
+ * @param provider the provider to transact on and to build entity objects from
21
+ * @param contextUser the user the writes are attributed to
22
+ */
23
+ export declare function ReconcileFieldPermissions(entity: EntityInfo, provider: IMetadataProvider, contextUser: UserInfo): Promise<FieldPermissionReconcileResult>;
24
+ /**
25
+ * Runs {@link ReconcileFieldPermissions} and swallows failures into a logged error.
26
+ *
27
+ * For the adapters that reconcile as a SIDE EFFECT of some other save — a role being granted
28
+ * entity access, say. The triggering save has already committed by then, so throwing would
29
+ * report a failure for work that succeeded. The rows stay missing until the next reconciliation,
30
+ * which is a visible loss of access rather than a silent loss of protection: a field with no row
31
+ * on an enabled entity is denied.
32
+ */
33
+ export declare function ReconcileFieldPermissionsQuietly(entity: EntityInfo, provider: IMetadataProvider, contextUser: UserInfo): Promise<FieldPermissionReconcileResult>;
34
+ //# sourceMappingURL=fieldPermissionReconciler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fieldPermissionReconciler.d.ts","sourceRoot":"","sources":["../../src/custom/fieldPermissionReconciler.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,UAAU,EACV,iBAAiB,EAIjB,QAAQ,EACX,MAAM,sBAAsB,CAAC;AAI9B,gDAAgD;AAChD,MAAM,MAAM,8BAA8B,GAAG;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACnB,CAAC;AAIF;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,yBAAyB,CAC3C,MAAM,EAAE,UAAU,EAClB,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,QAAQ,GACtB,OAAO,CAAC,8BAA8B,CAAC,CAmBzC;AAuDD;;;;;;;;GAQG;AACH,wBAAsB,gCAAgC,CAClD,MAAM,EAAE,UAAU,EAClB,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,QAAQ,GACtB,OAAO,CAAC,8BAA8B,CAAC,CAOzC"}