@memberjunction/core 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/generic/baseEngine.d.ts +52 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +93 -4
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +173 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +357 -12
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +461 -1
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +568 -6
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/interfaces.d.ts +5 -0
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +30 -1
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +48 -1
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +164 -2
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +398 -23
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.d.ts +164 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.js +279 -0
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -0
- package/dist/generic/saveEntityGraphOperation.d.ts +10 -0
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +2 -1
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/wellKnownUserSource.d.ts +70 -0
- package/dist/generic/wellKnownUserSource.d.ts.map +1 -0
- package/dist/generic/wellKnownUserSource.js +82 -0
- package/dist/generic/wellKnownUserSource.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -323,6 +323,15 @@ export class EntityUserPermissionInfo {
|
|
|
323
323
|
* Controls which roles can perform create, read, update, and delete operations.
|
|
324
324
|
*/
|
|
325
325
|
export class EntityPermissionInfo extends BaseInfo {
|
|
326
|
+
/**
|
|
327
|
+
* True when this row is a Deny row (`Type = 'Deny'`, compared case- and whitespace-insensitively;
|
|
328
|
+
* a null/blank Type — rows created before the column existed — is Allow). On a Deny row a set
|
|
329
|
+
* `Can*` flag means "deny that operation", so nothing that reads a `Can*` flag as a GRANT may
|
|
330
|
+
* look at a Deny row: `GetUserPermisions` subtracts these, and the RLS readers skip them.
|
|
331
|
+
*/
|
|
332
|
+
get IsDeny() {
|
|
333
|
+
return (this.Type || 'Allow').trim().toLowerCase() === 'deny';
|
|
334
|
+
}
|
|
326
335
|
get CreateRLSFilterObject() {
|
|
327
336
|
return this.RLSFilter(EntityPermissionType.Create);
|
|
328
337
|
}
|
|
@@ -387,6 +396,190 @@ export class EntityPermissionInfo extends BaseInfo {
|
|
|
387
396
|
this.copyInitData(initData);
|
|
388
397
|
}
|
|
389
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* The three states a single field-level permission verb can hold, modelled on SQL Server's
|
|
401
|
+
* posture:
|
|
402
|
+
*
|
|
403
|
+
* - `No Access` — neutral, and the default. Grants nothing and blocks nothing; another role's
|
|
404
|
+
* Allow still wins.
|
|
405
|
+
* - `Allow` — grants the action for this role.
|
|
406
|
+
* - `Deny` — trumps everything. One Deny across any of the user's roles wins no matter how
|
|
407
|
+
* many Allows sit beside it.
|
|
408
|
+
*/
|
|
409
|
+
export const FieldPermissionAccess = {
|
|
410
|
+
Allow: 'Allow',
|
|
411
|
+
Deny: 'Deny',
|
|
412
|
+
NoAccess: 'No Access',
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* The transport-only key carrying the server's authoritative answer to "which fields on this
|
|
416
|
+
* entity may the caller of THIS request read".
|
|
417
|
+
*
|
|
418
|
+
* **Why it lists READABLE fields rather than denied ones.** The two carry the same information
|
|
419
|
+
* only while the client already holds the full permission matrix, which it does today — the
|
|
420
|
+
* `MJ_Metadata` dataset ships `MJ: Entity Fields` and `MJ: Entity Field Permissions` unfiltered.
|
|
421
|
+
* That is scheduled to change (MJ issue #3485, metadata filtering for restricted users), and a
|
|
422
|
+
* payload that named DENIED fields would hand back exactly what such filtering exists to withhold:
|
|
423
|
+
* the names of columns you are not allowed to know about. A readable list names only fields the
|
|
424
|
+
* caller may already see, so it discloses nothing under any filtering design.
|
|
425
|
+
*
|
|
426
|
+
* **Why it is needed at all.** The server omits denied fields from the response object, but
|
|
427
|
+
* GraphQL emits every SELECTED field regardless — so a denied field the client asked for arrives
|
|
428
|
+
* as an explicit `null`, indistinguishable from a genuine one. The client cannot settle that from
|
|
429
|
+
* its own metadata: in the window after a permission change (and permanently, once metadata is
|
|
430
|
+
* filtered) the client's copy disagrees with the server's. This key is the server stating it
|
|
431
|
+
* in-band, for the request that actually ran.
|
|
432
|
+
*
|
|
433
|
+
* Suffixed `___` following the established transport-only convention (`OldValues___`,
|
|
434
|
+
* `RestoreContext___`) so it cannot collide with a real column name.
|
|
435
|
+
*/
|
|
436
|
+
export const ReadableFieldsTransportKey = 'ReadableFields___';
|
|
437
|
+
/**
|
|
438
|
+
* True when a rule takes access AWAY rather than granting or abstaining.
|
|
439
|
+
*
|
|
440
|
+
* `Deny` is the only restricting value *within a single rule*. `No Access` is the aggregation's
|
|
441
|
+
* identity element: it can leave a role without access, but it can never remove access one of the
|
|
442
|
+
* user's other roles granted.
|
|
443
|
+
*
|
|
444
|
+
* **This answers a question about one rule, not about a change.** Whether a CHANGE restricts a
|
|
445
|
+
* user is a property of the aggregate across every role they hold — setting each of a user's roles
|
|
446
|
+
* to `No Access` in turn writes no `Deny` anywhere and still ends with the field denied. So this
|
|
447
|
+
* is sound for an INSERT, which can only add rules and therefore cannot remove an existing
|
|
448
|
+
* `Allow`, and is NOT sufficient for an edit or a delete. Those must project the resulting rule
|
|
449
|
+
* set and aggregate it — see {@link EntityFieldInfo.AggregateFieldRulesForUser}.
|
|
450
|
+
*/
|
|
451
|
+
export function IsRestrictingFieldRule(rule) {
|
|
452
|
+
return (rule?.ReadAccess === FieldPermissionAccess.Deny ||
|
|
453
|
+
rule?.UpdateAccess === FieldPermissionAccess.Deny ||
|
|
454
|
+
rule?.CreateAccess === FieldPermissionAccess.Deny);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* The single wording for "you cannot use this field", modelled on SQL Server's posture of never
|
|
458
|
+
* disclosing whether an object is missing or merely inaccessible.
|
|
459
|
+
*
|
|
460
|
+
* Naming the field is safe — the caller supplied it, so it tells them nothing they did not
|
|
461
|
+
* already know. Naming the REASON is not: confirming "this field exists and is restricted"
|
|
462
|
+
* turns any predicate into an oracle for probing which columns a deployment considers
|
|
463
|
+
* sensitive. The ambiguity also stays correct after
|
|
464
|
+
* [#3485](https://github.com/MemberJunction/MJ/issues/3485) tiers metadata and restricted fields
|
|
465
|
+
* stop shipping to clients at all, at which point "does not exist" becomes literally true from
|
|
466
|
+
* the client's vantage point.
|
|
467
|
+
*
|
|
468
|
+
* Lives here rather than on `ProviderBase` so the write path in `BaseEntity` can reach it
|
|
469
|
+
* without importing the provider layer, which imports `BaseEntity` in turn.
|
|
470
|
+
*/
|
|
471
|
+
export function FieldSecurityDenialMessage(fieldName, entityName) {
|
|
472
|
+
return `Field '${fieldName}' does not exist on entity '${entityName}' or you do not have access to it.`;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* The wording for "you may not WRITE this field" — used only when the caller can READ it.
|
|
476
|
+
*
|
|
477
|
+
* Naming the reason here discloses nothing. The caller can see the field and its values, so
|
|
478
|
+
* both facts the ambiguous wording withholds — that the column exists, and that it is
|
|
479
|
+
* restricted for them — are already theirs. All this adds is *which* permission is missing,
|
|
480
|
+
* which they would learn by trying anyway.
|
|
481
|
+
*
|
|
482
|
+
* The two justifications behind {@link FieldSecurityDenialMessage} do not reach this case:
|
|
483
|
+
* predicate probing is a question about columns the caller cannot READ, and the
|
|
484
|
+
* [#3485](https://github.com/MemberJunction/MJ/issues/3485) argument — that "does not exist"
|
|
485
|
+
* becomes literally true once restricted fields stop shipping to clients — is false for a
|
|
486
|
+
* readable field, which keeps shipping. Ambiguity there does not age into truth; it just tells
|
|
487
|
+
* someone that a field they are looking at might not exist.
|
|
488
|
+
*
|
|
489
|
+
* A field the caller cannot read must still use the ambiguous wording. That is not hypothetical:
|
|
490
|
+
* `SetMany` deliberately skips the readability assertion (it is the hydration and resolver-apply
|
|
491
|
+
* path), so server-side code can dirty a read-denied field and reach the update gate.
|
|
492
|
+
*/
|
|
493
|
+
export function FieldSecurityWriteDenialMessage(fieldName, entityName) {
|
|
494
|
+
return `You do not have permission to update field '${fieldName}' on entity '${entityName}'.`;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* The error thrown when field-level security refuses a request — a caller-authored predicate
|
|
498
|
+
* naming an unreadable field, a typed accessor touching one, or a save modifying a field the
|
|
499
|
+
* caller may not write.
|
|
500
|
+
*
|
|
501
|
+
* A DISTINCT class because its message is the one security rejection that is deliberately safe
|
|
502
|
+
* to show a caller: both {@link FieldSecurityDenialMessage} and
|
|
503
|
+
* {@link FieldSecurityWriteDenialMessage} were designed for exactly that surface and disclose
|
|
504
|
+
* nothing (see their docs). Transport layers that rightly swallow arbitrary resolver errors
|
|
505
|
+
* (whose messages can carry SQL text or internal state) recognize this one and let it through,
|
|
506
|
+
* so the intended wording reaches the wire instead of degenerating into a generic transport
|
|
507
|
+
* error.
|
|
508
|
+
*
|
|
509
|
+
* Recognize it by `name === FieldSecurityError.ErrorName` rather than `instanceof` where
|
|
510
|
+
* bundling might duplicate the class.
|
|
511
|
+
*/
|
|
512
|
+
export class FieldSecurityError extends Error {
|
|
513
|
+
static { this.ErrorName = 'FieldSecurityError'; }
|
|
514
|
+
/**
|
|
515
|
+
* Defaults to the ambiguous wording, which is correct for every READ denial. Pass `message`
|
|
516
|
+
* only through a named factory such as {@link FieldSecurityError.WriteDenial}, so the choice
|
|
517
|
+
* of wording is always a deliberate, reviewable decision rather than an inline string.
|
|
518
|
+
*/
|
|
519
|
+
constructor(fieldName, entityName, message) {
|
|
520
|
+
super(message ?? FieldSecurityDenialMessage(fieldName, entityName));
|
|
521
|
+
this.name = FieldSecurityError.ErrorName;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* A write refusal on a field the caller CAN read — names the missing permission instead of
|
|
525
|
+
* hiding behind "or it does not exist", which would be actively misleading about a field
|
|
526
|
+
* whose values they are looking at. Callers must confirm readability first; see
|
|
527
|
+
* {@link FieldSecurityWriteDenialMessage}.
|
|
528
|
+
*/
|
|
529
|
+
static WriteDenial(fieldName, entityName) {
|
|
530
|
+
return new FieldSecurityError(fieldName, entityName, FieldSecurityWriteDenialMessage(fieldName, entityName));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Field-level (column-level) security settings. Maps an entity FIELD to a role, carrying three
|
|
535
|
+
* independent trinary verbs — Read, Update and Create. One row per (field, role).
|
|
536
|
+
*
|
|
537
|
+
* These rows are only consulted when the parent entity has
|
|
538
|
+
* {@link EntityInfo.EnableFieldLevelSecurity} set. Aggregation across the roles a user holds,
|
|
539
|
+
* per verb: `effective = (any matching row Allows) AND NOT (any matching row Denies)`. See
|
|
540
|
+
* {@link EntityFieldInfo.GetUserFieldPermissions}.
|
|
541
|
+
*/
|
|
542
|
+
export class EntityFieldPermissionInfo extends BaseInfo {
|
|
543
|
+
/**
|
|
544
|
+
* @param initData raw metadata row off the wire. `BaseInfo.copyInitData` only ever reads
|
|
545
|
+
* `Object.keys()` off it, so a plain record is wide enough.
|
|
546
|
+
*/
|
|
547
|
+
constructor(initData = null) {
|
|
548
|
+
super();
|
|
549
|
+
this.ID = null;
|
|
550
|
+
this.EntityFieldID = null;
|
|
551
|
+
this.RoleID = null;
|
|
552
|
+
/**
|
|
553
|
+
* Whether this role may READ the field's values. The aggregation normalizes defensively and
|
|
554
|
+
* treats anything unrecognized as `No Access`, so a bad value off the wire fails closed
|
|
555
|
+
* rather than granting.
|
|
556
|
+
*/
|
|
557
|
+
this.ReadAccess = FieldPermissionAccess.NoAccess;
|
|
558
|
+
/**
|
|
559
|
+
* Whether this role may modify the field's value on an EXISTING record.
|
|
560
|
+
*
|
|
561
|
+
* Requires {@link ReadAccess} = Allow — a field a user cannot see is one they cannot
|
|
562
|
+
* change. Enforced per row by a CHECK constraint, and again after aggregation because the
|
|
563
|
+
* constraint cannot see across roles.
|
|
564
|
+
*/
|
|
565
|
+
this.UpdateAccess = FieldPermissionAccess.NoAccess;
|
|
566
|
+
/**
|
|
567
|
+
* Whether this role may supply the field's value when INSERTing a record. Requires
|
|
568
|
+
* {@link ReadAccess} = Allow, on the same two-layer basis as {@link UpdateAccess}.
|
|
569
|
+
*
|
|
570
|
+
* A user who may not create a field does not get an error — the supplied value is dropped
|
|
571
|
+
* and the column takes its default, matching the read path where a denied field is simply
|
|
572
|
+
* absent.
|
|
573
|
+
*/
|
|
574
|
+
this.CreateAccess = FieldPermissionAccess.NoAccess;
|
|
575
|
+
this.__mj_CreatedAt = null;
|
|
576
|
+
this.__mj_UpdatedAt = null;
|
|
577
|
+
// virtual fields - returned by the database VIEW
|
|
578
|
+
this.EntityField = null;
|
|
579
|
+
this.Role = null;
|
|
580
|
+
this.copyInitData(initData);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
390
583
|
export const EntityFieldTSType = {
|
|
391
584
|
String: 'string',
|
|
392
585
|
Number: 'number',
|
|
@@ -530,6 +723,201 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
530
723
|
}
|
|
531
724
|
return this._EntityFieldValues;
|
|
532
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Field-level (column-level) security records configured for THIS field, across all roles.
|
|
728
|
+
* Empty for the overwhelming majority of fields — see {@link HasFieldPermissions}.
|
|
729
|
+
*/
|
|
730
|
+
get FieldPermissions() {
|
|
731
|
+
return this._FieldPermissions;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* True when at least one {@link EntityFieldPermissionInfo} record exists for this field.
|
|
735
|
+
*
|
|
736
|
+
* **Not an enforcement gate** — it answers "does any configuration target this field",
|
|
737
|
+
* which CodeGen's DB-tier emission and the system-user entanglement guard both need. The
|
|
738
|
+
* access decision is {@link EntityInfo.EnableFieldLevelSecurity} plus the aggregation; on an
|
|
739
|
+
* enabled entity a field with no records is denied, not open.
|
|
740
|
+
*/
|
|
741
|
+
get HasFieldPermissions() {
|
|
742
|
+
return this._FieldPermissions.length > 0;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Entities whose fields can never be restricted by field-level security.
|
|
746
|
+
*
|
|
747
|
+
* Two distinct reasons, both amounting to "a configuration that cannot be undone through
|
|
748
|
+
* the product":
|
|
749
|
+
*
|
|
750
|
+
* 1. **The security-configuration surface** (Entities, Entity Fields, Entity Permissions,
|
|
751
|
+
* Entity Field Permissions, Roles). Restricting `CanRead` on the Entity Field Permissions
|
|
752
|
+
* entity itself would leave the admin screen unable to render the very rows needed to
|
|
753
|
+
* reverse the restriction — recovery would require direct SQL against the database.
|
|
754
|
+
* 2. **The identity surface** (Users, User Roles). Role resolution and the auth path read
|
|
755
|
+
* these on every request; restricting a column here degrades far more than one screen.
|
|
756
|
+
*
|
|
757
|
+
* Note this is deliberately a guard on WHICH ENTITIES are restrictable, not an exemption for
|
|
758
|
+
* particular USERS. No user is above a Deny — that would undercut the entire point of the
|
|
759
|
+
* feature for the confidentiality use cases (compensation, donor giving) that motivate it.
|
|
760
|
+
*
|
|
761
|
+
* Stored lowercased; compare with a trimmed, lowercased entity name.
|
|
762
|
+
*/
|
|
763
|
+
static { this.UnrestrictableEntityNames = new Set([
|
|
764
|
+
'mj: entities',
|
|
765
|
+
'mj: entity fields',
|
|
766
|
+
'mj: entity permissions',
|
|
767
|
+
'mj: entity field permissions',
|
|
768
|
+
'mj: roles',
|
|
769
|
+
'mj: users',
|
|
770
|
+
'mj: user roles',
|
|
771
|
+
]); }
|
|
772
|
+
/**
|
|
773
|
+
* True when this field belongs to an entity that field-level security may never restrict.
|
|
774
|
+
* See {@link EntityFieldInfo.UnrestrictableEntityNames} for the rationale.
|
|
775
|
+
*/
|
|
776
|
+
get IsOnUnrestrictableEntity() {
|
|
777
|
+
return EntityFieldInfo.UnrestrictableEntityNames.has((this.Entity ?? '').trim().toLowerCase());
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* True for fields that must remain readable regardless of any permission record:
|
|
781
|
+
* primary keys (hard or soft) and `__mj_` system columns.
|
|
782
|
+
*
|
|
783
|
+
* Stripping a primary key from a result breaks entity load, {@link CompositeKey}
|
|
784
|
+
* construction, relationship resolution, and cache fingerprinting — the failure surfaces
|
|
785
|
+
* far from the permission record that caused it. This is enforced here AND at save time on
|
|
786
|
+
* the permission record itself, so a row inserted outside the entity path still cannot take
|
|
787
|
+
* a primary key out of a result set.
|
|
788
|
+
*/
|
|
789
|
+
get IsUnrestrictableField() {
|
|
790
|
+
return this.IsPrimaryKey === true || this.IsSoftPrimaryKey === true || (this.Name ?? '').startsWith('__mj_');
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Returns the effective field-level access this user has to this field, aggregating the
|
|
794
|
+
* field's permission records across every role the user holds.
|
|
795
|
+
*
|
|
796
|
+
* **PRECONDITION: the caller has already established that the parent entity has
|
|
797
|
+
* {@link EntityInfo.EnableFieldLevelSecurity} set.** The flag is a required parameter rather
|
|
798
|
+
* than something this method looks up, because `EntityFieldInfo` holds its entity's NAME and
|
|
799
|
+
* not a reference to the `EntityInfo` — and a method that silently answered "denied" for a
|
|
800
|
+
* field on a non-FLS entity would be a trap. Pass `false` and every field comes back fully
|
|
801
|
+
* open.
|
|
802
|
+
*
|
|
803
|
+
* Per verb, across the user's matching roles:
|
|
804
|
+
* `effective = (any row Allows) AND NOT (any row Denies)`. Deny is absorbing and No Access
|
|
805
|
+
* is the identity, so three states collapse to that one expression.
|
|
806
|
+
*
|
|
807
|
+
* Outcomes:
|
|
808
|
+
* - **Field security disabled on the entity** → fully open.
|
|
809
|
+
* - **No records on the field** (enabled) → fully closed. Snapshot initialization creates a
|
|
810
|
+
* row for every (field, role) that should have one, so a missing row means reconciliation
|
|
811
|
+
* has not run — failing closed makes that visible.
|
|
812
|
+
* - **Records exist, none match the user's roles** → fully closed, for want of an Allow.
|
|
813
|
+
*
|
|
814
|
+
* **There is no exempt user — not even the MJ system user.** Every account, including the
|
|
815
|
+
* one the server runs its own background work as, gets its access from the rows. The system
|
|
816
|
+
* user stays working because it holds the standard roles (UI, Developer, Integration),
|
|
817
|
+
* snapshot initialization writes them `Allow` rows like any other role holding entity read,
|
|
818
|
+
* and the save-time configuration guards refuse a `Deny` aimed at a role it holds. That is a
|
|
819
|
+
* constraint on what can be CONFIGURED, which an administrator can see and reason about —
|
|
820
|
+
* unlike a runtime bypass, which is invisible at the point where access is decided and has
|
|
821
|
+
* to be trusted rather than checked.
|
|
822
|
+
*
|
|
823
|
+
* PERFORMANCE: this is the per-FIELD primitive. Enforcement points must never call it
|
|
824
|
+
* inside a per-row loop — `MapFieldNamesToCodeNames` runs once per row, so a naive call
|
|
825
|
+
* site costs `fields x rows` aggregations. Compute the denied-field Set once per
|
|
826
|
+
* (entity, user) per request and pass it into the row loop.
|
|
827
|
+
*
|
|
828
|
+
* @param user the user whose effective access is being resolved
|
|
829
|
+
* @param entityFieldSecurityEnabled the parent entity's `EnableFieldLevelSecurity` flag
|
|
830
|
+
*/
|
|
831
|
+
GetUserFieldPermissions(user, entityFieldSecurityEnabled) {
|
|
832
|
+
if (!entityFieldSecurityEnabled) {
|
|
833
|
+
return EntityFieldInfo.fullyOpenFieldPermissions();
|
|
834
|
+
}
|
|
835
|
+
// Records on an unrestrictable entity are ignored entirely rather than half-applied.
|
|
836
|
+
// Save-time validation rejects such rows, so reaching here means they were written
|
|
837
|
+
// outside the entity path.
|
|
838
|
+
if (this.IsOnUnrestrictableEntity) {
|
|
839
|
+
return EntityFieldInfo.fullyOpenFieldPermissions();
|
|
840
|
+
}
|
|
841
|
+
// Primary keys and __mj_ system columns are forced open BEFORE aggregation rather than
|
|
842
|
+
// patched afterwards: a half-corrected result (readable but not creatable) would break
|
|
843
|
+
// inserts on entities whose primary key the caller supplies.
|
|
844
|
+
if (this.IsUnrestrictableField) {
|
|
845
|
+
return EntityFieldInfo.fullyOpenFieldPermissions();
|
|
846
|
+
}
|
|
847
|
+
return this.aggregateUserFieldPermissions(user);
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* The "field security does not apply here" answer, named so the policy exits above cannot
|
|
851
|
+
* drift apart from one another.
|
|
852
|
+
*/
|
|
853
|
+
static fullyOpenFieldPermissions() {
|
|
854
|
+
return { CanRead: true, CanUpdate: true, CanCreate: true };
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Trinary aggregation across the user's roles. Split out of
|
|
858
|
+
* {@link GetUserFieldPermissions} so the guards there read as policy and this reads as
|
|
859
|
+
* arithmetic.
|
|
860
|
+
*/
|
|
861
|
+
aggregateUserFieldPermissions(user) {
|
|
862
|
+
return EntityFieldInfo.AggregateFieldRulesForUser(this._FieldPermissions, user);
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* The same aggregation {@link GetUserFieldPermissions} performs, over a rule list the caller
|
|
866
|
+
* supplies rather than this field's stored one.
|
|
867
|
+
*
|
|
868
|
+
* Exists so save-time guards can evaluate a **prospective** outcome — the rules as they would
|
|
869
|
+
* stand after a proposed insert, edit or delete — instead of classifying a single row in
|
|
870
|
+
* isolation. That distinction is load-bearing: whether a change restricts a user is a property
|
|
871
|
+
* of the AGGREGATE across all the roles they hold, not of any one rule. A rule reading
|
|
872
|
+
* `No Access` restricts nobody on its own, yet setting every one of a user's roles to
|
|
873
|
+
* `No Access` leaves no `Allow` standing and denies the field outright.
|
|
874
|
+
*
|
|
875
|
+
* @param rules the rules to aggregate — any shape carrying a `RoleID` and the three verbs
|
|
876
|
+
* @param user the user whose roles select which rules apply
|
|
877
|
+
*/
|
|
878
|
+
static AggregateFieldRulesForUser(rules, user) {
|
|
879
|
+
const allow = { CanRead: false, CanUpdate: false, CanCreate: false };
|
|
880
|
+
const deny = { CanRead: false, CanUpdate: false, CanCreate: false };
|
|
881
|
+
for (const fp of rules) {
|
|
882
|
+
const roleMatch = user?.UserRoles?.find((r) => UUIDsEqual(r.RoleID, fp.RoleID));
|
|
883
|
+
if (!roleMatch) {
|
|
884
|
+
continue; // user does not hold this role
|
|
885
|
+
}
|
|
886
|
+
EntityFieldInfo.applyAccessToBuckets(fp.ReadAccess, allow, deny, 'CanRead');
|
|
887
|
+
EntityFieldInfo.applyAccessToBuckets(fp.UpdateAccess, allow, deny, 'CanUpdate');
|
|
888
|
+
EntityFieldInfo.applyAccessToBuckets(fp.CreateAccess, allow, deny, 'CanCreate');
|
|
889
|
+
}
|
|
890
|
+
const effective = {
|
|
891
|
+
CanRead: allow.CanRead && !deny.CanRead,
|
|
892
|
+
CanUpdate: allow.CanUpdate && !deny.CanUpdate,
|
|
893
|
+
CanCreate: allow.CanCreate && !deny.CanCreate,
|
|
894
|
+
};
|
|
895
|
+
// Read is required for Update and Create. A CHECK constraint enforces this per ROW and
|
|
896
|
+
// cannot enforce it here: role A granting Read+Update and role B denying Read are each
|
|
897
|
+
// individually legal, yet a user holding both aggregates to read-denied +
|
|
898
|
+
// update-allowed. This clamp is what makes that combination unreachable at runtime.
|
|
899
|
+
if (!effective.CanRead) {
|
|
900
|
+
effective.CanUpdate = false;
|
|
901
|
+
effective.CanCreate = false;
|
|
902
|
+
}
|
|
903
|
+
return effective;
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Folds one trinary verb into the Allow/Deny buckets. Anything unrecognized is treated as
|
|
907
|
+
* `No Access`, so a value reaching here outside the CHECK constraint grants nothing.
|
|
908
|
+
*/
|
|
909
|
+
static applyAccessToBuckets(access, allow, deny, verb) {
|
|
910
|
+
switch ((access ?? '').trim().toLowerCase()) {
|
|
911
|
+
case 'allow':
|
|
912
|
+
allow[verb] = true;
|
|
913
|
+
break;
|
|
914
|
+
case 'deny':
|
|
915
|
+
deny[verb] = true;
|
|
916
|
+
break;
|
|
917
|
+
default:
|
|
918
|
+
break; // 'No Access', and anything unrecognized: neutral
|
|
919
|
+
}
|
|
920
|
+
}
|
|
533
921
|
/**
|
|
534
922
|
* Returns the ValueListType using the EntityFieldValueListType enum.
|
|
535
923
|
*/
|
|
@@ -1477,6 +1865,7 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
1477
1865
|
* Flag to track if RelatedEntityJoinFieldsConfig parsing failed to avoid repeated parse attempts on bad JSON.
|
|
1478
1866
|
*/
|
|
1479
1867
|
this._relatedEntityJoinFieldsFailedParsing = false;
|
|
1868
|
+
this._FieldPermissions = [];
|
|
1480
1869
|
/** Memoized {@link TSType} — `Type` is immutable after metadata load, so the classification never changes. */
|
|
1481
1870
|
this._tsType = undefined;
|
|
1482
1871
|
/**
|
|
@@ -1499,6 +1888,15 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
1499
1888
|
this._EntityFieldValues = [];
|
|
1500
1889
|
this._entityFieldValuesConstructed = true;
|
|
1501
1890
|
}
|
|
1891
|
+
// Field-level security records. Constructed eagerly rather than lazily (unlike
|
|
1892
|
+
// EntityFieldValues above) because the array is empty for virtually every field in
|
|
1893
|
+
// every deployment — there is no ~36,000-object construction cost to defer, and
|
|
1894
|
+
// HasFieldPermissions is read on enforcement paths where a lazy hydration check
|
|
1895
|
+
// would cost more than the construction it avoids.
|
|
1896
|
+
const efp = initData.EntityFieldPermissions || initData._FieldPermissions || initData.FieldPermissions;
|
|
1897
|
+
if (efp && efp.length > 0) {
|
|
1898
|
+
this._FieldPermissions = efp.map((p) => new EntityFieldPermissionInfo(p));
|
|
1899
|
+
}
|
|
1502
1900
|
}
|
|
1503
1901
|
}
|
|
1504
1902
|
/**
|
|
@@ -1703,6 +2101,140 @@ export class EntityInfo extends BaseInfo {
|
|
|
1703
2101
|
get ConfigurationObject() {
|
|
1704
2102
|
return this.Configuration;
|
|
1705
2103
|
}
|
|
2104
|
+
/**
|
|
2105
|
+
* The set of field names this user may NOT READ on this entity — the per-request primitive
|
|
2106
|
+
* every field-security enforcement point is built on.
|
|
2107
|
+
*
|
|
2108
|
+
* Compute this ONCE per (entity, user) per request and pass the Set into any row loop.
|
|
2109
|
+
* `GetUserFieldPermissions` is the per-FIELD primitive; calling it per row costs
|
|
2110
|
+
* `fields x rows` aggregations (40,000 for a 1,000-row x 40-column result), each of which
|
|
2111
|
+
* re-scans `user.UserRoles` and allocates. A Set lookup costs neither.
|
|
2112
|
+
*
|
|
2113
|
+
* Names are lowercased so callers can match case-insensitively, consistent with
|
|
2114
|
+
* {@link ProjectRowsToFields}. Returns an EMPTY set — never null — both when the entity has
|
|
2115
|
+
* field security switched off and when the user is denied nothing, so callers can treat
|
|
2116
|
+
* `size === 0` as the single "nothing to do" condition.
|
|
2117
|
+
*/
|
|
2118
|
+
GetDeniedReadFields(user) {
|
|
2119
|
+
return this.getDeniedFields(user, (p) => !p.CanRead);
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Whether this user may READ the named field — the single-field question, answered the same
|
|
2123
|
+
* way {@link GetDeniedReadFields} answers it in bulk.
|
|
2124
|
+
*
|
|
2125
|
+
* Exists for **display code that is about to read a value it did not choose**: a form
|
|
2126
|
+
* toolbar rendering the entity's Name field, an FK control rendering the joined display
|
|
2127
|
+
* column, an IS-A card walking a sibling record's fields. `BaseEntity.Get()` throws for a
|
|
2128
|
+
* denied field, so those call sites have to ask before they read or they take out the whole
|
|
2129
|
+
* screen instead of hiding one value.
|
|
2130
|
+
*
|
|
2131
|
+
* This is a PREDICATE, deliberately — not a value accessor that quietly returns nothing.
|
|
2132
|
+
* The caller still decides what to render in place of the value, which is the part that
|
|
2133
|
+
* differs per surface and should not be hidden inside a getter.
|
|
2134
|
+
*
|
|
2135
|
+
* **Fails open** on a missing user, a missing field name, or an entity with field security
|
|
2136
|
+
* switched off — matching `BaseEntity`'s own gate and `MjFormFieldComponent`. The server is
|
|
2137
|
+
* the real boundary; a UI that blanked out fields because no user had resolved yet would be
|
|
2138
|
+
* worse than one that shows them.
|
|
2139
|
+
*
|
|
2140
|
+
* PERFORMANCE: this delegates to {@link GetDeniedReadFields}, which walks every field on the
|
|
2141
|
+
* entity. Fine for the handful of chrome reads it exists for; do NOT call it per row in a
|
|
2142
|
+
* grid loop — compute the denied set once and test against it.
|
|
2143
|
+
*
|
|
2144
|
+
* @param fieldName the field about to be read
|
|
2145
|
+
* @param user the acting user
|
|
2146
|
+
*/
|
|
2147
|
+
IsFieldReadableByUser(fieldName, user) {
|
|
2148
|
+
if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
|
|
2149
|
+
return true;
|
|
2150
|
+
}
|
|
2151
|
+
return !this.GetDeniedReadFields(user).has(fieldName.trim().toLowerCase());
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Whether this user may UPDATE the named field. Companion to
|
|
2155
|
+
* {@link IsFieldReadableByUser}, for UI that needs to render a control read-only rather than
|
|
2156
|
+
* let a user type into something the server will reject on save. Fails open on the same
|
|
2157
|
+
* three conditions.
|
|
2158
|
+
*/
|
|
2159
|
+
IsFieldUpdatableByUser(fieldName, user) {
|
|
2160
|
+
if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
|
|
2161
|
+
return true;
|
|
2162
|
+
}
|
|
2163
|
+
return !this.GetDeniedUpdateFields(user).has(fieldName.trim().toLowerCase());
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* Whether this user may supply a value for the named field when CREATING a record.
|
|
2167
|
+
* Companion to {@link IsFieldReadableByUser}. Fails open on the same three conditions.
|
|
2168
|
+
*
|
|
2169
|
+
* Note the server does not REJECT a create-denied value — it drops it and takes the column
|
|
2170
|
+
* default. So a UI that leaves such a field editable on a new record silently discards what
|
|
2171
|
+
* the user typed, which is the case this exists to prevent.
|
|
2172
|
+
*/
|
|
2173
|
+
IsFieldCreatableByUser(fieldName, user) {
|
|
2174
|
+
if (!this.EnableFieldLevelSecurity || !fieldName || !user) {
|
|
2175
|
+
return true;
|
|
2176
|
+
}
|
|
2177
|
+
return !this.GetDeniedCreateFields(user).has(fieldName.trim().toLowerCase());
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* The set of field names this user may NOT UPDATE on this entity. Same per-request
|
|
2181
|
+
* precompute contract as {@link GetDeniedReadFields}.
|
|
2182
|
+
*
|
|
2183
|
+
* A field can be readable and not updatable. The reverse cannot happen — Read is required
|
|
2184
|
+
* for Update — so denied-read is always a subset of denied-update, but ask for the set you
|
|
2185
|
+
* actually need rather than relying on that.
|
|
2186
|
+
*/
|
|
2187
|
+
GetDeniedUpdateFields(user) {
|
|
2188
|
+
return this.getDeniedFields(user, (p) => !p.CanUpdate);
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* The set of field names this user may NOT supply a value for when CREATING a record. Same
|
|
2192
|
+
* per-request precompute contract as {@link GetDeniedReadFields}.
|
|
2193
|
+
*
|
|
2194
|
+
* Unlike the update set, this does not drive a rejection: a value supplied for a
|
|
2195
|
+
* create-denied field is dropped and the column takes its default.
|
|
2196
|
+
*/
|
|
2197
|
+
GetDeniedCreateFields(user) {
|
|
2198
|
+
return this.getDeniedFields(user, (p) => !p.CanCreate);
|
|
2199
|
+
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Shared walk behind {@link GetDeniedReadFields} / {@link GetDeniedUpdateFields} /
|
|
2202
|
+
* {@link GetDeniedCreateFields}, short-circuiting on {@link EnableFieldLevelSecurity}.
|
|
2203
|
+
*
|
|
2204
|
+
* Every field is aggregated, including those carrying no permission records — on an enabled
|
|
2205
|
+
* entity those are denied. Unrestrictable fields (primary keys, `__mj_` columns) come back
|
|
2206
|
+
* open, decided inside `GetUserFieldPermissions` rather than skipped here.
|
|
2207
|
+
*
|
|
2208
|
+
* **Carries BOTH `Name` and `CodeName`**, because the callers do not all live in the same key
|
|
2209
|
+
* space and a set holding only one of them silently no-ops in the other. `BaseEntity` and the
|
|
2210
|
+
* predicate gate ask about field *Names*; the row projections
|
|
2211
|
+
* (`ProviderBase.OmitFieldsFromRows`, the Record Changes payload projector) match against a
|
|
2212
|
+
* *row's own keys*, and rows are keyed by `CodeName` — `getRunTimeViewFieldString` emits
|
|
2213
|
+
* `[Name] AS [CodeName]` whenever the two differ, and `CodeNameFromString` replaces every
|
|
2214
|
+
* `[^a-zA-Z0-9_]` with `_`. So for a column named `Base Salary` a Name-only set holds
|
|
2215
|
+
* `base salary` while the rows are keyed `Base_Salary`, nothing matches, and the denied values
|
|
2216
|
+
* are returned in full. Every shipped MJ field name is already a valid identifier, so the two
|
|
2217
|
+
* coincide throughout core and no fixture caught this; it needs a customer entity with a
|
|
2218
|
+
* column like `Base Salary` or `Emp #` — which is the population this feature exists for.
|
|
2219
|
+
*
|
|
2220
|
+
* Widening cannot over-deny. The only way an extra entry could catch an innocent field is if a
|
|
2221
|
+
* DENIED field's `CodeName` equalled a different, permitted field's `Name` — but two fields
|
|
2222
|
+
* that collide on `CodeName` already collide on their generated property, which is not a
|
|
2223
|
+
* schema CodeGen can emit.
|
|
2224
|
+
*/
|
|
2225
|
+
getDeniedFields(user, isDenied) {
|
|
2226
|
+
const denied = new Set();
|
|
2227
|
+
if (!this.EnableFieldLevelSecurity) {
|
|
2228
|
+
return denied;
|
|
2229
|
+
}
|
|
2230
|
+
for (const field of this._Fields) {
|
|
2231
|
+
if (isDenied(field.GetUserFieldPermissions(user, true))) {
|
|
2232
|
+
denied.add(field.Name.trim().toLowerCase());
|
|
2233
|
+
denied.add(field.CodeName.trim().toLowerCase());
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
return denied;
|
|
2237
|
+
}
|
|
1706
2238
|
/**
|
|
1707
2239
|
* O(1) case-insensitive field lookup by name. Use this instead of `Fields.find(f => f.Name === name)`
|
|
1708
2240
|
* on hot paths — it builds a lowercased+trimmed `Map` once (lazily) and reuses it.
|
|
@@ -2161,7 +2693,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2161
2693
|
const allow = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
|
|
2162
2694
|
const deny = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
|
|
2163
2695
|
for (const ep of permissionList) {
|
|
2164
|
-
const isDeny =
|
|
2696
|
+
const isDeny = ep.IsDeny;
|
|
2165
2697
|
const bucket = isDeny ? deny : allow;
|
|
2166
2698
|
bucket.CanCreate = bucket.CanCreate || !!ep.CanCreate;
|
|
2167
2699
|
bucket.CanRead = bucket.CanRead || !!ep.CanRead;
|
|
@@ -2203,6 +2735,9 @@ export class EntityInfo extends BaseInfo {
|
|
|
2203
2735
|
UserExemptFromRowLevelSecurity(user, type) {
|
|
2204
2736
|
for (let j = 0; j < this.Permissions.length; j++) {
|
|
2205
2737
|
const ep = this.Permissions[j];
|
|
2738
|
+
if (ep.IsDeny) {
|
|
2739
|
+
continue; // a Deny row's Can* flags are denials, never grants — it cannot exempt anyone
|
|
2740
|
+
}
|
|
2206
2741
|
const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
|
|
2207
2742
|
if (roleMatch) { // user has this role
|
|
2208
2743
|
switch (type) {
|
|
@@ -2228,7 +2763,18 @@ export class EntityInfo extends BaseInfo {
|
|
|
2228
2763
|
return false; // if we get here, the user is NOT exempt from RLS for this Permission Type
|
|
2229
2764
|
}
|
|
2230
2765
|
/**
|
|
2231
|
-
* Returns RLS security info attributes for a given user and permission type
|
|
2766
|
+
* Returns RLS security info attributes for a given user and permission type.
|
|
2767
|
+
*
|
|
2768
|
+
* Only permission rows that GRANT the operation contribute a filter: an Allow row whose matching
|
|
2769
|
+
* `Can*` flag is true. Deny rows are skipped outright — on a Deny row a set `Can*` flag means
|
|
2770
|
+
* "deny that operation", and a user carrying one fails the permission gate before this runs
|
|
2771
|
+
* (`GetUserPermisions` subtracts Deny from Allow), so reading it as a grant would be wrong even
|
|
2772
|
+
* though it is unreachable. The filters of a user's roles are OR'd together by the caller, so a filter collected
|
|
2773
|
+
* from a row that does not grant the operation would WIDEN the clause: a user granted Create by
|
|
2774
|
+
* role A (bound to filter F1) would create against `F1 OR F2` when role B keeps a leftover
|
|
2775
|
+
* `CreateRLSFilterID = F2` beside `CanCreate = false`. `GetUserPermisions` aggregates the flags
|
|
2776
|
+
* across roles, so such a user passes the permission gate on role A alone; nothing else stops
|
|
2777
|
+
* F2 from applying. A user with no granting row gets no clause here — and no permission either.
|
|
2232
2778
|
* @param user
|
|
2233
2779
|
* @param type
|
|
2234
2780
|
* @returns
|
|
@@ -2237,24 +2783,27 @@ export class EntityInfo extends BaseInfo {
|
|
|
2237
2783
|
const rlsList = [];
|
|
2238
2784
|
for (let j = 0; j < this.Permissions.length; j++) {
|
|
2239
2785
|
const ep = this.Permissions[j];
|
|
2786
|
+
if (ep.IsDeny) {
|
|
2787
|
+
continue; // never a grant — see the doc comment
|
|
2788
|
+
}
|
|
2240
2789
|
const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
|
|
2241
2790
|
if (roleMatch) { // user has this role
|
|
2242
2791
|
let matchObject = null;
|
|
2243
2792
|
switch (type) {
|
|
2244
2793
|
case EntityPermissionType.Create:
|
|
2245
|
-
if (ep.CreateRLSFilterID)
|
|
2794
|
+
if (ep.CanCreate && ep.CreateRLSFilterID)
|
|
2246
2795
|
matchObject = ep.CreateRLSFilterObject;
|
|
2247
2796
|
break;
|
|
2248
2797
|
case EntityPermissionType.Read:
|
|
2249
|
-
if (ep.ReadRLSFilterID)
|
|
2798
|
+
if (ep.CanRead && ep.ReadRLSFilterID)
|
|
2250
2799
|
matchObject = ep.ReadRLSFilterObject;
|
|
2251
2800
|
break;
|
|
2252
2801
|
case EntityPermissionType.Update:
|
|
2253
|
-
if (ep.UpdateRLSFilterID)
|
|
2802
|
+
if (ep.CanUpdate && ep.UpdateRLSFilterID)
|
|
2254
2803
|
matchObject = ep.UpdateRLSFilterObject;
|
|
2255
2804
|
break;
|
|
2256
2805
|
case EntityPermissionType.Delete:
|
|
2257
|
-
if (ep.DeleteRLSFilterID)
|
|
2806
|
+
if (ep.CanDelete && ep.DeleteRLSFilterID)
|
|
2258
2807
|
matchObject = ep.DeleteRLSFilterObject;
|
|
2259
2808
|
break;
|
|
2260
2809
|
}
|
|
@@ -2790,6 +3339,19 @@ export class EntityInfo extends BaseInfo {
|
|
|
2790
3339
|
* client-side IndexedDB cache. Zero overhead on hot save/query paths.
|
|
2791
3340
|
*/
|
|
2792
3341
|
this.AllowCaching = false;
|
|
3342
|
+
/**
|
|
3343
|
+
* Whether field-level (column-level) security is enforced for this entity.
|
|
3344
|
+
*
|
|
3345
|
+
* This is the single gate every field-security enforcement point checks first, and it is
|
|
3346
|
+
* explicit — never inferred from whether permission rows happen to exist. It is `false` for
|
|
3347
|
+
* nearly every entity in nearly every deployment, so enforcement collapses to one boolean
|
|
3348
|
+
* test: no field iteration, no aggregation, no allocation.
|
|
3349
|
+
*
|
|
3350
|
+
* Turning it on snapshots the entity's existing entity-level permissions into per-field
|
|
3351
|
+
* rows, so enabling changes no behavior until an administrator tightens a field. Turning it
|
|
3352
|
+
* off leaves the rows in place, inactive, so re-enabling does not lose the configuration.
|
|
3353
|
+
*/
|
|
3354
|
+
this.EnableFieldLevelSecurity = false;
|
|
2793
3355
|
/**
|
|
2794
3356
|
* Whether this entity is available through the GraphQL API
|
|
2795
3357
|
*/
|