@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
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from '@memberjunction/core';
|
|
22
22
|
import { MJAuditLogEntity, MJErrorLogEntity, MJUserViewEntityExtended } from '@memberjunction/core-entities';
|
|
23
23
|
import { SQLServerDataProvider, UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
24
|
+
import { ClientClauseScreen, type EntityBaseViewAllowList } from '@memberjunction/generic-database-provider';
|
|
24
25
|
import { PubSubEngine, AuthorizationError } from 'type-graphql';
|
|
25
26
|
import { GraphQLError } from 'graphql';
|
|
26
27
|
import { GetAPIKeyEngine } from '@memberjunction/api-keys';
|
|
@@ -30,14 +31,27 @@ import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
|
|
|
30
31
|
import { RunViewGenericParams, UserPayload } from '../types.js';
|
|
31
32
|
import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
|
|
32
33
|
import { DeleteOptionsInput } from './DeleteOptionsInput.js';
|
|
33
|
-
import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
34
|
+
import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
35
|
+
import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
|
|
36
|
+
import type { SQLParserDialect } from '@memberjunction/sql-dialect';
|
|
34
37
|
import { EncryptionEngine } from '@memberjunction/encryption';
|
|
35
|
-
import { PUSH_STATUS_UPDATES_TOPIC } from './PushStatusResolver.js';
|
|
36
|
-
import { CACHE_INVALIDATION_TOPIC } from './CacheInvalidationResolver.js';
|
|
38
|
+
import { PUSH_STATUS_UPDATES_TOPIC, publishStatusUpdate } from './PushStatusResolver.js';
|
|
39
|
+
import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
|
|
37
40
|
import { PubSubManager } from './PubSubManager.js';
|
|
38
41
|
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
|
|
39
42
|
import { Subscription } from 'rxjs';
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
|
|
46
|
+
* do not track record changes: their server-side write rules compare against the prior state, which a client could
|
|
47
|
+
* otherwise supply itself (lower-case names).
|
|
48
|
+
*
|
|
49
|
+
* `MJ: Data Context Items`: its server class lets only an administrator save an item that holds SQL, and decides
|
|
50
|
+
* that from the item's `Type` and `SQL`. Hydrated from OldValues, a client could leave both out, and the stored
|
|
51
|
+
* procedure would keep the stored SQL item while the rule saw none.
|
|
52
|
+
*/
|
|
53
|
+
const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW: ReadonlySet<string> = new Set(['mj: data context items']);
|
|
54
|
+
|
|
41
55
|
export class ResolverBase {
|
|
42
56
|
private static _emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null;
|
|
43
57
|
private static _cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {};
|
|
@@ -252,13 +266,16 @@ export class ResolverBase {
|
|
|
252
266
|
* @param dataObject - The data object containing field values
|
|
253
267
|
* @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
|
|
254
268
|
* @param contextUser - User context for encryption operations
|
|
269
|
+
* @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
|
|
270
|
+
* default) or field Names (raw provider rows, such as dataset item results)
|
|
255
271
|
* @returns The filtered data object
|
|
256
272
|
*/
|
|
257
273
|
protected async FilterEncryptedFieldsForAPI(
|
|
258
274
|
entityName: string,
|
|
259
275
|
dataObject: Record<string, unknown>,
|
|
260
276
|
contextUser: UserInfo,
|
|
261
|
-
provider?: IMetadataProvider
|
|
277
|
+
provider?: IMetadataProvider,
|
|
278
|
+
fieldKey: 'CodeName' | 'Name' = 'CodeName'
|
|
262
279
|
): Promise<Record<string, unknown>> {
|
|
263
280
|
if (!dataObject) return dataObject;
|
|
264
281
|
|
|
@@ -272,7 +289,7 @@ export class ResolverBase {
|
|
|
272
289
|
|
|
273
290
|
// Process each encrypted field
|
|
274
291
|
for (const field of encryptedFields) {
|
|
275
|
-
const fieldName = field.CodeName;
|
|
292
|
+
const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
|
|
276
293
|
const value = dataObject[fieldName];
|
|
277
294
|
|
|
278
295
|
// Skip null/undefined values
|
|
@@ -313,13 +330,15 @@ export class ResolverBase {
|
|
|
313
330
|
}
|
|
314
331
|
|
|
315
332
|
/**
|
|
316
|
-
* Filters encrypted fields for an array of data objects
|
|
333
|
+
* Filters encrypted fields for an array of data objects. `fieldKey` is as for
|
|
334
|
+
* {@link FilterEncryptedFieldsForAPI}.
|
|
317
335
|
*/
|
|
318
336
|
protected async ArrayFilterEncryptedFieldsForAPI(
|
|
319
337
|
entityName: string,
|
|
320
338
|
dataObjectArray: Record<string, unknown>[],
|
|
321
339
|
contextUser: UserInfo,
|
|
322
|
-
provider?: IMetadataProvider
|
|
340
|
+
provider?: IMetadataProvider,
|
|
341
|
+
fieldKey: 'CodeName' | 'Name' = 'CodeName'
|
|
323
342
|
): Promise<Record<string, unknown>[]> {
|
|
324
343
|
if (!dataObjectArray || dataObjectArray.length === 0) return dataObjectArray;
|
|
325
344
|
|
|
@@ -333,12 +352,64 @@ export class ResolverBase {
|
|
|
333
352
|
|
|
334
353
|
// Process each element
|
|
335
354
|
for (const element of dataObjectArray) {
|
|
336
|
-
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
|
|
355
|
+
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
|
|
337
356
|
}
|
|
338
357
|
|
|
339
358
|
return dataObjectArray;
|
|
340
359
|
}
|
|
341
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Renders one `findBy` predicate value as the SQL literal text it will become inside the
|
|
363
|
+
* `ExtraFilter` that method builds.
|
|
364
|
+
*
|
|
365
|
+
* The two slots have different exposure, so they are handled differently:
|
|
366
|
+
*
|
|
367
|
+
* - **Quoted slot** (`NeedsQuotes` — every type except Number and Boolean). The value sits
|
|
368
|
+
* inside a single-quoted literal, so it is escaped with `EscapeSQLString`: quotes are
|
|
369
|
+
* doubled and null bytes stripped, leaving the payload inert *inside* the literal.
|
|
370
|
+
* - **Unquoted slot** (Number and Boolean fields). There is no literal to break out of
|
|
371
|
+
* here — the value lands in the clause bare, so a string value simply *is* SQL and no
|
|
372
|
+
* amount of quote escaping helps. Only a genuine number or boolean is accepted; anything
|
|
373
|
+
* else is refused rather than interpolated. Numeric and boolean strings are accepted too
|
|
374
|
+
* (a client may legitimately send `"42"`), since neither can carry SQL.
|
|
375
|
+
*
|
|
376
|
+
* A `null`/`undefined` value is refused in either slot. `EscapeSQLString` maps both to `''`
|
|
377
|
+
* by design, which would silently turn a by-value lookup into `Field = ''` — for this
|
|
378
|
+
* caller a missing value is a bug, not a query.
|
|
379
|
+
*
|
|
380
|
+
* @param value the raw value supplied for the field
|
|
381
|
+
* @param fieldName field name, for the error message
|
|
382
|
+
* @param entity entity name, for the error message
|
|
383
|
+
* @param needsQuotes whether the field's type requires a quoted literal
|
|
384
|
+
* @returns the literal text (including quotes when the slot is quoted)
|
|
385
|
+
* @throws when the value cannot be rendered safely for the slot it is destined for
|
|
386
|
+
*/
|
|
387
|
+
private formatFilterValue(value: unknown, fieldName: string, entity: string, needsQuotes: boolean): string {
|
|
388
|
+
if (value === null || value === undefined) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`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.`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (needsQuotes) {
|
|
395
|
+
return `'${EscapeSQLString(String(value))}'`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Unquoted slot — accept only what cannot carry SQL, and render it exactly as before.
|
|
399
|
+
if (typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) {
|
|
400
|
+
return String(value);
|
|
401
|
+
}
|
|
402
|
+
if (typeof value === 'string') {
|
|
403
|
+
const trimmed = value.trim();
|
|
404
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
405
|
+
if (/^(true|false)$/i.test(trimmed)) return trimmed.toLowerCase();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
throw new Error(
|
|
409
|
+
`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}.`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
342
413
|
protected async findBy<T = any>(provider: DatabaseProviderBase, entity: string, params: any, contextUser: UserInfo): Promise<Array<T>> {
|
|
343
414
|
// build the SQL query based on the params passed in
|
|
344
415
|
const rv = provider as any as IRunViewProvider;
|
|
@@ -352,8 +423,7 @@ export class ResolverBase {
|
|
|
352
423
|
// look up the field in the entityInfo to see if it needs quotes
|
|
353
424
|
const field = e.Fields.find((f) => f.Name === k);
|
|
354
425
|
if (!field) throw new Error(`Field ${k} not found in entity ${entity}`);
|
|
355
|
-
|
|
356
|
-
extraFilter += `${k} = ${quotes}${params[k]}${quotes}`;
|
|
426
|
+
extraFilter += `${k} = ${this.formatFilterValue(params[k], k, entity, field.NeedsQuotes)}`;
|
|
357
427
|
});
|
|
358
428
|
|
|
359
429
|
// ok, now we have a SQL string, run it and return the results
|
|
@@ -380,7 +450,8 @@ export class ResolverBase {
|
|
|
380
450
|
const rv = provider as any as IRunViewProvider;
|
|
381
451
|
const result = await rv.RunView<MJUserViewEntityExtended>({
|
|
382
452
|
EntityName: 'MJ: User Views',
|
|
383
|
-
|
|
453
|
+
// SECURITY: the view name is client-supplied and lands inside a SQL literal.
|
|
454
|
+
ExtraFilter: "Name='" + EscapeSQLString(viewInput.ViewName) + "'",
|
|
384
455
|
}, userPayload.userRecord);
|
|
385
456
|
if (result && result.Success && result.Results.length > 0) {
|
|
386
457
|
const viewInfo = result.Results[0];
|
|
@@ -733,6 +804,186 @@ export class ResolverBase {
|
|
|
733
804
|
}
|
|
734
805
|
}
|
|
735
806
|
|
|
807
|
+
/**
|
|
808
|
+
* SECURITY — GraphQL-boundary screen for one client-supplied SQL clause fragment.
|
|
809
|
+
*
|
|
810
|
+
* `ValidateUserProvidedSQLClause` still blocks stacked statements, DML, comments, UNION
|
|
811
|
+
* and WAITFOR, and still permits SELECT (server-internal engines pass richer filters).
|
|
812
|
+
* A keyword ban on SELECT/EXISTS at this boundary (#4253) broke first-party clients that
|
|
813
|
+
* use `IN (SELECT … FROM <entity base view>)` — that is a legitimate ExtraFilter.
|
|
814
|
+
*
|
|
815
|
+
* This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
|
|
816
|
+
* wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
|
|
817
|
+
* allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
|
|
818
|
+
* and catalogs are rejected. Server-internal RunView callers get only the provider's
|
|
819
|
+
* statement-separator check, which cannot refuse a legitimate filter.
|
|
820
|
+
*
|
|
821
|
+
* The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
|
|
822
|
+
* with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
|
|
823
|
+
*
|
|
824
|
+
* Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
|
|
825
|
+
* provider and the tenant hook splice it inside parentheses and AND their own filters after
|
|
826
|
+
* it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
|
|
827
|
+
* security and tenant scope on one side of an OR the caller controls.
|
|
828
|
+
* The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
|
|
829
|
+
* A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
|
|
830
|
+
* inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
|
|
831
|
+
* there, so the text after it could be code that the parse-based checks never saw.
|
|
832
|
+
*
|
|
833
|
+
* RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
|
|
834
|
+
* into the view, and RunView removes denied fields from its own result only. A subquery reads a
|
|
835
|
+
* base view directly with neither, so the screen refuses one over any entity the user reads under
|
|
836
|
+
* a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
|
|
837
|
+
* entity included.
|
|
838
|
+
*/
|
|
839
|
+
protected assertClientClauseUsesEntityBaseViews(
|
|
840
|
+
clause: string | undefined | null,
|
|
841
|
+
label: string,
|
|
842
|
+
provider?: IMetadataProvider,
|
|
843
|
+
user?: UserInfo,
|
|
844
|
+
options?: { LeaveUnparseableToProvider?: boolean },
|
|
845
|
+
): void {
|
|
846
|
+
if (!clause?.trim()) return;
|
|
847
|
+
|
|
848
|
+
// The balance and backslash checks run before the shared screen: it parses the fragment
|
|
849
|
+
// inside its own parentheses, so it cannot see a fragment that closes the parentheses the
|
|
850
|
+
// provider and the tenant hook splice it into.
|
|
851
|
+
const dialect = this.dialectForProvider(provider);
|
|
852
|
+
if (SQLParser.HasStackedStatements(clause, dialect)) {
|
|
853
|
+
throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
|
|
854
|
+
}
|
|
855
|
+
const balance = IsBalancedSQLFragment(clause, dialect);
|
|
856
|
+
if (!balance.IsBalanced) {
|
|
857
|
+
throw new Error(`Invalid ${label}: ${balance.Reason}`);
|
|
858
|
+
}
|
|
859
|
+
if (this.hasBackslashInQuotes(clause, dialect)) {
|
|
860
|
+
throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
|
|
868
|
+
* to the SELECT-only validation the caller performs, every table reference must resolve to an
|
|
869
|
+
* entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
|
|
870
|
+
* CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
|
|
871
|
+
* own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
|
|
872
|
+
* for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
|
|
873
|
+
* Fails closed: no user, or an unresolvable reference, refuses.
|
|
874
|
+
*
|
|
875
|
+
* The references come from a walk of the whole parse tree
|
|
876
|
+
* (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
|
|
877
|
+
* account for every SELECT in the text, or when a FROM item names no table (a table function or
|
|
878
|
+
* a row list).
|
|
879
|
+
*
|
|
880
|
+
* Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
|
|
881
|
+
* row-level security, and raw SQL does not apply it. Rather than try to rewrite arbitrary SQL
|
|
882
|
+
* (joins, CTEs) to compose that policy, this refuses any entity that carries a row filter for
|
|
883
|
+
* the caller — those reads belong on RunView. For
|
|
884
|
+
* the remaining entities CanRead genuinely means "every row and field of the view".
|
|
885
|
+
*/
|
|
886
|
+
protected assertFullQueryUsesReadableEntityViews(
|
|
887
|
+
sqlText: string,
|
|
888
|
+
provider: IMetadataProvider | undefined,
|
|
889
|
+
user: UserInfo | undefined,
|
|
890
|
+
label = 'SQL',
|
|
891
|
+
): void {
|
|
892
|
+
if (!user) {
|
|
893
|
+
throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
|
|
894
|
+
}
|
|
895
|
+
const dialect = this.dialectForProvider(provider);
|
|
896
|
+
ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
|
|
897
|
+
const parser = new SQLParser(sqlText, dialect);
|
|
898
|
+
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
899
|
+
throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
|
|
900
|
+
}
|
|
901
|
+
if (this.astContainsWriteNode(parser.AST)) {
|
|
902
|
+
throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
|
|
903
|
+
}
|
|
904
|
+
const reads = ClientClauseScreen.CollectStatementReads(parser);
|
|
905
|
+
ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
|
|
906
|
+
const allowed = this.entityBaseViewAllowList(provider);
|
|
907
|
+
for (const relation of reads.Relations) {
|
|
908
|
+
if (relation.IsCTEReference) continue;
|
|
909
|
+
const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
|
|
910
|
+
if (entity) this.assertUnscopedRead(entity, user, label);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
|
|
915
|
+
private assertUnscopedRead(entity: EntityInfo, user: UserInfo, label: string): void {
|
|
916
|
+
ClientClauseScreen.AssertUnscopedRead(entity, user, label);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
|
|
921
|
+
* a view request can carry that is actually a SQL fragment. GraphQL entry points
|
|
922
|
+
* (RunViewByName, RunViewByID, RunDynamicView, RunViews) funnel through
|
|
923
|
+
* RunViewGenericInternal / RunViewsGenericInternal.
|
|
924
|
+
*
|
|
925
|
+
* 🚨 `UserSearchString` is deliberately NOT screened here (#4392). It is not a clause — it is
|
|
926
|
+
* the free text a person typed into a search box, and it never reaches SQL as a fragment.
|
|
927
|
+
* `GenericDatabaseProvider.createViewUserSearchSQL` builds the predicate itself from
|
|
928
|
+
* `IncludeInUserSearchAPI` metadata and lands the text only as a literal, doubling single
|
|
929
|
+
* quotes and escaping LIKE metacharacters with an explicit `ESCAPE`. Running it through a
|
|
930
|
+
* screen that parses its argument as SQL and fails closed rejected every term that is not
|
|
931
|
+
* coincidentally valid SQL: `Marcus Chen` parses as nothing, `O'Leary` as an unterminated
|
|
932
|
+
* literal — so essentially every real name search returned 0 rows.
|
|
933
|
+
*
|
|
934
|
+
* The provider-level denylist does NOT back this up — it was removed from `UserSearchString`
|
|
935
|
+
* in the same change, for the same reason (it refused `Union Pacific`). What protects the value
|
|
936
|
+
* is that it is never SQL: it reaches the database only as a quoted, quote-doubled literal.
|
|
937
|
+
* The single place that is not true is a field carrying `UserSearchParamFormatAPI`, whose
|
|
938
|
+
* admin-authored format may splice the term in unquoted; `createViewUserSearchSQL` re-applies
|
|
939
|
+
* `ValidateUserProvidedSQLClause` for exactly those entities.
|
|
940
|
+
*
|
|
941
|
+
* Each `Aggregates[].expression` gets the same screen, except that text the parser cannot read
|
|
942
|
+
* goes on to the provider, which refuses it per aggregate. The provider never runs the caller's
|
|
943
|
+
* aggregate text: it runs the SQL that `CheckAggregateExpression` rebuilds from a single
|
|
944
|
+
* aggregate call over the entity's columns, and refuses everything else per aggregate.
|
|
945
|
+
*/
|
|
946
|
+
protected screenClientViewClauses(
|
|
947
|
+
clauses: {
|
|
948
|
+
extraFilter?: string | null;
|
|
949
|
+
orderBy?: string | null;
|
|
950
|
+
userSearchString?: string | null;
|
|
951
|
+
overrideExcludeFilter?: string | null;
|
|
952
|
+
aggregates?: AggregateExpression[] | null;
|
|
953
|
+
},
|
|
954
|
+
provider?: IMetadataProvider,
|
|
955
|
+
user?: UserInfo,
|
|
956
|
+
): void {
|
|
957
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider, user);
|
|
958
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider, user);
|
|
959
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider, user);
|
|
960
|
+
for (const aggregate of clauses.aggregates ?? []) {
|
|
961
|
+
this.assertClientClauseUsesEntityBaseViews(aggregate?.expression, 'Aggregate', provider, user, { LeaveUnparseableToProvider: true });
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* True when a string literal or quoted identifier in `clause`, read the way the database reads
|
|
967
|
+
* it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
|
|
968
|
+
*/
|
|
969
|
+
private hasBackslashInQuotes(clause: string, dialect: SQLParserDialect): boolean {
|
|
970
|
+
return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
|
|
974
|
+
private astContainsWriteNode(node: unknown): boolean {
|
|
975
|
+
return ClientClauseScreen.AstContainsWriteNode(node);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
private dialectForProvider(provider?: IMetadataProvider): SQLParserDialect {
|
|
979
|
+
return ClientClauseScreen.DialectFor(provider);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** The entity base-view allow-list for a provider's entities. */
|
|
983
|
+
private entityBaseViewAllowList(provider?: IMetadataProvider): EntityBaseViewAllowList {
|
|
984
|
+
return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
|
|
985
|
+
}
|
|
986
|
+
|
|
736
987
|
/**
|
|
737
988
|
* Optimized RunViewGenericInternal implementation with:
|
|
738
989
|
* - Field filtering at source (Fix #7)
|
|
@@ -763,10 +1014,6 @@ export class ResolverBase {
|
|
|
763
1014
|
try {
|
|
764
1015
|
if (!viewInfo || !userPayload) return null;
|
|
765
1016
|
|
|
766
|
-
// Check API key scope authorization for view operations
|
|
767
|
-
await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
|
|
768
|
-
|
|
769
|
-
const md = provider
|
|
770
1017
|
// Prefer the authenticated session's payload user — it is the authoritative per-request
|
|
771
1018
|
// identity and (for magic-link sessions) carries the per-session resource scope / synthesized
|
|
772
1019
|
// roles that drive RLS. The cached lookup is a fallback for paths where the payload user
|
|
@@ -775,6 +1022,20 @@ export class ResolverBase {
|
|
|
775
1022
|
?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
|
|
776
1023
|
if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
|
|
777
1024
|
|
|
1025
|
+
// SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
|
|
1026
|
+
// Screen at this boundary: parse, reject writes, allow only entity BaseViews the
|
|
1027
|
+
// acting user can read (subqueries do not inherit the top entity's permission check).
|
|
1028
|
+
this.screenClientViewClauses(
|
|
1029
|
+
{ extraFilter, orderBy, userSearchString, overrideExcludeFilter, aggregates },
|
|
1030
|
+
provider as unknown as IMetadataProvider,
|
|
1031
|
+
user,
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
// Check API key scope authorization for view operations
|
|
1035
|
+
await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
|
|
1036
|
+
|
|
1037
|
+
const md = provider
|
|
1038
|
+
|
|
778
1039
|
const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
|
|
779
1040
|
if (!entityInfo) throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
|
|
780
1041
|
|
|
@@ -920,6 +1181,19 @@ export class ResolverBase {
|
|
|
920
1181
|
|
|
921
1182
|
// Transform parameters
|
|
922
1183
|
for (const param of params) {
|
|
1184
|
+
// SECURITY: same GraphQL-boundary BaseView screen as RunViewGenericInternal.
|
|
1185
|
+
this.screenClientViewClauses(
|
|
1186
|
+
{
|
|
1187
|
+
extraFilter: param.extraFilter,
|
|
1188
|
+
orderBy: param.orderBy,
|
|
1189
|
+
userSearchString: param.userSearchString,
|
|
1190
|
+
overrideExcludeFilter: param.overrideExcludeFilter,
|
|
1191
|
+
aggregates: param.aggregates,
|
|
1192
|
+
},
|
|
1193
|
+
md,
|
|
1194
|
+
contextUser ?? undefined,
|
|
1195
|
+
);
|
|
1196
|
+
|
|
923
1197
|
if (param.viewInfo) {
|
|
924
1198
|
// Validate entity only once per entity type
|
|
925
1199
|
const entityName = param.viewInfo.Entity;
|
|
@@ -1118,16 +1392,40 @@ export class ResolverBase {
|
|
|
1118
1392
|
* Publishes a CACHE_INVALIDATION event to connected browser clients after a successful
|
|
1119
1393
|
* entity save or delete. Includes the originSessionId so the originating browser can
|
|
1120
1394
|
* skip redundant re-fetches (it already handled the event locally).
|
|
1395
|
+
*
|
|
1396
|
+
* The row itself rides along ONLY for entities opted in via
|
|
1397
|
+
* `cacheSettings.recordDataBroadcastEntities` — this event reaches every connected client
|
|
1398
|
+
* unfiltered, so the row would otherwise be readable by sessions that could not read the record.
|
|
1121
1399
|
*/
|
|
1122
1400
|
protected PublishCacheInvalidation(entityObject: BaseEntity, action: 'save' | 'delete', userPayload: UserPayload): void {
|
|
1401
|
+
const entityName = entityObject.EntityInfo.Name;
|
|
1123
1402
|
PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
|
|
1124
|
-
entityName
|
|
1403
|
+
entityName,
|
|
1125
1404
|
primaryKeyValues: JSON.stringify(entityObject.PrimaryKey.KeyValuePairs),
|
|
1126
1405
|
action,
|
|
1127
1406
|
sourceServerId: MJGlobal.Instance.ProcessUUID,
|
|
1128
1407
|
timestamp: new Date(),
|
|
1129
1408
|
originSessionId: userPayload?.sessionId || null,
|
|
1130
|
-
recordData:
|
|
1409
|
+
recordData:
|
|
1410
|
+
action === 'save' && MayBroadcastRecordData(entityName)
|
|
1411
|
+
? JSON.stringify(entityObject.GetAll())
|
|
1412
|
+
: undefined,
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Publishes a push-status update to the client on {@link PUSH_STATUS_UPDATES_TOPIC}, stamping the
|
|
1418
|
+
* authenticated owner's user ID from `userPayload` so the subscription filter can bind delivery
|
|
1419
|
+
* to identity (see B49 / `statusUpdatesFilter`). The ergonomic wrapper every resolver should use
|
|
1420
|
+
* instead of calling `pubSub.publish` on the topic directly — it makes omitting identity
|
|
1421
|
+
* impossible. Non-resolver publishers (services, the liveness heartbeat) call the shared
|
|
1422
|
+
* `publishStatusUpdate()` function directly with an explicit `ownerUserId`.
|
|
1423
|
+
*/
|
|
1424
|
+
protected PublishStatusUpdate(pubSub: PubSubEngine, sessionId: string, message: string | undefined, userPayload: UserPayload): void {
|
|
1425
|
+
publishStatusUpdate(pubSub, {
|
|
1426
|
+
sessionId,
|
|
1427
|
+
ownerUserId: userPayload?.userRecord?.ID ?? '',
|
|
1428
|
+
message,
|
|
1131
1429
|
});
|
|
1132
1430
|
}
|
|
1133
1431
|
|
|
@@ -1160,16 +1458,13 @@ export class ResolverBase {
|
|
|
1160
1458
|
const baseEntityEvent = event.args as BaseEntityEvent;
|
|
1161
1459
|
// message from our entity object, relay it to the client
|
|
1162
1460
|
LogDebug('ResolverBase.ListenForEntityMessages: About to publish PUSH_STATUS_UPDATES_TOPIC');
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
}),
|
|
1171
|
-
sessionId: userPayload.sessionId,
|
|
1172
|
-
});
|
|
1461
|
+
this.PublishStatusUpdate(pubSub, userPayload.sessionId, JSON.stringify({
|
|
1462
|
+
status: 'OK',
|
|
1463
|
+
type: 'EntityObjectStatusMessage',
|
|
1464
|
+
entityName: baseEntityEvent.baseEntity.EntityInfo.Name,
|
|
1465
|
+
primaryKey: baseEntityEvent.baseEntity.PrimaryKey,
|
|
1466
|
+
message: event.args.payload,
|
|
1467
|
+
}), userPayload);
|
|
1173
1468
|
}
|
|
1174
1469
|
}
|
|
1175
1470
|
});
|
|
@@ -1246,8 +1541,9 @@ export class ResolverBase {
|
|
|
1246
1541
|
}
|
|
1247
1542
|
});
|
|
1248
1543
|
|
|
1249
|
-
if (entityInfo.TrackRecordChanges || !input.OldValues___) {
|
|
1250
|
-
// We get here because
|
|
1544
|
+
if (entityInfo.TrackRecordChanges || !input.OldValues___ || ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase())) {
|
|
1545
|
+
// We get here because the entity tracks record changes, the client did not provide OldValues, or the entity's
|
|
1546
|
+
// write rules need the stored row (ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW), so we load the old values from the DB
|
|
1251
1547
|
const cKey = new CompositeKey(
|
|
1252
1548
|
entityInfo.PrimaryKeys.map((pk) => {
|
|
1253
1549
|
return {
|
|
@@ -1300,7 +1596,15 @@ export class ResolverBase {
|
|
|
1300
1596
|
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
1301
1597
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
|
|
1302
1598
|
} else {
|
|
1303
|
-
|
|
1599
|
+
// CompleteMessage, not Message. A validation refusal puts its reasons in `Errors` and leaves
|
|
1600
|
+
// `Message` unset, so reading `Message` here fell through to 'Unknown error' and discarded
|
|
1601
|
+
// every field-named explanation the entity had just produced. CreateRecord already read
|
|
1602
|
+
// CompleteMessage; this path did not, which is why the SAME rule on the SAME entity explained
|
|
1603
|
+
// itself on a create and said nothing on an update. CompleteMessage is a strict superset — it
|
|
1604
|
+
// starts from `Message`, then appends `Error` and `Errors` — so nothing previously reported is
|
|
1605
|
+
// lost, and it still yields undefined when there is genuinely nothing to say, leaving the
|
|
1606
|
+
// fallback below to fire rather than showing the user a blank error.
|
|
1607
|
+
throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
|
|
1304
1608
|
extensions: { code: 'SAVE_ENTITY_ERROR', entityName },
|
|
1305
1609
|
});
|
|
1306
1610
|
}
|
|
@@ -1526,7 +1830,11 @@ export class ResolverBase {
|
|
|
1526
1830
|
// Cache invalidation is now handled globally by the MJGlobal listener in index.ts
|
|
1527
1831
|
return returnValue;
|
|
1528
1832
|
} else {
|
|
1529
|
-
|
|
1833
|
+
// CompleteMessage, for the same reason as the update path above. It matters more going
|
|
1834
|
+
// forward: apps already refuse deletes by overriding Delete() and returning false with the
|
|
1835
|
+
// reason on LatestResult, and #3971 proposes a first-class delete-validation seam. Every one
|
|
1836
|
+
// of those reasons was being replaced by 'Unknown error' at the API boundary.
|
|
1837
|
+
throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
|
|
1530
1838
|
extensions: { code: 'DELETE_ENTITY_ERROR', entityName },
|
|
1531
1839
|
});
|
|
1532
1840
|
}
|
|
@@ -1055,6 +1055,8 @@ export class RunViewResolver extends ResolverBase {
|
|
|
1055
1055
|
@Ctx() { providers, userPayload }: AppContext
|
|
1056
1056
|
): Promise<RunViewsWithCacheCheckOutput> {
|
|
1057
1057
|
try {
|
|
1058
|
+
// Same scope as RunViews: this is the transport RunViews uses for a CacheLocal batch.
|
|
1059
|
+
await this.CheckAPIKeyScopeAuthorization('view:batch', '*', userPayload);
|
|
1058
1060
|
const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
|
|
1059
1061
|
|
|
1060
1062
|
// Cast provider to SQLServerDataProvider to access RunViewsWithCacheCheck method
|
|
@@ -1063,6 +1065,21 @@ export class RunViewResolver extends ResolverBase {
|
|
|
1063
1065
|
throw new Error('Provider does not support RunViewsWithCacheCheck');
|
|
1064
1066
|
}
|
|
1065
1067
|
|
|
1068
|
+
// SECURITY: same GraphQL-boundary clause screen as RunViews, before the provider runs.
|
|
1069
|
+
const user = this.GetUserFromPayload(userPayload);
|
|
1070
|
+
for (const item of input) {
|
|
1071
|
+
this.screenClientViewClauses(
|
|
1072
|
+
{
|
|
1073
|
+
extraFilter: item.params.ExtraFilter,
|
|
1074
|
+
orderBy: item.params.OrderBy,
|
|
1075
|
+
userSearchString: item.params.UserSearchString,
|
|
1076
|
+
overrideExcludeFilter: item.params.OverrideExcludeFilter,
|
|
1077
|
+
},
|
|
1078
|
+
provider,
|
|
1079
|
+
user,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1066
1083
|
// Convert GraphQL input types to core types
|
|
1067
1084
|
const coreParams: RunViewWithCacheCheckParams[] = input.map(item => ({
|
|
1068
1085
|
params: {
|