@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.
Files changed (40) hide show
  1. package/dist/generic/baseEngine.d.ts +52 -1
  2. package/dist/generic/baseEngine.d.ts.map +1 -1
  3. package/dist/generic/baseEngine.js +93 -4
  4. package/dist/generic/baseEngine.js.map +1 -1
  5. package/dist/generic/baseEntity.d.ts +173 -0
  6. package/dist/generic/baseEntity.d.ts.map +1 -1
  7. package/dist/generic/baseEntity.js +357 -12
  8. package/dist/generic/baseEntity.js.map +1 -1
  9. package/dist/generic/entityInfo.d.ts +461 -1
  10. package/dist/generic/entityInfo.d.ts.map +1 -1
  11. package/dist/generic/entityInfo.js +568 -6
  12. package/dist/generic/entityInfo.js.map +1 -1
  13. package/dist/generic/interfaces.d.ts +5 -0
  14. package/dist/generic/interfaces.d.ts.map +1 -1
  15. package/dist/generic/interfaces.js.map +1 -1
  16. package/dist/generic/localCacheManager.d.ts +30 -1
  17. package/dist/generic/localCacheManager.d.ts.map +1 -1
  18. package/dist/generic/localCacheManager.js +48 -1
  19. package/dist/generic/localCacheManager.js.map +1 -1
  20. package/dist/generic/providerBase.d.ts +164 -2
  21. package/dist/generic/providerBase.d.ts.map +1 -1
  22. package/dist/generic/providerBase.js +398 -23
  23. package/dist/generic/providerBase.js.map +1 -1
  24. package/dist/generic/recordChangeFieldSecurity.d.ts +164 -0
  25. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -0
  26. package/dist/generic/recordChangeFieldSecurity.js +279 -0
  27. package/dist/generic/recordChangeFieldSecurity.js.map +1 -0
  28. package/dist/generic/saveEntityGraphOperation.d.ts +10 -0
  29. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  30. package/dist/generic/saveEntityGraphOperation.js +2 -1
  31. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  32. package/dist/generic/wellKnownUserSource.d.ts +70 -0
  33. package/dist/generic/wellKnownUserSource.d.ts.map +1 -0
  34. package/dist/generic/wellKnownUserSource.js +82 -0
  35. package/dist/generic/wellKnownUserSource.js.map +1 -0
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. 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 = (ep.Type || 'Allow').trim().toLowerCase() === 'deny';
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
  */