@memberjunction/server 5.51.3 → 5.51.4
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/README.md +34 -1
- package/dist/agentSessions/SessionManager.d.ts +5 -2
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +17 -8
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
- package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
- package/dist/agentSessions/SessionRunIDVerifier.js +136 -0
- package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
- package/dist/agentSessions/index.d.ts +1 -0
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +1 -0
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/auth/actionAuthorization.d.ts +40 -0
- package/dist/auth/actionAuthorization.d.ts.map +1 -0
- package/dist/auth/actionAuthorization.js +94 -0
- package/dist/auth/actionAuthorization.js.map +1 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
- package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
- package/dist/auth/scopeLimitedPrincipal.js +49 -0
- package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +34 -1
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +24 -34
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +3262 -3273
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +124 -4
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +170 -15
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
- package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.js +5 -4
- package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
- package/dist/generic/PushStatusResolver.d.ts +58 -0
- package/dist/generic/PushStatusResolver.d.ts.map +1 -1
- package/dist/generic/PushStatusResolver.js +42 -3
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +148 -3
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +287 -30
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +56 -48
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts.map +1 -1
- package/dist/multiTenancy/index.js +22 -2
- package/dist/multiTenancy/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +2 -2
- package/dist/realtimeWidget/widgetGuestElevation.js +2 -2
- package/dist/resolvers/ActionResolver.d.ts +31 -5
- package/dist/resolvers/ActionResolver.d.ts.map +1 -1
- package/dist/resolvers/ActionResolver.js +75 -14
- package/dist/resolvers/ActionResolver.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +39 -17
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +97 -157
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/DatasetResolver.d.ts +24 -3
- package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
- package/dist/resolvers/DatasetResolver.js +57 -8
- package/dist/resolvers/DatasetResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +14 -4
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +18 -6
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
- package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.js +117 -38
- package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +13 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/MCPResolver.d.ts.map +1 -1
- package/dist/resolvers/MCPResolver.js +13 -16
- package/dist/resolvers/MCPResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +26 -14
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +63 -46
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +24 -27
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +30 -40
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +12 -11
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTestResolver.js +90 -112
- package/dist/resolvers/RunTestResolver.js.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
- package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
- package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
- package/dist/resolvers/VersionHistoryResolver.js +8 -12
- package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts +3 -1
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +29 -17
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/services/TaskOrchestrator.d.ts.map +1 -1
- package/dist/services/TaskOrchestrator.js +17 -15
- package/dist/services/TaskOrchestrator.js.map +1 -1
- package/package.json +90 -89
- package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
- package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
- package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +136 -0
- package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +318 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +66 -27
- package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +134 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +221 -0
- package/src/__tests__/ResolverBase.aggregateScreen.test.ts +173 -0
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +116 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +351 -0
- package/src/__tests__/RunTemplateResolver.test.ts +132 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +164 -0
- package/src/__tests__/SessionManager.test.ts +88 -13
- package/src/__tests__/SessionRunIDVerifier.test.ts +255 -0
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +155 -0
- package/src/__tests__/actionAuthorization.test.ts +130 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +241 -0
- package/src/__tests__/multiTenancy.postgresRewrite.test.ts +180 -0
- package/src/__tests__/multiTenancy.security.test.ts +51 -0
- package/src/__tests__/multiTenancy.test.ts +1 -1
- package/src/__tests__/newUserRolesDefault.test.ts +81 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
- package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
- package/src/__tests__/resolverBase.rls.test.ts +2 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/agentSessions/SessionManager.ts +24 -25
- package/src/agentSessions/SessionRunIDVerifier.ts +179 -0
- package/src/agentSessions/index.ts +1 -0
- package/src/auth/actionAuthorization.ts +119 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/scopeLimitedPrincipal.ts +50 -0
- package/src/config.ts +34 -1
- package/src/context.ts +25 -34
- package/src/generated/generated.ts +3262 -3273
- package/src/generic/CacheInvalidationResolver.ts +206 -15
- package/src/generic/FireAndForgetHeartbeat.ts +10 -4
- package/src/generic/PushStatusResolver.ts +79 -4
- package/src/generic/ResolverBase.ts +339 -31
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/index.ts +63 -50
- package/src/multiTenancy/index.ts +24 -2
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/realtimeWidget/widgetGuestElevation.ts +2 -2
- package/src/resolvers/ActionResolver.ts +85 -22
- package/src/resolvers/AdhocQueryResolver.ts +110 -177
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +24 -7
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +14 -1
- package/src/resolvers/MCPResolver.ts +13 -15
- package/src/resolvers/RealtimeClientSessionResolver.ts +82 -50
- package/src/resolvers/RemoteBrowserActionResolver.ts +30 -26
- package/src/resolvers/RunAIAgentResolver.ts +30 -40
- package/src/resolvers/RunTemplateResolver.ts +13 -12
- package/src/resolvers/RunTestResolver.ts +90 -112
- package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
- package/src/resolvers/VersionHistoryResolver.ts +10 -13
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/__tests__/ReportResolver.test.ts +2 -0
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/rest/ViewOperationsHandler.ts +37 -18
- package/src/services/TaskOrchestrator.ts +17 -17
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -42
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -50
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { BaseEntity, BaseEntityEvent, CompositeKey, EntityFieldTSType, LogDebug, LogError, LogStatus, Metadata, } from '@memberjunction/core';
|
|
2
2
|
import { UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
3
|
+
import { ClientClauseScreen } from '@memberjunction/generic-database-provider';
|
|
3
4
|
import { AuthorizationError } from 'type-graphql';
|
|
4
5
|
import { GraphQLError } from 'graphql';
|
|
5
6
|
import { GetAPIKeyEngine } from '@memberjunction/api-keys';
|
|
6
7
|
import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
|
|
7
|
-
import { MJEventType, MJGlobal, ENCRYPTED_SENTINEL, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
8
|
+
import { MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
9
|
+
import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
|
|
8
10
|
import { EncryptionEngine } from '@memberjunction/encryption';
|
|
9
|
-
import {
|
|
10
|
-
import { CACHE_INVALIDATION_TOPIC } from './CacheInvalidationResolver.js';
|
|
11
|
+
import { publishStatusUpdate } from './PushStatusResolver.js';
|
|
12
|
+
import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
|
|
11
13
|
import { PubSubManager } from './PubSubManager.js';
|
|
12
14
|
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
|
|
15
|
+
/**
|
|
16
|
+
* Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
|
|
17
|
+
* do not track record changes: their server-side write rules compare against the prior state, which a client could
|
|
18
|
+
* otherwise supply itself (lower-case names).
|
|
19
|
+
*
|
|
20
|
+
* `MJ: Data Context Items`: its server class lets only an administrator save an item that holds SQL, and decides
|
|
21
|
+
* that from the item's `Type` and `SQL`. Hydrated from OldValues, a client could leave both out, and the stored
|
|
22
|
+
* procedure would keep the stored SQL item while the rule saw none.
|
|
23
|
+
*/
|
|
24
|
+
const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW = new Set(['mj: data context items']);
|
|
13
25
|
export class ResolverBase {
|
|
14
26
|
static { this._emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null; }
|
|
15
27
|
static { this._cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {}; }
|
|
@@ -213,9 +225,11 @@ export class ResolverBase {
|
|
|
213
225
|
* @param dataObject - The data object containing field values
|
|
214
226
|
* @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
|
|
215
227
|
* @param contextUser - User context for encryption operations
|
|
228
|
+
* @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
|
|
229
|
+
* default) or field Names (raw provider rows, such as dataset item results)
|
|
216
230
|
* @returns The filtered data object
|
|
217
231
|
*/
|
|
218
|
-
async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider) {
|
|
232
|
+
async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider, fieldKey = 'CodeName') {
|
|
219
233
|
if (!dataObject)
|
|
220
234
|
return dataObject;
|
|
221
235
|
const md = provider ?? new Metadata();
|
|
@@ -228,7 +242,7 @@ export class ResolverBase {
|
|
|
228
242
|
return dataObject;
|
|
229
243
|
// Process each encrypted field
|
|
230
244
|
for (const field of encryptedFields) {
|
|
231
|
-
const fieldName = field.CodeName;
|
|
245
|
+
const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
|
|
232
246
|
const value = dataObject[fieldName];
|
|
233
247
|
// Skip null/undefined values
|
|
234
248
|
if (value === null || value === undefined)
|
|
@@ -264,9 +278,10 @@ export class ResolverBase {
|
|
|
264
278
|
return dataObject;
|
|
265
279
|
}
|
|
266
280
|
/**
|
|
267
|
-
* Filters encrypted fields for an array of data objects
|
|
281
|
+
* Filters encrypted fields for an array of data objects. `fieldKey` is as for
|
|
282
|
+
* {@link FilterEncryptedFieldsForAPI}.
|
|
268
283
|
*/
|
|
269
|
-
async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider) {
|
|
284
|
+
async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider, fieldKey = 'CodeName') {
|
|
270
285
|
if (!dataObjectArray || dataObjectArray.length === 0)
|
|
271
286
|
return dataObjectArray;
|
|
272
287
|
// Check if entity has any encrypted fields first to avoid unnecessary processing
|
|
@@ -279,10 +294,56 @@ export class ResolverBase {
|
|
|
279
294
|
return dataObjectArray;
|
|
280
295
|
// Process each element
|
|
281
296
|
for (const element of dataObjectArray) {
|
|
282
|
-
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
|
|
297
|
+
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
|
|
283
298
|
}
|
|
284
299
|
return dataObjectArray;
|
|
285
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Renders one `findBy` predicate value as the SQL literal text it will become inside the
|
|
303
|
+
* `ExtraFilter` that method builds.
|
|
304
|
+
*
|
|
305
|
+
* The two slots have different exposure, so they are handled differently:
|
|
306
|
+
*
|
|
307
|
+
* - **Quoted slot** (`NeedsQuotes` — every type except Number and Boolean). The value sits
|
|
308
|
+
* inside a single-quoted literal, so it is escaped with `EscapeSQLString`: quotes are
|
|
309
|
+
* doubled and null bytes stripped, leaving the payload inert *inside* the literal.
|
|
310
|
+
* - **Unquoted slot** (Number and Boolean fields). There is no literal to break out of
|
|
311
|
+
* here — the value lands in the clause bare, so a string value simply *is* SQL and no
|
|
312
|
+
* amount of quote escaping helps. Only a genuine number or boolean is accepted; anything
|
|
313
|
+
* else is refused rather than interpolated. Numeric and boolean strings are accepted too
|
|
314
|
+
* (a client may legitimately send `"42"`), since neither can carry SQL.
|
|
315
|
+
*
|
|
316
|
+
* A `null`/`undefined` value is refused in either slot. `EscapeSQLString` maps both to `''`
|
|
317
|
+
* by design, which would silently turn a by-value lookup into `Field = ''` — for this
|
|
318
|
+
* caller a missing value is a bug, not a query.
|
|
319
|
+
*
|
|
320
|
+
* @param value the raw value supplied for the field
|
|
321
|
+
* @param fieldName field name, for the error message
|
|
322
|
+
* @param entity entity name, for the error message
|
|
323
|
+
* @param needsQuotes whether the field's type requires a quoted literal
|
|
324
|
+
* @returns the literal text (including quotes when the slot is quoted)
|
|
325
|
+
* @throws when the value cannot be rendered safely for the slot it is destined for
|
|
326
|
+
*/
|
|
327
|
+
formatFilterValue(value, fieldName, entity, needsQuotes) {
|
|
328
|
+
if (value === null || value === undefined) {
|
|
329
|
+
throw new Error(`Field ${fieldName} in entity ${entity} was given a ${value === null ? 'null' : 'undefined'} value. findBy builds an equality predicate, which needs a value to compare against.`);
|
|
330
|
+
}
|
|
331
|
+
if (needsQuotes) {
|
|
332
|
+
return `'${EscapeSQLString(String(value))}'`;
|
|
333
|
+
}
|
|
334
|
+
// Unquoted slot — accept only what cannot carry SQL, and render it exactly as before.
|
|
335
|
+
if (typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) {
|
|
336
|
+
return String(value);
|
|
337
|
+
}
|
|
338
|
+
if (typeof value === 'string') {
|
|
339
|
+
const trimmed = value.trim();
|
|
340
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed))
|
|
341
|
+
return trimmed;
|
|
342
|
+
if (/^(true|false)$/i.test(trimmed))
|
|
343
|
+
return trimmed.toLowerCase();
|
|
344
|
+
}
|
|
345
|
+
throw new Error(`Field ${fieldName} in entity ${entity} is a numeric or boolean field, so its value is not quoted in the filter — only a number or boolean can be used. Received: ${typeof value}.`);
|
|
346
|
+
}
|
|
286
347
|
async findBy(provider, entity, params, contextUser) {
|
|
287
348
|
// build the SQL query based on the params passed in
|
|
288
349
|
const rv = provider;
|
|
@@ -299,8 +360,7 @@ export class ResolverBase {
|
|
|
299
360
|
const field = e.Fields.find((f) => f.Name === k);
|
|
300
361
|
if (!field)
|
|
301
362
|
throw new Error(`Field ${k} not found in entity ${entity}`);
|
|
302
|
-
|
|
303
|
-
extraFilter += `${k} = ${quotes}${params[k]}${quotes}`;
|
|
363
|
+
extraFilter += `${k} = ${this.formatFilterValue(params[k], k, entity, field.NeedsQuotes)}`;
|
|
304
364
|
});
|
|
305
365
|
// ok, now we have a SQL string, run it and return the results
|
|
306
366
|
// use the SQLServerDataProvider
|
|
@@ -324,7 +384,8 @@ export class ResolverBase {
|
|
|
324
384
|
const rv = provider;
|
|
325
385
|
const result = await rv.RunView({
|
|
326
386
|
EntityName: 'MJ: User Views',
|
|
327
|
-
|
|
387
|
+
// SECURITY: the view name is client-supplied and lands inside a SQL literal.
|
|
388
|
+
ExtraFilter: "Name='" + EscapeSQLString(viewInput.ViewName) + "'",
|
|
328
389
|
}, userPayload.userRecord);
|
|
329
390
|
if (result && result.Success && result.Results.length > 0) {
|
|
330
391
|
const viewInfo = result.Results[0];
|
|
@@ -573,6 +634,158 @@ export class ResolverBase {
|
|
|
573
634
|
`Denial reason: ${result.Reason}`);
|
|
574
635
|
}
|
|
575
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* SECURITY — GraphQL-boundary screen for one client-supplied SQL clause fragment.
|
|
639
|
+
*
|
|
640
|
+
* `ValidateUserProvidedSQLClause` still blocks stacked statements, DML, comments, UNION
|
|
641
|
+
* and WAITFOR, and still permits SELECT (server-internal engines pass richer filters).
|
|
642
|
+
* A keyword ban on SELECT/EXISTS at this boundary (#4253) broke first-party clients that
|
|
643
|
+
* use `IN (SELECT … FROM <entity base view>)` — that is a legitimate ExtraFilter.
|
|
644
|
+
*
|
|
645
|
+
* This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
|
|
646
|
+
* wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
|
|
647
|
+
* allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
|
|
648
|
+
* and catalogs are rejected. Server-internal RunView callers get only the provider's
|
|
649
|
+
* statement-separator check, which cannot refuse a legitimate filter.
|
|
650
|
+
*
|
|
651
|
+
* The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
|
|
652
|
+
* with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
|
|
653
|
+
*
|
|
654
|
+
* Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
|
|
655
|
+
* provider and the tenant hook splice it inside parentheses and AND their own filters after
|
|
656
|
+
* it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
|
|
657
|
+
* security and tenant scope on one side of an OR the caller controls.
|
|
658
|
+
* The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
|
|
659
|
+
* A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
|
|
660
|
+
* inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
|
|
661
|
+
* there, so the text after it could be code that the parse-based checks never saw.
|
|
662
|
+
*
|
|
663
|
+
* RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
|
|
664
|
+
* into the view, and RunView removes denied fields from its own result only. A subquery reads a
|
|
665
|
+
* base view directly with neither, so the screen refuses one over any entity the user reads under
|
|
666
|
+
* a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
|
|
667
|
+
* entity included.
|
|
668
|
+
*/
|
|
669
|
+
assertClientClauseUsesEntityBaseViews(clause, label, provider, user, options) {
|
|
670
|
+
if (!clause?.trim())
|
|
671
|
+
return;
|
|
672
|
+
// The balance and backslash checks run before the shared screen: it parses the fragment
|
|
673
|
+
// inside its own parentheses, so it cannot see a fragment that closes the parentheses the
|
|
674
|
+
// provider and the tenant hook splice it into.
|
|
675
|
+
const dialect = this.dialectForProvider(provider);
|
|
676
|
+
if (SQLParser.HasStackedStatements(clause, dialect)) {
|
|
677
|
+
throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
|
|
678
|
+
}
|
|
679
|
+
const balance = IsBalancedSQLFragment(clause, dialect);
|
|
680
|
+
if (!balance.IsBalanced) {
|
|
681
|
+
throw new Error(`Invalid ${label}: ${balance.Reason}`);
|
|
682
|
+
}
|
|
683
|
+
if (this.hasBackslashInQuotes(clause, dialect)) {
|
|
684
|
+
throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
|
|
685
|
+
}
|
|
686
|
+
ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
|
|
690
|
+
* to the SELECT-only validation the caller performs, every table reference must resolve to an
|
|
691
|
+
* entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
|
|
692
|
+
* CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
|
|
693
|
+
* own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
|
|
694
|
+
* for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
|
|
695
|
+
* Fails closed: no user, or an unresolvable reference, refuses.
|
|
696
|
+
*
|
|
697
|
+
* The references come from a walk of the whole parse tree
|
|
698
|
+
* (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
|
|
699
|
+
* account for every SELECT in the text, or when a FROM item names no table (a table function or
|
|
700
|
+
* a row list).
|
|
701
|
+
*
|
|
702
|
+
* Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
|
|
703
|
+
* row-level security, and raw SQL does not apply it. Rather than try to rewrite arbitrary SQL
|
|
704
|
+
* (joins, CTEs) to compose that policy, this refuses any entity that carries a row filter for
|
|
705
|
+
* the caller — those reads belong on RunView. For
|
|
706
|
+
* the remaining entities CanRead genuinely means "every row and field of the view".
|
|
707
|
+
*/
|
|
708
|
+
assertFullQueryUsesReadableEntityViews(sqlText, provider, user, label = 'SQL') {
|
|
709
|
+
if (!user) {
|
|
710
|
+
throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
|
|
711
|
+
}
|
|
712
|
+
const dialect = this.dialectForProvider(provider);
|
|
713
|
+
ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
|
|
714
|
+
const parser = new SQLParser(sqlText, dialect);
|
|
715
|
+
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
716
|
+
throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
|
|
717
|
+
}
|
|
718
|
+
if (this.astContainsWriteNode(parser.AST)) {
|
|
719
|
+
throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
|
|
720
|
+
}
|
|
721
|
+
const reads = ClientClauseScreen.CollectStatementReads(parser);
|
|
722
|
+
ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
|
|
723
|
+
const allowed = this.entityBaseViewAllowList(provider);
|
|
724
|
+
for (const relation of reads.Relations) {
|
|
725
|
+
if (relation.IsCTEReference)
|
|
726
|
+
continue;
|
|
727
|
+
const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
|
|
728
|
+
if (entity)
|
|
729
|
+
this.assertUnscopedRead(entity, user, label);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
|
|
733
|
+
assertUnscopedRead(entity, user, label) {
|
|
734
|
+
ClientClauseScreen.AssertUnscopedRead(entity, user, label);
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
|
|
738
|
+
* a view request can carry that is actually a SQL fragment. GraphQL entry points
|
|
739
|
+
* (RunViewByName, RunViewByID, RunDynamicView, RunViews) funnel through
|
|
740
|
+
* RunViewGenericInternal / RunViewsGenericInternal.
|
|
741
|
+
*
|
|
742
|
+
* 🚨 `UserSearchString` is deliberately NOT screened here (#4392). It is not a clause — it is
|
|
743
|
+
* the free text a person typed into a search box, and it never reaches SQL as a fragment.
|
|
744
|
+
* `GenericDatabaseProvider.createViewUserSearchSQL` builds the predicate itself from
|
|
745
|
+
* `IncludeInUserSearchAPI` metadata and lands the text only as a literal, doubling single
|
|
746
|
+
* quotes and escaping LIKE metacharacters with an explicit `ESCAPE`. Running it through a
|
|
747
|
+
* screen that parses its argument as SQL and fails closed rejected every term that is not
|
|
748
|
+
* coincidentally valid SQL: `Marcus Chen` parses as nothing, `O'Leary` as an unterminated
|
|
749
|
+
* literal — so essentially every real name search returned 0 rows.
|
|
750
|
+
*
|
|
751
|
+
* The provider-level denylist does NOT back this up — it was removed from `UserSearchString`
|
|
752
|
+
* in the same change, for the same reason (it refused `Union Pacific`). What protects the value
|
|
753
|
+
* is that it is never SQL: it reaches the database only as a quoted, quote-doubled literal.
|
|
754
|
+
* The single place that is not true is a field carrying `UserSearchParamFormatAPI`, whose
|
|
755
|
+
* admin-authored format may splice the term in unquoted; `createViewUserSearchSQL` re-applies
|
|
756
|
+
* `ValidateUserProvidedSQLClause` for exactly those entities.
|
|
757
|
+
*
|
|
758
|
+
* Each `Aggregates[].expression` gets the same screen, except that text the parser cannot read
|
|
759
|
+
* goes on to the provider, which refuses it per aggregate. The provider never runs the caller's
|
|
760
|
+
* aggregate text: it runs the SQL that `CheckAggregateExpression` rebuilds from a single
|
|
761
|
+
* aggregate call over the entity's columns, and refuses everything else per aggregate.
|
|
762
|
+
*/
|
|
763
|
+
screenClientViewClauses(clauses, provider, user) {
|
|
764
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider, user);
|
|
765
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider, user);
|
|
766
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider, user);
|
|
767
|
+
for (const aggregate of clauses.aggregates ?? []) {
|
|
768
|
+
this.assertClientClauseUsesEntityBaseViews(aggregate?.expression, 'Aggregate', provider, user, { LeaveUnparseableToProvider: true });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* True when a string literal or quoted identifier in `clause`, read the way the database reads
|
|
773
|
+
* it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
|
|
774
|
+
*/
|
|
775
|
+
hasBackslashInQuotes(clause, dialect) {
|
|
776
|
+
return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
|
|
777
|
+
}
|
|
778
|
+
/** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
|
|
779
|
+
astContainsWriteNode(node) {
|
|
780
|
+
return ClientClauseScreen.AstContainsWriteNode(node);
|
|
781
|
+
}
|
|
782
|
+
dialectForProvider(provider) {
|
|
783
|
+
return ClientClauseScreen.DialectFor(provider);
|
|
784
|
+
}
|
|
785
|
+
/** The entity base-view allow-list for a provider's entities. */
|
|
786
|
+
entityBaseViewAllowList(provider) {
|
|
787
|
+
return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
|
|
788
|
+
}
|
|
576
789
|
/**
|
|
577
790
|
* Optimized RunViewGenericInternal implementation with:
|
|
578
791
|
* - Field filtering at source (Fix #7)
|
|
@@ -582,9 +795,6 @@ export class ResolverBase {
|
|
|
582
795
|
try {
|
|
583
796
|
if (!viewInfo || !userPayload)
|
|
584
797
|
return null;
|
|
585
|
-
// Check API key scope authorization for view operations
|
|
586
|
-
await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
|
|
587
|
-
const md = provider;
|
|
588
798
|
// Prefer the authenticated session's payload user — it is the authoritative per-request
|
|
589
799
|
// identity and (for magic-link sessions) carries the per-session resource scope / synthesized
|
|
590
800
|
// roles that drive RLS. The cached lookup is a fallback for paths where the payload user
|
|
@@ -593,6 +803,13 @@ export class ResolverBase {
|
|
|
593
803
|
?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
|
|
594
804
|
if (!user)
|
|
595
805
|
throw new Error(`User ${userPayload?.email} not found in metadata`);
|
|
806
|
+
// SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
|
|
807
|
+
// Screen at this boundary: parse, reject writes, allow only entity BaseViews the
|
|
808
|
+
// acting user can read (subqueries do not inherit the top entity's permission check).
|
|
809
|
+
this.screenClientViewClauses({ extraFilter, orderBy, userSearchString, overrideExcludeFilter, aggregates }, provider, user);
|
|
810
|
+
// Check API key scope authorization for view operations
|
|
811
|
+
await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
|
|
812
|
+
const md = provider;
|
|
596
813
|
const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
|
|
597
814
|
if (!entityInfo)
|
|
598
815
|
throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
|
|
@@ -718,6 +935,14 @@ export class ResolverBase {
|
|
|
718
935
|
md = params[0].provider;
|
|
719
936
|
// Transform parameters
|
|
720
937
|
for (const param of params) {
|
|
938
|
+
// SECURITY: same GraphQL-boundary BaseView screen as RunViewGenericInternal.
|
|
939
|
+
this.screenClientViewClauses({
|
|
940
|
+
extraFilter: param.extraFilter,
|
|
941
|
+
orderBy: param.orderBy,
|
|
942
|
+
userSearchString: param.userSearchString,
|
|
943
|
+
overrideExcludeFilter: param.overrideExcludeFilter,
|
|
944
|
+
aggregates: param.aggregates,
|
|
945
|
+
}, md, contextUser ?? undefined);
|
|
721
946
|
if (param.viewInfo) {
|
|
722
947
|
// Validate entity only once per entity type
|
|
723
948
|
const entityName = param.viewInfo.Entity;
|
|
@@ -889,16 +1114,38 @@ export class ResolverBase {
|
|
|
889
1114
|
* Publishes a CACHE_INVALIDATION event to connected browser clients after a successful
|
|
890
1115
|
* entity save or delete. Includes the originSessionId so the originating browser can
|
|
891
1116
|
* skip redundant re-fetches (it already handled the event locally).
|
|
1117
|
+
*
|
|
1118
|
+
* The row itself rides along ONLY for entities opted in via
|
|
1119
|
+
* `cacheSettings.recordDataBroadcastEntities` — this event reaches every connected client
|
|
1120
|
+
* unfiltered, so the row would otherwise be readable by sessions that could not read the record.
|
|
892
1121
|
*/
|
|
893
1122
|
PublishCacheInvalidation(entityObject, action, userPayload) {
|
|
1123
|
+
const entityName = entityObject.EntityInfo.Name;
|
|
894
1124
|
PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
|
|
895
|
-
entityName
|
|
1125
|
+
entityName,
|
|
896
1126
|
primaryKeyValues: JSON.stringify(entityObject.PrimaryKey.KeyValuePairs),
|
|
897
1127
|
action,
|
|
898
1128
|
sourceServerId: MJGlobal.Instance.ProcessUUID,
|
|
899
1129
|
timestamp: new Date(),
|
|
900
1130
|
originSessionId: userPayload?.sessionId || null,
|
|
901
|
-
recordData: action === 'save'
|
|
1131
|
+
recordData: action === 'save' && MayBroadcastRecordData(entityName)
|
|
1132
|
+
? JSON.stringify(entityObject.GetAll())
|
|
1133
|
+
: undefined,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Publishes a push-status update to the client on {@link PUSH_STATUS_UPDATES_TOPIC}, stamping the
|
|
1138
|
+
* authenticated owner's user ID from `userPayload` so the subscription filter can bind delivery
|
|
1139
|
+
* to identity (see B49 / `statusUpdatesFilter`). The ergonomic wrapper every resolver should use
|
|
1140
|
+
* instead of calling `pubSub.publish` on the topic directly — it makes omitting identity
|
|
1141
|
+
* impossible. Non-resolver publishers (services, the liveness heartbeat) call the shared
|
|
1142
|
+
* `publishStatusUpdate()` function directly with an explicit `ownerUserId`.
|
|
1143
|
+
*/
|
|
1144
|
+
PublishStatusUpdate(pubSub, sessionId, message, userPayload) {
|
|
1145
|
+
publishStatusUpdate(pubSub, {
|
|
1146
|
+
sessionId,
|
|
1147
|
+
ownerUserId: userPayload?.userRecord?.ID ?? '',
|
|
1148
|
+
message,
|
|
902
1149
|
});
|
|
903
1150
|
}
|
|
904
1151
|
ListenForEntityMessages(entityObject, pubSub, userPayload) {
|
|
@@ -926,16 +1173,13 @@ export class ResolverBase {
|
|
|
926
1173
|
const baseEntityEvent = event.args;
|
|
927
1174
|
// message from our entity object, relay it to the client
|
|
928
1175
|
LogDebug('ResolverBase.ListenForEntityMessages: About to publish PUSH_STATUS_UPDATES_TOPIC');
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
}),
|
|
937
|
-
sessionId: userPayload.sessionId,
|
|
938
|
-
});
|
|
1176
|
+
this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
|
|
1177
|
+
status: 'OK',
|
|
1178
|
+
type: 'EntityObjectStatusMessage',
|
|
1179
|
+
entityName: baseEntityEvent.baseEntity.EntityInfo.Name,
|
|
1180
|
+
primaryKey: baseEntityEvent.baseEntity.PrimaryKey,
|
|
1181
|
+
message: event.args.payload,
|
|
1182
|
+
}), userPayload);
|
|
939
1183
|
}
|
|
940
1184
|
}
|
|
941
1185
|
});
|
|
@@ -1004,8 +1248,9 @@ export class ResolverBase {
|
|
|
1004
1248
|
clientNewValues[key] = input[key];
|
|
1005
1249
|
}
|
|
1006
1250
|
});
|
|
1007
|
-
if (entityInfo.TrackRecordChanges || !input.OldValues___) {
|
|
1008
|
-
// We get here because
|
|
1251
|
+
if (entityInfo.TrackRecordChanges || !input.OldValues___ || ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase())) {
|
|
1252
|
+
// We get here because the entity tracks record changes, the client did not provide OldValues, or the entity's
|
|
1253
|
+
// write rules need the stored row (ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW), so we load the old values from the DB
|
|
1009
1254
|
const cKey = new CompositeKey(entityInfo.PrimaryKeys.map((pk) => {
|
|
1010
1255
|
return {
|
|
1011
1256
|
FieldName: pk.Name,
|
|
@@ -1053,7 +1298,15 @@ export class ResolverBase {
|
|
|
1053
1298
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
|
|
1054
1299
|
}
|
|
1055
1300
|
else {
|
|
1056
|
-
|
|
1301
|
+
// CompleteMessage, not Message. A validation refusal puts its reasons in `Errors` and leaves
|
|
1302
|
+
// `Message` unset, so reading `Message` here fell through to 'Unknown error' and discarded
|
|
1303
|
+
// every field-named explanation the entity had just produced. CreateRecord already read
|
|
1304
|
+
// CompleteMessage; this path did not, which is why the SAME rule on the SAME entity explained
|
|
1305
|
+
// itself on a create and said nothing on an update. CompleteMessage is a strict superset — it
|
|
1306
|
+
// starts from `Message`, then appends `Error` and `Errors` — so nothing previously reported is
|
|
1307
|
+
// lost, and it still yields undefined when there is genuinely nothing to say, leaving the
|
|
1308
|
+
// fallback below to fire rather than showing the user a blank error.
|
|
1309
|
+
throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
|
|
1057
1310
|
extensions: { code: 'SAVE_ENTITY_ERROR', entityName },
|
|
1058
1311
|
});
|
|
1059
1312
|
}
|
|
@@ -1260,7 +1513,11 @@ export class ResolverBase {
|
|
|
1260
1513
|
return returnValue;
|
|
1261
1514
|
}
|
|
1262
1515
|
else {
|
|
1263
|
-
|
|
1516
|
+
// CompleteMessage, for the same reason as the update path above. It matters more going
|
|
1517
|
+
// forward: apps already refuse deletes by overriding Delete() and returning false with the
|
|
1518
|
+
// reason on LatestResult, and #3971 proposes a first-class delete-validation seam. Every one
|
|
1519
|
+
// of those reasons was being replaced by 'Unknown error' at the API boundary.
|
|
1520
|
+
throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
|
|
1264
1521
|
extensions: { code: 'DELETE_ENTITY_ERROR', entityName },
|
|
1265
1522
|
});
|
|
1266
1523
|
}
|