@memberjunction/generic-database-provider 6.1.0-edge.6 → 6.1.0-edge.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -936,6 +936,21 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
936
936
  // entity.Get(), which would assert active status (deprecation warning / disabled throw)
937
937
  // for what is NOT user use of the field. (EntityField.Value itself never asserts.)
938
938
  const theField = entity.GetFieldByName(f.Name);
939
+ // Not-loaded fields (the hydration source omitted them — e.g. field-security
940
+ // stripping) are OMITTED from the SP call entirely: every generated param has a
941
+ // default, the update procs' ISNULL(@p, [Col]) merge preserves the stored value,
942
+ // and the create procs substitute the column default. Skipping here also
943
+ // suppresses the _Clear companion, which RenderSaveCallBinding derives from the
944
+ // fieldValueMap this loop builds — so a not-loaded nullable field can never be
945
+ // wiped to NULL by its own construction state.
946
+ if (theField?.NotLoaded)
947
+ continue;
948
+ // Field security on INSERT: a field this user may not create is omitted so the
949
+ // column takes its database default. Distinct from NotLoaded — the value here is
950
+ // real, it is simply not one this user is permitted to supply — which is why the
951
+ // flag is separate and why validation still ran against it normally.
952
+ if (!isUpdate && theField?.CreateSuppressed)
953
+ continue;
939
954
  const rawValue = theField?.Value;
940
955
  // PK-on-CREATE with no explicit value: omit so the SP default fires.
941
956
  const isPKOnCreate = !isUpdate && f.IsPrimaryKey && !f.AutoIncrement;
@@ -1395,7 +1410,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1395
1410
  maxRowsForQuery = entityInfo.UserViewMaxRows;
1396
1411
  }
1397
1412
  // ── Field selection ──
1398
- const fields = this.getRunTimeViewFieldString(params, viewEntity);
1413
+ const fields = this.getRunTimeViewFieldString(params, viewEntity, user);
1399
1414
  // ── Build SELECT and COUNT SQL ──
1400
1415
  // DataSource:'Materialized' routes the read to the entity's materialized wrapper view
1401
1416
  // (same shape, so RLS/paging/fields all apply identically); default stays the live base view.
@@ -1437,10 +1452,25 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1437
1452
  bHasWhere = true;
1438
1453
  }
1439
1454
  // 3. User search string
1455
+ //
1456
+ // 🚨 NOT screened by ValidateUserProvidedSQLClause (#4392). That denylist exists for
1457
+ // caller-supplied SQL FRAGMENTS (ExtraFilter / OrderBy / OverrideExcludeFilter, both
1458
+ // still screened above and below). UserSearchString is not a fragment — it is the free
1459
+ // text a person typed into a search box, and createViewUserSearchSQL never splices it
1460
+ // into SQL as one: it builds every predicate itself from IncludeInUserSearchAPI
1461
+ // metadata and lands the text only INSIDE a string literal, with single quotes doubled
1462
+ // and LIKE metacharacters escaped under an explicit ESCAPE.
1463
+ //
1464
+ // Screening it as SQL rejected ordinary searches. The denylist word-boundary-matches
1465
+ // keywords against the raw text, so "Union Pacific", "Update Request" and "drop
1466
+ // shipment" were all refused — and the grid surfaced a null error message, so the
1467
+ // search box simply appeared broken.
1468
+ //
1469
+ // Quote-doubling, not keyword matching, is the correct protection for a value landing
1470
+ // in a literal: it is what keeps the text inside the quotes, where no keyword it
1471
+ // contains can mean anything to the parser.
1440
1472
  if (userSearchString.length > 0) {
1441
- if (!this.ValidateUserProvidedSQLClause(userSearchString))
1442
- throw new Error(`Invalid User Search SQL clause: ${userSearchString}, contains one more for forbidden keywords`);
1443
- const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, userSearchString);
1473
+ const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, userSearchString, user);
1444
1474
  if (sUserSearchSQL.length > 0) {
1445
1475
  whereSQL = bHasWhere ? `${whereSQL} AND (${sUserSearchSQL})` : `(${sUserSearchSQL})`;
1446
1476
  bHasWhere = true;
@@ -1459,8 +1489,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1459
1489
  sExcludeSQL += ` UserViewID=${viewEntity?.ID})`;
1460
1490
  else {
1461
1491
  // SECURITY: excludeUserViewRunID is user-supplied (GraphQL input) and is
1462
- // interpolated directly into SQL here. Unlike ExtraFilter/UserSearchString/
1463
- // OverrideExcludeFilter (all passed through ValidateUserProvidedSQLClause),
1492
+ // interpolated directly into SQL here. Unlike ExtraFilter/OrderBy/
1493
+ // OverrideExcludeFilter (all passed through ValidateUserProvidedSQLClause
1494
+ // UserSearchString is free text and is escaped into a literal instead, #4392),
1464
1495
  // this value historically had NO validation — allowing SQL injection into the
1465
1496
  // view WHERE clause. It is only ever a UserViewRun.ID (a GUID), so reject
1466
1497
  // anything that is not a well-formed GUID before it reaches the query.
@@ -1697,12 +1728,36 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1697
1728
  /**************************************************************************/
1698
1729
  // InternalRunView Helpers
1699
1730
  /**************************************************************************/
1731
+ /**
1732
+ * Returns the SELECT list for a single-record load: `*` normally, or an explicit list of
1733
+ * the columns this user is allowed to read when field security denies them any.
1734
+ *
1735
+ * Deliberately NOT routed through RunView, which is the other way to get this behavior.
1736
+ * That reroute is the tidier long-term shape and is planned separately (it must pass
1737
+ * `BypassCache` — a PK load has never been cache-served and must not silently start being
1738
+ * — and it changes relationship loading, which stays on its current path). Filtering the
1739
+ * column list here buys the same protection without touching either.
1740
+ */
1741
+ buildFieldSecuritySelectList(entityInfo, user) {
1742
+ if (!user || !entityInfo.EnableFieldLevelSecurity) {
1743
+ return '*'; // the overwhelmingly common case — one boolean, unchanged SQL
1744
+ }
1745
+ const denied = entityInfo.GetDeniedReadFields(user);
1746
+ if (denied.size === 0) {
1747
+ return '*';
1748
+ }
1749
+ const allowed = entityInfo.Fields.filter(f => !denied.has(f.Name.trim().toLowerCase()));
1750
+ if (allowed.length === 0) {
1751
+ return '*'; // degenerate; PKs are unrestrictable so this should be unreachable
1752
+ }
1753
+ return allowed.map(f => this.QuoteIdentifier(f.Name)).join(', ');
1754
+ }
1700
1755
  /**
1701
1756
  * Builds the SQL field list string for a view query, using dialect-neutral quoting.
1702
1757
  * Returns '*' if no specific fields are resolved.
1703
1758
  */
1704
- getRunTimeViewFieldString(params, viewEntity) {
1705
- const fieldList = this.getRunTimeViewFieldArray(params, viewEntity);
1759
+ getRunTimeViewFieldString(params, viewEntity, contextUser) {
1760
+ const fieldList = this.getRunTimeViewFieldArray(params, viewEntity, contextUser);
1706
1761
  if (fieldList.length === 0)
1707
1762
  return '*';
1708
1763
  return fieldList
@@ -1715,8 +1770,19 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1715
1770
  /**
1716
1771
  * Resolves the list of EntityFieldInfo objects for a view query.
1717
1772
  * Priority: params.Fields > view columns > all entity fields (wildcard).
1773
+ *
1774
+ * Field-level security intersects every resolution path with the user's ALLOWED set, so a
1775
+ * denied column never appears in the SELECT list and its values never leave the database:
1776
+ * - explicit `params.Fields` and saved-view columns are silently narrowed (a denied entry
1777
+ * is dropped without the "Field not found" error — `Fields` describes output shape, not
1778
+ * a predicate, and the caller learns nothing projection didn't already show them);
1779
+ * - an empty field list, which otherwise emits `SELECT *`, becomes the explicit
1780
+ * allowed-column list;
1781
+ * - `entity_object` requests are EXEMPT — entities must hydrate from every column (see
1782
+ * ApplyFieldSecurityProjection) and their enforcement stays at the output boundary;
1783
+ * - PKs are unrestrictable and always survive (the existing force-add).
1718
1784
  */
1719
- getRunTimeViewFieldArray(params, viewEntity) {
1785
+ getRunTimeViewFieldArray(params, viewEntity, contextUser) {
1720
1786
  const fieldList = [];
1721
1787
  try {
1722
1788
  let entityInfo = null;
@@ -1728,12 +1794,18 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1728
1794
  if (!entityInfo)
1729
1795
  throw new Error(`Entity ${params.EntityName} not found in metadata`);
1730
1796
  }
1797
+ const flsUser = contextUser ?? this.CurrentUser;
1798
+ const denied = params.ResultType !== 'entity_object' && flsUser && entityInfo.EnableFieldLevelSecurity
1799
+ ? entityInfo.GetDeniedReadFields(flsUser)
1800
+ : new Set();
1731
1801
  if (params.Fields) {
1732
1802
  for (const ef of entityInfo.PrimaryKeys) {
1733
1803
  if (!params.Fields.find((f) => f.trim().toLowerCase() === ef.Name.toLowerCase()))
1734
1804
  fieldList.push(ef);
1735
1805
  }
1736
1806
  params.Fields.forEach((f) => {
1807
+ if (denied.has(f.trim().toLowerCase()))
1808
+ return; // silent narrowing — deliberately NOT the "not found" error
1737
1809
  const field = entityInfo.FieldByName(f); // O(1) index (was O(F) Fields.find → O(F²) over the loop)
1738
1810
  if (field)
1739
1811
  fieldList.push(field);
@@ -1745,6 +1817,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1745
1817
  viewEntity.Columns.forEach((c) => {
1746
1818
  if (!c.hidden) {
1747
1819
  if (c.EntityField) {
1820
+ if (denied.has(c.EntityField.Name.trim().toLowerCase()))
1821
+ return; // silent narrowing
1748
1822
  fieldList.push(c.EntityField);
1749
1823
  }
1750
1824
  else {
@@ -1757,6 +1831,15 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1757
1831
  fieldList.push(ef);
1758
1832
  }
1759
1833
  }
1834
+ else if (denied.size > 0) {
1835
+ // No explicit fields and no saved view would emit `SELECT *` — for a restricted
1836
+ // user that pulls denied columns out of the database, so emit the explicit
1837
+ // allowed-column list instead (PKs are unrestrictable and always included).
1838
+ for (const ef of entityInfo.Fields) {
1839
+ if (!denied.has(ef.Name.trim().toLowerCase()))
1840
+ fieldList.push(ef);
1841
+ }
1842
+ }
1760
1843
  }
1761
1844
  catch (e) {
1762
1845
  LogError(e);
@@ -1774,16 +1857,37 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1774
1857
  * - skips fields that are not sensible text-search targets (non-text types,
1775
1858
  * unbounded text columns when FTX is off).
1776
1859
  */
1777
- createViewUserSearchSQL(entityInfo, userSearchString) {
1860
+ createViewUserSearchSQL(entityInfo, userSearchString, contextUser) {
1778
1861
  let sUserSearchSQL = '';
1779
1862
  const safeUserSearchString = userSearchString.replace(/'/g, "''");
1780
- if (entityInfo.FullTextSearchEnabled) {
1863
+ // Field-level security: never search fields the user cannot read. Matching against a
1864
+ // denied column is a value oracle — "search for 250000 → the row comes back" probes the
1865
+ // secret one term at a time. UserSearchString is not rejected (the term is not a
1866
+ // caller-authored predicate; see AssertPredicatesRespectFieldSecurity) — the denied
1867
+ // fields are simply excluded from the searched set. Computed once per call, never per
1868
+ // field (the per-request precompute contract).
1869
+ const user = contextUser ?? this.CurrentUser;
1870
+ const deniedSearchFields = entityInfo.EnableFieldLevelSecurity && user
1871
+ ? entityInfo.GetDeniedReadFields(user)
1872
+ : new Set();
1873
+ // The full-text index spans its indexed columns as one unit — a denied FTS-indexed
1874
+ // column cannot be excluded from the index function. When (and only when) a denied
1875
+ // field is FTS-indexed, fall back to the per-field LIKE path, which CAN exclude it.
1876
+ const ftsCoversDeniedField = deniedSearchFields.size > 0 &&
1877
+ entityInfo.Fields.some(f => f.FullTextSearchEnabled && deniedSearchFields.has(f.Name.trim().toLowerCase()));
1878
+ if (entityInfo.FullTextSearchEnabled && !ftsCoversDeniedField) {
1781
1879
  let u = safeUserSearchString;
1782
1880
  const uUpper = u.toUpperCase();
1783
- if (uUpper.includes(' AND ') || uUpper.includes(' OR ') || uUpper.includes(' NOT ')) {
1881
+ // WORD-boundary tests, not substring tests (#4392). As substrings, `OR` matches
1882
+ // "C-OR-PORATE" and `AND` matches "ST-AND-ARD", so ordinary two-word searches —
1883
+ // "Corporate Office", "Standard Rate", "North America" — fell into the branch below
1884
+ // and were emitted as `Corporate%Office`. `%` is not a full-text operator, so those
1885
+ // searches produced a syntax error instead of results. Only a STANDALONE AND/OR/NOT
1886
+ // is a boolean operator the caller meant.
1887
+ if (/ (AND|OR|NOT) /.test(uUpper)) {
1784
1888
  u = uUpper.replace(/ /g, '%').replace(/%AND%/g, ' AND ').replace(/%OR%/g, ' OR ').replace(/%NOT%/g, ' NOT ');
1785
1889
  }
1786
- else if (uUpper.includes('AND') || uUpper.includes('OR') || uUpper.includes('NOT')) {
1890
+ else if (/\b(AND|OR|NOT)\b/.test(uUpper)) {
1787
1891
  u = u.replace(/ /g, '%');
1788
1892
  }
1789
1893
  else if (u.includes(' ')) {
@@ -1798,10 +1902,31 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1798
1902
  sUserSearchSQL = `${pkName} IN (SELECT ${pkName} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.FullTextSearchFunction ?? '')}('${u}'))`;
1799
1903
  }
1800
1904
  else {
1905
+ // 🚨 SECURITY (#4392): the search term is free text, and on every predicate this
1906
+ // method builds itself it is CONFINED — wrapped in a string literal with single
1907
+ // quotes doubled — so no keyword it contains can reach the parser and the SQL
1908
+ // fragment denylist is neither needed nor appropriate.
1909
+ //
1910
+ // `UserSearchParamFormatAPI` is the ONE exception. That format is admin-authored and
1911
+ // may splice `{0}` in UNQUOTED: ` = {0}` on a numeric field is a documented, tested
1912
+ // case (see createViewUserSearchSQL.test.ts). There the term IS SQL, with no quote
1913
+ // keeping it contained, so the fragment denylist still has to apply. Screen only when
1914
+ // such a field actually participates — ordinary entities keep accepting the ordinary
1915
+ // searches that #4392 was about.
1916
+ //
1917
+ // Note this restores the pre-#4392 screen for this path; it does not close the
1918
+ // unquoted-format hole, which the denylist never covered (`1) OR 1=1` carries no
1919
+ // forbidden keyword). Quoting `{0}` in the format is what actually closes that.
1920
+ if (this.userSearchFieldsUseCustomFormat(entityInfo, deniedSearchFields) && !this.ValidateUserProvidedSQLClause(userSearchString)) {
1921
+ throw new Error(`Invalid User Search string: this entity has a field using UserSearchParamFormatAPI, ` +
1922
+ `which splices the term directly into SQL, and the term contains forbidden keywords.`);
1923
+ }
1801
1924
  const escapedTerm = this.escapeLikeTerm(safeUserSearchString);
1802
1925
  for (const field of entityInfo.Fields) {
1803
1926
  if (!field.IncludeInUserSearchAPI)
1804
1927
  continue;
1928
+ if (deniedSearchFields.has(field.Name.trim().toLowerCase()))
1929
+ continue; // field security: not searchable
1805
1930
  const sParam = this.buildPerFieldSearchPredicate(field, escapedTerm, safeUserSearchString);
1806
1931
  if (!sParam)
1807
1932
  continue;
@@ -1835,6 +1960,23 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1835
1960
  const columns = entityInfo.PrimaryKeys.map(pk => pk.Name).join(', ');
1836
1961
  throw new Error(`${feature} requires a single-column primary key. Entity "${entityInfo.Name}" has ${entityInfo.PrimaryKeys.length} primary key columns (${columns}).`);
1837
1962
  }
1963
+ /**
1964
+ * True when a field that will ACTUALLY PARTICIPATE in this search carries a
1965
+ * `UserSearchParamFormatAPI`. Such a format is admin-authored and may place `{0}` outside
1966
+ * quotes, so for those entities the search term is not guaranteed to land inside a literal
1967
+ * and still needs the SQL-fragment denylist (#4392).
1968
+ *
1969
+ * `deniedFields` must be the same field-level-security exclusion set the predicate loop
1970
+ * applies. A field the caller may not read is skipped there, so its format never reaches the
1971
+ * SQL — screening the term on its behalf would refuse ordinary searches ("Union Pacific") for
1972
+ * exactly the users with the LEAST access. Participation, not mere configuration, is what
1973
+ * makes the denylist necessary.
1974
+ */
1975
+ userSearchFieldsUseCustomFormat(entityInfo, deniedFields) {
1976
+ return entityInfo.Fields.some(f => f.IncludeInUserSearchAPI &&
1977
+ !!f.UserSearchParamFormatAPI && f.UserSearchParamFormatAPI.length > 0 &&
1978
+ !deniedFields?.has(f.Name.trim().toLowerCase()));
1979
+ }
1838
1980
  /**
1839
1981
  * Build the SQL fragment that compares one EntityField against a user search term.
1840
1982
  *
@@ -2021,6 +2163,14 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2021
2163
  // served cached rows unprojected — one client's shape poisoned the slot
2022
2164
  // for every subsequent caller.
2023
2165
  const callerFieldsByIndex = new Map();
2166
+ // Field-security predicate rejections, per item. This operation is directly
2167
+ // client-invokable over GraphQL and its legs call InternalRunView / the cache
2168
+ // directly — the AssertPredicatesRespectFieldSecurity gate that PreRunView/
2169
+ // PreRunViews run is otherwise SKIPPED here, leaving ExtraFilter/OrderBy/
2170
+ // Aggregates over denied fields as a live reconstruction channel on this path.
2171
+ // Rejection is per-item (the response shape has per-item error status), with
2172
+ // the same deliberately ambiguous message as every other rejection path.
2173
+ const gateRejectedByIndex = new Map();
2024
2174
  for (let i = 0; i < params.length; i++) {
2025
2175
  // Shallow-clone: widening must never leak into the caller's objects
2026
2176
  params[i] = { ...params[i], params: { ...params[i].params } };
@@ -2039,12 +2189,23 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2039
2189
  this.ResolvePlatformSQLInParams(params[i].params);
2040
2190
  params[i].params = await this.RunPreRunViewHooks(params[i].params, user);
2041
2191
  const p = params[i].params;
2192
+ // Gate AFTER hooks (injected filters are scanned too), same as PreRunViews.
2193
+ try {
2194
+ this.AssertPredicatesRespectFieldSecurity(p, user);
2195
+ }
2196
+ catch (gateError) {
2197
+ gateRejectedByIndex.set(i, gateError instanceof Error ? gateError.message : String(gateError));
2198
+ continue;
2199
+ }
2042
2200
  const widenEntity = p.EntityName ? this.EntityByName(p.EntityName) : null;
2043
2201
  if (widenEntity && this.runViewCacheEligible(p)) {
2044
2202
  const requested = p.Fields && p.Fields.length > 0
2045
2203
  ? p.Fields.map(f => f.trim().toLowerCase())
2046
2204
  : null;
2047
- p.Fields = widenEntity.Fields.map(f => f.Name);
2205
+ // ALL fields, for every user: server slots are full-width and shared, and
2206
+ // field security narrows per request at read time via
2207
+ // ApplyFieldSecurityProjection rather than at fetch time.
2208
+ p.Fields = this.ComputeRunViewFetchFields(widenEntity);
2048
2209
  if (requested) {
2049
2210
  callerFieldsByIndex.set(i, ProviderBase.UnionFieldsWithPrimaryKeys(requested, widenEntity));
2050
2211
  }
@@ -2063,6 +2224,13 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2063
2224
  const errorResults = [];
2064
2225
  for (let i = 0; i < params.length; i++) {
2065
2226
  const item = params[i];
2227
+ // Field-security gate rejections short-circuit every leg for this item —
2228
+ // no cache consult, no currency check, no execution.
2229
+ const gateRejection = gateRejectedByIndex.get(i);
2230
+ if (gateRejection !== undefined) {
2231
+ errorResults.push({ viewIndex: i, status: 'error', errorMessage: gateRejection });
2232
+ continue;
2233
+ }
2066
2234
  // Keyset queries bypass the cache entirely (per the AfterKey API contract):
2067
2235
  // each call uses a different seek key, so cached entries would never be
2068
2236
  // reusable. Route directly to standard execution path.
@@ -2163,7 +2331,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2163
2331
  for (const entry of itemsWithoutCacheCheck) {
2164
2332
  if (LocalCacheManager.Instance.IsInitialized) {
2165
2333
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(entry.item.params, contextUser);
2166
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(entry.item.params, this.InstanceConnectionString, rlsWhereClause);
2334
+ const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(entry.item.params);
2335
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(entry.item.params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
2167
2336
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
2168
2337
  if (cached) {
2169
2338
  const entityLabel = entry.item.params.EntityName || 'unknown';
@@ -2205,6 +2374,41 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2205
2374
  // cache, which was masked when an earlier 'stale'/'differential'
2206
2375
  // response populated it. Uses params[viewIndex] — the same hooked
2207
2376
  // params object the Pre hooks produced.
2377
+ // ── Field-level security projection (OUTPUT boundary of this path) ──
2378
+ // Every row-bearing leg above (serve-from-cache, full query, differential)
2379
+ // returns rows WITHOUT traversing PostRunView, so the projections that guard
2380
+ // the standard pipeline never run here — and unrestricted users' slots and
2381
+ // full-width DB reads can carry denied columns. Apply them per item, for BOTH
2382
+ // full results and differential updatedRows, before the Post hooks run.
2383
+ //
2384
+ // BOTH projections, in this order — they are siblings, not alternatives, and
2385
+ // `PostRunView` calls them as a pair at all four of its projection points. Applying
2386
+ // only the first leaves the audit trail leaking: `ApplyFieldSecurityProjection`
2387
+ // short-circuits on the RunView entity's own `EnableFieldLevelSecurity`, and
2388
+ // `MJ: Record Changes` has that flag OFF by design, so it is a no-op on exactly the
2389
+ // rows that matter. The denied values there are INSIDE the `ChangesJSON` /
2390
+ // `FullRecordJSON` payload columns, which no amount of column stripping reaches —
2391
+ // `ApplyRecordChangeFieldSecurityProjection` is what projects them against the entity
2392
+ // each row is about. Reachable from a browser: this transport is selected when
2393
+ // `params.some(p => p.CacheLocal)` (providerBase.ts), so a Record Changes view
2394
+ // batched alongside any cache-local view rides onto it.
2395
+ for (const item of allResults) {
2396
+ const flsBearing = item;
2397
+ const flsParams = params[flsBearing.viewIndex]?.params;
2398
+ if (!flsParams) {
2399
+ continue;
2400
+ }
2401
+ if (Array.isArray(flsBearing.results)) {
2402
+ flsBearing.results = this.ApplyFieldSecurityProjection(flsBearing.results, flsParams, user);
2403
+ flsBearing.results = this.ApplyRecordChangeFieldSecurityProjection(flsBearing.results, flsParams, user);
2404
+ }
2405
+ if (Array.isArray(flsBearing.differentialData?.updatedRows)) {
2406
+ flsBearing.differentialData.updatedRows =
2407
+ this.ApplyFieldSecurityProjection(flsBearing.differentialData.updatedRows, flsParams, user);
2408
+ flsBearing.differentialData.updatedRows =
2409
+ this.ApplyRecordChangeFieldSecurityProjection(flsBearing.differentialData.updatedRows, flsParams, user);
2410
+ }
2411
+ }
2208
2412
  for (const item of allResults) {
2209
2413
  const rowBearing = item;
2210
2414
  if (!Array.isArray(rowBearing.results)) {
@@ -2251,10 +2455,10 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2251
2455
  whereSQL = `(${extraFilter})`;
2252
2456
  bHasWhere = true;
2253
2457
  }
2458
+ // Free text, not a SQL fragment — deliberately NOT run through
2459
+ // ValidateUserProvidedSQLClause. See the equivalent note on the view path above (#4392).
2254
2460
  if (params.UserSearchString && params.UserSearchString.length > 0) {
2255
- if (!this.ValidateUserProvidedSQLClause(params.UserSearchString))
2256
- throw new Error(`Invalid User Search SQL clause: ${params.UserSearchString}`);
2257
- const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, params.UserSearchString);
2461
+ const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, params.UserSearchString, user);
2258
2462
  if (sUserSearchSQL.length > 0) {
2259
2463
  whereSQL = bHasWhere ? `${whereSQL} AND (${sUserSearchSQL})` : `(${sUserSearchSQL})`;
2260
2464
  bHasWhere = true;
@@ -2339,7 +2543,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2339
2543
  const ttlMs = await this.resolveExternalCacheTTLMs(params, contextUser);
2340
2544
  if (ttlMs !== 0) {
2341
2545
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params, contextUser);
2342
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
2546
+ const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(params);
2547
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
2343
2548
  const maxUpdatedAt = result.maxUpdatedAt || new Date().toISOString();
2344
2549
  // Pass the aggregates (B38-family omission #4). This slot is ALSO written by
2345
2550
  // InternalRunView's normal PostRunView path WITH aggregates — two writers,
@@ -2485,7 +2690,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2485
2690
  if (!LocalCacheManager.Instance.IsInitialized)
2486
2691
  return null;
2487
2692
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(item.params, contextUser);
2488
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(item.params, this.InstanceConnectionString, rlsWhereClause);
2693
+ const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(item.params);
2694
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(item.params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
2489
2695
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
2490
2696
  if (!cached)
2491
2697
  return null;
@@ -3850,7 +4056,17 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3850
4056
  fullWhere = `${where} AND (${rlsWhereClause})`;
3851
4057
  }
3852
4058
  }
3853
- const sql = `SELECT * FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${fullWhere}`;
4059
+ // Field security: a user with denied columns gets an explicit allowed-column list
4060
+ // instead of `SELECT *`, so denied values never leave the database — not even into
4061
+ // server memory. This is the single-record counterpart of the SELECT-list filtering
4062
+ // RunView already does, and it is what stops an agent running under a restricted
4063
+ // service account from holding values its user may not read. The columns that ARE
4064
+ // omitted come back as absent keys, which marks them not-loaded on the entity, so the
4065
+ // next save skips them and the stored values survive.
4066
+ //
4067
+ // Unrestricted users keep the literal `SELECT *` — byte-identical SQL, no plan churn.
4068
+ const selectList = this.buildFieldSecuritySelectList(entityInfo, user);
4069
+ const sql = `SELECT ${selectList} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${fullWhere}`;
3854
4070
  const rawData = await this.ExecuteSQL(sql, undefined, undefined, user);
3855
4071
  const d = await this.PostProcessRows(rawData, entityInfo, user);
3856
4072
  if (d && d.length > 0) {