@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.
- package/dist/DatabaseWellKnownUserSource.d.ts +48 -0
- package/dist/DatabaseWellKnownUserSource.d.ts.map +1 -0
- package/dist/DatabaseWellKnownUserSource.js +101 -0
- package/dist/DatabaseWellKnownUserSource.js.map +1 -0
- package/dist/GenericDatabaseProvider.d.ts +45 -3
- package/dist/GenericDatabaseProvider.d.ts.map +1 -1
- package/dist/GenericDatabaseProvider.js +251 -21
- package/dist/GenericDatabaseProvider.js.map +1 -1
- package/dist/SystemUserFieldAccessCheck.d.ts +64 -0
- package/dist/SystemUserFieldAccessCheck.d.ts.map +1 -0
- package/dist/SystemUserFieldAccessCheck.js +137 -0
- package/dist/SystemUserFieldAccessCheck.js.map +1 -0
- package/dist/UserCache.d.ts.map +1 -1
- package/dist/UserCache.js +2 -2
- package/dist/UserCache.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/queryPagingEngine.d.ts +19 -0
- package/dist/queryPagingEngine.d.ts.map +1 -1
- package/dist/queryPagingEngine.js +45 -0
- package/dist/queryPagingEngine.js.map +1 -1
- package/dist/systemUser.d.ts +27 -0
- package/dist/systemUser.d.ts.map +1 -0
- package/dist/systemUser.js +29 -0
- package/dist/systemUser.js.map +1 -0
- package/dist/systemUserFieldAccess.d.ts +114 -0
- package/dist/systemUserFieldAccess.d.ts.map +1 -0
- package/dist/systemUserFieldAccess.js +198 -0
- package/dist/systemUserFieldAccess.js.map +1 -0
- package/package.json +14 -14
|
@@ -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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|