@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.
- package/dist/custom/MJAIBridgeProviderEntityServer.server.d.ts +1 -5
- package/dist/custom/MJAIBridgeProviderEntityServer.server.d.ts.map +1 -1
- package/dist/custom/MJAIBridgeProviderEntityServer.server.js +13 -12
- package/dist/custom/MJAIBridgeProviderEntityServer.server.js.map +1 -1
- package/dist/custom/MJAIRemoteBrowserProviderEntityServer.server.d.ts +2 -6
- package/dist/custom/MJAIRemoteBrowserProviderEntityServer.server.d.ts.map +1 -1
- package/dist/custom/MJAIRemoteBrowserProviderEntityServer.server.js +16 -14
- package/dist/custom/MJAIRemoteBrowserProviderEntityServer.server.js.map +1 -1
- package/dist/custom/MJEntityEntityServer.server.d.ts +36 -0
- package/dist/custom/MJEntityEntityServer.server.d.ts.map +1 -0
- package/dist/custom/MJEntityEntityServer.server.js +77 -0
- package/dist/custom/MJEntityEntityServer.server.js.map +1 -0
- package/dist/custom/MJEntityFieldPermissionEntityServer.server.d.ts +167 -0
- package/dist/custom/MJEntityFieldPermissionEntityServer.server.d.ts.map +1 -0
- package/dist/custom/MJEntityFieldPermissionEntityServer.server.js +347 -0
- package/dist/custom/MJEntityFieldPermissionEntityServer.server.js.map +1 -0
- package/dist/custom/MJUserRoleEntityServer.server.d.ts +105 -8
- package/dist/custom/MJUserRoleEntityServer.server.d.ts.map +1 -1
- package/dist/custom/MJUserRoleEntityServer.server.js +170 -10
- package/dist/custom/MJUserRoleEntityServer.server.js.map +1 -1
- package/dist/custom/fieldPermissionDelta.d.ts +57 -0
- package/dist/custom/fieldPermissionDelta.d.ts.map +1 -0
- package/dist/custom/fieldPermissionDelta.js +196 -0
- package/dist/custom/fieldPermissionDelta.js.map +1 -0
- package/dist/custom/fieldPermissionReconciler.d.ts +34 -0
- package/dist/custom/fieldPermissionReconciler.d.ts.map +1 -0
- package/dist/custom/fieldPermissionReconciler.js +96 -0
- package/dist/custom/fieldPermissionReconciler.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- 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
|
-
|
|
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
|
|
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
|
-
*
|
|
85
|
-
* provider, no engine, no I/O — so it costs nothing per save/delete and is
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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":"
|
|
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"}
|