@memberjunction/core 6.1.0-edge.6 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/baseEngine.d.ts +52 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +93 -4
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +173 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +357 -12
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +461 -1
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +568 -6
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/interfaces.d.ts +5 -0
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +30 -1
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +48 -1
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +164 -2
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +398 -23
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.d.ts +164 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.js +279 -0
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -0
- package/dist/generic/saveEntityGraphOperation.d.ts +10 -0
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +2 -1
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/wellKnownUserSource.d.ts +70 -0
- package/dist/generic/wellKnownUserSource.d.ts.map +1 -0
- package/dist/generic/wellKnownUserSource.js +82 -0
- package/dist/generic/wellKnownUserSource.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -8,9 +8,9 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
8
8
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
9
|
};
|
|
10
10
|
var EntityField_1;
|
|
11
|
-
import { IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
|
|
11
|
+
import { DeserializeValidationErrors, IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
|
|
12
12
|
import { GetDataHooks } from './dataHooks.js';
|
|
13
|
-
import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
|
|
13
|
+
import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, FieldSecurityError, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
|
|
14
14
|
import { EntitySubtypeResolver } from './entitySubtypeResolver.js';
|
|
15
15
|
import { BaseEngineRegistry } from './baseEngineRegistry.js';
|
|
16
16
|
import { IsPermittedImageFieldValue, IsValidCssColor, TryParseJsonText } from './extendedTypeValue.js';
|
|
@@ -52,6 +52,57 @@ let EntityField = class EntityField {
|
|
|
52
52
|
"real": { min: -3.402823466e+38, max: 3.402823466e+38 },
|
|
53
53
|
"money": { min: -922337203685477.5808, max: 922337203685477.5807 },
|
|
54
54
|
}; }
|
|
55
|
+
/**
|
|
56
|
+
* True when the source this entity was hydrated from OMITTED this field's key — most
|
|
57
|
+
* commonly because field-level security stripped it before the payload reached us, but
|
|
58
|
+
* equally for any partial hydration. The field's in-memory state (its metadata default,
|
|
59
|
+
* else null) is a construction artifact, not data: the save path skips not-loaded fields
|
|
60
|
+
* entirely (the generated procs' `ISNULL(@p, [Col])` merge then preserves the stored
|
|
61
|
+
* value), {@link Dirty} always reports false for them, and {@link Validate} exempts them
|
|
62
|
+
* from the required/null check.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately DISTINCT from `_NeverSet`, which means "no set since construction," exists
|
|
65
|
+
* to permit the one-time write to ReadOnly fields on load, and is re-armed wholesale by
|
|
66
|
+
* `InnerLoad` — reusing it would conflate defaults with omissions. This flag is set only
|
|
67
|
+
* by the hydration paths (via {@link MarkNotLoaded}) when a source omits the key, is
|
|
68
|
+
* cleared by ANY explicit set (an intentional blind write to a read-denied field is a
|
|
69
|
+
* legitimate write-only update and must save), and is never present on new (unhydrated)
|
|
70
|
+
* entities — their fields legitimately hold metadata defaults for INSERT.
|
|
71
|
+
*/
|
|
72
|
+
get NotLoaded() {
|
|
73
|
+
return this._NotLoaded;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Framework-internal: hydration paths call this for each field whose key the hydration
|
|
77
|
+
* source omitted. Application code should never need it — an explicit {@link Value} set
|
|
78
|
+
* clears the flag.
|
|
79
|
+
*/
|
|
80
|
+
MarkNotLoaded() {
|
|
81
|
+
this._NotLoaded = true;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* True when field-level security bars this user from supplying the field's value on INSERT,
|
|
85
|
+
* so the save path must omit it and let the column take its database default.
|
|
86
|
+
*
|
|
87
|
+
* Deliberately DISTINCT from {@link NotLoaded}, even though both end in "leave this
|
|
88
|
+
* parameter out of the SP call". `NotLoaded` means "the hydration source omitted this key"
|
|
89
|
+
* and carries consequences this must not: it suppresses the `_Clear` companion, exempts the
|
|
90
|
+
* field from required/null validation, and forces {@link Dirty} to false. A create-suppressed
|
|
91
|
+
* field, by contrast, holds a perfectly real value the user typed — it is simply not one they
|
|
92
|
+
* are permitted to supply. Conflating them would silently disarm validation on fields a user
|
|
93
|
+
* IS allowed to create.
|
|
94
|
+
*
|
|
95
|
+
* Set per-save by the create-path gate and cleared at the start of every save, because the
|
|
96
|
+
* answer depends on the acting user and the same entity object can be saved by different
|
|
97
|
+
* users over its lifetime.
|
|
98
|
+
*/
|
|
99
|
+
get CreateSuppressed() {
|
|
100
|
+
return this._CreateSuppressed;
|
|
101
|
+
}
|
|
102
|
+
/** Framework-internal: set by the save path's field-security create gate. */
|
|
103
|
+
SetCreateSuppressed(suppressed) {
|
|
104
|
+
this._CreateSuppressed = suppressed;
|
|
105
|
+
}
|
|
55
106
|
get Name() {
|
|
56
107
|
return this._entityFieldInfo.Name;
|
|
57
108
|
}
|
|
@@ -126,8 +177,16 @@ let EntityField = class EntityField {
|
|
|
126
177
|
value = value.replace(/ +$/, '');
|
|
127
178
|
}
|
|
128
179
|
this._Value = value;
|
|
180
|
+
// Any explicit set means the field now holds REAL data — including a blind write to
|
|
181
|
+
// a read-denied field (the write-only case), which must flow to the save. Captured
|
|
182
|
+
// BEFORE clearing so the OldValue branch below can see it: a set onto a not-loaded
|
|
183
|
+
// field is an EDIT (must become dirty and reach the save), never the "initial value
|
|
184
|
+
// set" of record setup — the record's setup moment was the hydration that omitted
|
|
185
|
+
// this field.
|
|
186
|
+
const wasNotLoaded = this._NotLoaded;
|
|
187
|
+
this._NotLoaded = false;
|
|
129
188
|
// in the below, we set the OldValue, but only if (a) we have never set the value before, or (b) the value or the old value is not null - which means that we are in a record setup scenario
|
|
130
|
-
if (this._NeverSet &&
|
|
189
|
+
if (this._NeverSet && !wasNotLoaded &&
|
|
131
190
|
(value !== null || this._OldValue !== null)) {
|
|
132
191
|
// initial value set
|
|
133
192
|
this._OldValue = value;
|
|
@@ -145,9 +204,11 @@ let EntityField = class EntityField {
|
|
|
145
204
|
}
|
|
146
205
|
/**
|
|
147
206
|
* Returns true if the field is dirty, false otherwise. A field is considered dirty if the value is different from the old value. If the field is read only, it is never dirty.
|
|
207
|
+
* A {@link NotLoaded} field is never dirty either — its in-memory state is a construction
|
|
208
|
+
* artifact, not data, and nothing that was never loaded can have been changed.
|
|
148
209
|
*/
|
|
149
210
|
get Dirty() {
|
|
150
|
-
if (this.ReadOnly)
|
|
211
|
+
if (this.ReadOnly || this._NotLoaded)
|
|
151
212
|
return false;
|
|
152
213
|
else {
|
|
153
214
|
const oldNull = this._OldValue === null || this.OldValue === undefined || Number.isNaN(this.OldValue); // check for NaN because sometimes we have old values that are NaN and we need to account for that
|
|
@@ -281,7 +342,13 @@ let EntityField = class EntityField {
|
|
|
281
342
|
// no longer false-warns or throws here.
|
|
282
343
|
if (!ef.ReadOnly && !ef.SkipValidation) {
|
|
283
344
|
// only do validation on updatable fields and skip the special case fields defined inside the SkipValidation property (like ID/CreatedAt/UpdatedAt)
|
|
284
|
-
|
|
345
|
+
// NotLoaded exemption: a field the hydration source omitted holds null/default by
|
|
346
|
+
// construction and is SKIPPED by the save SQL, so the stored value is untouched —
|
|
347
|
+
// failing the required check here would make every partially hydrated record
|
|
348
|
+
// unsaveable for unrelated edits (the exact NOT-NULL breakage mode the not-loaded
|
|
349
|
+
// design exists to remove). Length/type checks below still apply to whatever the
|
|
350
|
+
// constructor state is; only the required/null check is meaningless for it.
|
|
351
|
+
if (!ef.AllowsNull && !this._NotLoaded && (this.Value === null || this.Value === undefined)) {
|
|
285
352
|
// make sure this isn't a field that has a default value and we are inside a new record
|
|
286
353
|
if (ef.DefaultValue === null || ef.DefaultValue === undefined || ef.DefaultValue.trim().length === 0) {
|
|
287
354
|
// we have no default value, so this is an error
|
|
@@ -364,6 +431,8 @@ let EntityField = class EntityField {
|
|
|
364
431
|
}
|
|
365
432
|
constructor(fieldInfo, Value) {
|
|
366
433
|
this._NeverSet = true;
|
|
434
|
+
this._NotLoaded = false;
|
|
435
|
+
this._CreateSuppressed = false;
|
|
367
436
|
// NOTE: constructing an EntityField for a deprecated/disabled field is always allowed — the
|
|
368
437
|
// column is physically part of the entity and the instance must exist. Active-status is
|
|
369
438
|
// asserted only when code accesses the field by name via BaseEntity.Get/Set/SetMany, never
|
|
@@ -521,6 +590,21 @@ export class BaseEntityAIActionParams {
|
|
|
521
590
|
*/
|
|
522
591
|
export class BaseEntityResult {
|
|
523
592
|
constructor(success, message, type) {
|
|
593
|
+
/**
|
|
594
|
+
* Set by a producer whose `Message` ALREADY renders every entry of `Errors`, so that
|
|
595
|
+
* {@link CompleteMessage} does not say the same thing twice.
|
|
596
|
+
*
|
|
597
|
+
* Two kinds of producer build `Message` out of `Errors`: the client-side providers, which copy
|
|
598
|
+
* the SERVER's `CompleteMessage` (= its errors, joined) into `Message` and then rehydrate the same
|
|
599
|
+
* entries into `Errors` so a form can paint the fields; and the IS-A parent-failure paths, which
|
|
600
|
+
* write "Failed to save parent entity … : <errors joined>". Without this flag every one of them
|
|
601
|
+
* read twice in `CompleteMessage`. A substring dedupe was tried and reverted (it is lossy in ways
|
|
602
|
+
* a reader cannot detect — see `CompleteMessage`); a producer stating the fact is exact.
|
|
603
|
+
*
|
|
604
|
+
* Only honoured when `Message` actually has text: a producer that set the flag and left `Message`
|
|
605
|
+
* empty is contradicting itself, and the errors are still rendered rather than lost.
|
|
606
|
+
*/
|
|
607
|
+
this.MessageIncludesErrors = false;
|
|
524
608
|
/**
|
|
525
609
|
* A copy of the values of the entity object BEFORE the operation was performed
|
|
526
610
|
*/
|
|
@@ -612,8 +696,12 @@ export class BaseEntityResult {
|
|
|
612
696
|
// or dropped depending on ARRAY ORDER; and a distinct error vanishes when its text happens to
|
|
613
697
|
// appear inside the summary. Saying something twice is ugly. Silently reporting one problem
|
|
614
698
|
// when there were three is the failure this whole class of bug is about, so the duplication
|
|
615
|
-
// stays
|
|
616
|
-
|
|
699
|
+
// stays UNLESS the producer states, via `MessageIncludesErrors`, that `Message` already
|
|
700
|
+
// renders every entry — an exact fact, not a guess, and the only producer-side fix that
|
|
701
|
+
// removes the repeat at the source.
|
|
702
|
+
const messageHasText = !!this.Message && this.Message.trim().length > 0;
|
|
703
|
+
const errorsAlreadyInMessage = this.MessageIncludesErrors && messageHasText;
|
|
704
|
+
if (this.Errors && this.Errors.length > 0 && !errorsAlreadyInMessage) {
|
|
617
705
|
// append
|
|
618
706
|
msg = (msg ? msg + '\n' : '') + this.Errors.map(err => BaseEntityResult.ErrorText(err)).join('\n');
|
|
619
707
|
}
|
|
@@ -1578,6 +1666,8 @@ export class BaseEntity {
|
|
|
1578
1666
|
// ignoreNonExistentFields=true remains as a safety net; ownedFieldsFrom already
|
|
1579
1667
|
// dropped columns that belong to another level of the IS-A chain.
|
|
1580
1668
|
this.SetMany(this.ownedFieldsFrom(data), true, true, true);
|
|
1669
|
+
// Hydrate is a hydration entry point: keys the source omitted are not-loaded.
|
|
1670
|
+
this.markFieldsOmittedBySourceAsNotLoaded(data);
|
|
1581
1671
|
}
|
|
1582
1672
|
/**
|
|
1583
1673
|
* Columns on `data` that this entity actually defines (by field name or CodeName).
|
|
@@ -1821,7 +1911,10 @@ export class BaseEntity {
|
|
|
1821
1911
|
}, { provider: this.ProviderToUse, user: this.ActiveUser });
|
|
1822
1912
|
if (!result.Success || !result.Output?.Success) {
|
|
1823
1913
|
const detail = result.ErrorMessage ?? result.Output?.ErrorMessage ?? 'unknown error';
|
|
1824
|
-
|
|
1914
|
+
// The structured refusal rides alongside the prose so a form can paint the fields a
|
|
1915
|
+
// server-side ValidateAsync named — the same thing a plain save gets from the
|
|
1916
|
+
// GraphQL error's `extensions.validationErrors`.
|
|
1917
|
+
this.registerGraphFailure(detail, 'save', result.Output?.ValidationErrors);
|
|
1825
1918
|
this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
|
|
1826
1919
|
return false;
|
|
1827
1920
|
}
|
|
@@ -2240,11 +2333,16 @@ export class BaseEntity {
|
|
|
2240
2333
|
*
|
|
2241
2334
|
* @param message - The failure detail.
|
|
2242
2335
|
*/
|
|
2243
|
-
registerGraphFailure(message, operation = 'save') {
|
|
2336
|
+
registerGraphFailure(message, operation = 'save', validationErrors) {
|
|
2244
2337
|
const result = new BaseEntityResult();
|
|
2245
2338
|
result.Success = false;
|
|
2246
2339
|
result.Type = operation === 'delete' ? 'delete' : this.IsSaved ? 'update' : 'create';
|
|
2247
2340
|
result.Message = message ?? 'Entity graph operation failed';
|
|
2341
|
+
// Rehydrated into real ValidationErrorInfo instances so `LatestResult.Errors` reads exactly as
|
|
2342
|
+
// it does after a local `Validate()` refusal; `[]` when the server sent none.
|
|
2343
|
+
result.Errors = DeserializeValidationErrors(validationErrors);
|
|
2344
|
+
// `message` is the server's CompleteMessage — the same errors, already joined — so say so.
|
|
2345
|
+
result.MessageIncludesErrors = result.Errors.length > 0 && !!message;
|
|
2248
2346
|
result.StartedAt = new Date();
|
|
2249
2347
|
result.EndedAt = new Date();
|
|
2250
2348
|
result.OriginalValues = this.Fields.map(f => ({ FieldName: f.CodeName, Value: f.OldValue }));
|
|
@@ -2702,6 +2800,7 @@ export class BaseEntity {
|
|
|
2702
2800
|
if (this.EntityInfo?.HasInactiveFields) {
|
|
2703
2801
|
this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Set');
|
|
2704
2802
|
}
|
|
2803
|
+
this.AssertFieldReadable(FieldName);
|
|
2705
2804
|
this.SetLocal(FieldName, Value);
|
|
2706
2805
|
}
|
|
2707
2806
|
}
|
|
@@ -2721,6 +2820,33 @@ export class BaseEntity {
|
|
|
2721
2820
|
EntityFieldInfo.AssertEntityFieldActiveStatus(fi, caller);
|
|
2722
2821
|
}
|
|
2723
2822
|
}
|
|
2823
|
+
/**
|
|
2824
|
+
* Field-level security choke point for the strongly-typed accessor path. Throws when the
|
|
2825
|
+
* acting user may not READ the field.
|
|
2826
|
+
*
|
|
2827
|
+
* Called by `Get()` and `Set()` — every generated typed accessor
|
|
2828
|
+
* (`get Salary() { return this.Get('Salary'); }`) routes through them, so these two sites
|
|
2829
|
+
* cover the whole typed surface. Gated on both READ, because a field a user cannot see is
|
|
2830
|
+
* one they cannot meaningfully address by name at all; update and create denials are
|
|
2831
|
+
* enforced on the write path, where a rejection can name a save rather than a keystroke.
|
|
2832
|
+
*
|
|
2833
|
+
* **Deliberately NOT called by `SetMany`.** That is the hydration and resolver-apply path —
|
|
2834
|
+
* throwing there would break loading a record that merely CONTAINS a restricted column.
|
|
2835
|
+
*
|
|
2836
|
+
* **Fails open when no user resolves.** `ActiveUser` is legitimately null in plenty of
|
|
2837
|
+
* server paths, and a gate that threw there would break unrelated code in ways that look
|
|
2838
|
+
* nothing like field security.
|
|
2839
|
+
*
|
|
2840
|
+
* Framework-internal value machinery (`Dirty`, `Validate`, `GetAll`, hydration, save-SQL
|
|
2841
|
+
* build) reads `EntityField.Value` directly and never routes through here — that exemption
|
|
2842
|
+
* is load-bearing, not an oversight. Do not "fix" it.
|
|
2843
|
+
*/
|
|
2844
|
+
AssertFieldReadable(fieldName) {
|
|
2845
|
+
const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
|
|
2846
|
+
if (denied?.has(fieldName?.trim().toLowerCase())) {
|
|
2847
|
+
throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2724
2850
|
/**
|
|
2725
2851
|
* Internal helper that sets a field value directly on THIS entity's own Fields array
|
|
2726
2852
|
* without IS-A routing. Used by Set() for own-fields and for mirroring parent field values.
|
|
@@ -2788,6 +2914,10 @@ export class BaseEntity {
|
|
|
2788
2914
|
if (this.EntityInfo?.HasInactiveFields) {
|
|
2789
2915
|
this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Get');
|
|
2790
2916
|
}
|
|
2917
|
+
// Field security sits BEFORE the raw-mode fast path below, not after: an entity whose
|
|
2918
|
+
// fields are not yet hydrated would otherwise return denied values straight out of _raw
|
|
2919
|
+
// without the gate ever running.
|
|
2920
|
+
this.AssertFieldReadable(FieldName);
|
|
2791
2921
|
// Raw mode fast path: read directly from the cached data without building EntityField
|
|
2792
2922
|
// instances. This is the dominant cost in engine warm-loads — generated typed getters
|
|
2793
2923
|
// (e.g. `get Name() { return this.Get('Name'); }`) flow through here, so a consumer that
|
|
@@ -2944,6 +3074,13 @@ export class BaseEntity {
|
|
|
2944
3074
|
GetAll(oldValues = false, onlyDirtyFields = false) {
|
|
2945
3075
|
let obj = {};
|
|
2946
3076
|
for (let field of this.Fields) {
|
|
3077
|
+
// NotLoaded fields are OMITTED (D-3 decision): their in-memory state is a
|
|
3078
|
+
// construction artifact, and serializing it would launder a default/null into
|
|
3079
|
+
// something downstream code treats as data — the exact masquerade the flag exists
|
|
3080
|
+
// to prevent. Key-absence also propagates the flag naturally: hydrating another
|
|
3081
|
+
// entity from this output re-marks the same fields not-loaded.
|
|
3082
|
+
if (field.NotLoaded)
|
|
3083
|
+
continue;
|
|
2947
3084
|
if (!onlyDirtyFields || (onlyDirtyFields && field.Dirty)) {
|
|
2948
3085
|
// Reads field.Value directly — serialization is framework-internal, so it does not
|
|
2949
3086
|
// (and must not) assert active status. No suppression toggle needed: the assertion no
|
|
@@ -3135,6 +3272,9 @@ export class BaseEntity {
|
|
|
3135
3272
|
}
|
|
3136
3273
|
}
|
|
3137
3274
|
}
|
|
3275
|
+
// _raw IS a hydration source (LoadFromData fast path) — fields whose key it omitted
|
|
3276
|
+
// are not-loaded, not defaulted. See EntityField.NotLoaded.
|
|
3277
|
+
this.markFieldsOmittedBySourceAsNotLoaded(this._raw);
|
|
3138
3278
|
// Raw data has been promoted into Fields — release the reference so we don't carry
|
|
3139
3279
|
// duplicate state. Fields hold their own copies, so a frozen source no longer
|
|
3140
3280
|
// constrains anything from here on.
|
|
@@ -3142,6 +3282,22 @@ export class BaseEntity {
|
|
|
3142
3282
|
this._rawConverted = null;
|
|
3143
3283
|
}
|
|
3144
3284
|
}
|
|
3285
|
+
/**
|
|
3286
|
+
* Marks every field whose key the given HYDRATION SOURCE omitted as {@link EntityField.NotLoaded}.
|
|
3287
|
+
* Called only by the hydration entry points ({@link LoadFromData} both modes, {@link Hydrate},
|
|
3288
|
+
* {@link InnerLoad}) — never by plain {@link SetMany}, which is an incremental mutation API
|
|
3289
|
+
* where omitting a field means "leave it alone," not "this field was never loaded."
|
|
3290
|
+
* Both the field Name and CodeName are checked, matching SetMany's key acceptance.
|
|
3291
|
+
*/
|
|
3292
|
+
markFieldsOmittedBySourceAsNotLoaded(source) {
|
|
3293
|
+
if (!source || typeof source !== 'object')
|
|
3294
|
+
return;
|
|
3295
|
+
for (const field of this.Fields) {
|
|
3296
|
+
if (source[field.Name] === undefined && source[field.CodeName] === undefined) {
|
|
3297
|
+
field.MarkNotLoaded();
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3145
3301
|
/**
|
|
3146
3302
|
* This method will copy the values from the other entity object into the current one. This is useful for things like cloning a record.
|
|
3147
3303
|
* This method will ONLY copy values for fields that exist in the current entity object. If the other object has fields that don't exist in the current object, they will be ignored.
|
|
@@ -3155,7 +3311,9 @@ export class BaseEntity {
|
|
|
3155
3311
|
for (let field of this.Fields) {
|
|
3156
3312
|
if (!field.IsPrimaryKey || includePrimaryKeys) {
|
|
3157
3313
|
const otherField = other.GetFieldByName(field.Name);
|
|
3158
|
-
|
|
3314
|
+
// Skip fields the SOURCE never loaded (D-3): copying their construction
|
|
3315
|
+
// state would masquerade a default/null as real data on this entity.
|
|
3316
|
+
if (otherField && !otherField.NotLoaded) {
|
|
3159
3317
|
this.Set(field.Name, otherField.Value);
|
|
3160
3318
|
if (replaceOldValues) {
|
|
3161
3319
|
field.ResetOldValue();
|
|
@@ -3476,6 +3634,9 @@ export class BaseEntity {
|
|
|
3476
3634
|
`Failed to save parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
|
|
3477
3635
|
// Surface the parent's field-level errors so the caller can act on them.
|
|
3478
3636
|
newResult.Errors = parentErrors;
|
|
3637
|
+
// When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
|
|
3638
|
+
// them — say so, or CompleteMessage repeats every one.
|
|
3639
|
+
newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
|
|
3479
3640
|
newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
|
|
3480
3641
|
newResult.EndedAt = new Date();
|
|
3481
3642
|
this.RegisterResultHistoryEntry(newResult);
|
|
@@ -3486,6 +3647,8 @@ export class BaseEntity {
|
|
|
3486
3647
|
const type = this.IsSaved ? EntityPermissionType.Update : EntityPermissionType.Create;
|
|
3487
3648
|
const saveSubType = this.IsSaved ? 'update' : 'create';
|
|
3488
3649
|
this.CheckPermissions(type, true); // this will throw an error and exit out if we don't have permission
|
|
3650
|
+
this.CheckFieldLevelUpdatePermissions(); // field-level security — throws if a dirty field is not updatable by this user
|
|
3651
|
+
this.ApplyFieldLevelCreateSuppression(); // field-level security on INSERT — omits fields, never rejects
|
|
3489
3652
|
// IS-A disjoint subtype enforcement: on CREATE, ensure parent record
|
|
3490
3653
|
// isn't already claimed by another child type (e.g., can't create Meeting
|
|
3491
3654
|
// if a Publication already exists with the same Product ID).
|
|
@@ -3705,6 +3868,14 @@ export class BaseEntity {
|
|
|
3705
3868
|
// does not own (e.g. OrderHeader on Event Order Line). Keep only columns
|
|
3706
3869
|
// this entity defines, and ignore anything leftover.
|
|
3707
3870
|
this.SetMany(this.ownedFieldsFrom(fieldData), true, true, true);
|
|
3871
|
+
// finalizeSave re-hydrates from the save RESPONSE — a hydration source. Keys it
|
|
3872
|
+
// omitted (e.g. fields the server stripped for field security) must be marked
|
|
3873
|
+
// not-loaded, or the defaults init() just produced would masquerade as confirmed
|
|
3874
|
+
// values and be resent on the NEXT save (the create/update-response corner: a
|
|
3875
|
+
// trigger/system adjustment or concurrent change would be silently stomped).
|
|
3876
|
+
if (typeof fieldData === 'object' && !Array.isArray(fieldData)) {
|
|
3877
|
+
this.markFieldsOmittedBySourceAsNotLoaded(fieldData);
|
|
3878
|
+
}
|
|
3708
3879
|
this._everSaved = true; // Mark as saved after successful save
|
|
3709
3880
|
const result = this.LatestResult;
|
|
3710
3881
|
if (result)
|
|
@@ -3824,6 +3995,158 @@ export class BaseEntity {
|
|
|
3824
3995
|
else
|
|
3825
3996
|
return bAllowed;
|
|
3826
3997
|
}
|
|
3998
|
+
/**
|
|
3999
|
+
* Field-level security on the write path: rejects a save that modifies a field this user
|
|
4000
|
+
* has no update permission on.
|
|
4001
|
+
*
|
|
4002
|
+
* ENFORCEMENT LAYER — read this before treating it as the security boundary. `BaseEntity`
|
|
4003
|
+
* also runs in the browser, where this guard is trivially bypassable. The AUTHORITATIVE
|
|
4004
|
+
* check is the server-side execution of this same code: the MJServer mutation resolver
|
|
4005
|
+
* re-instantiates the entity and re-runs Save on the server, where the client cannot reach
|
|
4006
|
+
* it. The client-side occurrence is UX and defense-in-depth — fail fast with a clear
|
|
4007
|
+
* message before a network round-trip — and must never be relied on alone.
|
|
4008
|
+
*
|
|
4009
|
+
* UPDATE rejects; CREATE does not — see {@link ApplyFieldLevelCreateSuppression}.
|
|
4010
|
+
*
|
|
4011
|
+
* Note this checks DIRTY fields only. CLIENT-side that is safe on its own: nothing ever
|
|
4012
|
+
* nulls a restricted value in memory, so a field the user cannot see was never loaded as
|
|
4013
|
+
* null, is not dirty, and an unrelated edit saves cleanly with the restricted column
|
|
4014
|
+
* keeping its stored value.
|
|
4015
|
+
*
|
|
4016
|
+
* SERVER-side, dirty-only is safe only because `ResolverBase.UpdateRecord` guarantees the
|
|
4017
|
+
* entity was hydrated FROM THE DATABASE on every FLS entity. Two distinct resolver behaviours
|
|
4018
|
+
* carry that premise, and BOTH are load-bearing:
|
|
4019
|
+
*
|
|
4020
|
+
* 1. `StripDeniedReadFieldsFromClientInput` removes client-sent values for fields the caller
|
|
4021
|
+
* cannot READ, which `SetMany` would otherwise make genuinely dirty with fabricated data.
|
|
4022
|
+
* 2. `entityInfo.EnableFieldLevelSecurity` forces the truth-load branch, so the entity's
|
|
4023
|
+
* non-dirty baseline is the real stored row rather than the client's `OldValues___`.
|
|
4024
|
+
*
|
|
4025
|
+
* (2) is not redundant with (1). A value arriving through `LoadFromData` is recorded by the
|
|
4026
|
+
* EntityField setter as the field's INITIAL value, so it is not dirty — and this check would
|
|
4027
|
+
* never see it, while `GenerateSaveSQL` sends it anyway (it filters on `NotLoaded`, never on
|
|
4028
|
+
* `Dirty`). Without the forced truth-load, a caller with Read Allow + Update Deny — the
|
|
4029
|
+
* canonical FLS configuration, and one that leaves (1) with nothing to strip — could write an
|
|
4030
|
+
* update-denied field just by pinning its value in `OldValues___` and never naming it in the
|
|
4031
|
+
* mutation. If you are considering relaxing that branch condition, this check is what breaks.
|
|
4032
|
+
*
|
|
4033
|
+
* The refusal names the missing permission when the caller can READ the field, and falls back
|
|
4034
|
+
* to the ambiguous "does not exist or you do not have access" wording when they cannot. See
|
|
4035
|
+
* {@link FieldSecurityWriteDenialMessage} for why that split discloses nothing.
|
|
4036
|
+
*/
|
|
4037
|
+
CheckFieldLevelUpdatePermissions() {
|
|
4038
|
+
if (!this.IsSaved) {
|
|
4039
|
+
return; // INSERT — handled by ApplyFieldLevelCreateSuppression, which never rejects
|
|
4040
|
+
}
|
|
4041
|
+
const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedUpdateFields(u));
|
|
4042
|
+
if (!denied) {
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
// Resolved once, and only if we are actually going to reject: the wording depends on
|
|
4046
|
+
// whether the caller can READ the field they were refused a write on.
|
|
4047
|
+
let deniedRead;
|
|
4048
|
+
for (const field of this.Fields) {
|
|
4049
|
+
const key = field.Name.trim().toLowerCase();
|
|
4050
|
+
if (field.Dirty && denied.has(key)) {
|
|
4051
|
+
LogDebug(`[FieldSecurity] Rejected save on '${this.EntityInfo.Name}': ` +
|
|
4052
|
+
`field '${field.Name}' is not updatable by this user`);
|
|
4053
|
+
// A field the caller can READ gets the real reason. Both facts the ambiguous
|
|
4054
|
+
// wording protects — that the column exists, and that it is restricted for them —
|
|
4055
|
+
// are already theirs, so withholding the reason only tells someone a field whose
|
|
4056
|
+
// values they are looking at might not exist.
|
|
4057
|
+
//
|
|
4058
|
+
// A field they CANNOT read keeps the ambiguous wording. Not hypothetical: SetMany
|
|
4059
|
+
// deliberately skips the readability assertion (hydration / resolver-apply path),
|
|
4060
|
+
// so server-side code can dirty a read-denied field and land here.
|
|
4061
|
+
if (deniedRead === undefined) {
|
|
4062
|
+
deniedRead = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
|
|
4063
|
+
}
|
|
4064
|
+
throw deniedRead?.has(key)
|
|
4065
|
+
? new FieldSecurityError(field.Name, this.EntityInfo.Name)
|
|
4066
|
+
: FieldSecurityError.WriteDenial(field.Name, this.EntityInfo.Name);
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
/**
|
|
4071
|
+
* Field-level security on the INSERT path: marks the fields this user may not supply so the
|
|
4072
|
+
* save omits them and each column takes its database default.
|
|
4073
|
+
*
|
|
4074
|
+
* **This never rejects, and that is deliberate.** Rejecting would be inconsistent with the
|
|
4075
|
+
* read path (a denied field is simply absent, not an error) and would leak information — an
|
|
4076
|
+
* error naming `Salary` confirms the field exists and is restricted, which the ambiguous
|
|
4077
|
+
* denial wording exists to prevent. Silently defaulting is also what an unrestricted user
|
|
4078
|
+
* gets by leaving the field blank, so a restricted user creating a record ends up with the
|
|
4079
|
+
* same record SHAPE rather than a failure.
|
|
4080
|
+
*
|
|
4081
|
+
* The cost is that a user who supplies a value for a create-denied field gets no feedback
|
|
4082
|
+
* that it was dropped, which is why the drop is logged and why the admin UI should not
|
|
4083
|
+
* render the field at all.
|
|
4084
|
+
*
|
|
4085
|
+
* Runs on every save (clearing prior marks first) because the answer depends on the acting
|
|
4086
|
+
* user, and one entity object can be saved by different users over its lifetime.
|
|
4087
|
+
*/
|
|
4088
|
+
ApplyFieldLevelCreateSuppression() {
|
|
4089
|
+
for (const field of this.Fields) {
|
|
4090
|
+
field.SetCreateSuppressed(false);
|
|
4091
|
+
}
|
|
4092
|
+
if (this.IsSaved) {
|
|
4093
|
+
return; // UPDATE — CheckFieldLevelUpdatePermissions owns that path
|
|
4094
|
+
}
|
|
4095
|
+
const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedCreateFields(u));
|
|
4096
|
+
if (!denied) {
|
|
4097
|
+
return;
|
|
4098
|
+
}
|
|
4099
|
+
const suppressed = [];
|
|
4100
|
+
for (const field of this.Fields) {
|
|
4101
|
+
if (denied.has(field.Name.trim().toLowerCase())) {
|
|
4102
|
+
field.SetCreateSuppressed(true);
|
|
4103
|
+
suppressed.push(field.Name);
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
if (suppressed.length > 0) {
|
|
4107
|
+
LogDebug(`[FieldSecurity] Create on '${this.EntityInfo.Name}': ` +
|
|
4108
|
+
`omitted field(s) ${suppressed.join(', ')}; each column takes its default`);
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
/**
|
|
4112
|
+
* The denied-field set for the acting user, or null when field security does not apply —
|
|
4113
|
+
* the entity has it switched off, no user resolves, or the user is denied nothing.
|
|
4114
|
+
*
|
|
4115
|
+
* Returning null rather than an empty Set lets callers skip their loop entirely, and keeps
|
|
4116
|
+
* the three cheap short-circuits in one place instead of repeated at each gate.
|
|
4117
|
+
*/
|
|
4118
|
+
deniedFieldsForActiveUser(select) {
|
|
4119
|
+
if (!this.EntityInfo?.EnableFieldLevelSecurity) {
|
|
4120
|
+
return null; // one boolean for the overwhelming majority of entities
|
|
4121
|
+
}
|
|
4122
|
+
const u = this.resolveActiveUserOrNull();
|
|
4123
|
+
if (!u) {
|
|
4124
|
+
return null; // no user resolves — fail open, see AssertFieldReadable
|
|
4125
|
+
}
|
|
4126
|
+
const denied = select(u);
|
|
4127
|
+
return denied.size > 0 ? denied : null;
|
|
4128
|
+
}
|
|
4129
|
+
/**
|
|
4130
|
+
* {@link ActiveUser}, but null instead of throwing when no provider is configured to resolve
|
|
4131
|
+
* one from.
|
|
4132
|
+
*
|
|
4133
|
+
* `ActiveUser` ends in `Metadata.Provider.CurrentUser`, which throws a TypeError when there
|
|
4134
|
+
* is no global provider — during early boot, in tests, or in any context that never
|
|
4135
|
+
* configured one. That was harmless while only the save path consulted it, but `Get()` and
|
|
4136
|
+
* `Set()` now do on every access to an FLS-enabled entity, so an unresolvable provider would
|
|
4137
|
+
* turn an ordinary read into a crash.
|
|
4138
|
+
*
|
|
4139
|
+
* "No provider to ask" is the same answer as "no user" for this purpose, and field security
|
|
4140
|
+
* fails open on both.
|
|
4141
|
+
*/
|
|
4142
|
+
resolveActiveUserOrNull() {
|
|
4143
|
+
try {
|
|
4144
|
+
return this.ActiveUser ?? null;
|
|
4145
|
+
}
|
|
4146
|
+
catch {
|
|
4147
|
+
return null;
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
3827
4150
|
ThrowPermissionError(u, type, additionalInfoMessage) {
|
|
3828
4151
|
throw new Error(`User: ${u.Name} (ID: ${u.ID}, Email: ${u.Email})
|
|
3829
4152
|
Does NOT have permission to ${EntityPermissionType[type]} ${this.EntityInfo.Name} records.
|
|
@@ -3891,6 +4214,10 @@ export class BaseEntity {
|
|
|
3891
4214
|
f.ResetNeverSetFlag();
|
|
3892
4215
|
}
|
|
3893
4216
|
this.SetMany(data, false, true, true); // don't ignore non-existent fields, but DO replace old values
|
|
4217
|
+
// InnerLoad is a hydration entry point: any field the provider's row omitted (e.g.
|
|
4218
|
+
// a client-side load whose server response stripped read-denied fields) is
|
|
4219
|
+
// not-loaded, so its constructor state never masquerades as data on the next save.
|
|
4220
|
+
this.markFieldsOmittedBySourceAsNotLoaded(data);
|
|
3894
4221
|
if (EntityRelationshipsToLoad) {
|
|
3895
4222
|
for (let relationship of EntityRelationshipsToLoad) {
|
|
3896
4223
|
if (data[relationship]) {
|
|
@@ -4052,6 +4379,10 @@ export class BaseEntity {
|
|
|
4052
4379
|
// Hits when: subsequent LoadFromData call on an already-loaded instance, IS-A entity
|
|
4053
4380
|
// (parent or child), or non-plain-object input. Preserves original semantics exactly.
|
|
4054
4381
|
this.SetMany(data, true, _replaceOldValues, true); // ignore non-existent fields, but DO replace old values based on the provided param
|
|
4382
|
+
if (isPlainObject) {
|
|
4383
|
+
// LoadFromData is a hydration entry point: keys the source omitted are not-loaded.
|
|
4384
|
+
this.markFieldsOmittedBySourceAsNotLoaded(data);
|
|
4385
|
+
}
|
|
4055
4386
|
// now, check to see if we have the primary key set, if so, we should consider ourselves
|
|
4056
4387
|
// loaded from the database and set the _recordLoaded flag to true along with the _everSaved flag
|
|
4057
4388
|
if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
|
|
@@ -4361,6 +4692,9 @@ export class BaseEntity {
|
|
|
4361
4692
|
`Failed to delete parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
|
|
4362
4693
|
// Surface the parent's field-level errors so the caller can act on them.
|
|
4363
4694
|
newResult.Errors = parentErrors;
|
|
4695
|
+
// When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
|
|
4696
|
+
// them — say so, or CompleteMessage repeats every one.
|
|
4697
|
+
newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
|
|
4364
4698
|
newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
|
|
4365
4699
|
newResult.EndedAt = new Date();
|
|
4366
4700
|
this.RegisterResultHistoryEntry(newResult);
|
|
@@ -4653,9 +4987,20 @@ export class BaseEntity {
|
|
|
4653
4987
|
if (!f) {
|
|
4654
4988
|
return null;
|
|
4655
4989
|
}
|
|
4656
|
-
|
|
4657
|
-
|
|
4990
|
+
// Field security: the name field is an ordinary field and can be denied like any other.
|
|
4991
|
+
// `Get()` THROWS for a denied field, and this method runs AUTOMATICALLY after every
|
|
4992
|
+
// Load / LoadFromData / Save via CacheRecordName — so an unguarded read here does not
|
|
4993
|
+
// hide a name, it makes the record fail to load at all, with a message about the name
|
|
4994
|
+
// field that reads like the record itself is broken.
|
|
4995
|
+
//
|
|
4996
|
+
// Returning null is the same answer callers already handle for "this entity has no name
|
|
4997
|
+
// field", and every one of them degrades to the primary key. It also keeps a denied name
|
|
4998
|
+
// OUT of the provider's record-name cache, which is keyed by entity + primary key and NOT
|
|
4999
|
+
// by user — caching it would leak it to the next caller.
|
|
5000
|
+
if (!this.EntityInfo.IsFieldReadableByUser(f.Name, this.ActiveUser)) {
|
|
5001
|
+
return null;
|
|
4658
5002
|
}
|
|
5003
|
+
return this.Get(f.Name);
|
|
4659
5004
|
}
|
|
4660
5005
|
/**
|
|
4661
5006
|
* Static Utility method to get RecordChanges for a given entityName/KeyValuePair combination
|