@memberjunction/server 6.1.0-edge.5 → 6.1.0-edge.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -11
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionJanitor.js +2 -2
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
- package/dist/auth/exampleNewUserSubClass.js +9 -4
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +2 -8
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +4 -12
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/auth/principals.d.ts +92 -0
- package/dist/auth/principals.d.ts.map +1 -0
- package/dist/auth/principals.js +193 -0
- package/dist/auth/principals.js.map +1 -0
- package/dist/config.d.ts +106 -12
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +41 -4
- package/dist/config.js.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts +30 -0
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +58 -5
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +7607 -6853
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +25141 -21645
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +152 -2
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +389 -10
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +26 -2
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/generic/refusalExtensions.d.ts +52 -0
- package/dist/generic/refusalExtensions.d.ts.map +1 -0
- package/dist/generic/refusalExtensions.js +16 -0
- package/dist/generic/refusalExtensions.js.map +1 -0
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +131 -93
- package/dist/index.js.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.js +2 -12
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.js +20 -5
- package/dist/realtimeWidget/visitorIdentity.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +34 -5
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +64 -7
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
- package/dist/resolvers/FileCategoryResolver.js +1 -2
- package/dist/resolvers/FileCategoryResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +2 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +80 -4
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +13 -8
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +45 -8
- package/dist/resolvers/RemoteBrowserActionResolver.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/UserViewResolver.d.ts.map +1 -1
- package/dist/resolvers/UserViewResolver.js +8 -4
- package/dist/resolvers/UserViewResolver.js.map +1 -1
- package/dist/rest/EntityCRUDHandler.d.ts +2 -1
- package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
- package/dist/rest/EntityCRUDHandler.js +3 -18
- package/dist/rest/EntityCRUDHandler.js.map +1 -1
- package/dist/rest/RESTEndpointHandler.d.ts +2 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +3 -16
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/rest/RealtimeSdpBrokerHandler.d.ts +23 -0
- package/dist/rest/RealtimeSdpBrokerHandler.d.ts.map +1 -0
- package/dist/rest/RealtimeSdpBrokerHandler.js +78 -0
- package/dist/rest/RealtimeSdpBrokerHandler.js.map +1 -0
- package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
- package/dist/serverExtensionReservedRoots.js +2 -3
- package/dist/serverExtensionReservedRoots.js.map +1 -1
- package/package.json +92 -91
- package/src/__tests__/EntityCRUDHandler.test.ts +18 -5
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +118 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +20 -0
- package/src/__tests__/RealtimeSdpBrokerHandler.test.ts +191 -0
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +81 -2
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +190 -3
- package/src/__tests__/ResolverBase.readableFieldsTransport.test.ts +106 -0
- package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +114 -0
- package/src/__tests__/WidgetSessionService.test.ts +4 -1
- package/src/__tests__/generatedGraphQLSchema.structural.test.ts +46 -0
- package/src/__tests__/newUserRolesDefault.test.ts +81 -0
- package/src/__tests__/newUsers.test.ts +36 -14
- package/src/__tests__/principals.callSites.test.ts +112 -0
- package/src/__tests__/principals.logging.test.ts +160 -0
- package/src/__tests__/principals.test.ts +305 -0
- package/src/__tests__/resolverBase.fls.test.ts +483 -0
- package/src/__tests__/resolverBase.recordChangesFls.test.ts +358 -0
- package/src/__tests__/resolverBase.refusalExtensions.test.ts +101 -0
- package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
- package/src/__tests__/serverExtensionReservedRoots.test.ts +79 -11
- package/src/__tests__/visitorIdentity.resolve.test.ts +61 -0
- package/src/agentSessions/SessionJanitor.ts +2 -2
- package/src/auth/exampleNewUserSubClass.ts +9 -6
- package/src/auth/magicLink/MagicLinkService.ts +2 -8
- package/src/auth/newUsers.ts +7 -15
- package/src/auth/principals.ts +257 -0
- package/src/config.ts +44 -4
- package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +65 -6
- package/src/generated/graphql-schemas/__mj.ts +18966 -16452
- package/src/generic/ResolverBase.ts +467 -9
- package/src/generic/RunViewResolver.ts +28 -2
- package/src/generic/refusalExtensions.ts +59 -0
- package/src/index.ts +154 -102
- package/src/realtimeWidget/WidgetSessionService.ts +2 -14
- package/src/realtimeWidget/visitorIdentity.ts +21 -6
- package/src/resolvers/EntityRecordNameResolver.ts +74 -8
- package/src/resolvers/FileCategoryResolver.ts +2 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +2 -0
- package/src/resolvers/QuerySystemUserResolver.ts +1 -1
- package/src/resolvers/RealtimeClientSessionResolver.ts +128 -2
- package/src/resolvers/RemoteBrowserActionResolver.ts +47 -8
- package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
- package/src/resolvers/UserViewResolver.ts +8 -4
- package/src/rest/EntityCRUDHandler.ts +3 -19
- package/src/rest/RESTEndpointHandler.ts +3 -17
- package/src/rest/RealtimeSdpBrokerHandler.ts +101 -0
- package/src/serverExtensionReservedRoots.ts +2 -3
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts +0 -27
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/RingCentralTelephonyResolver.js +0 -87
- package/dist/resolvers/RingCentralTelephonyResolver.js.map +0 -1
- package/dist/resolvers/TeamsMeetingsResolver.d.ts +0 -28
- package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +0 -1
- package/dist/resolvers/TeamsMeetingsResolver.js +0 -91
- package/dist/resolvers/TeamsMeetingsResolver.js.map +0 -1
- package/dist/resolvers/TelephonyResolver.d.ts +0 -26
- package/dist/resolvers/TelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/TelephonyResolver.js +0 -86
- package/dist/resolvers/TelephonyResolver.js.map +0 -1
- package/dist/resolvers/VonageTelephonyResolver.d.ts +0 -26
- package/dist/resolvers/VonageTelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/VonageTelephonyResolver.js +0 -86
- package/dist/resolvers/VonageTelephonyResolver.js.map +0 -1
- package/dist/telephony/RingCentralTelephonyService.d.ts +0 -115
- package/dist/telephony/RingCentralTelephonyService.d.ts.map +0 -1
- package/dist/telephony/RingCentralTelephonyService.js +0 -262
- package/dist/telephony/RingCentralTelephonyService.js.map +0 -1
- package/dist/telephony/TeamsMeetingsRouter.d.ts +0 -40
- package/dist/telephony/TeamsMeetingsRouter.d.ts.map +0 -1
- package/dist/telephony/TeamsMeetingsRouter.js +0 -96
- package/dist/telephony/TeamsMeetingsRouter.js.map +0 -1
- package/dist/telephony/TeamsMeetingsService.d.ts +0 -113
- package/dist/telephony/TeamsMeetingsService.d.ts.map +0 -1
- package/dist/telephony/TeamsMeetingsService.js +0 -196
- package/dist/telephony/TeamsMeetingsService.js.map +0 -1
- package/dist/telephony/TwilioTelephonyRouter.d.ts +0 -36
- package/dist/telephony/TwilioTelephonyRouter.d.ts.map +0 -1
- package/dist/telephony/TwilioTelephonyRouter.js +0 -143
- package/dist/telephony/TwilioTelephonyRouter.js.map +0 -1
- package/dist/telephony/TwilioTelephonyService.d.ts +0 -95
- package/dist/telephony/TwilioTelephonyService.d.ts.map +0 -1
- package/dist/telephony/TwilioTelephonyService.js +0 -193
- package/dist/telephony/TwilioTelephonyService.js.map +0 -1
- package/dist/telephony/VonageTelephonyRouter.d.ts +0 -41
- package/dist/telephony/VonageTelephonyRouter.d.ts.map +0 -1
- package/dist/telephony/VonageTelephonyRouter.js +0 -201
- package/dist/telephony/VonageTelephonyRouter.js.map +0 -1
- package/dist/telephony/VonageTelephonyService.d.ts +0 -90
- package/dist/telephony/VonageTelephonyService.d.ts.map +0 -1
- package/dist/telephony/VonageTelephonyService.js +0 -191
- package/dist/telephony/VonageTelephonyService.js.map +0 -1
- package/dist/telephony/calendar-scheduler.d.ts +0 -67
- package/dist/telephony/calendar-scheduler.d.ts.map +0 -1
- package/dist/telephony/calendar-scheduler.js +0 -133
- package/dist/telephony/calendar-scheduler.js.map +0 -1
- package/dist/telephony/index.d.ts +0 -29
- package/dist/telephony/index.d.ts.map +0 -1
- package/dist/telephony/index.js +0 -38
- package/dist/telephony/index.js.map +0 -1
- package/dist/telephony/media-upgrade-router.d.ts +0 -47
- package/dist/telephony/media-upgrade-router.d.ts.map +0 -1
- package/dist/telephony/media-upgrade-router.js +0 -72
- package/dist/telephony/media-upgrade-router.js.map +0 -1
- package/dist/telephony/ringcentral-runtime.d.ts +0 -14
- package/dist/telephony/ringcentral-runtime.d.ts.map +0 -1
- package/dist/telephony/ringcentral-runtime.js +0 -18
- package/dist/telephony/ringcentral-runtime.js.map +0 -1
- package/dist/telephony/teams-meetings-runtime.d.ts +0 -16
- package/dist/telephony/teams-meetings-runtime.d.ts.map +0 -1
- package/dist/telephony/teams-meetings-runtime.js +0 -20
- package/dist/telephony/teams-meetings-runtime.js.map +0 -1
- package/dist/telephony/teamsAcsMediaRegistry.d.ts +0 -69
- package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/teamsAcsMediaRegistry.js +0 -118
- package/dist/telephony/teamsAcsMediaRegistry.js.map +0 -1
- package/dist/telephony/telephony-runtime.d.ts +0 -14
- package/dist/telephony/telephony-runtime.d.ts.map +0 -1
- package/dist/telephony/telephony-runtime.js +0 -18
- package/dist/telephony/telephony-runtime.js.map +0 -1
- package/dist/telephony/twilioMediaRegistry.d.ts +0 -60
- package/dist/telephony/twilioMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/twilioMediaRegistry.js +0 -106
- package/dist/telephony/twilioMediaRegistry.js.map +0 -1
- package/dist/telephony/vonage-runtime.d.ts +0 -14
- package/dist/telephony/vonage-runtime.d.ts.map +0 -1
- package/dist/telephony/vonage-runtime.js +0 -18
- package/dist/telephony/vonage-runtime.js.map +0 -1
- package/dist/telephony/vonageMediaRegistry.d.ts +0 -78
- package/dist/telephony/vonageMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/vonageMediaRegistry.js +0 -158
- package/dist/telephony/vonageMediaRegistry.js.map +0 -1
- package/src/__tests__/media-upgrade-router.test.ts +0 -43
- package/src/__tests__/teams-meetings-service.test.ts +0 -65
- package/src/__tests__/teamsAcsMediaRegistry.test.ts +0 -82
- package/src/__tests__/twilio-telephony-service.test.ts +0 -23
- package/src/__tests__/twilioMediaRegistry.test.ts +0 -103
- package/src/__tests__/vonageMediaRegistry.test.ts +0 -180
- package/src/resolvers/RingCentralTelephonyResolver.ts +0 -64
- package/src/resolvers/TeamsMeetingsResolver.ts +0 -68
- package/src/resolvers/TelephonyResolver.ts +0 -63
- package/src/resolvers/VonageTelephonyResolver.ts +0 -63
- package/src/telephony/RingCentralTelephonyService.ts +0 -342
- package/src/telephony/TeamsMeetingsRouter.ts +0 -124
- package/src/telephony/TeamsMeetingsService.ts +0 -268
- package/src/telephony/TwilioTelephonyRouter.ts +0 -184
- package/src/telephony/TwilioTelephonyService.ts +0 -261
- package/src/telephony/VonageTelephonyRouter.ts +0 -239
- package/src/telephony/VonageTelephonyService.ts +0 -259
- package/src/telephony/calendar-scheduler.ts +0 -176
- package/src/telephony/index.ts +0 -43
- package/src/telephony/media-upgrade-router.ts +0 -79
- package/src/telephony/ringcentral-runtime.ts +0 -22
- package/src/telephony/teams-meetings-runtime.ts +0 -24
- package/src/telephony/teamsAcsMediaRegistry.ts +0 -152
- package/src/telephony/telephony-runtime.ts +0 -22
- package/src/telephony/twilioMediaRegistry.ts +0 -137
- package/src/telephony/vonage-runtime.ts +0 -22
- package/src/telephony/vonageMediaRegistry.ts +0 -200
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
LogError,
|
|
15
15
|
LogStatus,
|
|
16
16
|
Metadata,
|
|
17
|
+
ReadableFieldsTransportKey,
|
|
18
|
+
RecordChangeFieldSecurityProjector,
|
|
19
|
+
RecordChangePayloadFields,
|
|
17
20
|
RunView,
|
|
18
21
|
RunViewParams,
|
|
19
22
|
RunViewResult,
|
|
@@ -24,6 +27,7 @@ import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
|
|
|
24
27
|
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
25
28
|
import { PubSubEngine, AuthorizationError } from 'type-graphql';
|
|
26
29
|
import { GraphQLError } from 'graphql';
|
|
30
|
+
import { RefusalExtensions } from './refusalExtensions.js';
|
|
27
31
|
import { GetAPIKeyEngine } from '@memberjunction/api-keys';
|
|
28
32
|
import sql from 'mssql';
|
|
29
33
|
import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
|
|
@@ -32,6 +36,8 @@ import { RunViewGenericParams, UserPayload } from '../types.js';
|
|
|
32
36
|
import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
|
|
33
37
|
import { DeleteOptionsInput } from './DeleteOptionsInput.js';
|
|
34
38
|
import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
39
|
+
import { SQLParser } from '@memberjunction/sql-parser';
|
|
40
|
+
import { PostgreSQLDialect, SQLServerDialect, type SQLParserDialect } from '@memberjunction/sql-dialect';
|
|
35
41
|
import { EncryptionEngine } from '@memberjunction/encryption';
|
|
36
42
|
import { PUSH_STATUS_UPDATES_TOPIC, publishStatusUpdate } from './PushStatusResolver.js';
|
|
37
43
|
import { CACHE_INVALIDATION_TOPIC } from './CacheInvalidationResolver.js';
|
|
@@ -78,7 +84,14 @@ export class ResolverBase {
|
|
|
78
84
|
* @param contextUser - Optional user context for decryption (required for encrypted fields)
|
|
79
85
|
* @returns A new object in transport shape, or null when there is nothing to map
|
|
80
86
|
*/
|
|
81
|
-
protected async MapFieldNamesToCodeNames(
|
|
87
|
+
protected async MapFieldNamesToCodeNames(
|
|
88
|
+
entityName: string,
|
|
89
|
+
dataObject: any,
|
|
90
|
+
contextUser?: UserInfo,
|
|
91
|
+
provider?: IMetadataProvider,
|
|
92
|
+
deniedReadFields?: Set<string>,
|
|
93
|
+
recordChangeProjector?: RecordChangeFieldSecurityProjector
|
|
94
|
+
): Promise<any> {
|
|
82
95
|
// Return null for empty objects (e.g. when no rows found due to RLS filtering)
|
|
83
96
|
if (!dataObject || Object.keys(dataObject).length === 0) {
|
|
84
97
|
return null;
|
|
@@ -96,11 +109,35 @@ export class ResolverBase {
|
|
|
96
109
|
const entityInfo = md.EntityByName(entityName);
|
|
97
110
|
if (!entityInfo) throw new Error(`Entity ${entityName} not found in metadata`);
|
|
98
111
|
// const fields = entityInfo.Fields.filter((f) => f.Name !== f.CodeName || f.Name.startsWith('__mj_'));
|
|
112
|
+
// FIELD-LEVEL SECURITY — this is the authoritative read boundary for every GraphQL
|
|
113
|
+
// return path (single-record resolvers, external-data-source loads, and anything else
|
|
114
|
+
// routed through here), sitting exactly where encryption masking already lives.
|
|
115
|
+
//
|
|
116
|
+
// The denied set is normally computed ONCE per (entity, user) by the caller and passed
|
|
117
|
+
// in: this method runs once per ROW, so resolving it here would cost fields x rows
|
|
118
|
+
// aggregations. We fall back to computing it when a caller doesn't supply it (the
|
|
119
|
+
// single-record resolvers, where "once per row" and "once per request" are the same
|
|
120
|
+
// thing) rather than failing open — a missing argument must never mean missing security.
|
|
121
|
+
const denied = deniedReadFields ?? (
|
|
122
|
+
entityInfo.EnableFieldLevelSecurity && contextUser
|
|
123
|
+
? entityInfo.GetDeniedReadFields(contextUser)
|
|
124
|
+
: null
|
|
125
|
+
);
|
|
126
|
+
|
|
99
127
|
const mapper = new FieldMapper();
|
|
100
128
|
entityInfo.Fields.forEach((f) => {
|
|
101
129
|
if (dataObject.hasOwnProperty(f.Name)) {
|
|
102
130
|
// GraphQL doesn't allow us to pass back fields with __ so we are mapping our special field cases that start with __mj_ to _mj__ for transport - they are converted back on the other side automatically
|
|
103
131
|
const mappedFieldName = mapper.MapFieldName(f.CodeName);
|
|
132
|
+
if (denied?.has(f.Name.trim().toLowerCase())) {
|
|
133
|
+
// Omit entirely rather than nulling — a null is indistinguishable from a real
|
|
134
|
+
// null value, and the client should see the field as absent, not as empty.
|
|
135
|
+
// Both key shapes are removed because callers reach this method with rows keyed
|
|
136
|
+
// either way depending on whether mapping has already run.
|
|
137
|
+
delete dataObject[f.Name];
|
|
138
|
+
delete dataObject[mappedFieldName];
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
104
141
|
if (mappedFieldName !== f.Name) {
|
|
105
142
|
dataObject[mappedFieldName] = dataObject[f.Name];
|
|
106
143
|
delete dataObject[f.Name];
|
|
@@ -108,6 +145,33 @@ export class ResolverBase {
|
|
|
108
145
|
}
|
|
109
146
|
});
|
|
110
147
|
|
|
148
|
+
// Field security, second half: say IN-BAND which fields this caller may read. Deleting the
|
|
149
|
+
// key above is not enough on its own — GraphQL emits every SELECTED field regardless, so a
|
|
150
|
+
// denied field the client asked for arrives as an explicit null it cannot tell apart from a
|
|
151
|
+
// genuine one. The client must not settle that from its own metadata: during the window
|
|
152
|
+
// after a permission change its copy disagrees with ours, and once metadata filtering lands
|
|
153
|
+
// (issue #3485) it may not hold the permission rows at all. Null for unrestricted callers,
|
|
154
|
+
// which is nearly every request.
|
|
155
|
+
const readableFields = this.BuildReadableFieldsTransportValue(entityInfo, denied);
|
|
156
|
+
if (readableFields) {
|
|
157
|
+
dataObject[ReadableFieldsTransportKey] = readableFields;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Field security, third part — the audit trail. A 'MJ: Record Changes' row carries the old
|
|
161
|
+
// and new values of ANOTHER entity's fields, and its own EnableFieldLevelSecurity flag is
|
|
162
|
+
// off, so everything above is a no-op for it and the payload would flow out untouched. The
|
|
163
|
+
// projector recomputes the denied set against the entity each row is about; see
|
|
164
|
+
// RecordChangeFieldSecurityProjector for what each payload column gets.
|
|
165
|
+
//
|
|
166
|
+
// Same "a missing argument must never mean missing security" rule as the denied set above:
|
|
167
|
+
// the projector is normally built ONCE per request by the array caller and passed in, and
|
|
168
|
+
// we build one here when it wasn't — the single-record resolvers, where once-per-row and
|
|
169
|
+
// once-per-request are the same thing.
|
|
170
|
+
if (contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)) {
|
|
171
|
+
const projector = recordChangeProjector ?? new RecordChangeFieldSecurityProjector(md, contextUser);
|
|
172
|
+
dataObject = projector.ProjectRow(dataObject);
|
|
173
|
+
}
|
|
174
|
+
|
|
111
175
|
// Handle encrypted fields - data from raw SQL queries is still encrypted
|
|
112
176
|
const encryptedFields = entityInfo.EncryptedFields;
|
|
113
177
|
if (encryptedFields.length > 0) {
|
|
@@ -207,13 +271,52 @@ export class ResolverBase {
|
|
|
207
271
|
if (!dataObjectArray || dataObjectArray.length === 0) {
|
|
208
272
|
return dataObjectArray;
|
|
209
273
|
}
|
|
274
|
+
// Resolve the field-security denied set ONCE for the whole array and pass it into the
|
|
275
|
+
// per-row mapper. MapFieldNamesToCodeNames runs once per row, so computing this inside
|
|
276
|
+
// the loop would be fields x rows aggregations — 40,000 for a 1,000-row x 40-column
|
|
277
|
+
// result, each re-scanning the user's roles and allocating. Gated on the entity-level
|
|
278
|
+
// flag so non-FLS entities (nearly all of them) don't even resolve the entity twice.
|
|
279
|
+
const md = provider ?? new Metadata();
|
|
280
|
+
const entityInfo = md.EntityByName(entityName);
|
|
281
|
+
const deniedReadFields = entityInfo?.EnableFieldLevelSecurity && contextUser
|
|
282
|
+
? entityInfo.GetDeniedReadFields(contextUser)
|
|
283
|
+
: undefined;
|
|
284
|
+
// Record Changes payload security is resolved per DISTINCT target entity, and one array can
|
|
285
|
+
// span many — so the projector, which owns that memo, must outlive the row loop. Built once
|
|
286
|
+
// here for the same reason the denied set above is: GetDeniedReadFields walks every field on
|
|
287
|
+
// an entity and must never be called per row.
|
|
288
|
+
const recordChangeProjector = contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)
|
|
289
|
+
? new RecordChangeFieldSecurityProjector(md, contextUser)
|
|
290
|
+
: undefined;
|
|
210
291
|
const mapped: any[] = [];
|
|
211
292
|
for (const element of dataObjectArray) {
|
|
212
|
-
mapped.push(await this.MapFieldNamesToCodeNames(entityName, element, contextUser, provider));
|
|
293
|
+
mapped.push(await this.MapFieldNamesToCodeNames(entityName, element, contextUser, provider, deniedReadFields, recordChangeProjector));
|
|
213
294
|
}
|
|
214
295
|
return mapped;
|
|
215
296
|
}
|
|
216
297
|
|
|
298
|
+
/**
|
|
299
|
+
* The CodeNames this user may read on this entity, for the field-security transport key — or
|
|
300
|
+
* null when there is nothing to state (no denials, so every field is readable and the client
|
|
301
|
+
* needs no help distinguishing a withheld null from a genuine one).
|
|
302
|
+
*
|
|
303
|
+
* Carries entity field NAMES, not CodeNames and not the `_mj__` transport shape. The client
|
|
304
|
+
* consumes this after it has already reversed the transport mapping, and it matches names
|
|
305
|
+
* against `EntityFieldInfo.Name` — the same key the denied set itself is built from. Sending
|
|
306
|
+
* the one shape both sides already agree on avoids a second mapping that could drift.
|
|
307
|
+
*/
|
|
308
|
+
protected BuildReadableFieldsTransportValue(
|
|
309
|
+
entityInfo: EntityInfo,
|
|
310
|
+
deniedReadFields: Set<string> | null | undefined
|
|
311
|
+
): string[] | null {
|
|
312
|
+
if (!deniedReadFields || deniedReadFields.size === 0) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
return entityInfo.Fields
|
|
316
|
+
.filter((f) => !deniedReadFields.has(f.Name.trim().toLowerCase()))
|
|
317
|
+
.map((f) => f.Name);
|
|
318
|
+
}
|
|
319
|
+
|
|
217
320
|
/**
|
|
218
321
|
* Loads a single external-data-source-backed entity record by primary key and returns it in
|
|
219
322
|
* GraphQL field-name (CodeName) shape, or null if not found.
|
|
@@ -798,6 +901,145 @@ export class ResolverBase {
|
|
|
798
901
|
}
|
|
799
902
|
}
|
|
800
903
|
|
|
904
|
+
/**
|
|
905
|
+
* SECURITY — GraphQL-boundary screen for one client-supplied SQL clause fragment.
|
|
906
|
+
*
|
|
907
|
+
* `ValidateUserProvidedSQLClause` still blocks stacked statements, DML, comments, UNION
|
|
908
|
+
* and WAITFOR, and still permits SELECT (server-internal engines pass richer filters).
|
|
909
|
+
* A keyword ban on SELECT/EXISTS at this boundary (#4253) broke first-party clients that
|
|
910
|
+
* use `IN (SELECT … FROM <entity base view>)` — that is a legitimate ExtraFilter.
|
|
911
|
+
*
|
|
912
|
+
* This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
|
|
913
|
+
* wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
|
|
914
|
+
* allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
|
|
915
|
+
* and catalogs are rejected. Server-internal RunView callers never hit this.
|
|
916
|
+
*
|
|
917
|
+
* RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
|
|
918
|
+
* into the view. Subqueries against another entity's BaseView therefore do not inherit that
|
|
919
|
+
* entity's RLS; they are still restricted to the view (not the table).
|
|
920
|
+
*/
|
|
921
|
+
protected assertClientClauseUsesEntityBaseViews(
|
|
922
|
+
clause: string | undefined | null,
|
|
923
|
+
label: string,
|
|
924
|
+
provider?: IMetadataProvider,
|
|
925
|
+
): void {
|
|
926
|
+
if (!clause?.trim()) return;
|
|
927
|
+
|
|
928
|
+
const dialect = this.dialectForProvider(provider);
|
|
929
|
+
if (SQLParser.HasStackedStatements(clause, dialect)) {
|
|
930
|
+
throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const wrapped =
|
|
934
|
+
label === 'OrderBy'
|
|
935
|
+
? `SELECT 1 FROM __mj_clause_screen ORDER BY ${clause}`
|
|
936
|
+
: `SELECT 1 FROM __mj_clause_screen WHERE (${clause})`;
|
|
937
|
+
const parser = new SQLParser(wrapped, dialect);
|
|
938
|
+
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
939
|
+
throw new Error(
|
|
940
|
+
`Invalid ${label}: not a safe read-only filter fragment — refusing under uncertainty`,
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
if (this.astContainsWriteNode(parser.AST)) {
|
|
944
|
+
throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const allowed = this.entityBaseViewAllowList(provider);
|
|
948
|
+
const tables = SQLParser.ExtractTableRefs(wrapped, dialect);
|
|
949
|
+
for (const t of tables) {
|
|
950
|
+
const table = this.stripSqlIdent(t.TableName);
|
|
951
|
+
const schema = this.stripSqlIdent(t.SchemaName);
|
|
952
|
+
if (table.toLowerCase() === '__mj_clause_screen') continue;
|
|
953
|
+
const qualified = `${schema}.${table}`.toLowerCase();
|
|
954
|
+
const bare = table.toLowerCase();
|
|
955
|
+
if (allowed.qualified.has(qualified) || allowed.bare.has(bare)) continue;
|
|
956
|
+
throw new Error(
|
|
957
|
+
`Invalid ${label}: subquery must use an entity base view, not '${schema}.${table}'`,
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
|
|
964
|
+
* a view request can carry that is actually a SQL fragment. GraphQL entry points
|
|
965
|
+
* (RunViewByName, RunViewByID, RunDynamicView, RunViews) funnel through
|
|
966
|
+
* RunViewGenericInternal / RunViewsGenericInternal.
|
|
967
|
+
*
|
|
968
|
+
* 🚨 `UserSearchString` is deliberately NOT screened here (#4392). It is not a clause — it is
|
|
969
|
+
* the free text a person typed into a search box, and it never reaches SQL as a fragment.
|
|
970
|
+
* `GenericDatabaseProvider.createViewUserSearchSQL` builds the predicate itself from
|
|
971
|
+
* `IncludeInUserSearchAPI` metadata and lands the text only as a literal, doubling single
|
|
972
|
+
* quotes and escaping LIKE metacharacters with an explicit `ESCAPE`. Running it through a
|
|
973
|
+
* screen that parses its argument as SQL and fails closed rejected every term that is not
|
|
974
|
+
* coincidentally valid SQL: `Marcus Chen` parses as nothing, `O'Leary` as an unterminated
|
|
975
|
+
* literal — so essentially every real name search returned 0 rows.
|
|
976
|
+
*
|
|
977
|
+
* The provider-level denylist does NOT back this up — it was removed from `UserSearchString`
|
|
978
|
+
* in the same change, for the same reason (it refused `Union Pacific`). What protects the value
|
|
979
|
+
* is that it is never SQL: it reaches the database only as a quoted, quote-doubled literal.
|
|
980
|
+
* The single place that is not true is a field carrying `UserSearchParamFormatAPI`, whose
|
|
981
|
+
* admin-authored format may splice the term in unquoted; `createViewUserSearchSQL` re-applies
|
|
982
|
+
* `ValidateUserProvidedSQLClause` for exactly those entities.
|
|
983
|
+
*/
|
|
984
|
+
protected screenClientViewClauses(
|
|
985
|
+
clauses: {
|
|
986
|
+
extraFilter?: string | null;
|
|
987
|
+
orderBy?: string | null;
|
|
988
|
+
userSearchString?: string | null;
|
|
989
|
+
overrideExcludeFilter?: string | null;
|
|
990
|
+
},
|
|
991
|
+
provider?: IMetadataProvider,
|
|
992
|
+
): void {
|
|
993
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider);
|
|
994
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider);
|
|
995
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** Same write-node walk as EDS `sqlReadOnlyScreen.astContainsWriteNode`. */
|
|
999
|
+
private astContainsWriteNode(node: unknown): boolean {
|
|
1000
|
+
if (!node || typeof node !== 'object') return false;
|
|
1001
|
+
if (Array.isArray(node)) return node.some((n) => this.astContainsWriteNode(n));
|
|
1002
|
+
const obj = node as Record<string, unknown>;
|
|
1003
|
+
const type = obj.type;
|
|
1004
|
+
if (typeof type === 'string' && ResolverBase.WRITE_NODE_TYPES.has(type.toLowerCase())) {
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
return Object.values(obj).some((v) => this.astContainsWriteNode(v));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
private static readonly WRITE_NODE_TYPES = new Set<string>([
|
|
1011
|
+
'insert', 'update', 'delete', 'merge', 'replace', 'drop', 'create', 'alter', 'truncate',
|
|
1012
|
+
'rename', 'call', 'exec', 'execute', 'grant', 'revoke', 'use', 'load', 'copy', 'do',
|
|
1013
|
+
]);
|
|
1014
|
+
|
|
1015
|
+
private dialectForProvider(provider?: IMetadataProvider): SQLParserDialect {
|
|
1016
|
+
const name = provider?.constructor?.name ?? '';
|
|
1017
|
+
if (/postgres/i.test(name)) return new PostgreSQLDialect();
|
|
1018
|
+
return new SQLServerDialect();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private stripSqlIdent(name: string | null | undefined): string {
|
|
1022
|
+
if (!name) return '';
|
|
1023
|
+
return name.replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').replace(/^`|`$/g, '');
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
private entityBaseViewAllowList(provider?: IMetadataProvider): {
|
|
1027
|
+
qualified: Set<string>;
|
|
1028
|
+
bare: Set<string>;
|
|
1029
|
+
} {
|
|
1030
|
+
const qualified = new Set<string>();
|
|
1031
|
+
const bare = new Set<string>();
|
|
1032
|
+
const entities = provider?.Entities ?? [];
|
|
1033
|
+
for (const e of entities) {
|
|
1034
|
+
const view = this.stripSqlIdent(e.BaseView);
|
|
1035
|
+
if (!view) continue;
|
|
1036
|
+
const schema = this.stripSqlIdent(e.SchemaName);
|
|
1037
|
+
bare.add(view.toLowerCase());
|
|
1038
|
+
if (schema) qualified.add(`${schema}.${view}`.toLowerCase());
|
|
1039
|
+
}
|
|
1040
|
+
return { qualified, bare };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
801
1043
|
/**
|
|
802
1044
|
* Optimized RunViewGenericInternal implementation with:
|
|
803
1045
|
* - Field filtering at source (Fix #7)
|
|
@@ -829,6 +1071,13 @@ export class ResolverBase {
|
|
|
829
1071
|
try {
|
|
830
1072
|
if (!viewInfo || !userPayload) return null;
|
|
831
1073
|
|
|
1074
|
+
// SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
|
|
1075
|
+
// Screen at this boundary: parse, reject writes, allow only entity BaseViews.
|
|
1076
|
+
this.screenClientViewClauses(
|
|
1077
|
+
{ extraFilter, orderBy, userSearchString, overrideExcludeFilter },
|
|
1078
|
+
provider as unknown as IMetadataProvider,
|
|
1079
|
+
);
|
|
1080
|
+
|
|
832
1081
|
// Check API key scope authorization for view operations
|
|
833
1082
|
await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
|
|
834
1083
|
|
|
@@ -990,6 +1239,17 @@ export class ResolverBase {
|
|
|
990
1239
|
|
|
991
1240
|
// Transform parameters
|
|
992
1241
|
for (const param of params) {
|
|
1242
|
+
// SECURITY: same GraphQL-boundary BaseView screen as RunViewGenericInternal.
|
|
1243
|
+
this.screenClientViewClauses(
|
|
1244
|
+
{
|
|
1245
|
+
extraFilter: param.extraFilter,
|
|
1246
|
+
orderBy: param.orderBy,
|
|
1247
|
+
userSearchString: param.userSearchString,
|
|
1248
|
+
overrideExcludeFilter: param.overrideExcludeFilter,
|
|
1249
|
+
},
|
|
1250
|
+
md,
|
|
1251
|
+
);
|
|
1252
|
+
|
|
993
1253
|
if (param.viewInfo) {
|
|
994
1254
|
// Validate entity only once per entity type
|
|
995
1255
|
const entityName = param.viewInfo.Entity;
|
|
@@ -1297,8 +1557,10 @@ export class ResolverBase {
|
|
|
1297
1557
|
}
|
|
1298
1558
|
// save failed, throw error with message
|
|
1299
1559
|
else {
|
|
1560
|
+
// The message is the prose a toast shows; `validationErrors` is the same refusal with its
|
|
1561
|
+
// field names intact, so the client can paint the fields a server-side ValidateAsync named.
|
|
1300
1562
|
throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error creating record', {
|
|
1301
|
-
extensions:
|
|
1563
|
+
extensions: RefusalExtensions('CREATE_ENTITY_ERROR', entityName, entityObject.LatestResult),
|
|
1302
1564
|
});
|
|
1303
1565
|
}
|
|
1304
1566
|
} else return null;
|
|
@@ -1330,8 +1592,21 @@ export class ResolverBase {
|
|
|
1330
1592
|
}
|
|
1331
1593
|
});
|
|
1332
1594
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1595
|
+
// Field-level security: any value the client sends for a field it cannot READ is
|
|
1596
|
+
// fabricated by construction (the field was stripped from every payload the client ever
|
|
1597
|
+
// received), so strip such values before anything applies them. When any field is
|
|
1598
|
+
// denied we also force the truth-load branch below — client OldValues for denied fields
|
|
1599
|
+
// are equally fabricated, so hydrating from them (the no-DB-load path) would write
|
|
1600
|
+
// fabricated state into the denied columns on save.
|
|
1601
|
+
const hasDeniedReadFields = this.StripDeniedReadFieldsFromClientInput(entityInfo, userInfo, input, clientNewValues);
|
|
1602
|
+
// The audit-trail equivalent. Its `true` matters most on THIS entity: 'MJ: Record Changes'
|
|
1603
|
+
// has TrackRecordChanges off, so without it a client sending OldValues takes the branch below
|
|
1604
|
+
// that hydrates from those values — never loading what the database actually holds.
|
|
1605
|
+
const hasNarrowedAuditPayload = this.StripRecordChangePayloadFromClientInput(entityInfo, userInfo, input, clientNewValues);
|
|
1606
|
+
|
|
1607
|
+
if (this.MustLoadTruthFromDatabase(entityInfo, input, hasDeniedReadFields, hasNarrowedAuditPayload)) {
|
|
1608
|
+
// We get here because the entity tracks record changes, OR the client did not provide OldValues,
|
|
1609
|
+
// OR field-level security is in play — in every case we need the true old values from the DB
|
|
1335
1610
|
const cKey = new CompositeKey(
|
|
1336
1611
|
entityInfo.PrimaryKeys.map((pk) => {
|
|
1337
1612
|
return {
|
|
@@ -1384,8 +1659,16 @@ export class ResolverBase {
|
|
|
1384
1659
|
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
1385
1660
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
|
|
1386
1661
|
} else {
|
|
1387
|
-
|
|
1388
|
-
|
|
1662
|
+
// CompleteMessage, not Message. A validation refusal puts its reasons in `Errors` and leaves
|
|
1663
|
+
// `Message` unset, so reading `Message` here fell through to 'Unknown error' and discarded
|
|
1664
|
+
// every field-named explanation the entity had just produced. CreateRecord already read
|
|
1665
|
+
// CompleteMessage; this path did not, which is why the SAME rule on the SAME entity explained
|
|
1666
|
+
// itself on a create and said nothing on an update. CompleteMessage is a strict superset — it
|
|
1667
|
+
// starts from `Message`, then appends `Error` and `Errors` — so nothing previously reported is
|
|
1668
|
+
// lost, and it still yields undefined when there is genuinely nothing to say, leaving the
|
|
1669
|
+
// fallback below to fire rather than showing the user a blank error.
|
|
1670
|
+
throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
|
|
1671
|
+
extensions: RefusalExtensions('SAVE_ENTITY_ERROR', entityName, entityObject.LatestResult),
|
|
1389
1672
|
});
|
|
1390
1673
|
}
|
|
1391
1674
|
} else
|
|
@@ -1394,6 +1677,177 @@ export class ResolverBase {
|
|
|
1394
1677
|
});
|
|
1395
1678
|
}
|
|
1396
1679
|
|
|
1680
|
+
/**
|
|
1681
|
+
* Whether `UpdateRecord` must hydrate the entity from the DATABASE rather than from the
|
|
1682
|
+
* client's `OldValues___`.
|
|
1683
|
+
*
|
|
1684
|
+
* The `OldValues___` path exists as an optimization: when nothing needs the true prior state,
|
|
1685
|
+
* the client already holds it and a round trip is wasted. Each condition below is a reason
|
|
1686
|
+
* that assumption fails.
|
|
1687
|
+
*
|
|
1688
|
+
* `EnableFieldLevelSecurity` is the security-critical one, and it is NOT redundant with
|
|
1689
|
+
* `hasDeniedReadFields`. That flag reports READ denials, and the canonical FLS configuration is
|
|
1690
|
+
* Read Allow + Update Deny — which leaves it false. Such a caller would be hydrated from its own
|
|
1691
|
+
* `OldValues___`, and a value it supplies there for an update-denied field arrives through
|
|
1692
|
+
* `LoadFromData`, which the `EntityField` setter records as that field's INITIAL value. The
|
|
1693
|
+
* field is therefore not dirty, `BaseEntity.CheckFieldLevelUpdatePermissions` only rejects
|
|
1694
|
+
* `field.Dirty && denied`, and `GenerateSaveSQL` sends every `IsSPParameter` field regardless of
|
|
1695
|
+
* dirtiness (it skips only `NotLoaded`). The fabricated value reached `spUpdate` having passed
|
|
1696
|
+
* every check: pinning a value in `OldValues___` was a write to a field the caller may not write.
|
|
1697
|
+
*
|
|
1698
|
+
* Forcing the truth-load closes that rather than relocating it. The entity is hydrated by
|
|
1699
|
+
* `InnerLoad` from the real row, and `TestAndSetClientOldValuesToDBValues` reads the client's
|
|
1700
|
+
* OldValues only to detect concurrent-edit overlap before ending in `SetMany(clientNewValues)` —
|
|
1701
|
+
* it never applies them to the entity. An update-denied field can then only become dirty by being
|
|
1702
|
+
* named in the mutation input itself, which is precisely the case that check does catch.
|
|
1703
|
+
*
|
|
1704
|
+
* Ordered so the boolean flag is evaluated last: the extra load lands only on entities that have
|
|
1705
|
+
* the feature switched on, which is almost none of them.
|
|
1706
|
+
*/
|
|
1707
|
+
protected MustLoadTruthFromDatabase(
|
|
1708
|
+
entityInfo: EntityInfo,
|
|
1709
|
+
input: { OldValues___?: Array<{ Key: string; Value: unknown }> },
|
|
1710
|
+
hasDeniedReadFields: boolean,
|
|
1711
|
+
hasNarrowedAuditPayload: boolean
|
|
1712
|
+
): boolean {
|
|
1713
|
+
return (
|
|
1714
|
+
entityInfo.TrackRecordChanges ||
|
|
1715
|
+
!input.OldValues___ ||
|
|
1716
|
+
hasDeniedReadFields ||
|
|
1717
|
+
hasNarrowedAuditPayload ||
|
|
1718
|
+
entityInfo.EnableFieldLevelSecurity
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* Field-level security guard for the update path.
|
|
1724
|
+
*
|
|
1725
|
+
* Every denied-read field is stripped — new values AND OldValues. A field the user cannot
|
|
1726
|
+
* read was absent from every payload that client ever received, so any value coming back for
|
|
1727
|
+
* it is the transport's invention rather than user intent, and applying it would silently
|
|
1728
|
+
* overwrite the real column. Stripping is what makes "load a record, edit an unrelated field,
|
|
1729
|
+
* save" safe for a restricted user.
|
|
1730
|
+
*
|
|
1731
|
+
* There is no split by update permission: Read is required for Update, so a user denied read
|
|
1732
|
+
* is denied update too and denied-read ∩ denied-update is just denied-read.
|
|
1733
|
+
*
|
|
1734
|
+
* Silent narrowing, not rejection — consistent with the output projection the client already
|
|
1735
|
+
* experiences, and with the ambiguous-error rule (naming the field would confirm it exists
|
|
1736
|
+
* and is restricted).
|
|
1737
|
+
*
|
|
1738
|
+
* Returns true when the user has a non-empty denied-read set on this entity. The caller
|
|
1739
|
+
* must then hydrate the entity from the DATABASE (never from client OldValues), so denied
|
|
1740
|
+
* fields hold true values that an omitted key leaves untouched.
|
|
1741
|
+
*/
|
|
1742
|
+
protected StripDeniedReadFieldsFromClientInput(
|
|
1743
|
+
entityInfo: EntityInfo,
|
|
1744
|
+
userInfo: UserInfo,
|
|
1745
|
+
input: { OldValues___?: Array<{ Key: string; Value: unknown }> } & Record<string, unknown>,
|
|
1746
|
+
clientNewValues: Record<string, unknown>
|
|
1747
|
+
): boolean {
|
|
1748
|
+
if (!entityInfo.EnableFieldLevelSecurity || !userInfo) {
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1751
|
+
const deniedReadNames = entityInfo.GetDeniedReadFields(userInfo);
|
|
1752
|
+
if (deniedReadNames.size === 0) {
|
|
1753
|
+
return false;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// Input keys are entity CodeNames (post ReverseMapInputFieldNames); the denied set holds
|
|
1757
|
+
// lowercased field Names — bridge via the field metadata once.
|
|
1758
|
+
const deniedReadCodeNames = new Set<string>();
|
|
1759
|
+
for (const field of entityInfo.Fields) {
|
|
1760
|
+
if (deniedReadNames.has(field.Name.trim().toLowerCase())) {
|
|
1761
|
+
deniedReadCodeNames.add(field.CodeName.trim().toLowerCase());
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
const stripped: string[] = [];
|
|
1766
|
+
for (const key of Object.keys(clientNewValues)) {
|
|
1767
|
+
if (deniedReadCodeNames.has(key.trim().toLowerCase())) {
|
|
1768
|
+
delete clientNewValues[key];
|
|
1769
|
+
delete input[key];
|
|
1770
|
+
stripped.push(key);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
if (Array.isArray(input.OldValues___)) {
|
|
1774
|
+
input.OldValues___ = input.OldValues___.filter(
|
|
1775
|
+
(item) => !deniedReadCodeNames.has(String(item.Key).trim().toLowerCase())
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
if (stripped.length > 0) {
|
|
1779
|
+
LogDebug(
|
|
1780
|
+
`[FieldSecurity] UpdateRecord on '${entityInfo.Name}' for user ${userInfo.Email}: ` +
|
|
1781
|
+
`stripped client-sent value(s) for denied-read field(s) ${stripped.join(', ')}`
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
return true;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* Refuses a client-sent `MJ: Record Changes` payload column from a caller who carries field
|
|
1789
|
+
* denials, and forces the update onto the load-truth-from-DB branch.
|
|
1790
|
+
*
|
|
1791
|
+
* This is the write half of the audit-trail projection, and without it that projection would
|
|
1792
|
+
* itself destroy audit history. A restricted caller is served a NARROWED `ChangesJSON` /
|
|
1793
|
+
* `FullRecordJSON` and no `ChangesDescription`. Those values hydrate a client-side entity as
|
|
1794
|
+
* ordinary loaded values, and `GenerateSaveSQL` writes EVERY `IsSPParameter` field rather than
|
|
1795
|
+
* only dirty ones — so a user who edits `Comments` on that record and saves would silently
|
|
1796
|
+
* overwrite the stored payload with the narrowed one they were shown. The row would keep looking
|
|
1797
|
+
* like a complete audit entry while the pruned fields were gone for everyone, permanently.
|
|
1798
|
+
*
|
|
1799
|
+
* The rule is the one field security already applies elsewhere: **a value a client could not
|
|
1800
|
+
* have seen in full is not user intent, it is a transport artifact, and it is ignored.** Here
|
|
1801
|
+
* that is widened from "fields the caller cannot read" to "the audit payload of a caller who
|
|
1802
|
+
* carries any denial", because the column is readable — it is its CONTENTS that were narrowed,
|
|
1803
|
+
* and which row's target entity did the narrowing is not knowable from the input alone.
|
|
1804
|
+
*
|
|
1805
|
+
* Returning true routes the caller through `InnerLoad`, so the stored values are restored from
|
|
1806
|
+
* the database before the save. Legitimate payload writes are unaffected: they happen
|
|
1807
|
+
* server-side (`SnapshotBuilder`, replay) and never through this resolver.
|
|
1808
|
+
*/
|
|
1809
|
+
protected StripRecordChangePayloadFromClientInput(
|
|
1810
|
+
entityInfo: EntityInfo,
|
|
1811
|
+
userInfo: UserInfo,
|
|
1812
|
+
input: { OldValues___?: Array<{ Key: string; Value: unknown }> } & Record<string, unknown>,
|
|
1813
|
+
clientNewValues: Record<string, unknown>,
|
|
1814
|
+
provider?: IMetadataProvider
|
|
1815
|
+
): boolean {
|
|
1816
|
+
if (!userInfo || !RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)) {
|
|
1817
|
+
return false;
|
|
1818
|
+
}
|
|
1819
|
+
const md = provider ?? new Metadata();
|
|
1820
|
+
if (!new RecordChangeFieldSecurityProjector(md, userInfo).CallerCarriesAnyDenial()) {
|
|
1821
|
+
return false; // this caller was served the payload whole, so what they send back is theirs
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
const payloadKeys = new Set(RecordChangePayloadFields.map((f) => f.toLowerCase()));
|
|
1825
|
+
const stripped: string[] = [];
|
|
1826
|
+
for (const key of Object.keys(clientNewValues)) {
|
|
1827
|
+
if (payloadKeys.has(key.trim().toLowerCase())) {
|
|
1828
|
+
delete clientNewValues[key];
|
|
1829
|
+
delete input[key];
|
|
1830
|
+
stripped.push(key);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
// OldValues too: a narrowed payload in the client's old values is not what the database holds,
|
|
1834
|
+
// so leaving it would read as a concurrent-edit conflict on a field nobody edited.
|
|
1835
|
+
if (Array.isArray(input.OldValues___)) {
|
|
1836
|
+
input.OldValues___ = input.OldValues___.filter((item) => !payloadKeys.has(String(item.Key).trim().toLowerCase()));
|
|
1837
|
+
}
|
|
1838
|
+
if (stripped.length > 0) {
|
|
1839
|
+
LogDebug(
|
|
1840
|
+
`[FieldSecurity] UpdateRecord on '${entityInfo.Name}' for user ${userInfo.Email}: ` +
|
|
1841
|
+
`ignored client-sent audit payload column(s) ${stripped.join(', ')} — the caller carries field ` +
|
|
1842
|
+
`denials, so the value they hold may be a narrowed projection rather than the stored one`
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
// True even when nothing was stripped: the DB truth-load is what guarantees the payload the
|
|
1846
|
+
// save writes is the stored one, and a client that sent no payload key at all would otherwise
|
|
1847
|
+
// fall through to the OldValues branch and write whatever it did send.
|
|
1848
|
+
return true;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1397
1851
|
/**
|
|
1398
1852
|
* This routine compares the OldValues property in the input object to the values in the DB that we just loaded. If there are differences, we need to check to see if the client
|
|
1399
1853
|
* is trying to update any of those fields (e.g. overlap). If there is overlap, we throw an error. If there is no overlap, we can proceed with the update even if the DB Values
|
|
@@ -1614,8 +2068,12 @@ export class ResolverBase {
|
|
|
1614
2068
|
// Cache invalidation is now handled globally by the MJGlobal listener in index.ts
|
|
1615
2069
|
return returnValue;
|
|
1616
2070
|
} else {
|
|
1617
|
-
|
|
1618
|
-
|
|
2071
|
+
// CompleteMessage, for the same reason as the update path above. It matters more going
|
|
2072
|
+
// forward: apps already refuse deletes by overriding Delete() and returning false with the
|
|
2073
|
+
// reason on LatestResult, and #3971 proposes a first-class delete-validation seam. Every one
|
|
2074
|
+
// of those reasons was being replaced by 'Unknown error' at the API boundary.
|
|
2075
|
+
throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
|
|
2076
|
+
extensions: RefusalExtensions('DELETE_ENTITY_ERROR', entityName, entityObject.LatestResult),
|
|
1619
2077
|
});
|
|
1620
2078
|
}
|
|
1621
2079
|
} else {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Arg, Ctx, Field, InputType, Int, ObjectType, PubSubEngine, Query, Resolver } from 'type-graphql';
|
|
2
2
|
import { AppContext } from '../types.js';
|
|
3
3
|
import { ResolverBase } from './ResolverBase.js';
|
|
4
|
-
import { LogError, LogStatus, EntityInfo, RunViewWithCacheCheckResult, RunViewsWithCacheCheckResponse, RunViewWithCacheCheckParams, AggregateResult, CompositeKey } from '@memberjunction/core';
|
|
4
|
+
import { LogError, LogStatus, EntityInfo, FieldSecurityError, RunViewWithCacheCheckResult, RunViewsWithCacheCheckResponse, RunViewWithCacheCheckParams, AggregateResult, CompositeKey } from '@memberjunction/core';
|
|
5
|
+
|
|
5
6
|
import { UUIDsEqual } from '@memberjunction/global';
|
|
6
7
|
import { RequireSystemUser } from '../directives/RequireSystemUser.js';
|
|
7
8
|
import { GetReadOnlyProvider } from '../util.js';
|
|
@@ -9,6 +10,21 @@ import { MJUserViewEntityExtended } from '@memberjunction/core-entities';
|
|
|
9
10
|
import { CompositeKeyInputType, KeyValuePairOutputType } from './KeyInputOutputTypes.js';
|
|
10
11
|
import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Rethrows a field-level-security denial so its deliberately ambiguous wording reaches the
|
|
15
|
+
* client as a GraphQL error. The surrounding catch blocks rightly swallow arbitrary resolver
|
|
16
|
+
* errors (their messages can carry SQL text or internal state) and return null — but this one
|
|
17
|
+
* message was DESIGNED to be shown to the caller (see FieldSecurityDenialMessage), and
|
|
18
|
+
* swallowing it degrades a deliberate security rejection into a generic
|
|
19
|
+
* "Cannot return null for non-nullable field" transport error. Matched by name, not
|
|
20
|
+
* instanceof, so a bundler duplicating the class cannot break the recognition.
|
|
21
|
+
*/
|
|
22
|
+
function rethrowFieldSecurityDenial(err: unknown): void {
|
|
23
|
+
if (err instanceof Error && err.name === FieldSecurityError.ErrorName) {
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
12
28
|
/********************************************************************************
|
|
13
29
|
* The PURPOSE of this resolver is to provide a generic way to run a view and return the results.
|
|
14
30
|
* The best practice is to use the strongly typed sub-class of this resolver for each entity.
|
|
@@ -769,6 +785,7 @@ export class RunViewResolver extends ResolverBase {
|
|
|
769
785
|
AggregateExecutionTime: rawData?.AggregateExecutionTime,
|
|
770
786
|
};
|
|
771
787
|
} catch (err) {
|
|
788
|
+
rethrowFieldSecurityDenial(err);
|
|
772
789
|
console.log(err);
|
|
773
790
|
return null;
|
|
774
791
|
}
|
|
@@ -801,6 +818,7 @@ export class RunViewResolver extends ResolverBase {
|
|
|
801
818
|
AggregateExecutionTime: rawData?.AggregateExecutionTime,
|
|
802
819
|
};
|
|
803
820
|
} catch (err) {
|
|
821
|
+
rethrowFieldSecurityDenial(err);
|
|
804
822
|
console.log(err);
|
|
805
823
|
return null;
|
|
806
824
|
}
|
|
@@ -831,6 +849,7 @@ export class RunViewResolver extends ResolverBase {
|
|
|
831
849
|
AggregateExecutionTime: rawData?.AggregateExecutionTime,
|
|
832
850
|
};
|
|
833
851
|
} catch (err) {
|
|
852
|
+
rethrowFieldSecurityDenial(err);
|
|
834
853
|
console.log(err);
|
|
835
854
|
return null;
|
|
836
855
|
}
|
|
@@ -872,6 +891,7 @@ export class RunViewResolver extends ResolverBase {
|
|
|
872
891
|
|
|
873
892
|
return results;
|
|
874
893
|
} catch (err) {
|
|
894
|
+
rethrowFieldSecurityDenial(err);
|
|
875
895
|
LogError(err);
|
|
876
896
|
return null;
|
|
877
897
|
}
|
|
@@ -1067,6 +1087,7 @@ export class RunViewResolver extends ResolverBase {
|
|
|
1067
1087
|
|
|
1068
1088
|
return results;
|
|
1069
1089
|
} catch (err) {
|
|
1090
|
+
rethrowFieldSecurityDenial(err);
|
|
1070
1091
|
LogError(err);
|
|
1071
1092
|
return null;
|
|
1072
1093
|
}
|
|
@@ -1105,7 +1126,12 @@ export class RunViewResolver extends ResolverBase {
|
|
|
1105
1126
|
MaxRows: item.params.MaxRows,
|
|
1106
1127
|
ForceAuditLog: item.params.ForceAuditLog,
|
|
1107
1128
|
AuditLogDescription: item.params.AuditLogDescription,
|
|
1108
|
-
|
|
1129
|
+
// entity_object is forced to 'simple', matching RunViewGenericInternal: over the
|
|
1130
|
+
// wire the server always returns plain rows (the CLIENT materializes entities),
|
|
1131
|
+
// and server-side enforcement (field-security projection) deliberately exempts
|
|
1132
|
+
// genuine server-internal entity_object results — a wire caller must never be
|
|
1133
|
+
// able to claim that exemption. count_only passes through unchanged.
|
|
1134
|
+
ResultType: (item.params.ResultType === 'entity_object' ? 'simple' : (item.params.ResultType || 'simple')) as 'simple' | 'entity_object' | 'count_only',
|
|
1109
1135
|
StartRow: item.params.StartRow,
|
|
1110
1136
|
// Forward the aggregate request to the engine (B40) — omitted here as well as in the
|
|
1111
1137
|
// client's input map, so aggregates never reached InternalRunView on this transport.
|