@memberjunction/core 6.2.0-edge.5 → 6.2.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.
Files changed (148) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +15 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +21 -0
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/EntityFieldRules.d.ts.map +1 -1
  6. package/dist/generic/EntityFieldRules.js +1 -0
  7. package/dist/generic/EntityFieldRules.js.map +1 -1
  8. package/dist/generic/alternateBaseViews.d.ts +119 -0
  9. package/dist/generic/alternateBaseViews.d.ts.map +1 -0
  10. package/dist/generic/alternateBaseViews.js +223 -0
  11. package/dist/generic/alternateBaseViews.js.map +1 -0
  12. package/dist/generic/baseEngine.d.ts +171 -17
  13. package/dist/generic/baseEngine.d.ts.map +1 -1
  14. package/dist/generic/baseEngine.js +722 -117
  15. package/dist/generic/baseEngine.js.map +1 -1
  16. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  17. package/dist/generic/baseEngineRegistry.js +2 -1
  18. package/dist/generic/baseEngineRegistry.js.map +1 -1
  19. package/dist/generic/baseEntity.d.ts +216 -21
  20. package/dist/generic/baseEntity.d.ts.map +1 -1
  21. package/dist/generic/baseEntity.js +969 -100
  22. package/dist/generic/baseEntity.js.map +1 -1
  23. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  24. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  25. package/dist/generic/baseRemotableOperation.js +112 -8
  26. package/dist/generic/baseRemotableOperation.js.map +1 -1
  27. package/dist/generic/contextTokens.d.ts +136 -0
  28. package/dist/generic/contextTokens.d.ts.map +1 -0
  29. package/dist/generic/contextTokens.js +319 -0
  30. package/dist/generic/contextTokens.js.map +1 -0
  31. package/dist/generic/databaseProviderBase.d.ts +103 -5
  32. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  33. package/dist/generic/databaseProviderBase.js +381 -52
  34. package/dist/generic/databaseProviderBase.js.map +1 -1
  35. package/dist/generic/embeddedRecord.d.ts +6 -0
  36. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  37. package/dist/generic/embeddedRecord.js +15 -1
  38. package/dist/generic/embeddedRecord.js.map +1 -1
  39. package/dist/generic/entityCompanion.d.ts +7 -0
  40. package/dist/generic/entityCompanion.d.ts.map +1 -1
  41. package/dist/generic/entityCompanion.js +8 -0
  42. package/dist/generic/entityCompanion.js.map +1 -1
  43. package/dist/generic/entityInfo.d.ts +234 -9
  44. package/dist/generic/entityInfo.d.ts.map +1 -1
  45. package/dist/generic/entityInfo.js +592 -77
  46. package/dist/generic/entityInfo.js.map +1 -1
  47. package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
  48. package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
  49. package/dist/generic/entityValueFieldSecurity.js +250 -0
  50. package/dist/generic/entityValueFieldSecurity.js.map +1 -0
  51. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  52. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  53. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  54. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  55. package/dist/generic/fieldSecuritySQLGate.d.ts +359 -0
  56. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
  57. package/dist/generic/fieldSecuritySQLGate.js +902 -0
  58. package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
  59. package/dist/generic/indexRestrictedFields.d.ts +47 -0
  60. package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
  61. package/dist/generic/indexRestrictedFields.js +129 -0
  62. package/dist/generic/indexRestrictedFields.js.map +1 -0
  63. package/dist/generic/interfaces.d.ts +64 -2
  64. package/dist/generic/interfaces.d.ts.map +1 -1
  65. package/dist/generic/interfaces.js.map +1 -1
  66. package/dist/generic/listMembershipFilter.d.ts +5 -0
  67. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  68. package/dist/generic/listMembershipFilter.js +23 -0
  69. package/dist/generic/listMembershipFilter.js.map +1 -0
  70. package/dist/generic/localCacheManager.d.ts +126 -14
  71. package/dist/generic/localCacheManager.d.ts.map +1 -1
  72. package/dist/generic/localCacheManager.js +348 -48
  73. package/dist/generic/localCacheManager.js.map +1 -1
  74. package/dist/generic/logging.d.ts.map +1 -1
  75. package/dist/generic/logging.js +7 -1
  76. package/dist/generic/logging.js.map +1 -1
  77. package/dist/generic/metadata.d.ts +3 -2
  78. package/dist/generic/metadata.d.ts.map +1 -1
  79. package/dist/generic/metadata.js +5 -2
  80. package/dist/generic/metadata.js.map +1 -1
  81. package/dist/generic/platformSQL.d.ts +54 -0
  82. package/dist/generic/platformSQL.d.ts.map +1 -1
  83. package/dist/generic/platformSQL.js +55 -0
  84. package/dist/generic/platformSQL.js.map +1 -1
  85. package/dist/generic/providerBase.d.ts +408 -57
  86. package/dist/generic/providerBase.d.ts.map +1 -1
  87. package/dist/generic/providerBase.js +1051 -310
  88. package/dist/generic/providerBase.js.map +1 -1
  89. package/dist/generic/queryInfo.d.ts +13 -0
  90. package/dist/generic/queryInfo.d.ts.map +1 -1
  91. package/dist/generic/queryInfo.js +13 -0
  92. package/dist/generic/queryInfo.js.map +1 -1
  93. package/dist/generic/queryInfoInterfaces.d.ts +8 -0
  94. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  95. package/dist/generic/queryTemplateContext.d.ts +49 -0
  96. package/dist/generic/queryTemplateContext.d.ts.map +1 -0
  97. package/dist/generic/queryTemplateContext.js +113 -0
  98. package/dist/generic/queryTemplateContext.js.map +1 -0
  99. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
  100. package/dist/generic/recordChangeFieldSecurity.js +13 -2
  101. package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
  102. package/dist/generic/relatedRecordCollection.d.ts +18 -0
  103. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  104. package/dist/generic/relatedRecordCollection.js +55 -7
  105. package/dist/generic/relatedRecordCollection.js.map +1 -1
  106. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  107. package/dist/generic/remoteOperationDispatch.js +14 -2
  108. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  109. package/dist/generic/runQuery.d.ts +42 -0
  110. package/dist/generic/runQuery.d.ts.map +1 -1
  111. package/dist/generic/runQuery.js +14 -0
  112. package/dist/generic/runQuery.js.map +1 -1
  113. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  114. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  115. package/dist/generic/runQuerySQLFilterImplementations.js +119 -29
  116. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  117. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  118. package/dist/generic/saveEntityGraphOperation.js +3 -0
  119. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  120. package/dist/generic/securityInfo.d.ts +11 -0
  121. package/dist/generic/securityInfo.d.ts.map +1 -1
  122. package/dist/generic/securityInfo.js +15 -0
  123. package/dist/generic/securityInfo.js.map +1 -1
  124. package/dist/generic/telemetryManager.d.ts +9 -0
  125. package/dist/generic/telemetryManager.d.ts.map +1 -1
  126. package/dist/generic/telemetryManager.js +5 -1
  127. package/dist/generic/telemetryManager.js.map +1 -1
  128. package/dist/generic/transportError.d.ts +41 -0
  129. package/dist/generic/transportError.d.ts.map +1 -0
  130. package/dist/generic/transportError.js +167 -0
  131. package/dist/generic/transportError.js.map +1 -0
  132. package/dist/generic/util.d.ts +12 -0
  133. package/dist/generic/util.d.ts.map +1 -1
  134. package/dist/generic/util.js +52 -3
  135. package/dist/generic/util.js.map +1 -1
  136. package/dist/index.d.ts +9 -0
  137. package/dist/index.d.ts.map +1 -1
  138. package/dist/index.js +9 -0
  139. package/dist/index.js.map +1 -1
  140. package/dist/views/runView.d.ts +102 -0
  141. package/dist/views/runView.d.ts.map +1 -1
  142. package/dist/views/runView.js +40 -0
  143. package/dist/views/runView.js.map +1 -1
  144. package/dist/views/viewProperties.d.ts +55 -0
  145. package/dist/views/viewProperties.d.ts.map +1 -0
  146. package/dist/views/viewProperties.js +258 -0
  147. package/dist/views/viewProperties.js.map +1 -0
  148. package/package.json +3 -3
@@ -0,0 +1,902 @@
1
+ import { NormalizeUUID, ScanSQLForScreening } from '@memberjunction/global';
2
+ import { AutoQuoteLeavesWordBare, GetDialect, IdentifierMatches } from '@memberjunction/sql-dialect';
3
+ import { CurrentFieldSecurityGeneration, FieldSecurityError, FieldSecurityUserRolesKey } from './entityInfo.js';
4
+ /** Functions whose `*` argument counts rows and reads no column. */
5
+ const ROW_COUNT_FUNCTIONS = new Set(['count', 'count_big']);
6
+ /** Words after which a relation name declares a relation (`FROM x`, `JOIN x`, `APPLY x`). */
7
+ const RELATION_KEYWORDS = new Set(['from', 'join', 'apply']);
8
+ /** Words that may stand between a relation keyword and the relation (`FROM ONLY x`, `JOIN LATERAL f(…)`). */
9
+ const RELATION_MODIFIERS = new Set(['only', 'lateral']);
10
+ /** Words in a relation position that are not a relation's name (`FROM (SELECT …)`, `FROM (VALUES …)`). */
11
+ const NOT_RELATION_NAMES = new Set(['select', 'values', 'with', 'table', 'only', 'lateral']);
12
+ /** Words that run a stored procedure, whose reads cannot be checked. */
13
+ const PROCEDURE_CALL_WORDS = new Set(['exec', 'execute', 'call']);
14
+ /**
15
+ * Words directly inside `EXISTS (…)` that make its `SELECT *` read column values: a set operation
16
+ * compares whole rows, and an ordinal ORDER BY with OFFSET/FETCH/LIMIT picks rows by a column.
17
+ */
18
+ const EXISTS_ROW_READING_WORDS = new Set(['union', 'intersect', 'except', 'order', 'offset', 'fetch', 'limit']);
19
+ /** Control, format and default-ignorable characters, which a database collation may give no weight to. */
20
+ const INVISIBLE_CHARACTER = /[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
21
+ /** Words that start a clause, so they never follow `*` as a multiplication operand. */
22
+ const CLAUSE_WORDS = new Set([
23
+ 'from', 'where', 'group', 'order', 'having', 'union', 'except', 'intersect', 'into', 'for', 'window',
24
+ 'limit', 'offset', 'fetch', 'as', 'option', 'returning', 'qualify',
25
+ ]);
26
+ /** Words that end in a position where `*` is a select list, not a multiplication. */
27
+ const SELECT_LIST_WORDS = new Set(['select', 'distinct', 'all', 'top', 'percent', 'ties', 'returning', 'by', 'as']);
28
+ /** Words that cannot be a relation alias, because the grammar reads them as the next clause. */
29
+ const ALIAS_STOP_WORDS = new Set([
30
+ 'where', 'join', 'inner', 'left', 'right', 'full', 'cross', 'outer', 'on', 'group', 'order', 'having', 'union',
31
+ 'except', 'intersect', 'limit', 'offset', 'fetch', 'for', 'window', 'apply', 'pivot', 'unpivot', 'tablesample',
32
+ 'natural', 'lateral', 'with', 'using', 'select', 'returning', 'into', 'values', 'set', 'option', 'as', 'and', 'or',
33
+ ]);
34
+ /** Per-catalog, per-user denial summaries (see {@link FieldSecuritySQLGate.CatalogHasDenialsForUser}). */
35
+ const catalogDenialCache = new WeakMap();
36
+ /** Per-catalog relation index, rebuilt when the catalog array changes length. */
37
+ const relationIndexCache = new WeakMap();
38
+ /**
39
+ * SECURITY — the field-level security gate for SQL text a caller authors: RunView filters, sorts and
40
+ * aggregate expressions, a saved view's stored clauses, dataset item filters, and the rendered SQL
41
+ * of a saved query.
42
+ *
43
+ * Output projection alone is not enough. A user denied `Salary` who can write
44
+ * `ExtraFilter: "Salary > 200000"`, sort by it, or aggregate `MIN(Salary)` reconstructs its values
45
+ * from which rows come back. This gate refuses such text before it reaches the cache or the
46
+ * database, for every caller: client requests, agents and actions alike.
47
+ *
48
+ * ## How it reads the text
49
+ *
50
+ * The text is split into tokens with the database's own lexical rules
51
+ * (`ScanSQLForScreening`), so a name inside a string literal or a comment is not a reference, and a
52
+ * `[bracketed]` or `"quoted"` name is read as the bare name it is. Every unquoted word and quoted
53
+ * identifier is then compared with every denied field of every entity the SQL reads:
54
+ * - the entity the fragment filters (its columns can be named unqualified);
55
+ * - the entities whose base view or base table the text names, such as a subquery's FROM;
56
+ * - any entities the caller says the SQL reads.
57
+ *
58
+ * A name matches a denied field by the rule `IdentifierMatches` states, or when the two are equal
59
+ * after Unicode compatibility folding, accent stripping and case folding, the comparisons a
60
+ * database collation can make. So a look-alike spelling that the database would still resolve to
61
+ * the denied column is refused too. Each name is compared on its own, so one denied name inside
62
+ * another can never hide it.
63
+ *
64
+ * A reference qualified by an alias or view name that binds only to entities whose denied set
65
+ * does not hold that name is not a reference to a denied field. An unknown qualifier is checked
66
+ * against every restricted entity.
67
+ *
68
+ * ## Whole-row constructs
69
+ *
70
+ * For a user denied something on an entity the SQL reads, constructs that read a whole row without
71
+ * naming its columns are refused: `SELECT *` and `alias.*` (except directly inside `EXISTS (…)`
72
+ * whose body has no set operation or row-picking ORDER BY), `FOR JSON`, `FOR XML`, any function of
73
+ * `*` other than `COUNT(*)`/`COUNT_BIG(*)` (for example `CHECKSUM(*)`), `NATURAL JOIN`, a column
74
+ * alias list on such a relation, and a relation or its alias used as a value (PostgreSQL's
75
+ * whole-row reference, as in `row_to_json(e)` or `TABLE vw`). A user with no denials on the
76
+ * entities read is not affected; ordinary subqueries keep working.
77
+ *
78
+ * ## Failing closed
79
+ *
80
+ * Text the scanner cannot read the way the database does is refused for any user who carries a
81
+ * denial on an entity the SQL could read. So is a name holding a control, format or
82
+ * default-ignorable character (a zero-width joiner, a direction mark), which a collation may give
83
+ * no weight to.
84
+ *
85
+ * ## Unknown columns
86
+ *
87
+ * A reference to a column that does not exist reaches the database, which rejects it. For a user
88
+ * carrying denials, {@link FieldSecuritySQLGate.ErrorForInvalidColumn} turns that rejection into the
89
+ * same {@link FieldSecurityError} a denied column gets, so the two cannot be told apart.
90
+ */
91
+ export class FieldSecuritySQLGate {
92
+ /**
93
+ * The first reason `sql` must be refused for `context.User`, or null when it may run.
94
+ *
95
+ * @param sql A filter, sort or aggregate fragment, or a whole statement
96
+ * @param context The user, dialect, catalog and the entities the SQL is known to read
97
+ */
98
+ static FindViolation(sql, context) {
99
+ if (typeof sql !== 'string' || sql.trim().length === 0 || !context?.User) {
100
+ return null;
101
+ }
102
+ if (!FieldSecuritySQLGate.mayReadRestrictedEntity(context)) {
103
+ return null; // the common case: this user is denied nothing anywhere, so nothing is scanned
104
+ }
105
+ const scan = ScanSQLForScreening(sql, context.Dialect);
106
+ if (!scan.Classified) {
107
+ return { Kind: 'Unresolvable', Reference: scan.Error ?? 'the text could not be read', Entity: null };
108
+ }
109
+ const dynamicSQL = FieldSecuritySQLGate.findDynamicSQLCall(scan.Tokens, context.Dialect);
110
+ if (dynamicSQL) {
111
+ return { Kind: 'Unresolvable', Reference: `${dynamicSQL}()`, Entity: null };
112
+ }
113
+ if (scan.Tokens.some((t) => t.Kind !== 'string' && INVISIBLE_CHARACTER.test(t.Text))) {
114
+ return { Kind: 'Unresolvable', Reference: 'a name contains an invisible character', Entity: null };
115
+ }
116
+ const unknownRelation = context.RefuseUnknownRelations ? FieldSecuritySQLGate.findUnknownRelation(scan.Tokens, context.Catalog) : null;
117
+ if (unknownRelation) {
118
+ return { Kind: 'UnknownRelation', Reference: unknownRelation, Entity: null };
119
+ }
120
+ const analysis = FieldSecuritySQLGate.analyze(sql, scan.Tokens, context);
121
+ if (analysis.Restricted.size === 0) {
122
+ return null;
123
+ }
124
+ return FieldSecuritySQLGate.findDeniedReference(analysis) ?? FieldSecuritySQLGate.findWholeRowConstruct(analysis);
125
+ }
126
+ /**
127
+ * The first reason the sort fragment `orderBy` must be refused for `context.User`, or null.
128
+ * Everything {@link FindViolation} refuses, and, for a user denied a field on
129
+ * `context.TargetEntity`, a sort item that is a column position (`3 DESC`). A position names a
130
+ * column of the select list without spelling it, and an entity-object read selects every
131
+ * column, so `ORDER BY 3` can sort by a denied field. It is refused as a whole-row construct.
132
+ */
133
+ static FindSortViolation(orderBy, context) {
134
+ return FieldSecuritySQLGate.FindViolation(orderBy, context) ?? FieldSecuritySQLGate.findSortByPosition(orderBy, context);
135
+ }
136
+ /**
137
+ * Throws a {@link FieldSecurityError} when {@link FindViolation} refuses `sql`. The error names
138
+ * `entityName`, the entity the caller is reading, never the entity a subquery reaches, so a
139
+ * refusal says nothing a "no such column" error would not.
140
+ */
141
+ static AssertAllowed(sql, context, entityName) {
142
+ const violation = FieldSecuritySQLGate.FindViolation(sql, context);
143
+ if (violation) {
144
+ throw FieldSecuritySQLGate.ErrorForViolation(violation, entityName);
145
+ }
146
+ }
147
+ /**
148
+ * Checks a whole statement, such as a saved query's rendered SQL, against the denied fields of
149
+ * every entity it reads. The entities come from the base views and tables the text names, plus
150
+ * `readEntities`. References are checked wherever they appear: the select list, WHERE, JOIN,
151
+ * GROUP BY, ORDER BY and HAVING. For a user carrying any field denial, a relation the catalog
152
+ * does not know is refused too ({@link FieldSecuritySQLContext.RefuseUnknownRelations}).
153
+ * Throws a {@link FieldSecurityError} on the first violation, naming the entity whose denial
154
+ * it reached (or `fallbackEntityName` when none).
155
+ */
156
+ static AssertStatementAllowed(sql, user, catalog, dialect, readEntities, fallbackEntityName = 'query') {
157
+ const violation = FieldSecuritySQLGate.FindViolation(sql, {
158
+ User: user, Dialect: dialect, Catalog: catalog, ReadEntities: readEntities, RefuseUnknownRelations: true,
159
+ });
160
+ if (violation) {
161
+ throw FieldSecuritySQLGate.ErrorForViolation(violation, violation.Entity?.Name ?? fallbackEntityName);
162
+ }
163
+ }
164
+ /** The error a violation raises, naming `entityName`. */
165
+ static ErrorForViolation(violation, entityName) {
166
+ if (violation.Kind === 'DeniedField') {
167
+ return new FieldSecurityError(violation.Reference, entityName);
168
+ }
169
+ if (violation.Kind === 'WholeRow') {
170
+ return new FieldSecurityError('*', entityName, FieldSecurityWholeRowMessage(violation.Reference, entityName));
171
+ }
172
+ // Unresolvable and UnknownRelation share one message, which says nothing about what the text names.
173
+ return new FieldSecurityError('', entityName, FieldSecurityUnresolvableSQLMessage(entityName));
174
+ }
175
+ /**
176
+ * For a user carrying field denials on `entity`, the {@link FieldSecurityError} a database's
177
+ * "no such column" rejection becomes, so a column that does not exist and a column that is
178
+ * denied produce the same error. Null when the user carries no denials there, or the error is
179
+ * not a "no such column" rejection.
180
+ *
181
+ * Reads SQL Server's `Invalid column name 'x'` and `The multi-part identifier "a.x" could not be
182
+ * bound`, and PostgreSQL's `column "x" does not exist` and `column a.x does not exist`.
183
+ */
184
+ static ErrorForInvalidColumn(error, entity, user) {
185
+ if (!entity || !user || !FieldSecuritySQLGate.UserCarriesDenials([entity], user)) {
186
+ return null;
187
+ }
188
+ const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
189
+ const column = FieldSecuritySQLGate.invalidColumnName(message);
190
+ return column === null ? null : new FieldSecurityError(column, entity.Name);
191
+ }
192
+ /**
193
+ * The entities whose base view or base table `sql` reads as a relation: a name after FROM, JOIN
194
+ * or APPLY, or in a FROM list, wherever it appears (subqueries and CTE bodies included), with
195
+ * schema qualifiers skipped. A name in a literal or a comment is not a relation. A bare name
196
+ * shared by entities in several schemas yields each of them, so the set never misses a read.
197
+ * A relation inside a parenthesized join and PostgreSQL's `FROM ONLY x` count too. Null when
198
+ * the text cannot be read the way the database reads it.
199
+ */
200
+ static RelationEntities(sql, catalog, dialect) {
201
+ if (typeof sql !== 'string' || sql.trim().length === 0) {
202
+ return [];
203
+ }
204
+ const scan = ScanSQLForScreening(sql, dialect);
205
+ if (!scan.Classified) {
206
+ return null;
207
+ }
208
+ const index = FieldSecuritySQLGate.relationIndex(catalog);
209
+ const found = new Set();
210
+ scan.Tokens.forEach((token, i) => {
211
+ if (!FieldSecuritySQLGate.isName(token) || !FieldSecuritySQLGate.isRelationDeclaration(scan.Tokens, i))
212
+ return;
213
+ index.get(FoldIdentifier(token.Text))?.forEach((entity) => found.add(entity));
214
+ });
215
+ return [...found];
216
+ }
217
+ /** True when `user` is denied at least one field on at least one of `entities`. */
218
+ static UserCarriesDenials(entities, user) {
219
+ for (const entity of entities) {
220
+ if (entity?.FieldLevelSecurityApplies && entity.GetDeniedReadFields(user).size > 0) {
221
+ return true;
222
+ }
223
+ }
224
+ return false;
225
+ }
226
+ /**
227
+ * True when `user` is denied at least one field on any entity of `catalog`.
228
+ *
229
+ * Memoized per (catalog, user): after the first call it costs a few comparisons. The memo is
230
+ * discarded when the catalog array changes length, when the field-security generation moves
231
+ * (an entity rebuilt, a flag assigned, a cache invalidated; see
232
+ * `CurrentFieldSecurityGeneration`) or when the user's roles change, which are everything the
233
+ * answer depends on. A catalog swapped for a new array starts a new memo.
234
+ */
235
+ static CatalogHasDenialsForUser(catalog, user) {
236
+ return FieldSecuritySQLGate.catalogDenialSummary(catalog, user).HasAnyDenial;
237
+ }
238
+ /**
239
+ * Every field `user` may not read across `catalog`: one `entityID:field,field` entry per
240
+ * entity on which the user is denied something, sorted. '' for a user denied nothing.
241
+ * Memoized exactly as {@link CatalogHasDenialsForUser}.
242
+ */
243
+ static CatalogDenialProfileKey(catalog, user) {
244
+ const summary = FieldSecuritySQLGate.catalogDenialSummary(catalog, user);
245
+ if (!summary.HasAnyDenial) {
246
+ return '';
247
+ }
248
+ summary.ProfileKey ??= FieldSecuritySQLGate.buildDenialProfileKey(catalog ?? [], user);
249
+ return summary.ProfileKey;
250
+ }
251
+ /** The current summary for (`catalog`, `user`), rebuilt when any part of its key moved. */
252
+ static catalogDenialSummary(catalog, user) {
253
+ const entities = catalog ?? [];
254
+ const generation = CurrentFieldSecurityGeneration();
255
+ const rolesKey = FieldSecurityUserRolesKey(user);
256
+ let byUser = catalogDenialCache.get(entities);
257
+ const existing = byUser?.get(user);
258
+ if (existing && existing.Length === entities.length && existing.Generation === generation && existing.RolesKey === rolesKey) {
259
+ return existing;
260
+ }
261
+ const summary = {
262
+ Length: entities.length,
263
+ Generation: generation,
264
+ RolesKey: rolesKey,
265
+ HasAnyDenial: FieldSecuritySQLGate.UserCarriesDenials(entities, user),
266
+ };
267
+ if (!byUser) {
268
+ byUser = new WeakMap();
269
+ catalogDenialCache.set(entities, byUser);
270
+ }
271
+ byUser.set(user, summary);
272
+ return summary;
273
+ }
274
+ /** The uncached walk behind {@link CatalogDenialProfileKey}. */
275
+ static buildDenialProfileKey(catalog, user) {
276
+ const entries = [];
277
+ for (const entity of catalog) {
278
+ if (!entity?.FieldLevelSecurityApplies)
279
+ continue;
280
+ const denied = entity.GetDeniedReadFields(user);
281
+ if (denied.size > 0) {
282
+ entries.push(`${NormalizeUUID(entity.ID)}:${[...denied].sort().join(',')}`);
283
+ }
284
+ }
285
+ return entries.sort().join(';');
286
+ }
287
+ /**
288
+ * The column a database "no such column" message names, or null. A qualified name gives its
289
+ * last part, which is what the gate reports for the same reference.
290
+ */
291
+ static invalidColumnName(message) {
292
+ // Database messages are short; bounding the input keeps every scan below cheap even if an
293
+ // error echoes a long caller-supplied string.
294
+ const text = message.length > FieldSecuritySQLGate.maxErrorMessageScanLength
295
+ ? message.slice(0, FieldSecuritySQLGate.maxErrorMessageScanLength)
296
+ : message;
297
+ const patterns = [
298
+ /Invalid column name '((?:[^']|'')+)'/i,
299
+ /multi-part identifier "((?:[^"]|"")+)" could not be bound/i,
300
+ /column "((?:[^"]|"")+)" does not exist/i,
301
+ ];
302
+ for (const pattern of patterns) {
303
+ const match = pattern.exec(text);
304
+ if (match) {
305
+ return FieldSecuritySQLGate.lastIdentifierPart(match[1]);
306
+ }
307
+ }
308
+ const path = FieldSecuritySQLGate.postgresColumnPath(text);
309
+ return path === null ? null : FieldSecuritySQLGate.lastIdentifierPart(path);
310
+ }
311
+ /** Longest prefix of a database error message scanned for a column name. */
312
+ static { this.maxErrorMessageScanLength = 2000; }
313
+ /** The last dot-separated part of an identifier path, unquoted and unescaped. */
314
+ static lastIdentifierPart(path) {
315
+ const parts = path.split('.');
316
+ return parts[parts.length - 1].replace(/^"|"$/g, '').replace(/''/g, "'").replace(/""/g, '"');
317
+ }
318
+ /**
319
+ * The identifier path in a PostgreSQL `column <path> does not exist` message (for example
320
+ * `e.salry` or `"E"."Salry"`), or null. Scanned by hand rather than with a regular expression:
321
+ * the path's parts may themselves contain dots, and a pattern that allows that backtracks
322
+ * exponentially on crafted input.
323
+ */
324
+ static postgresColumnPath(message) {
325
+ const lower = message.toLowerCase();
326
+ const prefix = 'column ';
327
+ const suffix = ' does not exist';
328
+ let from = 0;
329
+ for (let at = lower.indexOf(prefix, from); at !== -1; at = lower.indexOf(prefix, from)) {
330
+ const start = at + prefix.length;
331
+ const end = FieldSecuritySQLGate.identifierPathEnd(message, start);
332
+ if (end > start && lower.startsWith(suffix, end)) {
333
+ return message.slice(start, end);
334
+ }
335
+ from = at + 1;
336
+ }
337
+ return null;
338
+ }
339
+ /**
340
+ * Index just past an identifier path starting at `start`: a run of non-space characters in
341
+ * which double-quoted segments (with `""` escapes) may contain spaces. Returns `start` when no
342
+ * path starts there, and -1 for an unterminated quote.
343
+ */
344
+ static identifierPathEnd(message, start) {
345
+ let i = start;
346
+ while (i < message.length && !/\s/.test(message[i])) {
347
+ if (message[i] !== '"') {
348
+ i++;
349
+ continue;
350
+ }
351
+ i++; // opening quote
352
+ while (i < message.length && !(message[i] === '"' && message[i + 1] !== '"')) {
353
+ i += message[i] === '"' ? 2 : 1; // an escaped "" counts as one character
354
+ }
355
+ if (i >= message.length)
356
+ return -1;
357
+ i++; // closing quote
358
+ }
359
+ return i;
360
+ }
361
+ /**
362
+ * The first call of a function the dialect forbids in caller SQL (one that runs SQL given as a
363
+ * string, reads files or other databases), or null. What such a call reads cannot be checked.
364
+ */
365
+ static findDynamicSQLCall(tokens, dialect) {
366
+ const forbidden = GetDialect(dialect).CallerSQLForbiddenFunctions;
367
+ for (let i = 0; i < tokens.length - 1; i++) {
368
+ const token = tokens[i];
369
+ if (!FieldSecuritySQLGate.isName(token) || !FieldSecuritySQLGate.isSymbol(tokens[i + 1], '('))
370
+ continue;
371
+ const name = token.Text.toLowerCase();
372
+ if (forbidden.some((p) => (p.endsWith('*') ? name.startsWith(p.slice(0, -1)) : name === p)))
373
+ return token.Text;
374
+ }
375
+ return null;
376
+ }
377
+ /**
378
+ * True when the user carries a denial on any entity the SQL could read: the target, the
379
+ * declared read entities (checked directly, as they need not be catalog members), or any
380
+ * catalog entity, since the text can name any of them. The catalog answer is memoized
381
+ * ({@link CatalogHasDenialsForUser}).
382
+ *
383
+ * Deliberately catalog-wide rather than "the target and read entities only". Which other
384
+ * entities a fragment reads is only known once its text has been read the way the database
385
+ * reads it: a subquery, a parenthesized join, a schema-qualified or bracketed name, or a name
386
+ * spelled with look-alike characters all reach another entity's view. And text that cannot be
387
+ * read at all, a dynamic-SQL call or an invisible character must be refused for such a user
388
+ * whatever it appears to name. Every one of those checks needs the tokens, so a user with a
389
+ * denial anywhere has every fragment tokenized; {@link analyze} then derives the read set, and
390
+ * a fragment whose read set carries none of the user's denials passes without the
391
+ * denied-name and whole-row checks.
392
+ */
393
+ static mayReadRestrictedEntity(context) {
394
+ const user = context.User;
395
+ if (context.TargetEntity && FieldSecuritySQLGate.UserCarriesDenials([context.TargetEntity], user)) {
396
+ return true;
397
+ }
398
+ if (context.ReadEntities && FieldSecuritySQLGate.UserCarriesDenials(context.ReadEntities, user)) {
399
+ return true;
400
+ }
401
+ return FieldSecuritySQLGate.CatalogHasDenialsForUser(context.Catalog, user);
402
+ }
403
+ /** Builds the relation bindings, the restricted set and its denied names. */
404
+ static analyze(sql, tokens, context) {
405
+ const analysis = {
406
+ SQL: sql,
407
+ Tokens: tokens,
408
+ Context: context,
409
+ Bindings: new Map(),
410
+ RelationDeclarations: new Set(),
411
+ AliasDeclarations: new Set(),
412
+ RestrictedQualifiers: new Set(),
413
+ Restricted: new Set(),
414
+ Denied: [],
415
+ };
416
+ for (const entity of context.ReadEntities ?? []) {
417
+ FieldSecuritySQLGate.addIfRestricted(analysis, entity);
418
+ }
419
+ const target = context.TargetEntity;
420
+ if (target && FieldSecuritySQLGate.addIfRestricted(analysis, target)) {
421
+ // The fragment runs inside `SELECT … FROM <base view>` with no alias, so the view's name
422
+ // is the outer row: a PostgreSQL whole-row reference unless it qualifies a column.
423
+ for (const name of [target.BaseView, target.BaseTable]) {
424
+ if (name)
425
+ FieldSecuritySQLGate.bind(analysis, name, [target], true);
426
+ }
427
+ }
428
+ FieldSecuritySQLGate.bindRelations(analysis, FieldSecuritySQLGate.relationIndex(context.Catalog));
429
+ for (const entity of analysis.Restricted) {
430
+ const denied = entity.GetDeniedReadFields(context.User);
431
+ for (const field of entity.Fields) {
432
+ if (denied.has(field.Name.trim().toLowerCase()))
433
+ analysis.Denied.push({ Name: field.Name, Entity: entity });
434
+ }
435
+ }
436
+ return analysis;
437
+ }
438
+ /** Adds `entity` to the restricted set when the user is denied a field on it. */
439
+ static addIfRestricted(analysis, entity) {
440
+ if (!entity.FieldLevelSecurityApplies || entity.GetDeniedReadFields(analysis.Context.User).size === 0) {
441
+ return false;
442
+ }
443
+ analysis.Restricted.add(entity);
444
+ return true;
445
+ }
446
+ /** Records each relation the text names, its alias, and which of them are restricted. */
447
+ static bindRelations(analysis, index) {
448
+ analysis.Tokens.forEach((token, i) => {
449
+ if (!FieldSecuritySQLGate.isName(token))
450
+ return;
451
+ const entities = index.get(FoldIdentifier(token.Text));
452
+ if (!entities)
453
+ return;
454
+ const restricted = entities.filter((e) => FieldSecuritySQLGate.addIfRestricted(analysis, e)).length > 0;
455
+ FieldSecuritySQLGate.bind(analysis, token.Text, entities, restricted);
456
+ if (!FieldSecuritySQLGate.isRelationDeclaration(analysis.Tokens, i))
457
+ return;
458
+ analysis.RelationDeclarations.add(i);
459
+ const aliasIndex = FieldSecuritySQLGate.aliasIndexAfter(analysis.Tokens, i);
460
+ if (aliasIndex !== null) {
461
+ analysis.AliasDeclarations.add(aliasIndex);
462
+ FieldSecuritySQLGate.bind(analysis, analysis.Tokens[aliasIndex].Text, entities, restricted);
463
+ }
464
+ });
465
+ }
466
+ /** Binds a qualifier to entities. */
467
+ static bind(analysis, name, entities, restricted) {
468
+ const key = FoldIdentifier(name);
469
+ const bound = analysis.Bindings.get(key) ?? new Set();
470
+ entities.forEach((e) => bound.add(e));
471
+ analysis.Bindings.set(key, bound);
472
+ if (restricted)
473
+ analysis.RestrictedQualifiers.add(key);
474
+ }
475
+ /**
476
+ * Whether the relation name at `i` declares a relation: it follows FROM, JOIN or APPLY, or a
477
+ * comma in a FROM list, with any `schema .` / `database . schema .` qualifiers, a PostgreSQL
478
+ * `ONLY`, and the parentheses of a parenthesized join skipped.
479
+ */
480
+ static isRelationDeclaration(tokens, i) {
481
+ let j = FieldSecuritySQLGate.qualifierStart(tokens, i) - 1;
482
+ while (j >= 0 && (FieldSecuritySQLGate.isWord(tokens[j], 'only') || FieldSecuritySQLGate.isSymbol(tokens[j], '('))) {
483
+ j--;
484
+ }
485
+ return FieldSecuritySQLGate.introducesRelation(tokens, j);
486
+ }
487
+ /** Whether the token at `j` is a FROM, JOIN or APPLY keyword, or a comma in a FROM list. */
488
+ static introducesRelation(tokens, j) {
489
+ const token = tokens[j];
490
+ if (!token)
491
+ return false;
492
+ if (token.Kind === 'word' && RELATION_KEYWORDS.has(token.Text.toLowerCase()))
493
+ return true;
494
+ return FieldSecuritySQLGate.isSymbol(token, ',') && FieldSecuritySQLGate.clauseAt(tokens, j) === 'from';
495
+ }
496
+ /** The index of the first part of the dotted name that ends at `i` (`db . schema . name`). */
497
+ static qualifierStart(tokens, i) {
498
+ let j = i;
499
+ while (j >= 2 && FieldSecuritySQLGate.isSymbol(tokens[j - 1], '.') && FieldSecuritySQLGate.isName(tokens[j - 2])) {
500
+ j -= 2;
501
+ }
502
+ return j;
503
+ }
504
+ /**
505
+ * The clause keyword that governs the token at `i` at its own parenthesis depth: the nearest
506
+ * preceding SELECT, FROM, JOIN, WHERE, ON, GROUP, ORDER or HAVING at that depth. Inside the
507
+ * parentheses of a parenthesized join (`FROM (a x, b y)`) with no such keyword, `from`.
508
+ */
509
+ static clauseAt(tokens, i) {
510
+ let depth = 0;
511
+ for (let j = i - 1; j >= 0; j--) {
512
+ const token = tokens[j];
513
+ if (FieldSecuritySQLGate.isSymbol(token, ')'))
514
+ depth++;
515
+ else if (FieldSecuritySQLGate.isSymbol(token, '(')) {
516
+ if (depth === 0)
517
+ return FieldSecuritySQLGate.opensJoinGroup(tokens, j) ? 'from' : null;
518
+ depth--;
519
+ }
520
+ else if (depth === 0 && token.Kind === 'word') {
521
+ const word = token.Text.toLowerCase();
522
+ if (word === 'join')
523
+ return 'from';
524
+ if (['select', 'from', 'where', 'on', 'group', 'order', 'having'].includes(word))
525
+ return word;
526
+ }
527
+ }
528
+ return null;
529
+ }
530
+ /** Whether the `(` at `j` opens a parenthesized join: it stands where a relation is declared. */
531
+ static opensJoinGroup(tokens, j) {
532
+ let k = j - 1;
533
+ while (k >= 0 && FieldSecuritySQLGate.isSymbol(tokens[k], '('))
534
+ k--;
535
+ return FieldSecuritySQLGate.introducesRelation(tokens, k);
536
+ }
537
+ /**
538
+ * The first relation the statement reads that is not a CTE and not the base view or base table
539
+ * of a catalog entity, or a stored-procedure call; null when there is none. A name qualified by
540
+ * a schema no entity of that name lives in, or by a database, is unknown; so is a function in
541
+ * a relation position (`FROM f(…)`).
542
+ */
543
+ static findUnknownRelation(tokens, catalog) {
544
+ const procedure = tokens.find((t, i) => t.Kind === 'word' && PROCEDURE_CALL_WORDS.has(t.Text.toLowerCase()) && !FieldSecuritySQLGate.isSymbol(tokens[i - 1], '.'));
545
+ if (procedure)
546
+ return procedure.Text;
547
+ const index = FieldSecuritySQLGate.relationIndex(catalog);
548
+ const cteNames = FieldSecuritySQLGate.cteNames(tokens);
549
+ for (let i = 0; i < tokens.length; i++) {
550
+ if (!FieldSecuritySQLGate.isRelationTarget(tokens, i))
551
+ continue;
552
+ if (!FieldSecuritySQLGate.isKnownRelation(tokens, i, index, cteNames))
553
+ return tokens[i].Text;
554
+ }
555
+ return null;
556
+ }
557
+ /**
558
+ * Whether the name at `i` is the last part of a relation the statement reads: after FROM (of a
559
+ * SELECT, not `EXTRACT(… FROM x)` or `IS DISTINCT FROM x`), JOIN, APPLY, a FROM-list comma or
560
+ * PostgreSQL's `TABLE`, past qualifiers, `ONLY`, `LATERAL` and join-group parentheses.
561
+ */
562
+ static isRelationTarget(tokens, i) {
563
+ const token = tokens[i];
564
+ if (!FieldSecuritySQLGate.isName(token) || FieldSecuritySQLGate.isSymbol(tokens[i + 1], '.'))
565
+ return false;
566
+ if (token.Kind === 'word' && NOT_RELATION_NAMES.has(token.Text.toLowerCase()))
567
+ return false;
568
+ let j = FieldSecuritySQLGate.qualifierStart(tokens, i) - 1;
569
+ while (j >= 0 && (FieldSecuritySQLGate.isSymbol(tokens[j], '(') || FieldSecuritySQLGate.isRelationModifier(tokens[j]))) {
570
+ j--;
571
+ }
572
+ if (FieldSecuritySQLGate.isWord(tokens[j], 'table'))
573
+ return true;
574
+ if (!FieldSecuritySQLGate.introducesRelation(tokens, j))
575
+ return false;
576
+ return !FieldSecuritySQLGate.isWord(tokens[j], 'from') || FieldSecuritySQLGate.isSelectFrom(tokens, j);
577
+ }
578
+ /** Whether a token is `ONLY` or `LATERAL`. */
579
+ static isRelationModifier(token) {
580
+ return !!token && token.Kind === 'word' && RELATION_MODIFIERS.has(token.Text.toLowerCase());
581
+ }
582
+ /** Whether the FROM at `j` is a SELECT's FROM clause. */
583
+ static isSelectFrom(tokens, j) {
584
+ const isDistinctFrom = FieldSecuritySQLGate.isWord(tokens[j - 1], 'distinct')
585
+ && (FieldSecuritySQLGate.isWord(tokens[j - 2], 'is') || FieldSecuritySQLGate.isWord(tokens[j - 2], 'not'));
586
+ return !isDistinctFrom && FieldSecuritySQLGate.clauseAt(tokens, j) === 'select';
587
+ }
588
+ /**
589
+ * Whether the relation whose last name part is at `i` is a CTE or a catalog entity's base view
590
+ * or base table, in that entity's schema when qualified.
591
+ */
592
+ static isKnownRelation(tokens, i, index, cteNames) {
593
+ if (FieldSecuritySQLGate.isSymbol(tokens[i + 1], '('))
594
+ return false; // a table-valued function
595
+ const name = FoldIdentifier(tokens[i].Text);
596
+ const qualifierCount = (i - FieldSecuritySQLGate.qualifierStart(tokens, i)) / 2;
597
+ if (qualifierCount === 0 && cteNames.has(name))
598
+ return true;
599
+ const entities = index.get(name);
600
+ if (!entities || qualifierCount > 1)
601
+ return false;
602
+ if (qualifierCount === 0)
603
+ return true;
604
+ const schema = FoldIdentifier(tokens[i - 2].Text);
605
+ return entities.some((e) => typeof e.SchemaName === 'string' && FoldIdentifier(e.SchemaName) === schema);
606
+ }
607
+ /**
608
+ * The folded names of the CTEs a statement defines: a name after WITH, RECURSIVE or a comma
609
+ * that is followed by an optional column list, AS, an optional `[NOT] MATERIALIZED`, and `(`.
610
+ */
611
+ static cteNames(tokens) {
612
+ const names = new Set();
613
+ tokens.forEach((token, i) => {
614
+ const before = tokens[i - 1];
615
+ const introduced = FieldSecuritySQLGate.isWord(before, 'with') || FieldSecuritySQLGate.isWord(before, 'recursive')
616
+ || FieldSecuritySQLGate.isSymbol(before, ',');
617
+ if (introduced && FieldSecuritySQLGate.isName(token) && FieldSecuritySQLGate.opensCTEBody(tokens, i + 1)) {
618
+ names.add(FoldIdentifier(token.Text));
619
+ }
620
+ });
621
+ return names;
622
+ }
623
+ /** Whether the tokens from `j` are `[(columns)] AS [NOT] [MATERIALIZED] (`. */
624
+ static opensCTEBody(tokens, j) {
625
+ let k = j;
626
+ if (FieldSecuritySQLGate.isSymbol(tokens[k], '(')) {
627
+ const close = FieldSecuritySQLGate.closingParen(tokens, k);
628
+ if (close === -1)
629
+ return false;
630
+ k = close + 1;
631
+ }
632
+ if (!FieldSecuritySQLGate.isWord(tokens[k], 'as'))
633
+ return false;
634
+ k++;
635
+ if (FieldSecuritySQLGate.isWord(tokens[k], 'not'))
636
+ k++;
637
+ if (FieldSecuritySQLGate.isWord(tokens[k], 'materialized'))
638
+ k++;
639
+ return FieldSecuritySQLGate.isSymbol(tokens[k], '(');
640
+ }
641
+ /** The index of the `)` that closes the `(` at `open`, or -1. */
642
+ static closingParen(tokens, open) {
643
+ let depth = 0;
644
+ for (let k = open; k < tokens.length; k++) {
645
+ if (FieldSecuritySQLGate.isSymbol(tokens[k], '('))
646
+ depth++;
647
+ else if (FieldSecuritySQLGate.isSymbol(tokens[k], ')') && --depth === 0)
648
+ return k;
649
+ }
650
+ return -1;
651
+ }
652
+ /**
653
+ * Whether the body of the `EXISTS (` whose parenthesis is at `open` has, at its own depth, a
654
+ * word that makes a `SELECT *` there read column values ({@link EXISTS_ROW_READING_WORDS}).
655
+ * An unclosed body counts as one that does.
656
+ */
657
+ static existsBodyReadsRows(tokens, open) {
658
+ const close = FieldSecuritySQLGate.closingParen(tokens, open);
659
+ if (close === -1)
660
+ return true;
661
+ let depth = 0;
662
+ for (let k = open + 1; k < close; k++) {
663
+ const token = tokens[k];
664
+ if (FieldSecuritySQLGate.isSymbol(token, '('))
665
+ depth++;
666
+ else if (FieldSecuritySQLGate.isSymbol(token, ')'))
667
+ depth--;
668
+ else if (depth === 0 && token.Kind === 'word' && EXISTS_ROW_READING_WORDS.has(token.Text.toLowerCase()))
669
+ return true;
670
+ }
671
+ return false;
672
+ }
673
+ /** The index of the alias after the relation at `i` (`x a`, `x AS a`), or null. */
674
+ static aliasIndexAfter(tokens, i) {
675
+ let j = i + 1;
676
+ if (FieldSecuritySQLGate.isWord(tokens[j], 'as'))
677
+ j++;
678
+ const candidate = tokens[j];
679
+ if (!candidate)
680
+ return null;
681
+ if (candidate.Kind === 'identifier')
682
+ return j;
683
+ return candidate.Kind === 'word' && !ALIAS_STOP_WORDS.has(candidate.Text.toLowerCase()) ? j : null;
684
+ }
685
+ /** The first reference to a denied field, honoring qualifiers that bind to known entities. */
686
+ static findDeniedReference(analysis) {
687
+ for (let i = 0; i < analysis.Tokens.length; i++) {
688
+ const token = analysis.Tokens[i];
689
+ if (!FieldSecuritySQLGate.isName(token) || analysis.RelationDeclarations.has(i) || analysis.AliasDeclarations.has(i))
690
+ continue;
691
+ const candidates = FieldSecuritySQLGate.candidatesFor(analysis, i);
692
+ const hit = candidates.find((d) => ReferenceMayNameField(token.Text, d.Name));
693
+ if (hit) {
694
+ return { Kind: 'DeniedField', Reference: FieldSecuritySQLGate.reportedName(analysis.SQL, token, analysis.Context.Dialect), Entity: hit.Entity };
695
+ }
696
+ }
697
+ return null;
698
+ }
699
+ /**
700
+ * The denied names a reference at `i` could reach: those of the entities its qualifier binds to,
701
+ * or every restricted entity's when it is unqualified or its qualifier is unknown.
702
+ */
703
+ static candidatesFor(analysis, i) {
704
+ const dot = analysis.Tokens[i - 1];
705
+ const qualifier = analysis.Tokens[i - 2];
706
+ if (!FieldSecuritySQLGate.isSymbol(dot, '.') || !FieldSecuritySQLGate.isName(qualifier)) {
707
+ return analysis.Denied;
708
+ }
709
+ const bound = analysis.Bindings.get(FoldIdentifier(qualifier.Text));
710
+ return bound ? analysis.Denied.filter((d) => bound.has(d.Entity)) : analysis.Denied;
711
+ }
712
+ /**
713
+ * A sort item of `orderBy` that is a column position, as a whole-row violation, or null. Only
714
+ * for a user denied a field on the target entity. An item counts when its first token, inside
715
+ * any opening parentheses, is a number.
716
+ */
717
+ static findSortByPosition(orderBy, context) {
718
+ const target = context?.TargetEntity;
719
+ if (typeof orderBy !== 'string' || !context.User || !target || !FieldSecuritySQLGate.UserCarriesDenials([target], context.User)) {
720
+ return null;
721
+ }
722
+ const scan = ScanSQLForScreening(orderBy, context.Dialect);
723
+ let depth = 0;
724
+ let itemStart = true;
725
+ for (const token of scan.Tokens) {
726
+ if (itemStart && token.Kind === 'number') {
727
+ return { Kind: 'WholeRow', Reference: `ORDER BY ${token.Text}`, Entity: target };
728
+ }
729
+ if (FieldSecuritySQLGate.isSymbol(token, '(')) {
730
+ depth++;
731
+ continue;
732
+ }
733
+ if (FieldSecuritySQLGate.isSymbol(token, ')')) {
734
+ depth--;
735
+ }
736
+ itemStart = depth === 0 && FieldSecuritySQLGate.isSymbol(token, ',');
737
+ }
738
+ return null;
739
+ }
740
+ /** The first whole-row construct, or null. */
741
+ static findWholeRowConstruct(analysis) {
742
+ const tokens = analysis.Tokens;
743
+ for (let i = 0; i < tokens.length; i++) {
744
+ const construct = FieldSecuritySQLGate.wholeRowConstructAt(analysis, i);
745
+ if (construct) {
746
+ return { Kind: 'WholeRow', Reference: construct, Entity: [...analysis.Restricted][0] ?? null };
747
+ }
748
+ }
749
+ return null;
750
+ }
751
+ /** The whole-row construct that starts at token `i`, or null. */
752
+ static wholeRowConstructAt(analysis, i) {
753
+ const tokens = analysis.Tokens;
754
+ const token = tokens[i];
755
+ if (FieldSecuritySQLGate.isWord(token, 'for') && (FieldSecuritySQLGate.isWord(tokens[i + 1], 'json') || FieldSecuritySQLGate.isWord(tokens[i + 1], 'xml'))) {
756
+ return `FOR ${tokens[i + 1].Text.toUpperCase()}`;
757
+ }
758
+ if (FieldSecuritySQLGate.isWord(token, 'natural')) {
759
+ return 'NATURAL JOIN';
760
+ }
761
+ if (FieldSecuritySQLGate.isSymbol(token, '*')) {
762
+ return FieldSecuritySQLGate.starConstruct(analysis, i);
763
+ }
764
+ return FieldSecuritySQLGate.relationValueConstruct(analysis, i);
765
+ }
766
+ /**
767
+ * What a `*` at `i` reads: null for a multiplication, `COUNT(*)`, `EXISTS (SELECT *` whose body
768
+ * has no set operation or row-picking ORDER BY at its own depth, `alias.*` whose alias binds
769
+ * only to entities the user is denied nothing on, or a projection star
770
+ * ({@link FieldSecuritySQLContext.ProjectionStarOffsets}).
771
+ */
772
+ static starConstruct(analysis, i) {
773
+ const tokens = analysis.Tokens;
774
+ if (analysis.Context.ProjectionStarOffsets?.has(tokens[i].Start)) {
775
+ return null; // a projection star: its denied columns are dropped from the result instead
776
+ }
777
+ const prev = tokens[i - 1];
778
+ if (FieldSecuritySQLGate.isSymbol(prev, '.')) {
779
+ const qualifier = tokens[i - 2];
780
+ const bound = FieldSecuritySQLGate.isName(qualifier) ? analysis.Bindings.get(FoldIdentifier(qualifier.Text)) : undefined;
781
+ const unrestricted = !!bound && [...bound].every((e) => !analysis.Restricted.has(e));
782
+ return unrestricted ? null : 'alias.*';
783
+ }
784
+ if (FieldSecuritySQLGate.isSymbol(prev, '(')) {
785
+ const fn = tokens[i - 2];
786
+ return fn?.Kind === 'word' && ROW_COUNT_FUNCTIONS.has(fn.Text.toLowerCase()) ? null : `${fn?.Text?.toUpperCase() ?? ''}(*)`;
787
+ }
788
+ if (FieldSecuritySQLGate.isWord(prev, 'select') && FieldSecuritySQLGate.isSymbol(tokens[i - 2], '(') && FieldSecuritySQLGate.isWord(tokens[i - 3], 'exists')) {
789
+ return FieldSecuritySQLGate.existsBodyReadsRows(tokens, i - 2) ? 'SELECT * compared or ordered inside EXISTS' : null;
790
+ }
791
+ return FieldSecuritySQLGate.endsOperand(prev) && FieldSecuritySQLGate.startsOperand(tokens[i + 1]) ? null : 'SELECT *';
792
+ }
793
+ /**
794
+ * A restricted relation name or alias used other than as a declaration or a qualifier (`x.`):
795
+ * a whole-row value on PostgreSQL, or a column alias list after the declaration.
796
+ */
797
+ static relationValueConstruct(analysis, i) {
798
+ const token = analysis.Tokens[i];
799
+ if (!FieldSecuritySQLGate.isName(token) || !analysis.RestrictedQualifiers.has(FoldIdentifier(token.Text))) {
800
+ return null;
801
+ }
802
+ const next = analysis.Tokens[i + 1];
803
+ if (analysis.AliasDeclarations.has(i)) {
804
+ return FieldSecuritySQLGate.isSymbol(next, '(') ? 'column alias list' : null;
805
+ }
806
+ if (analysis.RelationDeclarations.has(i) || FieldSecuritySQLGate.isSymbol(next, '.')) {
807
+ return null;
808
+ }
809
+ return `whole-row reference '${token.Text}'`;
810
+ }
811
+ /** Whether a token can end the left operand of a multiplication. */
812
+ static endsOperand(token) {
813
+ if (!token)
814
+ return false;
815
+ if (token.Kind === 'word')
816
+ return !SELECT_LIST_WORDS.has(token.Text.toLowerCase());
817
+ return token.Kind !== 'symbol' || token.Text === ')';
818
+ }
819
+ /** Whether a token can start the right operand of a multiplication. */
820
+ static startsOperand(token) {
821
+ if (!token)
822
+ return false;
823
+ if (token.Kind === 'word')
824
+ return !CLAUSE_WORDS.has(token.Text.toLowerCase());
825
+ return token.Kind !== 'symbol' || ['(', '-', '+', '~'].includes(token.Text);
826
+ }
827
+ /**
828
+ * The name a "no such column" error would give for `name` as `sql` writes it, so a denied
829
+ * column is reported exactly as an unknown one. On PostgreSQL, MJ quotes a capitalized or
830
+ * dot-qualified word before the database sees it (`AutoQuoteLeavesWordBare`), so the error
831
+ * names such a word as written; a word left bare is folded to lower case. A quoted name, and
832
+ * every name on SQL Server, is reported as written. The first token spelled `name` decides.
833
+ *
834
+ * @param sql The caller's SQL
835
+ * @param name The reference as the database reads it
836
+ * @param dialect The dialect the SQL runs under
837
+ */
838
+ static ReportedColumnName(sql, name, dialect) {
839
+ const token = ScanSQLForScreening(sql, dialect).Tokens.find((t) => t.Text === name);
840
+ return token ? FieldSecuritySQLGate.reportedName(sql, token, dialect) : name;
841
+ }
842
+ /** A reference's name as {@link ReportedColumnName} gives it. */
843
+ static reportedName(sql, token, dialect) {
844
+ if (dialect !== 'postgresql' || token.Kind !== 'word' || !AutoQuoteLeavesWordBare(sql, token.Start, token.End)) {
845
+ return token.Text;
846
+ }
847
+ return token.Text.replace(/[A-Z]/g, (c) => c.toLowerCase());
848
+ }
849
+ static isName(token) {
850
+ return !!token && (token.Kind === 'word' || token.Kind === 'identifier');
851
+ }
852
+ static isWord(token, word) {
853
+ return !!token && token.Kind === 'word' && token.Text.toLowerCase() === word;
854
+ }
855
+ static isSymbol(token, symbol) {
856
+ return !!token && token.Kind === 'symbol' && token.Text === symbol;
857
+ }
858
+ /** Folded base view and base table names of every entity in the catalog. */
859
+ static relationIndex(catalog) {
860
+ const cached = relationIndexCache.get(catalog);
861
+ if (cached && cached.Length === catalog.length) {
862
+ return cached.Index;
863
+ }
864
+ const index = new Map();
865
+ for (const entity of catalog) {
866
+ for (const name of new Set([entity.BaseView, entity.BaseTable].filter((n) => typeof n === 'string' && n.trim().length > 0))) {
867
+ const key = FoldIdentifier(name);
868
+ index.set(key, [...(index.get(key) ?? []), entity]);
869
+ }
870
+ }
871
+ relationIndexCache.set(catalog, { Length: catalog.length, Index: index });
872
+ return index;
873
+ }
874
+ }
875
+ /**
876
+ * An identifier reduced to the form a database collation may compare it in: Unicode compatibility
877
+ * decomposition (a KELVIN SIGN reads as `K`, a full-width letter as its ASCII form), accents and
878
+ * other combining marks removed, control, format and default-ignorable characters (zero-width
879
+ * joiners, direction marks, a soft hyphen) removed, case folded through upper case (`ß` and `ẞ` both read `ss`, a
880
+ * final `ς` reads `σ`), and surrounding spaces trimmed.
881
+ */
882
+ export function FoldIdentifier(name) {
883
+ return name.normalize('NFKD').replace(/[\p{M}\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/gu, '').toLowerCase().toUpperCase().toLowerCase().trim();
884
+ }
885
+ /**
886
+ * Whether a reference as written may name `fieldName` on some database: by the rule
887
+ * `IdentifierMatches` states, or when both fold to the same form ({@link FoldIdentifier}). Looser
888
+ * than any collation, so a security check that refuses on it never lets through a reference the
889
+ * database would resolve to the field.
890
+ */
891
+ export function ReferenceMayNameField(reference, fieldName) {
892
+ return IdentifierMatches(reference, fieldName) || FoldIdentifier(reference) === FoldIdentifier(fieldName);
893
+ }
894
+ /** The message for a whole-row construct refused for a user carrying field denials. */
895
+ export function FieldSecurityWholeRowMessage(construct, entityName) {
896
+ return `SQL that reads entity '${entityName}' may not read whole rows (${construct}) — name each column it reads.`;
897
+ }
898
+ /** The message for SQL that could not be read well enough to check against field permissions. */
899
+ export function FieldSecurityUnresolvableSQLMessage(entityName) {
900
+ return `SQL that reads entity '${entityName}' could not be checked against your field permissions — rewrite it so it reads as plain SQL.`;
901
+ }
902
+ //# sourceMappingURL=fieldSecuritySQLGate.js.map