@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.
- 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 +38 -3
- package/dist/GenericDatabaseProvider.d.ts.map +1 -1
- package/dist/GenericDatabaseProvider.js +237 -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
|
@@ -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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|