@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
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { BaseEntity } from "./baseEntity.js";
|
|
2
|
-
import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermissionType } from "./entityInfo.js";
|
|
2
|
+
import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermissionType, FieldSecurityDenialMessage, FieldSecurityError } from "./entityInfo.js";
|
|
3
3
|
import { AllMetadata } from "./interfaces.js";
|
|
4
|
+
import { RecordChangeFieldSecurityProjector } from "./recordChangeFieldSecurity.js";
|
|
4
5
|
import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
|
|
5
6
|
import { LocalCacheManager } from "./localCacheManager.js";
|
|
6
7
|
import { ApplicationInfo } from "../generic/applicationInfo.js";
|
|
7
8
|
import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
|
|
8
9
|
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, MJLruCache, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
|
|
10
|
+
import { FindReferencedIdentifiers } from "@memberjunction/sql-dialect";
|
|
9
11
|
import { TelemetryManager } from "./telemetryManager.js";
|
|
10
|
-
import { LogError, LogStatus, LogStatusEx } from "./logging.js";
|
|
12
|
+
import { LogDebug, LogError, LogStatus, LogStatusEx } from "./logging.js";
|
|
11
13
|
import { QueryCategoryInfo, QueryFieldInfo, QueryInfo, QueryPermissionInfo, QueryEntityInfo, QueryParameterInfo, QueryDependencyInfo, SQLDialectInfo, QuerySQLInfo } from "./queryInfo.js";
|
|
12
14
|
import { LibraryInfo } from "./libraryInfo.js";
|
|
13
15
|
import { CompositeKey } from "./compositeKey.js";
|
|
@@ -1244,6 +1246,8 @@ export class ProviderBase {
|
|
|
1244
1246
|
param.ResultType !== 'count_only' &&
|
|
1245
1247
|
(param.CacheLocal === true || this.TrustLocalCacheCompletely) &&
|
|
1246
1248
|
this.IsServerCacheAllowedForEntity(param);
|
|
1249
|
+
// Deliberately not user-dependent: server slots are full-width and shared, so field
|
|
1250
|
+
// security never changes whether a query is cacheable, only what is projected out of it.
|
|
1247
1251
|
}
|
|
1248
1252
|
/** True when the entity is a CodeGen materialized-query wrapper (materialized_vw*) whose snapshot is
|
|
1249
1253
|
* refreshed out-of-band — the same one IsServerCacheAllowedForEntity excludes from the server cache. */
|
|
@@ -1388,7 +1392,13 @@ export class ProviderBase {
|
|
|
1388
1392
|
if (memoized) {
|
|
1389
1393
|
return memoized;
|
|
1390
1394
|
}
|
|
1391
|
-
|
|
1395
|
+
// The fls: segment participates in CLIENT slot identity too, keyed on the ALLOWED list
|
|
1396
|
+
// (see ComputeClientFLSAllowedKey). Without it, a user whose field access is TIGHTENED
|
|
1397
|
+
// keeps being served their persisted IndexedDB slot: the currency check compares only
|
|
1398
|
+
// maxUpdatedAt and rowCount, neither of which notices a column, so the server answers
|
|
1399
|
+
// "current" and the browser shows a column that was just taken away — until the rows
|
|
1400
|
+
// change. Empty for unrestricted users, whose fingerprints are unchanged.
|
|
1401
|
+
const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, undefined, this.ComputeClientFLSAllowedKey(param));
|
|
1392
1402
|
// Normalize a FULL-COVERAGE field list to '*' — in the FINGERPRINT only (B44).
|
|
1393
1403
|
//
|
|
1394
1404
|
// entity_object params get widened to an explicit list of every entity field
|
|
@@ -2084,6 +2094,70 @@ export class ProviderBase {
|
|
|
2084
2094
|
return '';
|
|
2085
2095
|
return entity.GetEffectiveRowFilterWhereClause(user, EntityPermissionType.Read, '');
|
|
2086
2096
|
}
|
|
2097
|
+
/**
|
|
2098
|
+
* The CLIENT's field-security cache key: a canonical list of the fields this user MAY read.
|
|
2099
|
+
*
|
|
2100
|
+
* Keyed on the ALLOWED set rather than the denied set for two reasons. Once metadata ships
|
|
2101
|
+
* to browsers filtered to what a user may see ([#3485](https://github.com/MemberJunction/MJ/issues/3485)),
|
|
2102
|
+
* a denied field will not appear in the client's field list at all — so a denied-set key
|
|
2103
|
+
* would be empty and would silently stop segmenting. The allowed list also resolves the
|
|
2104
|
+
* `f:*` ambiguity in the client's projection segment, where "full width" means different
|
|
2105
|
+
* columns for different users.
|
|
2106
|
+
*
|
|
2107
|
+
* Returns '' when the entity has field security off or the user is denied nothing, so
|
|
2108
|
+
* unrestricted users keep byte-identical fingerprints and shared slots.
|
|
2109
|
+
*
|
|
2110
|
+
* Only takes effect once the client's metadata refreshes. A client on stale metadata
|
|
2111
|
+
* computes a stale key; the backstop is that the server strips denied columns from every
|
|
2112
|
+
* fresh fetch regardless.
|
|
2113
|
+
*/
|
|
2114
|
+
ComputeClientFLSAllowedKey(params) {
|
|
2115
|
+
const user = this.CurrentUser;
|
|
2116
|
+
if (!user || !params.EntityName)
|
|
2117
|
+
return '';
|
|
2118
|
+
const entity = this.EntityByName(params.EntityName);
|
|
2119
|
+
if (!entity?.EnableFieldLevelSecurity)
|
|
2120
|
+
return '';
|
|
2121
|
+
const denied = entity.GetDeniedReadFields(user);
|
|
2122
|
+
if (denied.size === 0)
|
|
2123
|
+
return '';
|
|
2124
|
+
return entity.Fields
|
|
2125
|
+
.map(f => f.Name.trim().toLowerCase())
|
|
2126
|
+
.filter(n => !denied.has(n))
|
|
2127
|
+
.sort()
|
|
2128
|
+
.join(',');
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* The field-security segment for a {@link LocalCacheManager} RunView fingerprint — the one
|
|
2132
|
+
* place the client/server asymmetry is decided, so the two tiers cannot drift.
|
|
2133
|
+
*
|
|
2134
|
+
* **SERVER → no segment.** Its slots are full-width and shared by every user; per-request
|
|
2135
|
+
* narrowing happens at read time in {@link ApplyFieldSecurityProjection}, which runs on
|
|
2136
|
+
* every hit and every miss. A segment here would fragment one shared slot into one per
|
|
2137
|
+
* permission class and protect nothing the projection does not already handle.
|
|
2138
|
+
*
|
|
2139
|
+
* **CLIENT → the allowed-list key.** Its slots are stored exactly as the server returned
|
|
2140
|
+
* them (already narrowed on the wire) and are not projected on read, so slot identity has to
|
|
2141
|
+
* carry the field set. Without it, a user whose access is tightened keeps being served their
|
|
2142
|
+
* persisted IndexedDB slot: the currency check compares `maxUpdatedAt` and `rowCount` only,
|
|
2143
|
+
* neither of which notices a column, so the server answers "current" and the browser keeps
|
|
2144
|
+
* showing a column that was just taken away.
|
|
2145
|
+
*
|
|
2146
|
+
* Empty/undefined for unrestricted users on both tiers, so their fingerprints stay
|
|
2147
|
+
* byte-identical and keep sharing slots.
|
|
2148
|
+
*/
|
|
2149
|
+
ComputeRunViewFLSFingerprintKey(params) {
|
|
2150
|
+
return this.TrustLocalCacheCompletely ? undefined : this.ComputeClientFLSAllowedKey(params);
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* The fetch-widening field list for a cache-eligible request: always every entity field.
|
|
2154
|
+
* One slot per (entity, filter, order) serves every caller regardless of the field subset
|
|
2155
|
+
* they asked for, and field security narrows per request at read time via
|
|
2156
|
+
* {@link ApplyFieldSecurityProjection}.
|
|
2157
|
+
*/
|
|
2158
|
+
ComputeRunViewFetchFields(entity) {
|
|
2159
|
+
return entity.Fields.map(f => f.Name);
|
|
2160
|
+
}
|
|
2087
2161
|
/**
|
|
2088
2162
|
* Pre-processing hook for RunView.
|
|
2089
2163
|
* Handles telemetry, validation, entity status check, and cache lookup.
|
|
@@ -2091,6 +2165,210 @@ export class ProviderBase {
|
|
|
2091
2165
|
* @param contextUser - Optional user context
|
|
2092
2166
|
* @returns Pre-processing result with cache status and optional cached result
|
|
2093
2167
|
*/
|
|
2168
|
+
// ========================================================================
|
|
2169
|
+
// FIELD-LEVEL SECURITY
|
|
2170
|
+
// ========================================================================
|
|
2171
|
+
/**
|
|
2172
|
+
* Rejects a RunView whose `ExtraFilter`, `OrderBy`, or `Aggregates` expressions reference a
|
|
2173
|
+
* field the user cannot read.
|
|
2174
|
+
*
|
|
2175
|
+
* Output projection alone is security theater. A user denied `Salary` can send
|
|
2176
|
+
* `ExtraFilter: "Salary > 200000"` or `OrderBy: "Salary DESC"` and reconstruct the values
|
|
2177
|
+
* from which rows come back and in what order — the column never appears in a result, so
|
|
2178
|
+
* every output-stripping point reports "secure." Aggregates are the same channel in a purer
|
|
2179
|
+
* form: `Aggregates: [{expression: 'MIN(Salary)'}]` under a narrow filter returns a denied
|
|
2180
|
+
* field's exact values directly. Predicate validation is a first-class enforcement point,
|
|
2181
|
+
* not a belt-and-braces afterthought. Together these cover every caller-authored expression
|
|
2182
|
+
* surface (`UserSearchString` is handled by excluding denied fields from the searched set,
|
|
2183
|
+
* not by rejection — see below).
|
|
2184
|
+
*
|
|
2185
|
+
* Lives at the provider layer rather than in the GraphQL resolver (where the plan first
|
|
2186
|
+
* placed it) because every RunView funnels through here — the batch path, server-internal
|
|
2187
|
+
* agents and actions running under a restricted `contextUser`, and the resolver alike. One
|
|
2188
|
+
* gate, no path left uncovered.
|
|
2189
|
+
*
|
|
2190
|
+
* The error deliberately does not say whether the field is missing or merely forbidden —
|
|
2191
|
+
* see {@link ProviderBase.FieldSecurityDenialMessage}.
|
|
2192
|
+
*/
|
|
2193
|
+
AssertPredicatesRespectFieldSecurity(params, contextUser) {
|
|
2194
|
+
if (!contextUser || !params.EntityName) {
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
const entity = this.EntityByName(params.EntityName);
|
|
2198
|
+
if (!entity?.EnableFieldLevelSecurity) {
|
|
2199
|
+
return; // the overwhelmingly common case — one boolean, no allocation
|
|
2200
|
+
}
|
|
2201
|
+
const denied = entity.GetDeniedReadFields(contextUser);
|
|
2202
|
+
if (denied.size === 0) {
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
// Map lowercased denied names back to their real casing for the message.
|
|
2206
|
+
const deniedByName = new Map();
|
|
2207
|
+
for (const field of entity.Fields) {
|
|
2208
|
+
if (denied.has(field.Name.trim().toLowerCase())) {
|
|
2209
|
+
deniedByName.set(field.Name.trim().toLowerCase(), field.Name);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
// Aliases are deliberately not scanned — an alias names the output column, no data
|
|
2213
|
+
// flows through it.
|
|
2214
|
+
const clauses = [
|
|
2215
|
+
params.ExtraFilter,
|
|
2216
|
+
params.OrderBy,
|
|
2217
|
+
...(params.Aggregates ?? []).map(a => a?.expression),
|
|
2218
|
+
];
|
|
2219
|
+
for (const clause of clauses) {
|
|
2220
|
+
if (typeof clause !== 'string' || clause.length === 0) {
|
|
2221
|
+
continue;
|
|
2222
|
+
}
|
|
2223
|
+
const hits = FindReferencedIdentifiers(clause, deniedByName.values());
|
|
2224
|
+
if (hits.length > 0) {
|
|
2225
|
+
LogDebug(`[FieldSecurity] Rejected RunView on '${entity.Name}' for user ${contextUser.Email}: ` +
|
|
2226
|
+
`predicate references denied field(s) ${hits.join(', ')}`);
|
|
2227
|
+
throw new FieldSecurityError(hits[0], entity.Name);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
// UserSearchString is not a predicate the caller authored — it is a search term the
|
|
2231
|
+
// platform expands across IncludeInUserSearchAPI fields. There is nothing to reject;
|
|
2232
|
+
// the correct handling is to not search the denied fields, which the SQL builder does
|
|
2233
|
+
// by consulting the same denied set. Rejecting here would make the search box fail
|
|
2234
|
+
// for a term that merely happens to match a restricted field's name.
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* The single wording for "you cannot use this field," modeled on SQL Server's posture of
|
|
2238
|
+
* never disclosing whether an object is missing or merely inaccessible.
|
|
2239
|
+
*
|
|
2240
|
+
* Naming the field is safe — the caller supplied it, so it tells them nothing they did not
|
|
2241
|
+
* already know. Naming the REASON is not: confirming "this field exists and is restricted"
|
|
2242
|
+
* turns any predicate into an oracle for probing which columns a deployment considers
|
|
2243
|
+
* sensitive. The ambiguity also keeps this message correct after
|
|
2244
|
+
* [#3485](https://github.com/MemberJunction/MJ/issues/3485) tiers metadata and restricted
|
|
2245
|
+
* fields stop shipping to clients at all, at which point "does not exist" becomes literally
|
|
2246
|
+
* true from the client's vantage point.
|
|
2247
|
+
*/
|
|
2248
|
+
static FieldSecurityDenialMessage(fieldName, entityName) {
|
|
2249
|
+
return FieldSecurityDenialMessage(fieldName, entityName);
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Strips fields the user cannot read from plain-object result rows.
|
|
2253
|
+
*
|
|
2254
|
+
* This is the primary read-time control for list results, and it runs on BOTH cache hits
|
|
2255
|
+
* and cache misses — the property the rest of the cache design is arranged around. Because
|
|
2256
|
+
* it reads live metadata, a permission change takes effect on the next metadata refresh
|
|
2257
|
+
* with no result-cache invalidation: the cached full-width superset stays valid and only
|
|
2258
|
+
* the projection changes.
|
|
2259
|
+
*
|
|
2260
|
+
* It composes with two per-request, never-cached narrowings that avoid pulling columns a
|
|
2261
|
+
* restricted service account has no business holding: the `simple`-path SELECT-list
|
|
2262
|
+
* intersection, and `Load()`'s allowed-column SELECT.
|
|
2263
|
+
*
|
|
2264
|
+
* NEVER applied to `entity_object` results. Those become `BaseEntity` instances whose
|
|
2265
|
+
* fields round-trip through `GenerateSaveSQL`, which iterates ALL `IsSPParameter` fields
|
|
2266
|
+
* reading `field.Value` — not just dirty ones. A stripped field would therefore be written
|
|
2267
|
+
* back as a real NULL on the user's next save: silent data loss. Entity objects keep their
|
|
2268
|
+
* values in server memory exactly as encrypted fields do; the trust boundary is the API
|
|
2269
|
+
* output, which the GraphQL layer enforces separately.
|
|
2270
|
+
*/
|
|
2271
|
+
ApplyFieldSecurityProjection(rows, params, contextUser) {
|
|
2272
|
+
if (!rows?.length || !contextUser || !params.EntityName || params.ResultType === 'entity_object') {
|
|
2273
|
+
return rows;
|
|
2274
|
+
}
|
|
2275
|
+
const entity = this.EntityByName(params.EntityName);
|
|
2276
|
+
if (!entity?.EnableFieldLevelSecurity) {
|
|
2277
|
+
return rows;
|
|
2278
|
+
}
|
|
2279
|
+
const denied = entity.GetDeniedReadFields(contextUser);
|
|
2280
|
+
if (denied.size === 0) {
|
|
2281
|
+
return rows;
|
|
2282
|
+
}
|
|
2283
|
+
return ProviderBase.OmitFieldsFromRows(rows, denied);
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Row-loop half of {@link ApplyFieldSecurityProjection}, split out so the policy above reads
|
|
2287
|
+
* as policy. Mirrors {@link ProjectRowsToFields}' matching semantics (trim + lowercase) and
|
|
2288
|
+
* its no-op probe: when the first row carries none of the denied keys, the whole result is
|
|
2289
|
+
* returned untouched rather than rebuilt row by row.
|
|
2290
|
+
*
|
|
2291
|
+
* @param deniedLowercase field names to omit, already lowercased
|
|
2292
|
+
*/
|
|
2293
|
+
static OmitFieldsFromRows(rows, deniedLowercase) {
|
|
2294
|
+
const probe = rows[0];
|
|
2295
|
+
if (!probe || typeof probe !== 'object') {
|
|
2296
|
+
return rows;
|
|
2297
|
+
}
|
|
2298
|
+
const keysToOmit = Object.keys(probe).filter(k => deniedLowercase.has(k.trim().toLowerCase()));
|
|
2299
|
+
if (keysToOmit.length === 0) {
|
|
2300
|
+
return rows;
|
|
2301
|
+
}
|
|
2302
|
+
const omit = new Set(keysToOmit);
|
|
2303
|
+
return rows.map((row) => {
|
|
2304
|
+
const source = row;
|
|
2305
|
+
const filtered = {};
|
|
2306
|
+
for (const key of Object.keys(source)) {
|
|
2307
|
+
if (!omit.has(key)) {
|
|
2308
|
+
filtered[key] = source[key];
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
return filtered;
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Strips or narrows the `MJ: Record Changes` payload columns a user may not read, using the
|
|
2316
|
+
* denied set of the entity each row is ABOUT rather than of Record Changes itself.
|
|
2317
|
+
*
|
|
2318
|
+
* A sibling of {@link ApplyFieldSecurityProjection} rather than part of it, because that
|
|
2319
|
+
* method short-circuits on `EnableFieldLevelSecurity` for the entity named in the RunView
|
|
2320
|
+
* params — which here is `MJ: Record Changes`, whose flag is off in every default deployment.
|
|
2321
|
+
* Everything about the per-row denied set, the payload treatment, and the fail-closed decision
|
|
2322
|
+
* lives in {@link RecordChangeFieldSecurityProjector}; this is only the RunView wiring.
|
|
2323
|
+
*
|
|
2324
|
+
* Runs at all four RunView projection points, matching the main projection: both cache-hit
|
|
2325
|
+
* paths and both cache-miss paths. The cache-hit path is not optional — it is the exact path
|
|
2326
|
+
* the original cross-user leak runs through, an unrestricted user warming a full-width slot
|
|
2327
|
+
* that a restricted user then hits.
|
|
2328
|
+
*
|
|
2329
|
+
* NEVER applied to `entity_object` results, for the reason the main projection is exempt plus
|
|
2330
|
+
* one specific to this one. The main reason transfers directly: an entity object's fields
|
|
2331
|
+
* round-trip through `GenerateSaveSQL`, which reads EVERY `IsSPParameter` field's value rather
|
|
2332
|
+
* than only dirty ones, so a withheld `ChangesDescription` would be written back as a real
|
|
2333
|
+
* NULL and a narrowed `ChangesJSON` as the narrowed payload — destroying audit history instead
|
|
2334
|
+
* of merely hiding it. Record Changes rows genuinely are saved through the entity layer
|
|
2335
|
+
* (replay writes `Status`/`ErrorLog`, users write `Comments`), so this is not hypothetical.
|
|
2336
|
+
* The additional reason is that the exemption cannot become a hole: the GraphQL RunView
|
|
2337
|
+
* resolver coerces `entity_object` to `simple` on the wire, so an `entity_object` Record
|
|
2338
|
+
* Changes result is by construction server-internal — and server-internal code holding full
|
|
2339
|
+
* values in memory is the documented trust boundary (FLS guide §3.4), exactly as for
|
|
2340
|
+
* encrypted fields.
|
|
2341
|
+
*/
|
|
2342
|
+
ApplyRecordChangeFieldSecurityProjection(rows, params, contextUser) {
|
|
2343
|
+
if (!rows?.length || !contextUser || params.ResultType === 'entity_object') {
|
|
2344
|
+
return rows;
|
|
2345
|
+
}
|
|
2346
|
+
if (!RecordChangeFieldSecurityProjector.IsRecordChangesEntity(this.ResolveRunViewEntitySync(params))) {
|
|
2347
|
+
return rows;
|
|
2348
|
+
}
|
|
2349
|
+
return new RecordChangeFieldSecurityProjector(this, contextUser).ProjectRows(rows);
|
|
2350
|
+
}
|
|
2351
|
+
/**
|
|
2352
|
+
* The entity a RunView targets, resolved with NO I/O — for gates that run on the result path,
|
|
2353
|
+
* where an async `RunView.GetEntityNameFromRunViewParams` (which issues a User Views query for
|
|
2354
|
+
* a bare `ViewID`) would be a query per result set.
|
|
2355
|
+
*
|
|
2356
|
+
* Covers the two shapes every caller in this repository uses: an explicit `EntityName`, and a
|
|
2357
|
+
* loaded `ViewEntity`. A request carrying only `ViewID`/`ViewName` with neither is not
|
|
2358
|
+
* resolvable here and returns undefined — the same shape `RunView`'s own row normalization
|
|
2359
|
+
* already declines to handle for the same reason, and the same one
|
|
2360
|
+
* {@link cacheDeniedForViewOnlyRequest} exists to fail closed for on the cache path.
|
|
2361
|
+
*/
|
|
2362
|
+
ResolveRunViewEntitySync(params) {
|
|
2363
|
+
if (params.EntityName) {
|
|
2364
|
+
return this.EntityByName(params.EntityName);
|
|
2365
|
+
}
|
|
2366
|
+
// Weak typing on ViewEntity is deliberate and pre-existing: this is MJCore, so it cannot
|
|
2367
|
+
// import the UserView subclass from core-entities without a circular dependency. Same
|
|
2368
|
+
// idiom as cacheDeniedForViewOnlyRequest and RunView.GetEntityNameFromRunViewParams.
|
|
2369
|
+
const viewEntityID = params.ViewEntity?.Get('EntityID');
|
|
2370
|
+
return typeof viewEntityID === 'string' ? this.EntityByID(viewEntityID) : undefined;
|
|
2371
|
+
}
|
|
2094
2372
|
async PreRunView(params, contextUser) {
|
|
2095
2373
|
const preViewStart = performance.now();
|
|
2096
2374
|
// Resolve any PlatformSQL values to plain strings for the active platform
|
|
@@ -2099,6 +2377,10 @@ export class ProviderBase {
|
|
|
2099
2377
|
// Hooks run after PlatformSQL resolution so they see plain-string filters,
|
|
2100
2378
|
// and before cache fingerprinting so injected filters affect the cache key.
|
|
2101
2379
|
params = await this.RunPreRunViewHooks(params, contextUser);
|
|
2380
|
+
// Field-level security: reject predicates referencing unreadable fields. Runs AFTER
|
|
2381
|
+
// hooks so injected filters are scanned too, and BEFORE the cache lookup so a rejected
|
|
2382
|
+
// request never consults (or warms) a cache slot.
|
|
2383
|
+
this.AssertPredicatesRespectFieldSecurity(params, contextUser);
|
|
2102
2384
|
// Start telemetry tracking
|
|
2103
2385
|
const telemetryStart = performance.now();
|
|
2104
2386
|
// After ResolvePlatformSQLInParams, ExtraFilter/OrderBy are guaranteed to be strings
|
|
@@ -2131,13 +2413,23 @@ export class ProviderBase {
|
|
|
2131
2413
|
let callerRequestedFields = params.Fields && params.Fields.length > 0
|
|
2132
2414
|
? params.Fields.map(f => f.trim().toLowerCase())
|
|
2133
2415
|
: null; // null = caller wants all fields
|
|
2134
|
-
// Only override Fields to
|
|
2416
|
+
// Only override Fields to the widened fetch set when caching will actually happen
|
|
2135
2417
|
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
2136
|
-
// end-to-end — there's no cache-coherence concern to preserve.
|
|
2418
|
+
// end-to-end — there's no cache-coherence concern to preserve. The widened set is
|
|
2419
|
+
// ALL entity fields, for every user: the server cache holds full-width slots and
|
|
2420
|
+
// narrows per request at read time (ApplyFieldSecurityProjection).
|
|
2137
2421
|
const entity = params.EntityName ? this.EntityByName(params.EntityName) : null;
|
|
2138
2422
|
const willCache = this.runViewCacheEligible(params);
|
|
2139
|
-
|
|
2140
|
-
|
|
2423
|
+
// entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
|
|
2424
|
+
// every column it is entitled to, or it round-trips partial state into a save. This
|
|
2425
|
+
// must happen here rather than only in the server's PreProcessRunView, which is not on
|
|
2426
|
+
// the client's path — a client entity_object request without CacheLocal would otherwise
|
|
2427
|
+
// ship the caller's narrow Fields over the wire and materialize partial entities. What a
|
|
2428
|
+
// field-restricted user RECEIVES is still their allowed set: the server strips denied
|
|
2429
|
+
// columns on the wire, and the missing keys mark those fields not-loaded.
|
|
2430
|
+
const widenForEntityObject = params.ResultType === 'entity_object';
|
|
2431
|
+
if (entity && (willCache || widenForEntityObject)) {
|
|
2432
|
+
params.Fields = this.ComputeRunViewFetchFields(entity);
|
|
2141
2433
|
// Platform contract: explicit Fields always include the primary key(s) —
|
|
2142
2434
|
// project back down to requested ∪ PK, matching the direct SQL path.
|
|
2143
2435
|
if (callerRequestedFields) {
|
|
@@ -2163,7 +2455,8 @@ export class ProviderBase {
|
|
|
2163
2455
|
this.cacheDeniedForViewOnlyRequest(params, contextUser);
|
|
2164
2456
|
if (willCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
2165
2457
|
const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params, contextUser);
|
|
2166
|
-
|
|
2458
|
+
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(params);
|
|
2459
|
+
fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
2167
2460
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
2168
2461
|
if (cached) {
|
|
2169
2462
|
// These rows are the cache's shared, deep-frozen objects — the runtime freeze is
|
|
@@ -2173,6 +2466,17 @@ export class ProviderBase {
|
|
|
2173
2466
|
if (callerRequestedFields && params.ResultType !== 'entity_object') {
|
|
2174
2467
|
results = ProjectRowsToFields(results, callerRequestedFields);
|
|
2175
2468
|
}
|
|
2469
|
+
// Field-level security MUST be applied here, not only in PostRunView: this path
|
|
2470
|
+
// RETURNS (see RunView) without ever calling PostRunView, and it is the exact
|
|
2471
|
+
// path the cross-user leak runs through — an unrestricted user warms the cache
|
|
2472
|
+
// with every column, then a restricted user hits that same slot. The cache is
|
|
2473
|
+
// column-agnostic by design (one entry serves every field subset), so the
|
|
2474
|
+
// caller's Fields list is unrelated to security and cannot be relied on.
|
|
2475
|
+
results = this.ApplyFieldSecurityProjection(results, params, contextUser);
|
|
2476
|
+
// Record Changes payload security, on the same cache-hit path and for the same
|
|
2477
|
+
// reason: the slot is shared and full-width, so whoever warmed it decides nothing
|
|
2478
|
+
// about who may read what out of it.
|
|
2479
|
+
results = this.ApplyRecordChangeFieldSecurityProjection(results, params, contextUser);
|
|
2176
2480
|
// Reconstruct RunViewResult from cached data
|
|
2177
2481
|
cachedResult = {
|
|
2178
2482
|
Success: true,
|
|
@@ -2225,6 +2529,10 @@ export class ProviderBase {
|
|
|
2225
2529
|
// Run registered PreRunView hooks on each param in the batch
|
|
2226
2530
|
for (let i = 0; i < params.length; i++) {
|
|
2227
2531
|
params[i] = await this.RunPreRunViewHooks(params[i], contextUser);
|
|
2532
|
+
// Field-level security predicate gate, same contract as the single-view path.
|
|
2533
|
+
// One bad view rejects the whole batch — a batch is one caller request, and
|
|
2534
|
+
// silently returning partial results would hide the denial.
|
|
2535
|
+
this.AssertPredicatesRespectFieldSecurity(params[i], contextUser);
|
|
2228
2536
|
}
|
|
2229
2537
|
// Start telemetry tracking for batch operation
|
|
2230
2538
|
const fromEngine = params.some(p => p._fromEngine);
|
|
@@ -2287,13 +2595,16 @@ export class ProviderBase {
|
|
|
2287
2595
|
let callerFields = param.Fields && param.Fields.length > 0
|
|
2288
2596
|
? param.Fields.map(f => f.trim().toLowerCase())
|
|
2289
2597
|
: null;
|
|
2290
|
-
// Only override Fields to
|
|
2598
|
+
// Only override Fields to the widened fetch set when caching will actually happen
|
|
2291
2599
|
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
2292
|
-
// end-to-end — there's no cache-coherence concern to preserve.
|
|
2600
|
+
// end-to-end — there's no cache-coherence concern to preserve. Widened = ALL
|
|
2601
|
+
// fields, for every user (see PreRunView).
|
|
2293
2602
|
const batchEntity = param.EntityName ? this.EntityByName(param.EntityName) : null;
|
|
2294
2603
|
const batchWillCache = this.runViewCacheEligible(param);
|
|
2295
|
-
|
|
2296
|
-
|
|
2604
|
+
// Same entity_object-always-widens rule as the single-view path above.
|
|
2605
|
+
const batchWidenForEntityObject = param.ResultType === 'entity_object';
|
|
2606
|
+
if (batchEntity && (batchWillCache || batchWidenForEntityObject)) {
|
|
2607
|
+
param.Fields = this.ComputeRunViewFetchFields(batchEntity);
|
|
2297
2608
|
// Platform contract: explicit Fields always include the primary key(s)
|
|
2298
2609
|
if (callerFields) {
|
|
2299
2610
|
callerFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerFields, batchEntity);
|
|
@@ -2313,7 +2624,8 @@ export class ProviderBase {
|
|
|
2313
2624
|
this.cacheDeniedForViewOnlyRequest(param, contextUser);
|
|
2314
2625
|
if (batchWillCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
2315
2626
|
const rlsWhereClause = this.ComputeRunViewRLSWhereClause(param, contextUser);
|
|
2316
|
-
const
|
|
2627
|
+
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(param);
|
|
2628
|
+
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
2317
2629
|
fingerprintMap.set(i, fingerprint);
|
|
2318
2630
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
2319
2631
|
if (cached) {
|
|
@@ -2322,6 +2634,11 @@ export class ProviderBase {
|
|
|
2322
2634
|
if (callerFields && param.ResultType !== 'entity_object') {
|
|
2323
2635
|
results = ProjectRowsToFields(results, callerFields);
|
|
2324
2636
|
}
|
|
2637
|
+
// Field-level security on the batch cache-hit path — same reasoning as the
|
|
2638
|
+
// single-view path in PreRunView: this result is returned without
|
|
2639
|
+
// PostRunViews projecting it, and the cached superset is column-agnostic.
|
|
2640
|
+
results = this.ApplyFieldSecurityProjection(results, param, contextUser);
|
|
2641
|
+
results = this.ApplyRecordChangeFieldSecurityProjection(results, param, contextUser);
|
|
2325
2642
|
const cachedViewResult = {
|
|
2326
2643
|
Success: true,
|
|
2327
2644
|
Results: results,
|
|
@@ -2747,11 +3064,11 @@ export class ProviderBase {
|
|
|
2747
3064
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
2748
3065
|
await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
2749
3066
|
}
|
|
2750
|
-
else if (this.shouldAutoCache(params, result)) {
|
|
3067
|
+
else if (this.shouldAutoCache(params, result, contextUser)) {
|
|
2751
3068
|
// Server-side auto-cache: small, unfiltered, unsorted results are
|
|
2752
3069
|
// automatically cached even without explicit CacheLocal. These are
|
|
2753
3070
|
// safe for in-place upsert on entity changes (no filter to evaluate).
|
|
2754
|
-
const fingerprint = preResult.fingerprint || LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params, contextUser));
|
|
3071
|
+
const fingerprint = preResult.fingerprint || LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params, contextUser), undefined, this.ComputeRunViewFLSFingerprintKey(params));
|
|
2755
3072
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
2756
3073
|
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
2757
3074
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
@@ -2765,6 +3082,15 @@ export class ProviderBase {
|
|
|
2765
3082
|
if (result.Success && preResult.callerRequestedFields && params.ResultType !== 'entity_object') {
|
|
2766
3083
|
result.Results = ProjectRowsToFields(result.Results, preResult.callerRequestedFields);
|
|
2767
3084
|
}
|
|
3085
|
+
// Field-level security on the cache-MISS path (the hit path is enforced in PreRunView,
|
|
3086
|
+
// which returns before this hook ever runs). Must come AFTER the cache write above so
|
|
3087
|
+
// the cache keeps the universal superset — permission changes then take effect on the
|
|
3088
|
+
// next metadata refresh with no result-cache invalidation, because visibility is a pure
|
|
3089
|
+
// read-time projection rather than something baked into the cached rows.
|
|
3090
|
+
if (result.Success) {
|
|
3091
|
+
result.Results = this.ApplyFieldSecurityProjection(result.Results, params, contextUser);
|
|
3092
|
+
result.Results = this.ApplyRecordChangeFieldSecurityProjection(result.Results, params, contextUser);
|
|
3093
|
+
}
|
|
2768
3094
|
// Transform the result set into BaseEntity-derived objects, if needed
|
|
2769
3095
|
await this.TransformSimpleObjectToEntityObject(params, result, contextUser);
|
|
2770
3096
|
// Run registered PostRunView hooks (e.g., data masking, audit logging).
|
|
@@ -2819,7 +3145,7 @@ export class ProviderBase {
|
|
|
2819
3145
|
// per item. Compute lazily only for indexes PreRunViews skipped (cache
|
|
2820
3146
|
// was disabled for them but auto-cache/OnDataChanged may still need it).
|
|
2821
3147
|
const fingerprint = preResult.fingerprintMap?.get(i)
|
|
2822
|
-
?? LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params[i], contextUser));
|
|
3148
|
+
?? LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params[i], contextUser), undefined, this.ComputeRunViewFLSFingerprintKey(params[i]));
|
|
2823
3149
|
// CRITICAL: must be the SAME eligibility predicate PreRunViews used to decide
|
|
2824
3150
|
// whether to widen Fields. Writing a non-widened (narrow or keyset-paged)
|
|
2825
3151
|
// result here poisons the Fields-agnostic superset slot — this exact gate
|
|
@@ -2828,7 +3154,7 @@ export class ProviderBase {
|
|
|
2828
3154
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
2829
3155
|
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
2830
3156
|
}
|
|
2831
|
-
else if (this.shouldAutoCache(params[i], results[i])) {
|
|
3157
|
+
else if (this.shouldAutoCache(params[i], results[i], contextUser)) {
|
|
2832
3158
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
2833
3159
|
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
2834
3160
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunViews "${params[i].EntityName || params[i].ViewName || 'unknown'}" — ${results[i].Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
@@ -2857,6 +3183,18 @@ export class ProviderBase {
|
|
|
2857
3183
|
}
|
|
2858
3184
|
}
|
|
2859
3185
|
}
|
|
3186
|
+
// Field-level security on the batch cache-MISS results. Deliberately a separate loop
|
|
3187
|
+
// from the projection above rather than folded into it: that one is gated on
|
|
3188
|
+
// callerFieldsMap (present only when Fields were actually widened), while this must run
|
|
3189
|
+
// for every uncached result regardless of whether the caller narrowed Fields. Cache hits
|
|
3190
|
+
// are skipped because PreRunViews already projected them.
|
|
3191
|
+
for (let i = 0; i < results.length; i++) {
|
|
3192
|
+
if (preResult.cacheStatusMap?.get(i)?.status === 'hit' || !results[i].Success) {
|
|
3193
|
+
continue;
|
|
3194
|
+
}
|
|
3195
|
+
results[i].Results = this.ApplyFieldSecurityProjection(results[i].Results, params[i], contextUser);
|
|
3196
|
+
results[i].Results = this.ApplyRecordChangeFieldSecurityProjection(results[i].Results, params[i], contextUser);
|
|
3197
|
+
}
|
|
2860
3198
|
// Transform results to entity objects AFTER caching plain objects.
|
|
2861
3199
|
// Skip results that came from cache hits — they're already entity objects.
|
|
2862
3200
|
const transformPromises = [];
|
|
@@ -3064,15 +3402,16 @@ export class ProviderBase {
|
|
|
3064
3402
|
return false;
|
|
3065
3403
|
return entity.TrustServerCacheCompletely !== false;
|
|
3066
3404
|
}
|
|
3067
|
-
shouldAutoCache(params, result) {
|
|
3405
|
+
shouldAutoCache(params, result, contextUser) {
|
|
3068
3406
|
if (!this.TrustLocalCacheCompletely)
|
|
3069
3407
|
return false;
|
|
3070
3408
|
if (params.CacheLocal)
|
|
3071
3409
|
return false; // already handled
|
|
3072
3410
|
// Same eligibility predicate as the main cache path — covers BypassCache,
|
|
3073
|
-
// AfterKey (keyset pages), count_only,
|
|
3074
|
-
// auto-cached keyset page or
|
|
3075
|
-
// entity+filter slot just like the
|
|
3411
|
+
// AfterKey (keyset pages), count_only, cache-disallowed entities, and the
|
|
3412
|
+
// field-security entity_object exemption. An auto-cached keyset page or
|
|
3413
|
+
// count_only result would poison the entity+filter slot just like the
|
|
3414
|
+
// main-path variants of those bugs.
|
|
3076
3415
|
if (!this.runViewCacheEligible(params))
|
|
3077
3416
|
return false;
|
|
3078
3417
|
if (!LocalCacheManager.Instance.IsInitialized)
|
|
@@ -3623,6 +3962,7 @@ export class ProviderBase {
|
|
|
3623
3962
|
}
|
|
3624
3963
|
else {
|
|
3625
3964
|
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is current — no refresh needed`, verboseOnly: false });
|
|
3965
|
+
await this.RefreshCurrentUser();
|
|
3626
3966
|
}
|
|
3627
3967
|
}
|
|
3628
3968
|
catch (e) {
|
|
@@ -3666,6 +4006,9 @@ export class ProviderBase {
|
|
|
3666
4006
|
}
|
|
3667
4007
|
else {
|
|
3668
4008
|
LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata is current`, verboseOnly: false });
|
|
4009
|
+
// Even when entity metadata is current, refresh CurrentUser so user roles and permissions
|
|
4010
|
+
// stay in sync with the database without requiring a schema change or manual cache clear.
|
|
4011
|
+
await this.RefreshCurrentUser();
|
|
3669
4012
|
}
|
|
3670
4013
|
}
|
|
3671
4014
|
catch (e) {
|
|
@@ -3825,7 +4168,7 @@ export class ProviderBase {
|
|
|
3825
4168
|
simpleMetadata[r.Code] = r.Results;
|
|
3826
4169
|
}
|
|
3827
4170
|
// Post Process Entities because there's some special handling of the sub-objects
|
|
3828
|
-
simpleMetadata.AllEntities = this.PostProcessEntityMetadata(simpleMetadata.Entities, simpleMetadata.EntityFields, simpleMetadata.EntityFieldValues, simpleMetadata.EntityPermissions, simpleMetadata.EntityRelationships, simpleMetadata.EntitySettings, simpleMetadata.EntityOrganicKeys, simpleMetadata.EntityOrganicKeyRelatedEntities);
|
|
4171
|
+
simpleMetadata.AllEntities = this.PostProcessEntityMetadata(simpleMetadata.Entities, simpleMetadata.EntityFields, simpleMetadata.EntityFieldValues, simpleMetadata.EntityPermissions, simpleMetadata.EntityRelationships, simpleMetadata.EntitySettings, simpleMetadata.EntityOrganicKeys, simpleMetadata.EntityOrganicKeyRelatedEntities, simpleMetadata.EntityFieldPermissions);
|
|
3829
4172
|
// Post Process the Applications, because we want to handle the sub-objects properly.
|
|
3830
4173
|
simpleMetadata.AllApplications = simpleMetadata.Applications.map((a) => {
|
|
3831
4174
|
a.ApplicationEntities = simpleMetadata.ApplicationEntities.filter((ae) => UUIDsEqual(ae.ApplicationID, a.ID));
|
|
@@ -3848,6 +4191,25 @@ export class ProviderBase {
|
|
|
3848
4191
|
LogError(e);
|
|
3849
4192
|
}
|
|
3850
4193
|
}
|
|
4194
|
+
/**
|
|
4195
|
+
* Refreshes the CurrentUser from the server and updates local metadata in place.
|
|
4196
|
+
* Useful on warm boot or when user roles/permissions change dynamically without
|
|
4197
|
+
* entity schema changes.
|
|
4198
|
+
*/
|
|
4199
|
+
async RefreshCurrentUser() {
|
|
4200
|
+
try {
|
|
4201
|
+
const user = await this.GetCurrentUser();
|
|
4202
|
+
if (user && this._localMetadata) {
|
|
4203
|
+
this._localMetadata.CurrentUser = user;
|
|
4204
|
+
void this.SaveLocalMetadataToStorage();
|
|
4205
|
+
return user;
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
catch (e) {
|
|
4209
|
+
LogError(`[Metadata Cache] RefreshCurrentUser failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
4210
|
+
}
|
|
4211
|
+
return null;
|
|
4212
|
+
}
|
|
3851
4213
|
/**
|
|
3852
4214
|
* Post-processes entity metadata to establish relationships between entities and their child objects.
|
|
3853
4215
|
* Links fields, permissions, relationships, and settings to their parent entities.
|
|
@@ -3877,7 +4239,7 @@ export class ProviderBase {
|
|
|
3877
4239
|
}
|
|
3878
4240
|
return map;
|
|
3879
4241
|
}
|
|
3880
|
-
PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities) {
|
|
4242
|
+
PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities, fieldPermissions) {
|
|
3881
4243
|
const result = [];
|
|
3882
4244
|
// Sort entities alphabetically by name to ensure deterministic ordering
|
|
3883
4245
|
// This prevents non-deterministic output in CodeGen and other metadata consumers
|
|
@@ -3888,6 +4250,19 @@ export class ProviderBase {
|
|
|
3888
4250
|
f.EntityFieldValues = fieldValuesByFieldId.get(NormalizeUUID(f.ID)) || [];
|
|
3889
4251
|
}
|
|
3890
4252
|
}
|
|
4253
|
+
// Link field-level security records to their fields. Optional and typically absent:
|
|
4254
|
+
// the dataset item is only present on databases that have it, and the array is empty
|
|
4255
|
+
// in every deployment that has not configured field security — so the whole pass is
|
|
4256
|
+
// skipped rather than assigning an empty array to thousands of field rows.
|
|
4257
|
+
if (fieldPermissions && fieldPermissions.length > 0) {
|
|
4258
|
+
const fieldPermissionsByFieldId = this.groupByNormalizedUUID(fieldPermissions, fp => fp.EntityFieldID);
|
|
4259
|
+
for (const f of fields) {
|
|
4260
|
+
const fieldPerms = fieldPermissionsByFieldId.get(NormalizeUUID(f.ID));
|
|
4261
|
+
if (fieldPerms) {
|
|
4262
|
+
f.EntityFieldPermissions = fieldPerms;
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
3891
4266
|
// Link organic key related entities to their parent organic keys
|
|
3892
4267
|
if (organicKeys && organicKeyRelatedEntities && organicKeyRelatedEntities.length > 0) {
|
|
3893
4268
|
const okreByOrganicKeyId = this.groupByNormalizedUUID(organicKeyRelatedEntities, okre => okre.EntityOrganicKeyID);
|