@memberjunction/core 6.1.0-edge.6 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/generic/baseEngine.d.ts +52 -1
  2. package/dist/generic/baseEngine.d.ts.map +1 -1
  3. package/dist/generic/baseEngine.js +93 -4
  4. package/dist/generic/baseEngine.js.map +1 -1
  5. package/dist/generic/baseEntity.d.ts +173 -0
  6. package/dist/generic/baseEntity.d.ts.map +1 -1
  7. package/dist/generic/baseEntity.js +357 -12
  8. package/dist/generic/baseEntity.js.map +1 -1
  9. package/dist/generic/entityInfo.d.ts +461 -1
  10. package/dist/generic/entityInfo.d.ts.map +1 -1
  11. package/dist/generic/entityInfo.js +568 -6
  12. package/dist/generic/entityInfo.js.map +1 -1
  13. package/dist/generic/interfaces.d.ts +5 -0
  14. package/dist/generic/interfaces.d.ts.map +1 -1
  15. package/dist/generic/interfaces.js.map +1 -1
  16. package/dist/generic/localCacheManager.d.ts +30 -1
  17. package/dist/generic/localCacheManager.d.ts.map +1 -1
  18. package/dist/generic/localCacheManager.js +48 -1
  19. package/dist/generic/localCacheManager.js.map +1 -1
  20. package/dist/generic/providerBase.d.ts +164 -2
  21. package/dist/generic/providerBase.d.ts.map +1 -1
  22. package/dist/generic/providerBase.js +398 -23
  23. package/dist/generic/providerBase.js.map +1 -1
  24. package/dist/generic/recordChangeFieldSecurity.d.ts +164 -0
  25. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -0
  26. package/dist/generic/recordChangeFieldSecurity.js +279 -0
  27. package/dist/generic/recordChangeFieldSecurity.js.map +1 -0
  28. package/dist/generic/saveEntityGraphOperation.d.ts +10 -0
  29. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  30. package/dist/generic/saveEntityGraphOperation.js +2 -1
  31. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  32. package/dist/generic/wellKnownUserSource.d.ts +70 -0
  33. package/dist/generic/wellKnownUserSource.d.ts.map +1 -0
  34. package/dist/generic/wellKnownUserSource.js +82 -0
  35. package/dist/generic/wellKnownUserSource.js.map +1 -0
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/package.json +3 -3
@@ -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
- const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
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 all entity fields when caching will actually happen
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
- if (entity && willCache) {
2140
- params.Fields = entity.Fields.map(f => f.Name);
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
- fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
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 all entity fields when caching will actually happen
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
- if (batchEntity && batchWillCache) {
2296
- param.Fields = batchEntity.Fields.map(f => f.Name);
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 fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause);
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, and cache-disallowed entities. An
3074
- // auto-cached keyset page or count_only result would poison the
3075
- // entity+filter slot just like the main-path variants of those bugs.
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);