@memberjunction/core 6.1.0-edge.5 → 6.1.0-edge.7
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/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +27 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js.map +1 -0
- package/dist/generic/baseEngine.d.ts +52 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +95 -7
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +292 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +878 -98
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts +6 -0
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +19 -1
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/compositeKey.d.ts +108 -0
- package/dist/generic/compositeKey.d.ts.map +1 -1
- package/dist/generic/compositeKey.js +225 -10
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +49 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +90 -11
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +2 -1
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +9 -2
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +6 -1
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +7 -0
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +550 -8
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +695 -20
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySubtypeResolver.d.ts +19 -0
- package/dist/generic/entitySubtypeResolver.d.ts.map +1 -0
- package/dist/generic/entitySubtypeResolver.js +24 -0
- package/dist/generic/entitySubtypeResolver.js.map +1 -0
- package/dist/generic/entityTransactionScope.d.ts +6 -3
- package/dist/generic/entityTransactionScope.d.ts.map +1 -1
- package/dist/generic/entityTransactionScope.js +6 -3
- package/dist/generic/entityTransactionScope.js.map +1 -1
- package/dist/generic/extendedTypeValue.d.ts +47 -0
- package/dist/generic/extendedTypeValue.d.ts.map +1 -0
- package/dist/generic/extendedTypeValue.js +152 -0
- package/dist/generic/extendedTypeValue.js.map +1 -0
- package/dist/generic/filters/compositeFilter.d.ts +77 -0
- package/dist/generic/filters/compositeFilter.d.ts.map +1 -0
- package/dist/generic/filters/compositeFilter.js +324 -0
- package/dist/generic/filters/compositeFilter.js.map +1 -0
- package/dist/generic/filters/filter.types.d.ts +29 -0
- package/dist/generic/filters/filter.types.d.ts.map +1 -0
- package/dist/generic/filters/filter.types.js +28 -0
- package/dist/generic/filters/filter.types.js.map +1 -0
- package/dist/generic/filters/index.d.ts +3 -0
- package/dist/generic/filters/index.d.ts.map +1 -0
- package/dist/generic/filters/index.js +3 -0
- package/dist/generic/filters/index.js.map +1 -0
- package/dist/generic/geoFields.d.ts +64 -0
- package/dist/generic/geoFields.d.ts.map +1 -0
- package/dist/generic/geoFields.js +108 -0
- package/dist/generic/geoFields.js.map +1 -0
- package/dist/generic/interfaces.d.ts +16 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +34 -3
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +53 -4
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +282 -5
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +754 -93
- 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/relatedRecordBatchLoader.js +2 -2
- package/dist/generic/relatedRecordBatchLoader.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +2 -1
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +16 -4
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- 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 +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -3,8 +3,26 @@ import { Metadata } from "./metadata.js";
|
|
|
3
3
|
import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, FormatValue, CodeNameFromString } from "./util.js";
|
|
4
4
|
import { IsFixedWidthStringSQLType } from "@memberjunction/sql-dialect";
|
|
5
5
|
import { LogError } from "./logging.js";
|
|
6
|
-
import { WarningManager, SafeJSONParse, UUIDsEqual } from "@memberjunction/global";
|
|
6
|
+
import { WarningManager, SafeJSONParse, UUIDsEqual, ordinalCompare } from "@memberjunction/global";
|
|
7
7
|
import { ReadRelationshipJoinFields, } from "./entityConfiguration.js";
|
|
8
|
+
/**
|
|
9
|
+
* Runtime domain for {@link EntityFieldInfo.ExtendedType}. This array is the single source of
|
|
10
|
+
* truth; {@link EntityFieldExtendedType} is derived from it. CodeGen validates LLM suggestions
|
|
11
|
+
* against {@link EntityFieldInfo.ExtendedTypes} rather than duplicating the list.
|
|
12
|
+
*
|
|
13
|
+
* `Image` — the value is an image URL, a `data:image/...` URI, or raw image base64. UI surfaces
|
|
14
|
+
* render a thumbnail and (in edit mode) allow replacing it with an upload capped at the field's
|
|
15
|
+
* MaxLength.
|
|
16
|
+
* `Color` — the value is a CSS color (hex / rgb / hsl).
|
|
17
|
+
* `JSON` — the value is a JSON document; validated on save and pretty-printed in forms.
|
|
18
|
+
*/
|
|
19
|
+
export const EntityFieldExtendedTypes = [
|
|
20
|
+
'Code', 'Color', 'Email', 'FaceTime', 'Geo',
|
|
21
|
+
'GeoLatitude', 'GeoLongitude', 'GeoCountry', 'GeoStateProvince',
|
|
22
|
+
'GeoCity', 'GeoPostalCode', 'GeoAddress',
|
|
23
|
+
'HTML', 'Icon', 'Image', 'JSON', 'Markdown',
|
|
24
|
+
'MSTeams', 'Other', 'SIP', 'SMS', 'Skype', 'Tel', 'URL', 'WhatsApp', 'ZoomMtg',
|
|
25
|
+
];
|
|
8
26
|
/**
|
|
9
27
|
* The possible status values for a record change
|
|
10
28
|
*/
|
|
@@ -209,7 +227,7 @@ export class EntityOrganicKeyInfo extends BaseInfo {
|
|
|
209
227
|
sorted.sort((a, b) => {
|
|
210
228
|
const aSeq = a.Sequence ?? 999999;
|
|
211
229
|
const bSeq = b.Sequence ?? 999999;
|
|
212
|
-
return aSeq - bSeq;
|
|
230
|
+
return (aSeq - bSeq) || ordinalCompare(a.RelatedEntity, b.RelatedEntity) || ordinalCompare(a.ID, b.ID);
|
|
213
231
|
});
|
|
214
232
|
for (const item of sorted) {
|
|
215
233
|
this._RelatedEntities.push(new EntityOrganicKeyRelatedEntityInfo(item));
|
|
@@ -305,6 +323,15 @@ export class EntityUserPermissionInfo {
|
|
|
305
323
|
* Controls which roles can perform create, read, update, and delete operations.
|
|
306
324
|
*/
|
|
307
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
|
+
}
|
|
308
335
|
get CreateRLSFilterObject() {
|
|
309
336
|
return this.RLSFilter(EntityPermissionType.Create);
|
|
310
337
|
}
|
|
@@ -369,6 +396,190 @@ export class EntityPermissionInfo extends BaseInfo {
|
|
|
369
396
|
this.copyInitData(initData);
|
|
370
397
|
}
|
|
371
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
|
+
}
|
|
372
583
|
export const EntityFieldTSType = {
|
|
373
584
|
String: 'string',
|
|
374
585
|
Number: 'number',
|
|
@@ -428,6 +639,10 @@ export const GeneratedFormSectionType = {
|
|
|
428
639
|
* Includes data types, relationships, defaults, and UI display preferences.
|
|
429
640
|
*/
|
|
430
641
|
export class EntityFieldInfo extends BaseInfo {
|
|
642
|
+
/**
|
|
643
|
+
* Runtime domain for {@link ExtendedType}. Same array as {@link EntityFieldExtendedTypes}.
|
|
644
|
+
*/
|
|
645
|
+
static { this.ExtendedTypes = EntityFieldExtendedTypes; }
|
|
431
646
|
/**
|
|
432
647
|
* Optional JSON configuration bag (shape = {@link IEntityFieldConfiguration}).
|
|
433
648
|
* Defines field-level configurations such as Hierarchy options (IsHierarchy, MaxDepth).
|
|
@@ -508,6 +723,201 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
508
723
|
}
|
|
509
724
|
return this._EntityFieldValues;
|
|
510
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
|
+
}
|
|
511
921
|
/**
|
|
512
922
|
* Returns the ValueListType using the EntityFieldValueListType enum.
|
|
513
923
|
*/
|
|
@@ -878,6 +1288,42 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
878
1288
|
this.IsPrimaryKey ||
|
|
879
1289
|
this.IsSpecialDateField;
|
|
880
1290
|
}
|
|
1291
|
+
/**
|
|
1292
|
+
* True when {@link ExtendedType} is any Geo* tag (`Geo`, `GeoLatitude`, `GeoAddress`, …).
|
|
1293
|
+
* Used by maps, distance, and GeoCodeSyncService. Display-only virtuals still count.
|
|
1294
|
+
*/
|
|
1295
|
+
get IsGeoExtendedType() {
|
|
1296
|
+
const t = this.ExtendedType;
|
|
1297
|
+
return typeof t === 'string' && t.startsWith('Geo');
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* A Geo* field that can be written on Save. GeoCodeSyncService only runs when the
|
|
1301
|
+
* entity has at least one of these. Virtual / AllowUpdateAPI=0 fields (PrimaryAddress*,
|
|
1302
|
+
* `__mj_Latitude`, embedded `__mj_Latitude_{FK}`) are display-only — maps still use them.
|
|
1303
|
+
*/
|
|
1304
|
+
get IsWritableGeoField() {
|
|
1305
|
+
return this.IsGeoExtendedType && !this.IsVirtual && !!this.AllowUpdateAPI;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Native (table) latitude column — `ExtendedType=GeoLatitude`, or legacy `Geo` named Latitude.
|
|
1309
|
+
*/
|
|
1310
|
+
get IsNativeLatitudeField() {
|
|
1311
|
+
if (this.IsVirtual)
|
|
1312
|
+
return false;
|
|
1313
|
+
if (this.ExtendedType === 'GeoLatitude')
|
|
1314
|
+
return true;
|
|
1315
|
+
return this.ExtendedType === 'Geo' && /^lat(itude)?$/i.test(this.Name);
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Native (table) longitude column — `ExtendedType=GeoLongitude`, or legacy `Geo` named Long*.
|
|
1319
|
+
*/
|
|
1320
|
+
get IsNativeLongitudeField() {
|
|
1321
|
+
if (this.IsVirtual)
|
|
1322
|
+
return false;
|
|
1323
|
+
if (this.ExtendedType === 'GeoLongitude')
|
|
1324
|
+
return true;
|
|
1325
|
+
return this.ExtendedType === 'Geo' && /^(lng|lon|long|longitude)$/i.test(this.Name);
|
|
1326
|
+
}
|
|
881
1327
|
/**
|
|
882
1328
|
* Helper method that returns true if the field is one of the special reserved MJ date fields for tracking CreatedAt and UpdatedAt timestamps as well as the DeletedAt timestamp used for entities that
|
|
883
1329
|
* have DeleteType=Soft. This is only used when the entity has TrackRecordChanges=1 or for entities where DeleteType=Soft
|
|
@@ -1419,6 +1865,7 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
1419
1865
|
* Flag to track if RelatedEntityJoinFieldsConfig parsing failed to avoid repeated parse attempts on bad JSON.
|
|
1420
1866
|
*/
|
|
1421
1867
|
this._relatedEntityJoinFieldsFailedParsing = false;
|
|
1868
|
+
this._FieldPermissions = [];
|
|
1422
1869
|
/** Memoized {@link TSType} — `Type` is immutable after metadata load, so the classification never changes. */
|
|
1423
1870
|
this._tsType = undefined;
|
|
1424
1871
|
/**
|
|
@@ -1441,6 +1888,15 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
1441
1888
|
this._EntityFieldValues = [];
|
|
1442
1889
|
this._entityFieldValuesConstructed = true;
|
|
1443
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
|
+
}
|
|
1444
1900
|
}
|
|
1445
1901
|
}
|
|
1446
1902
|
/**
|
|
@@ -1577,6 +2033,40 @@ export class EntityInfo extends BaseInfo {
|
|
|
1577
2033
|
const layered = this.GeneratedBaseViewName?.trim();
|
|
1578
2034
|
return !!layered && layered.toLowerCase() !== (this.BaseView ?? '').toLowerCase();
|
|
1579
2035
|
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Parsed SubtypeSelector configuration, if configured.
|
|
2038
|
+
*/
|
|
2039
|
+
get SubtypeSelectorConfig() {
|
|
2040
|
+
if (this._subtypeSelectorConfig === undefined) {
|
|
2041
|
+
if (this.SubtypeSelector && typeof this.SubtypeSelector === 'string') {
|
|
2042
|
+
try {
|
|
2043
|
+
const parsed = JSON.parse(this.SubtypeSelector);
|
|
2044
|
+
if (parsed && typeof parsed['Path'] === 'string' && parsed['Path'].trim().length > 0) {
|
|
2045
|
+
this._subtypeSelectorConfig = { Path: parsed['Path'].trim() };
|
|
2046
|
+
}
|
|
2047
|
+
else {
|
|
2048
|
+
LogError(`EntityInfo '${this.Name}': SubtypeSelector JSON must contain a non-empty 'Path' string property. Found: ${this.SubtypeSelector}`);
|
|
2049
|
+
this._subtypeSelectorConfig = null;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
catch (err) {
|
|
2053
|
+
LogError(`EntityInfo '${this.Name}': failed to parse SubtypeSelector JSON '${this.SubtypeSelector}': ${err instanceof Error ? err.message : String(err)}`);
|
|
2054
|
+
this._subtypeSelectorConfig = null;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
else {
|
|
2058
|
+
this._subtypeSelectorConfig = null;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
return this._subtypeSelectorConfig;
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* True when at least one field is a writable Geo* source (street and/or native lat/lng).
|
|
2065
|
+
* Person/Org PrimaryAddress* are virtual display fields and do **not** count.
|
|
2066
|
+
*/
|
|
2067
|
+
get HasWritableGeoSourceFields() {
|
|
2068
|
+
return (this.Fields ?? []).some(f => f.IsWritableGeoField);
|
|
2069
|
+
}
|
|
1580
2070
|
/**
|
|
1581
2071
|
* Optional JSON configuration bag (shape = {@link IEntityConfiguration}).
|
|
1582
2072
|
* Nested `UI.Form` holds generated-form chrome: layout, auto left-nav
|
|
@@ -1611,6 +2101,140 @@ export class EntityInfo extends BaseInfo {
|
|
|
1611
2101
|
get ConfigurationObject() {
|
|
1612
2102
|
return this.Configuration;
|
|
1613
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
|
+
}
|
|
1614
2238
|
/**
|
|
1615
2239
|
* O(1) case-insensitive field lookup by name. Use this instead of `Fields.find(f => f.Name === name)`
|
|
1616
2240
|
* on hot paths — it builds a lowercased+trimmed `Map` once (lazily) and reuses it.
|
|
@@ -1638,6 +2262,12 @@ export class EntityInfo extends BaseInfo {
|
|
|
1638
2262
|
/**
|
|
1639
2263
|
* Returns the primary key field for the entity. For entities with a composite primary key, use the PrimaryKeys property which returns all.
|
|
1640
2264
|
* In the case of a composite primary key, the PrimaryKey property will return the first field in the sequence of the primary key fields.
|
|
2265
|
+
*
|
|
2266
|
+
* This is a single-column convenience for the places MJ is single-column *by design* — foreign-key
|
|
2267
|
+
* targets, keyset `ORDER BY`, IS-A shared keys, and the bare-value shorthand `CompositeKey.LoadFromURLSegment`
|
|
2268
|
+
* accepts. Do not use it to *construct* a load key for an arbitrary entity: that silently drops every
|
|
2269
|
+
* column but the first on a composite key. Build keys with `CompositeKey.FromURLSegment(entityInfo, recordId)`
|
|
2270
|
+
* or `CompositeKey.FromEntityRecord(entityInfo, row)`, which honor all of `PrimaryKeys`.
|
|
1641
2271
|
*/
|
|
1642
2272
|
get FirstPrimaryKey() {
|
|
1643
2273
|
if (this._firstPrimaryKeyCache === undefined) {
|
|
@@ -2063,7 +2693,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2063
2693
|
const allow = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
|
|
2064
2694
|
const deny = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
|
|
2065
2695
|
for (const ep of permissionList) {
|
|
2066
|
-
const isDeny =
|
|
2696
|
+
const isDeny = ep.IsDeny;
|
|
2067
2697
|
const bucket = isDeny ? deny : allow;
|
|
2068
2698
|
bucket.CanCreate = bucket.CanCreate || !!ep.CanCreate;
|
|
2069
2699
|
bucket.CanRead = bucket.CanRead || !!ep.CanRead;
|
|
@@ -2089,7 +2719,15 @@ export class EntityInfo extends BaseInfo {
|
|
|
2089
2719
|
}
|
|
2090
2720
|
}
|
|
2091
2721
|
/**
|
|
2092
|
-
* Determines if a given user, for a given permission type, is exempt from RowLevelSecurity or not
|
|
2722
|
+
* Determines if a given user, for a given permission type, is exempt from RowLevelSecurity or not.
|
|
2723
|
+
*
|
|
2724
|
+
* A permission row confers an exemption only for an operation it GRANTS (the matching `Can*`
|
|
2725
|
+
* flag is true) and leaves unfiltered. A row that does not grant the operation has no filter
|
|
2726
|
+
* for it either, and that absence means "not applicable", not "unrestricted" — so it must not
|
|
2727
|
+
* lift a filter that another of the user's roles binds. Without the `Can*` check, a role that
|
|
2728
|
+
* grants only reads (CanCreate=false, hence CreateRLSFilterID=null) made every holder of that
|
|
2729
|
+
* role exempt from CREATE row-level security, which is the shape of the 'UI' role every
|
|
2730
|
+
* authenticated user holds on nearly every entity.
|
|
2093
2731
|
* @param user
|
|
2094
2732
|
* @param type
|
|
2095
2733
|
* @returns
|
|
@@ -2097,23 +2735,26 @@ export class EntityInfo extends BaseInfo {
|
|
|
2097
2735
|
UserExemptFromRowLevelSecurity(user, type) {
|
|
2098
2736
|
for (let j = 0; j < this.Permissions.length; j++) {
|
|
2099
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
|
+
}
|
|
2100
2741
|
const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
|
|
2101
2742
|
if (roleMatch) { // user has this role
|
|
2102
2743
|
switch (type) {
|
|
2103
2744
|
case EntityPermissionType.Create:
|
|
2104
|
-
if (!ep.CreateRLSFilterID)
|
|
2745
|
+
if (ep.CanCreate && !ep.CreateRLSFilterID)
|
|
2105
2746
|
return true;
|
|
2106
2747
|
break;
|
|
2107
2748
|
case EntityPermissionType.Read:
|
|
2108
|
-
if (!ep.ReadRLSFilterID)
|
|
2749
|
+
if (ep.CanRead && !ep.ReadRLSFilterID)
|
|
2109
2750
|
return true;
|
|
2110
2751
|
break;
|
|
2111
2752
|
case EntityPermissionType.Update:
|
|
2112
|
-
if (!ep.UpdateRLSFilterID)
|
|
2753
|
+
if (ep.CanUpdate && !ep.UpdateRLSFilterID)
|
|
2113
2754
|
return true;
|
|
2114
2755
|
break;
|
|
2115
2756
|
case EntityPermissionType.Delete:
|
|
2116
|
-
if (!ep.DeleteRLSFilterID)
|
|
2757
|
+
if (ep.CanDelete && !ep.DeleteRLSFilterID)
|
|
2117
2758
|
return true;
|
|
2118
2759
|
break;
|
|
2119
2760
|
}
|
|
@@ -2122,7 +2763,18 @@ export class EntityInfo extends BaseInfo {
|
|
|
2122
2763
|
return false; // if we get here, the user is NOT exempt from RLS for this Permission Type
|
|
2123
2764
|
}
|
|
2124
2765
|
/**
|
|
2125
|
-
* 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.
|
|
2126
2778
|
* @param user
|
|
2127
2779
|
* @param type
|
|
2128
2780
|
* @returns
|
|
@@ -2131,24 +2783,27 @@ export class EntityInfo extends BaseInfo {
|
|
|
2131
2783
|
const rlsList = [];
|
|
2132
2784
|
for (let j = 0; j < this.Permissions.length; j++) {
|
|
2133
2785
|
const ep = this.Permissions[j];
|
|
2786
|
+
if (ep.IsDeny) {
|
|
2787
|
+
continue; // never a grant — see the doc comment
|
|
2788
|
+
}
|
|
2134
2789
|
const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
|
|
2135
2790
|
if (roleMatch) { // user has this role
|
|
2136
2791
|
let matchObject = null;
|
|
2137
2792
|
switch (type) {
|
|
2138
2793
|
case EntityPermissionType.Create:
|
|
2139
|
-
if (ep.CreateRLSFilterID)
|
|
2794
|
+
if (ep.CanCreate && ep.CreateRLSFilterID)
|
|
2140
2795
|
matchObject = ep.CreateRLSFilterObject;
|
|
2141
2796
|
break;
|
|
2142
2797
|
case EntityPermissionType.Read:
|
|
2143
|
-
if (ep.ReadRLSFilterID)
|
|
2798
|
+
if (ep.CanRead && ep.ReadRLSFilterID)
|
|
2144
2799
|
matchObject = ep.ReadRLSFilterObject;
|
|
2145
2800
|
break;
|
|
2146
2801
|
case EntityPermissionType.Update:
|
|
2147
|
-
if (ep.UpdateRLSFilterID)
|
|
2802
|
+
if (ep.CanUpdate && ep.UpdateRLSFilterID)
|
|
2148
2803
|
matchObject = ep.UpdateRLSFilterObject;
|
|
2149
2804
|
break;
|
|
2150
2805
|
case EntityPermissionType.Delete:
|
|
2151
|
-
if (ep.DeleteRLSFilterID)
|
|
2806
|
+
if (ep.CanDelete && ep.DeleteRLSFilterID)
|
|
2152
2807
|
matchObject = ep.DeleteRLSFilterObject;
|
|
2153
2808
|
break;
|
|
2154
2809
|
}
|
|
@@ -2284,7 +2939,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2284
2939
|
}
|
|
2285
2940
|
else {
|
|
2286
2941
|
// currently we only support a single value for FOREIGN KEYS, so we can just grab the first value in the primary key
|
|
2287
|
-
const firstKey = record.FirstPrimaryKey;
|
|
2942
|
+
const firstKey = record.FirstPrimaryKey; // first-pk-ok: FK target — a relationship's join field references one parent key column
|
|
2288
2943
|
keyValue = firstKey.Value;
|
|
2289
2944
|
//When creating a new record, the keyValue is null and the quotes are not needed
|
|
2290
2945
|
quotes = keyValue && firstKey.NeedsQuotes ? "'" : '';
|
|
@@ -2324,7 +2979,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2324
2979
|
if (rel)
|
|
2325
2980
|
return EntityInfo.BuildRelationshipViewParams(record, rel);
|
|
2326
2981
|
}
|
|
2327
|
-
const firstKey = record.FirstPrimaryKey;
|
|
2982
|
+
const firstKey = record.FirstPrimaryKey; // first-pk-ok: FK target — a relationship's join field references one parent key column
|
|
2328
2983
|
const keyValue = firstKey.Value;
|
|
2329
2984
|
const quotes = keyValue && firstKey.NeedsQuotes ? "'" : '';
|
|
2330
2985
|
const clauses = fields.map((field) => `[${field}] = ${quotes}${keyValue}${quotes}`);
|
|
@@ -2373,7 +3028,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2373
3028
|
const explicit = relationship?.EntityKeyField?.trim();
|
|
2374
3029
|
if (explicit)
|
|
2375
3030
|
return record.Get(explicit);
|
|
2376
|
-
const first = record.FirstPrimaryKey;
|
|
3031
|
+
const first = record.FirstPrimaryKey; // first-pk-ok: FK target — a relationship's join field references one parent key column
|
|
2377
3032
|
if (first?.Name)
|
|
2378
3033
|
return record.Get(first.Name);
|
|
2379
3034
|
return first?.Value;
|
|
@@ -2654,6 +3309,12 @@ export class EntityInfo extends BaseInfo {
|
|
|
2654
3309
|
* This flag is set on the **parent** entity and controls whether its children are exclusive.
|
|
2655
3310
|
*/
|
|
2656
3311
|
this.AllowMultipleSubtypes = false;
|
|
3312
|
+
/**
|
|
3313
|
+
* Optional JSON configuration specifying declarative prospective subtype resolution on an entity.
|
|
3314
|
+
* Stored in the SubtypeSelector column of Entity (shape = IEntitySubtypeSelectorConfig).
|
|
3315
|
+
*/
|
|
3316
|
+
this.SubtypeSelector = null;
|
|
3317
|
+
this._subtypeSelectorConfig = undefined;
|
|
2657
3318
|
/**
|
|
2658
3319
|
* Whether to audit when users access records from this entity
|
|
2659
3320
|
*/
|
|
@@ -2678,6 +3339,19 @@ export class EntityInfo extends BaseInfo {
|
|
|
2678
3339
|
* client-side IndexedDB cache. Zero overhead on hot save/query paths.
|
|
2679
3340
|
*/
|
|
2680
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;
|
|
2681
3355
|
/**
|
|
2682
3356
|
* Whether this entity is available through the GraphQL API
|
|
2683
3357
|
*/
|
|
@@ -2788,9 +3462,10 @@ export class EntityInfo extends BaseInfo {
|
|
|
2788
3462
|
*/
|
|
2789
3463
|
this.FullTextSearchFunctionGenerated = true;
|
|
2790
3464
|
/**
|
|
2791
|
-
* When true, this entity
|
|
2792
|
-
*
|
|
2793
|
-
*
|
|
3465
|
+
* When true, this entity participates in geo **read** features: map view, distance
|
|
3466
|
+
* calculations, and similar. That is independent of whether GeoCodeSyncService runs
|
|
3467
|
+
* on Save — the service only fires when {@link HasWritableGeoSourceFields} is true.
|
|
3468
|
+
* Auto-set by CodeGen when LLM detects geo-capable fields.
|
|
2794
3469
|
*/
|
|
2795
3470
|
this.SupportsGeoCoding = false;
|
|
2796
3471
|
/**
|
|
@@ -3053,7 +3728,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
3053
3728
|
er.sort((a, b) => {
|
|
3054
3729
|
const aSeq = a.Sequence !== null && a.Sequence !== undefined ? a.Sequence : 999999;
|
|
3055
3730
|
const bSeq = b.Sequence !== null && b.Sequence !== undefined ? b.Sequence : 999999;
|
|
3056
|
-
return aSeq - bSeq;
|
|
3731
|
+
return (aSeq - bSeq) || ordinalCompare(a.RelatedEntity, b.RelatedEntity) || ordinalCompare(a.ID, b.ID);
|
|
3057
3732
|
});
|
|
3058
3733
|
}
|
|
3059
3734
|
// now that we have prepared the er array by sorting it, if needed, let's load up the related entities
|