@memberjunction/generic-database-provider 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.
@@ -75,6 +75,13 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
75
75
  this._savepointStack = [];
76
76
  /** Physical handle is gone but outer frames still must settle. Queued nested begins must not become outermost. */
77
77
  this._doomed = false;
78
+ /**
79
+ * Set when an outermost commit failed and the physical handle was already abandoned (rolled
80
+ * back) on the way out. A caller's own rollback in its catch block then finds no transaction —
81
+ * which is the CORRECT state, not a second failure — so that rollback is a no-op instead of
82
+ * throwing 'No active transaction to rollback' on top of the real error (#4447).
83
+ */
84
+ this._abandonedByFailedCommit = false;
78
85
  this._txMutex = Promise.resolve();
79
86
  }
80
87
  /**
@@ -936,6 +943,21 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
936
943
  // entity.Get(), which would assert active status (deprecation warning / disabled throw)
937
944
  // for what is NOT user use of the field. (EntityField.Value itself never asserts.)
938
945
  const theField = entity.GetFieldByName(f.Name);
946
+ // Not-loaded fields (the hydration source omitted them — e.g. field-security
947
+ // stripping) are OMITTED from the SP call entirely: every generated param has a
948
+ // default, the update procs' ISNULL(@p, [Col]) merge preserves the stored value,
949
+ // and the create procs substitute the column default. Skipping here also
950
+ // suppresses the _Clear companion, which RenderSaveCallBinding derives from the
951
+ // fieldValueMap this loop builds — so a not-loaded nullable field can never be
952
+ // wiped to NULL by its own construction state.
953
+ if (theField?.NotLoaded)
954
+ continue;
955
+ // Field security on INSERT: a field this user may not create is omitted so the
956
+ // column takes its database default. Distinct from NotLoaded — the value here is
957
+ // real, it is simply not one this user is permitted to supply — which is why the
958
+ // flag is separate and why validation still ran against it normally.
959
+ if (!isUpdate && theField?.CreateSuppressed)
960
+ continue;
939
961
  const rawValue = theField?.Value;
940
962
  // PK-on-CREATE with no explicit value: omit so the SP default fires.
941
963
  const isPKOnCreate = !isUpdate && f.IsPrimaryKey && !f.AutoIncrement;
@@ -1395,7 +1417,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1395
1417
  maxRowsForQuery = entityInfo.UserViewMaxRows;
1396
1418
  }
1397
1419
  // ── Field selection ──
1398
- const fields = this.getRunTimeViewFieldString(params, viewEntity);
1420
+ const fields = this.getRunTimeViewFieldString(params, viewEntity, user);
1399
1421
  // ── Build SELECT and COUNT SQL ──
1400
1422
  // DataSource:'Materialized' routes the read to the entity's materialized wrapper view
1401
1423
  // (same shape, so RLS/paging/fields all apply identically); default stays the live base view.
@@ -1437,10 +1459,25 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1437
1459
  bHasWhere = true;
1438
1460
  }
1439
1461
  // 3. User search string
1462
+ //
1463
+ // 🚨 NOT screened by ValidateUserProvidedSQLClause (#4392). That denylist exists for
1464
+ // caller-supplied SQL FRAGMENTS (ExtraFilter / OrderBy / OverrideExcludeFilter, both
1465
+ // still screened above and below). UserSearchString is not a fragment — it is the free
1466
+ // text a person typed into a search box, and createViewUserSearchSQL never splices it
1467
+ // into SQL as one: it builds every predicate itself from IncludeInUserSearchAPI
1468
+ // metadata and lands the text only INSIDE a string literal, with single quotes doubled
1469
+ // and LIKE metacharacters escaped under an explicit ESCAPE.
1470
+ //
1471
+ // Screening it as SQL rejected ordinary searches. The denylist word-boundary-matches
1472
+ // keywords against the raw text, so "Union Pacific", "Update Request" and "drop
1473
+ // shipment" were all refused — and the grid surfaced a null error message, so the
1474
+ // search box simply appeared broken.
1475
+ //
1476
+ // Quote-doubling, not keyword matching, is the correct protection for a value landing
1477
+ // in a literal: it is what keeps the text inside the quotes, where no keyword it
1478
+ // contains can mean anything to the parser.
1440
1479
  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);
1480
+ const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, userSearchString, user);
1444
1481
  if (sUserSearchSQL.length > 0) {
1445
1482
  whereSQL = bHasWhere ? `${whereSQL} AND (${sUserSearchSQL})` : `(${sUserSearchSQL})`;
1446
1483
  bHasWhere = true;
@@ -1459,8 +1496,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1459
1496
  sExcludeSQL += ` UserViewID=${viewEntity?.ID})`;
1460
1497
  else {
1461
1498
  // SECURITY: excludeUserViewRunID is user-supplied (GraphQL input) and is
1462
- // interpolated directly into SQL here. Unlike ExtraFilter/UserSearchString/
1463
- // OverrideExcludeFilter (all passed through ValidateUserProvidedSQLClause),
1499
+ // interpolated directly into SQL here. Unlike ExtraFilter/OrderBy/
1500
+ // OverrideExcludeFilter (all passed through ValidateUserProvidedSQLClause
1501
+ // UserSearchString is free text and is escaped into a literal instead, #4392),
1464
1502
  // this value historically had NO validation — allowing SQL injection into the
1465
1503
  // view WHERE clause. It is only ever a UserViewRun.ID (a GUID), so reject
1466
1504
  // anything that is not a well-formed GUID before it reaches the query.
@@ -1697,12 +1735,36 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1697
1735
  /**************************************************************************/
1698
1736
  // InternalRunView Helpers
1699
1737
  /**************************************************************************/
1738
+ /**
1739
+ * Returns the SELECT list for a single-record load: `*` normally, or an explicit list of
1740
+ * the columns this user is allowed to read when field security denies them any.
1741
+ *
1742
+ * Deliberately NOT routed through RunView, which is the other way to get this behavior.
1743
+ * That reroute is the tidier long-term shape and is planned separately (it must pass
1744
+ * `BypassCache` — a PK load has never been cache-served and must not silently start being
1745
+ * — and it changes relationship loading, which stays on its current path). Filtering the
1746
+ * column list here buys the same protection without touching either.
1747
+ */
1748
+ buildFieldSecuritySelectList(entityInfo, user) {
1749
+ if (!user || !entityInfo.EnableFieldLevelSecurity) {
1750
+ return '*'; // the overwhelmingly common case — one boolean, unchanged SQL
1751
+ }
1752
+ const denied = entityInfo.GetDeniedReadFields(user);
1753
+ if (denied.size === 0) {
1754
+ return '*';
1755
+ }
1756
+ const allowed = entityInfo.Fields.filter(f => !denied.has(f.Name.trim().toLowerCase()));
1757
+ if (allowed.length === 0) {
1758
+ return '*'; // degenerate; PKs are unrestrictable so this should be unreachable
1759
+ }
1760
+ return allowed.map(f => this.QuoteIdentifier(f.Name)).join(', ');
1761
+ }
1700
1762
  /**
1701
1763
  * Builds the SQL field list string for a view query, using dialect-neutral quoting.
1702
1764
  * Returns '*' if no specific fields are resolved.
1703
1765
  */
1704
- getRunTimeViewFieldString(params, viewEntity) {
1705
- const fieldList = this.getRunTimeViewFieldArray(params, viewEntity);
1766
+ getRunTimeViewFieldString(params, viewEntity, contextUser) {
1767
+ const fieldList = this.getRunTimeViewFieldArray(params, viewEntity, contextUser);
1706
1768
  if (fieldList.length === 0)
1707
1769
  return '*';
1708
1770
  return fieldList
@@ -1715,8 +1777,19 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1715
1777
  /**
1716
1778
  * Resolves the list of EntityFieldInfo objects for a view query.
1717
1779
  * Priority: params.Fields > view columns > all entity fields (wildcard).
1780
+ *
1781
+ * Field-level security intersects every resolution path with the user's ALLOWED set, so a
1782
+ * denied column never appears in the SELECT list and its values never leave the database:
1783
+ * - explicit `params.Fields` and saved-view columns are silently narrowed (a denied entry
1784
+ * is dropped without the "Field not found" error — `Fields` describes output shape, not
1785
+ * a predicate, and the caller learns nothing projection didn't already show them);
1786
+ * - an empty field list, which otherwise emits `SELECT *`, becomes the explicit
1787
+ * allowed-column list;
1788
+ * - `entity_object` requests are EXEMPT — entities must hydrate from every column (see
1789
+ * ApplyFieldSecurityProjection) and their enforcement stays at the output boundary;
1790
+ * - PKs are unrestrictable and always survive (the existing force-add).
1718
1791
  */
1719
- getRunTimeViewFieldArray(params, viewEntity) {
1792
+ getRunTimeViewFieldArray(params, viewEntity, contextUser) {
1720
1793
  const fieldList = [];
1721
1794
  try {
1722
1795
  let entityInfo = null;
@@ -1728,12 +1801,18 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1728
1801
  if (!entityInfo)
1729
1802
  throw new Error(`Entity ${params.EntityName} not found in metadata`);
1730
1803
  }
1804
+ const flsUser = contextUser ?? this.CurrentUser;
1805
+ const denied = params.ResultType !== 'entity_object' && flsUser && entityInfo.EnableFieldLevelSecurity
1806
+ ? entityInfo.GetDeniedReadFields(flsUser)
1807
+ : new Set();
1731
1808
  if (params.Fields) {
1732
1809
  for (const ef of entityInfo.PrimaryKeys) {
1733
1810
  if (!params.Fields.find((f) => f.trim().toLowerCase() === ef.Name.toLowerCase()))
1734
1811
  fieldList.push(ef);
1735
1812
  }
1736
1813
  params.Fields.forEach((f) => {
1814
+ if (denied.has(f.trim().toLowerCase()))
1815
+ return; // silent narrowing — deliberately NOT the "not found" error
1737
1816
  const field = entityInfo.FieldByName(f); // O(1) index (was O(F) Fields.find → O(F²) over the loop)
1738
1817
  if (field)
1739
1818
  fieldList.push(field);
@@ -1745,6 +1824,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1745
1824
  viewEntity.Columns.forEach((c) => {
1746
1825
  if (!c.hidden) {
1747
1826
  if (c.EntityField) {
1827
+ if (denied.has(c.EntityField.Name.trim().toLowerCase()))
1828
+ return; // silent narrowing
1748
1829
  fieldList.push(c.EntityField);
1749
1830
  }
1750
1831
  else {
@@ -1757,6 +1838,15 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1757
1838
  fieldList.push(ef);
1758
1839
  }
1759
1840
  }
1841
+ else if (denied.size > 0) {
1842
+ // No explicit fields and no saved view would emit `SELECT *` — for a restricted
1843
+ // user that pulls denied columns out of the database, so emit the explicit
1844
+ // allowed-column list instead (PKs are unrestrictable and always included).
1845
+ for (const ef of entityInfo.Fields) {
1846
+ if (!denied.has(ef.Name.trim().toLowerCase()))
1847
+ fieldList.push(ef);
1848
+ }
1849
+ }
1760
1850
  }
1761
1851
  catch (e) {
1762
1852
  LogError(e);
@@ -1774,16 +1864,37 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1774
1864
  * - skips fields that are not sensible text-search targets (non-text types,
1775
1865
  * unbounded text columns when FTX is off).
1776
1866
  */
1777
- createViewUserSearchSQL(entityInfo, userSearchString) {
1867
+ createViewUserSearchSQL(entityInfo, userSearchString, contextUser) {
1778
1868
  let sUserSearchSQL = '';
1779
1869
  const safeUserSearchString = userSearchString.replace(/'/g, "''");
1780
- if (entityInfo.FullTextSearchEnabled) {
1870
+ // Field-level security: never search fields the user cannot read. Matching against a
1871
+ // denied column is a value oracle — "search for 250000 → the row comes back" probes the
1872
+ // secret one term at a time. UserSearchString is not rejected (the term is not a
1873
+ // caller-authored predicate; see AssertPredicatesRespectFieldSecurity) — the denied
1874
+ // fields are simply excluded from the searched set. Computed once per call, never per
1875
+ // field (the per-request precompute contract).
1876
+ const user = contextUser ?? this.CurrentUser;
1877
+ const deniedSearchFields = entityInfo.EnableFieldLevelSecurity && user
1878
+ ? entityInfo.GetDeniedReadFields(user)
1879
+ : new Set();
1880
+ // The full-text index spans its indexed columns as one unit — a denied FTS-indexed
1881
+ // column cannot be excluded from the index function. When (and only when) a denied
1882
+ // field is FTS-indexed, fall back to the per-field LIKE path, which CAN exclude it.
1883
+ const ftsCoversDeniedField = deniedSearchFields.size > 0 &&
1884
+ entityInfo.Fields.some(f => f.FullTextSearchEnabled && deniedSearchFields.has(f.Name.trim().toLowerCase()));
1885
+ if (entityInfo.FullTextSearchEnabled && !ftsCoversDeniedField) {
1781
1886
  let u = safeUserSearchString;
1782
1887
  const uUpper = u.toUpperCase();
1783
- if (uUpper.includes(' AND ') || uUpper.includes(' OR ') || uUpper.includes(' NOT ')) {
1888
+ // WORD-boundary tests, not substring tests (#4392). As substrings, `OR` matches
1889
+ // "C-OR-PORATE" and `AND` matches "ST-AND-ARD", so ordinary two-word searches —
1890
+ // "Corporate Office", "Standard Rate", "North America" — fell into the branch below
1891
+ // and were emitted as `Corporate%Office`. `%` is not a full-text operator, so those
1892
+ // searches produced a syntax error instead of results. Only a STANDALONE AND/OR/NOT
1893
+ // is a boolean operator the caller meant.
1894
+ if (/ (AND|OR|NOT) /.test(uUpper)) {
1784
1895
  u = uUpper.replace(/ /g, '%').replace(/%AND%/g, ' AND ').replace(/%OR%/g, ' OR ').replace(/%NOT%/g, ' NOT ');
1785
1896
  }
1786
- else if (uUpper.includes('AND') || uUpper.includes('OR') || uUpper.includes('NOT')) {
1897
+ else if (/\b(AND|OR|NOT)\b/.test(uUpper)) {
1787
1898
  u = u.replace(/ /g, '%');
1788
1899
  }
1789
1900
  else if (u.includes(' ')) {
@@ -1798,10 +1909,31 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1798
1909
  sUserSearchSQL = `${pkName} IN (SELECT ${pkName} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.FullTextSearchFunction ?? '')}('${u}'))`;
1799
1910
  }
1800
1911
  else {
1912
+ // 🚨 SECURITY (#4392): the search term is free text, and on every predicate this
1913
+ // method builds itself it is CONFINED — wrapped in a string literal with single
1914
+ // quotes doubled — so no keyword it contains can reach the parser and the SQL
1915
+ // fragment denylist is neither needed nor appropriate.
1916
+ //
1917
+ // `UserSearchParamFormatAPI` is the ONE exception. That format is admin-authored and
1918
+ // may splice `{0}` in UNQUOTED: ` = {0}` on a numeric field is a documented, tested
1919
+ // case (see createViewUserSearchSQL.test.ts). There the term IS SQL, with no quote
1920
+ // keeping it contained, so the fragment denylist still has to apply. Screen only when
1921
+ // such a field actually participates — ordinary entities keep accepting the ordinary
1922
+ // searches that #4392 was about.
1923
+ //
1924
+ // Note this restores the pre-#4392 screen for this path; it does not close the
1925
+ // unquoted-format hole, which the denylist never covered (`1) OR 1=1` carries no
1926
+ // forbidden keyword). Quoting `{0}` in the format is what actually closes that.
1927
+ if (this.userSearchFieldsUseCustomFormat(entityInfo, deniedSearchFields) && !this.ValidateUserProvidedSQLClause(userSearchString)) {
1928
+ throw new Error(`Invalid User Search string: this entity has a field using UserSearchParamFormatAPI, ` +
1929
+ `which splices the term directly into SQL, and the term contains forbidden keywords.`);
1930
+ }
1801
1931
  const escapedTerm = this.escapeLikeTerm(safeUserSearchString);
1802
1932
  for (const field of entityInfo.Fields) {
1803
1933
  if (!field.IncludeInUserSearchAPI)
1804
1934
  continue;
1935
+ if (deniedSearchFields.has(field.Name.trim().toLowerCase()))
1936
+ continue; // field security: not searchable
1805
1937
  const sParam = this.buildPerFieldSearchPredicate(field, escapedTerm, safeUserSearchString);
1806
1938
  if (!sParam)
1807
1939
  continue;
@@ -1835,6 +1967,23 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
1835
1967
  const columns = entityInfo.PrimaryKeys.map(pk => pk.Name).join(', ');
1836
1968
  throw new Error(`${feature} requires a single-column primary key. Entity "${entityInfo.Name}" has ${entityInfo.PrimaryKeys.length} primary key columns (${columns}).`);
1837
1969
  }
1970
+ /**
1971
+ * True when a field that will ACTUALLY PARTICIPATE in this search carries a
1972
+ * `UserSearchParamFormatAPI`. Such a format is admin-authored and may place `{0}` outside
1973
+ * quotes, so for those entities the search term is not guaranteed to land inside a literal
1974
+ * and still needs the SQL-fragment denylist (#4392).
1975
+ *
1976
+ * `deniedFields` must be the same field-level-security exclusion set the predicate loop
1977
+ * applies. A field the caller may not read is skipped there, so its format never reaches the
1978
+ * SQL — screening the term on its behalf would refuse ordinary searches ("Union Pacific") for
1979
+ * exactly the users with the LEAST access. Participation, not mere configuration, is what
1980
+ * makes the denylist necessary.
1981
+ */
1982
+ userSearchFieldsUseCustomFormat(entityInfo, deniedFields) {
1983
+ return entityInfo.Fields.some(f => f.IncludeInUserSearchAPI &&
1984
+ !!f.UserSearchParamFormatAPI && f.UserSearchParamFormatAPI.length > 0 &&
1985
+ !deniedFields?.has(f.Name.trim().toLowerCase()));
1986
+ }
1838
1987
  /**
1839
1988
  * Build the SQL fragment that compares one EntityField against a user search term.
1840
1989
  *
@@ -2021,6 +2170,14 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2021
2170
  // served cached rows unprojected — one client's shape poisoned the slot
2022
2171
  // for every subsequent caller.
2023
2172
  const callerFieldsByIndex = new Map();
2173
+ // Field-security predicate rejections, per item. This operation is directly
2174
+ // client-invokable over GraphQL and its legs call InternalRunView / the cache
2175
+ // directly — the AssertPredicatesRespectFieldSecurity gate that PreRunView/
2176
+ // PreRunViews run is otherwise SKIPPED here, leaving ExtraFilter/OrderBy/
2177
+ // Aggregates over denied fields as a live reconstruction channel on this path.
2178
+ // Rejection is per-item (the response shape has per-item error status), with
2179
+ // the same deliberately ambiguous message as every other rejection path.
2180
+ const gateRejectedByIndex = new Map();
2024
2181
  for (let i = 0; i < params.length; i++) {
2025
2182
  // Shallow-clone: widening must never leak into the caller's objects
2026
2183
  params[i] = { ...params[i], params: { ...params[i].params } };
@@ -2039,12 +2196,23 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2039
2196
  this.ResolvePlatformSQLInParams(params[i].params);
2040
2197
  params[i].params = await this.RunPreRunViewHooks(params[i].params, user);
2041
2198
  const p = params[i].params;
2199
+ // Gate AFTER hooks (injected filters are scanned too), same as PreRunViews.
2200
+ try {
2201
+ this.AssertPredicatesRespectFieldSecurity(p, user);
2202
+ }
2203
+ catch (gateError) {
2204
+ gateRejectedByIndex.set(i, gateError instanceof Error ? gateError.message : String(gateError));
2205
+ continue;
2206
+ }
2042
2207
  const widenEntity = p.EntityName ? this.EntityByName(p.EntityName) : null;
2043
2208
  if (widenEntity && this.runViewCacheEligible(p)) {
2044
2209
  const requested = p.Fields && p.Fields.length > 0
2045
2210
  ? p.Fields.map(f => f.trim().toLowerCase())
2046
2211
  : null;
2047
- p.Fields = widenEntity.Fields.map(f => f.Name);
2212
+ // ALL fields, for every user: server slots are full-width and shared, and
2213
+ // field security narrows per request at read time via
2214
+ // ApplyFieldSecurityProjection rather than at fetch time.
2215
+ p.Fields = this.ComputeRunViewFetchFields(widenEntity);
2048
2216
  if (requested) {
2049
2217
  callerFieldsByIndex.set(i, ProviderBase.UnionFieldsWithPrimaryKeys(requested, widenEntity));
2050
2218
  }
@@ -2063,6 +2231,13 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2063
2231
  const errorResults = [];
2064
2232
  for (let i = 0; i < params.length; i++) {
2065
2233
  const item = params[i];
2234
+ // Field-security gate rejections short-circuit every leg for this item —
2235
+ // no cache consult, no currency check, no execution.
2236
+ const gateRejection = gateRejectedByIndex.get(i);
2237
+ if (gateRejection !== undefined) {
2238
+ errorResults.push({ viewIndex: i, status: 'error', errorMessage: gateRejection });
2239
+ continue;
2240
+ }
2066
2241
  // Keyset queries bypass the cache entirely (per the AfterKey API contract):
2067
2242
  // each call uses a different seek key, so cached entries would never be
2068
2243
  // reusable. Route directly to standard execution path.
@@ -2163,7 +2338,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2163
2338
  for (const entry of itemsWithoutCacheCheck) {
2164
2339
  if (LocalCacheManager.Instance.IsInitialized) {
2165
2340
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(entry.item.params, contextUser);
2166
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(entry.item.params, this.InstanceConnectionString, rlsWhereClause);
2341
+ const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(entry.item.params);
2342
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(entry.item.params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
2167
2343
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
2168
2344
  if (cached) {
2169
2345
  const entityLabel = entry.item.params.EntityName || 'unknown';
@@ -2205,6 +2381,41 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2205
2381
  // cache, which was masked when an earlier 'stale'/'differential'
2206
2382
  // response populated it. Uses params[viewIndex] — the same hooked
2207
2383
  // params object the Pre hooks produced.
2384
+ // ── Field-level security projection (OUTPUT boundary of this path) ──
2385
+ // Every row-bearing leg above (serve-from-cache, full query, differential)
2386
+ // returns rows WITHOUT traversing PostRunView, so the projections that guard
2387
+ // the standard pipeline never run here — and unrestricted users' slots and
2388
+ // full-width DB reads can carry denied columns. Apply them per item, for BOTH
2389
+ // full results and differential updatedRows, before the Post hooks run.
2390
+ //
2391
+ // BOTH projections, in this order — they are siblings, not alternatives, and
2392
+ // `PostRunView` calls them as a pair at all four of its projection points. Applying
2393
+ // only the first leaves the audit trail leaking: `ApplyFieldSecurityProjection`
2394
+ // short-circuits on the RunView entity's own `EnableFieldLevelSecurity`, and
2395
+ // `MJ: Record Changes` has that flag OFF by design, so it is a no-op on exactly the
2396
+ // rows that matter. The denied values there are INSIDE the `ChangesJSON` /
2397
+ // `FullRecordJSON` payload columns, which no amount of column stripping reaches —
2398
+ // `ApplyRecordChangeFieldSecurityProjection` is what projects them against the entity
2399
+ // each row is about. Reachable from a browser: this transport is selected when
2400
+ // `params.some(p => p.CacheLocal)` (providerBase.ts), so a Record Changes view
2401
+ // batched alongside any cache-local view rides onto it.
2402
+ for (const item of allResults) {
2403
+ const flsBearing = item;
2404
+ const flsParams = params[flsBearing.viewIndex]?.params;
2405
+ if (!flsParams) {
2406
+ continue;
2407
+ }
2408
+ if (Array.isArray(flsBearing.results)) {
2409
+ flsBearing.results = this.ApplyFieldSecurityProjection(flsBearing.results, flsParams, user);
2410
+ flsBearing.results = this.ApplyRecordChangeFieldSecurityProjection(flsBearing.results, flsParams, user);
2411
+ }
2412
+ if (Array.isArray(flsBearing.differentialData?.updatedRows)) {
2413
+ flsBearing.differentialData.updatedRows =
2414
+ this.ApplyFieldSecurityProjection(flsBearing.differentialData.updatedRows, flsParams, user);
2415
+ flsBearing.differentialData.updatedRows =
2416
+ this.ApplyRecordChangeFieldSecurityProjection(flsBearing.differentialData.updatedRows, flsParams, user);
2417
+ }
2418
+ }
2208
2419
  for (const item of allResults) {
2209
2420
  const rowBearing = item;
2210
2421
  if (!Array.isArray(rowBearing.results)) {
@@ -2251,10 +2462,10 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2251
2462
  whereSQL = `(${extraFilter})`;
2252
2463
  bHasWhere = true;
2253
2464
  }
2465
+ // Free text, not a SQL fragment — deliberately NOT run through
2466
+ // ValidateUserProvidedSQLClause. See the equivalent note on the view path above (#4392).
2254
2467
  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);
2468
+ const sUserSearchSQL = this.createViewUserSearchSQL(entityInfo, params.UserSearchString, user);
2258
2469
  if (sUserSearchSQL.length > 0) {
2259
2470
  whereSQL = bHasWhere ? `${whereSQL} AND (${sUserSearchSQL})` : `(${sUserSearchSQL})`;
2260
2471
  bHasWhere = true;
@@ -2339,7 +2550,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2339
2550
  const ttlMs = await this.resolveExternalCacheTTLMs(params, contextUser);
2340
2551
  if (ttlMs !== 0) {
2341
2552
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params, contextUser);
2342
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
2553
+ const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(params);
2554
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
2343
2555
  const maxUpdatedAt = result.maxUpdatedAt || new Date().toISOString();
2344
2556
  // Pass the aggregates (B38-family omission #4). This slot is ALSO written by
2345
2557
  // InternalRunView's normal PostRunView path WITH aggregates — two writers,
@@ -2485,7 +2697,8 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
2485
2697
  if (!LocalCacheManager.Instance.IsInitialized)
2486
2698
  return null;
2487
2699
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(item.params, contextUser);
2488
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(item.params, this.InstanceConnectionString, rlsWhereClause);
2700
+ const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(item.params);
2701
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(item.params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
2489
2702
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
2490
2703
  if (!cached)
2491
2704
  return null;
@@ -3850,7 +4063,17 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
3850
4063
  fullWhere = `${where} AND (${rlsWhereClause})`;
3851
4064
  }
3852
4065
  }
3853
- const sql = `SELECT * FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${fullWhere}`;
4066
+ // Field security: a user with denied columns gets an explicit allowed-column list
4067
+ // instead of `SELECT *`, so denied values never leave the database — not even into
4068
+ // server memory. This is the single-record counterpart of the SELECT-list filtering
4069
+ // RunView already does, and it is what stops an agent running under a restricted
4070
+ // service account from holding values its user may not read. The columns that ARE
4071
+ // omitted come back as absent keys, which marks them not-loaded on the entity, so the
4072
+ // next save skips them and the stored values survive.
4073
+ //
4074
+ // Unrestricted users keep the literal `SELECT *` — byte-identical SQL, no plan churn.
4075
+ const selectList = this.buildFieldSecuritySelectList(entityInfo, user);
4076
+ const sql = `SELECT ${selectList} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)} WHERE ${fullWhere}`;
3854
4077
  const rawData = await this.ExecuteSQL(sql, undefined, undefined, user);
3855
4078
  const d = await this.PostProcessRows(rawData, entityInfo, user);
3856
4079
  if (d && d.length > 0) {
@@ -4731,6 +4954,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
4731
4954
  this._transactionDepth++;
4732
4955
  try {
4733
4956
  if (this._transactionDepth === 1) {
4957
+ this._abandonedByFailedCommit = false;
4734
4958
  await this.BeginPhysicalTransaction();
4735
4959
  return;
4736
4960
  }
@@ -4827,6 +5051,7 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
4827
5051
  catch (e) {
4828
5052
  await this.AbandonPhysicalTransaction();
4829
5053
  this.clearTransactionState();
5054
+ this._abandonedByFailedCommit = true;
4830
5055
  LogError(e);
4831
5056
  throw e;
4832
5057
  }
@@ -4860,6 +5085,11 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
4860
5085
  this.popDoomedFrame();
4861
5086
  return;
4862
5087
  }
5088
+ if (this._abandonedByFailedCommit) {
5089
+ // The failed commit already rolled the doomed handle back; there is nothing left to undo.
5090
+ this._abandonedByFailedCommit = false;
5091
+ return;
5092
+ }
4863
5093
  if (!this.HasPhysicalTransaction) {
4864
5094
  throw new Error('No active transaction to rollback');
4865
5095
  }