@memberjunction/server 6.2.0-edge.4 → 6.2.0-edge.6
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 +47 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionJanitor.d.ts +91 -30
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +311 -81
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts +17 -3
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +44 -10
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/apiRoot.d.ts +32 -0
- package/dist/apiRoot.d.ts.map +1 -0
- package/dist/apiRoot.js +67 -0
- package/dist/apiRoot.js.map +1 -0
- package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -1
- package/dist/auth/AuthProviderCatalogRouter.js +27 -0
- package/dist/auth/AuthProviderCatalogRouter.js.map +1 -1
- package/dist/auth/LoginScreenEngine.d.ts +22 -0
- package/dist/auth/LoginScreenEngine.d.ts.map +1 -0
- package/dist/auth/LoginScreenEngine.js +57 -0
- package/dist/auth/LoginScreenEngine.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 +52 -10
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/initializeProviders.d.ts +10 -0
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +37 -1
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/auth/localDevAuth.d.ts +79 -0
- package/dist/auth/localDevAuth.d.ts.map +1 -0
- package/dist/auth/localDevAuth.js +439 -0
- package/dist/auth/localDevAuth.js.map +1 -0
- package/dist/auth/magicLink/MagicLinkKeys.d.ts +2 -2
- package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkKeys.js +1 -1
- package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +3 -2
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/auth/magicLink/types.d.ts +12 -0
- package/dist/auth/magicLink/types.d.ts.map +1 -1
- package/dist/auth/sessionUserClone.d.ts +1 -1
- package/dist/auth/sessionUserClone.d.ts.map +1 -1
- package/dist/auth/sessionUserClone.js +3 -2
- package/dist/auth/sessionUserClone.js.map +1 -1
- package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.d.ts +292 -0
- package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.d.ts.map +1 -0
- package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.js +442 -0
- package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.js.map +1 -0
- package/dist/broadcast/index.d.ts +6 -0
- package/dist/broadcast/index.d.ts.map +1 -0
- package/dist/broadcast/index.js +6 -0
- package/dist/broadcast/index.js.map +1 -0
- package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.d.ts +43 -0
- package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.d.ts.map +1 -0
- package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.js +144 -0
- package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.js.map +1 -0
- package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.d.ts +46 -0
- package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.d.ts.map +1 -0
- package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.js +149 -0
- package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.js.map +1 -0
- package/dist/broadcast/providers/GCPPubSubBroadcastProvider.d.ts +49 -0
- package/dist/broadcast/providers/GCPPubSubBroadcastProvider.d.ts.map +1 -0
- package/dist/broadcast/providers/GCPPubSubBroadcastProvider.js +131 -0
- package/dist/broadcast/providers/GCPPubSubBroadcastProvider.js.map +1 -0
- package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.d.ts +24 -0
- package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.d.ts.map +1 -0
- package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.js +51 -0
- package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.js.map +1 -0
- package/dist/cache.d.ts +3 -2
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js.map +1 -1
- package/dist/config.d.ts +632 -22
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +104 -4
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +3 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +29 -8
- package/dist/context.js.map +1 -1
- package/dist/coreRoutes.d.ts +29 -0
- package/dist/coreRoutes.d.ts.map +1 -0
- package/dist/coreRoutes.js +156 -0
- package/dist/coreRoutes.js.map +1 -0
- package/dist/generated/graphql-schemas/__mj.d.ts +1016 -29
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +5125 -434
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +85 -30
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +175 -60
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -1
- package/dist/generic/OrphanedConversationDetailReconciler.js +2 -0
- package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -1
- package/dist/generic/PubSubManager.d.ts +10 -0
- package/dist/generic/PubSubManager.d.ts.map +1 -1
- package/dist/generic/PubSubManager.js +44 -1
- package/dist/generic/PubSubManager.js.map +1 -1
- package/dist/generic/PushStatusResolver.d.ts +39 -6
- package/dist/generic/PushStatusResolver.d.ts.map +1 -1
- package/dist/generic/PushStatusResolver.js +94 -12
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +168 -69
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +464 -405
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +11 -3
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +91 -13
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/generic/actionOutputFieldSecurity.d.ts +26 -0
- package/dist/generic/actionOutputFieldSecurity.d.ts.map +1 -0
- package/dist/generic/actionOutputFieldSecurity.js +47 -0
- package/dist/generic/actionOutputFieldSecurity.js.map +1 -0
- package/dist/generic/fieldSecurityTransport.d.ts +30 -0
- package/dist/generic/fieldSecurityTransport.d.ts.map +1 -0
- package/dist/generic/fieldSecurityTransport.js +30 -0
- package/dist/generic/fieldSecurityTransport.js.map +1 -0
- package/dist/globalCors.d.ts +33 -0
- package/dist/globalCors.d.ts.map +1 -0
- package/dist/globalCors.js +58 -0
- package/dist/globalCors.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +230 -222
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +6 -1
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/logging/variablesLoggingMiddleware.d.ts +12 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
- package/dist/logging/variablesLoggingMiddleware.js +50 -1
- package/dist/logging/variablesLoggingMiddleware.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/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.js +2 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.js +2 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -1
- package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts +100 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts.map +1 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.js +173 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.js.map +1 -0
- package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts.map +1 -1
- package/dist/realtimeSessions/SessionVerificationPortsBase.js +1 -0
- package/dist/realtimeSessions/SessionVerificationPortsBase.js.map +1 -1
- package/dist/realtimeSessions/index.d.ts +2 -0
- package/dist/realtimeSessions/index.d.ts.map +1 -1
- package/dist/realtimeSessions/index.js +2 -0
- package/dist/realtimeSessions/index.js.map +1 -1
- package/dist/realtimeSessions/ownedRealtimeSession.d.ts +32 -0
- package/dist/realtimeSessions/ownedRealtimeSession.d.ts.map +1 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js +56 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js.map +1 -0
- package/dist/realtimeSessions/sessionAccess.d.ts +91 -8
- package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -1
- package/dist/realtimeSessions/sessionAccess.js +127 -20
- package/dist/realtimeSessions/sessionAccess.js.map +1 -1
- package/dist/realtimeSessions/sessionConfigStamps.js +1 -1
- package/dist/realtimeSessions/sessionConfigStamps.js.map +1 -1
- package/dist/realtimeSessions/subscriptionAuthorization.d.ts.map +1 -1
- package/dist/realtimeSessions/subscriptionAuthorization.js +5 -2
- package/dist/realtimeSessions/subscriptionAuthorization.js.map +1 -1
- package/dist/realtimeSessions/verificationWorkflow.d.ts +2 -0
- package/dist/realtimeSessions/verificationWorkflow.d.ts.map +1 -1
- package/dist/realtimeSessions/verificationWorkflow.js +1 -1
- package/dist/realtimeSessions/verificationWorkflow.js.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.js +19 -3
- package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts +67 -7
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.js +201 -19
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
- package/dist/realtimeWidget/host-identity.d.ts +1 -1
- package/dist/realtimeWidget/host-identity.js +1 -1
- package/dist/realtimeWidget/widgetCore.d.ts +20 -1
- package/dist/realtimeWidget/widgetCore.d.ts.map +1 -1
- package/dist/realtimeWidget/widgetCore.js +17 -1
- package/dist/realtimeWidget/widgetCore.js.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.js +2 -1
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
- package/dist/resolvers/ActionResolver.d.ts +19 -1
- package/dist/resolvers/ActionResolver.d.ts.map +1 -1
- package/dist/resolvers/ActionResolver.js +63 -12
- package/dist/resolvers/ActionResolver.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +1 -0
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/AgentRunStatusPublisher.d.ts +18 -4
- package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -1
- package/dist/resolvers/AgentRunStatusPublisher.js +32 -8
- package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -1
- package/dist/resolvers/AgentSessionResolver.d.ts +10 -4
- package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/AgentSessionResolver.js +40 -17
- package/dist/resolvers/AgentSessionResolver.js.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts +5 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.js +9 -7
- package/dist/resolvers/ArtifactFileResolver.js.map +1 -1
- package/dist/resolvers/ConversationRunEventsResolver.d.ts +8 -4
- package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -1
- package/dist/resolvers/ConversationRunEventsResolver.js +6 -6
- package/dist/resolvers/ConversationRunEventsResolver.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/EntityCommunicationsResolver.d.ts +13 -1
- package/dist/resolvers/EntityCommunicationsResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityCommunicationsResolver.js +50 -14
- package/dist/resolvers/EntityCommunicationsResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +2 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +3 -2
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts +46 -3
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +211 -50
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
- package/dist/resolvers/FileCategoryResolver.js +1 -0
- package/dist/resolvers/FileCategoryResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +87 -11
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +292 -105
- package/dist/resolvers/FileResolver.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 +6 -3
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +49 -9
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.d.ts +8 -1
- package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.js +31 -3
- package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
- package/dist/resolvers/PipelineProgressResolver.d.ts +6 -0
- package/dist/resolvers/PipelineProgressResolver.d.ts.map +1 -1
- package/dist/resolvers/PipelineProgressResolver.js +5 -3
- package/dist/resolvers/PipelineProgressResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +2 -0
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +33 -6
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +2 -0
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +15 -6
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RSUResolver.d.ts.map +1 -1
- package/dist/resolvers/RSUResolver.js +12 -0
- package/dist/resolvers/RSUResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +18 -10
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +92 -54
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +11 -9
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +56 -8
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +241 -67
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +4 -4
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTestResolver.js +3 -1
- package/dist/resolvers/RunTestResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +12 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.js +15 -2
- package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.js +2 -3
- package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
- package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncDataResolver.js +4 -0
- package/dist/resolvers/SyncDataResolver.js.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts +2 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.js +28 -1
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.d.ts +24 -0
- package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.js +66 -13
- package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts +34 -2
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +150 -27
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/resolvers/UserViewResolver.d.ts +1 -1
- package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
- package/dist/resolvers/UserViewResolver.js +2 -1
- package/dist/resolvers/UserViewResolver.js.map +1 -1
- package/dist/resolvers/agent-run-data-guard.d.ts +31 -0
- package/dist/resolvers/agent-run-data-guard.d.ts.map +1 -0
- package/dist/resolvers/agent-run-data-guard.js +72 -0
- package/dist/resolvers/agent-run-data-guard.js.map +1 -0
- package/dist/rest/EntityCRUDHandler.d.ts +6 -0
- package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
- package/dist/rest/EntityCRUDHandler.js +19 -5
- package/dist/rest/EntityCRUDHandler.js.map +1 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +8 -5
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/rest/RealtimeRecordingHandler.d.ts +49 -0
- package/dist/rest/RealtimeRecordingHandler.d.ts.map +1 -0
- package/dist/rest/RealtimeRecordingHandler.js +383 -0
- package/dist/rest/RealtimeRecordingHandler.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts +9 -1
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +43 -17
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/rest/setupRESTEndpoints.d.ts +6 -0
- package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
- package/dist/rest/setupRESTEndpoints.js +6 -0
- package/dist/rest/setupRESTEndpoints.js.map +1 -1
- package/dist/serverExtensionReservedRoots.d.ts +12 -10
- package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
- package/dist/serverExtensionReservedRoots.js +14 -17
- package/dist/serverExtensionReservedRoots.js.map +1 -1
- package/dist/serverRoleConfigUnits.d.ts +29 -0
- package/dist/serverRoleConfigUnits.d.ts.map +1 -0
- package/dist/serverRoleConfigUnits.js +33 -0
- package/dist/serverRoleConfigUnits.js.map +1 -0
- package/dist/services/MJStorageBlobStore.d.ts +8 -1
- package/dist/services/MJStorageBlobStore.d.ts.map +1 -1
- package/dist/services/MJStorageBlobStore.js +25 -19
- package/dist/services/MJStorageBlobStore.js.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.js +2 -2
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +5 -3
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -5
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/dist/services/TaskGraphAgentRunner.d.ts +14 -2
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphAgentRunner.js +157 -9
- package/dist/services/TaskGraphAgentRunner.js.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.d.ts +59 -2
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +626 -16
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
- package/dist/sharedCache.d.ts.map +1 -1
- package/dist/sharedCache.js +3 -0
- package/dist/sharedCache.js.map +1 -1
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +99 -98
- package/src/__tests__/ActionResolver.fieldSecurity.test.ts +237 -0
- package/src/__tests__/AdhocQueryResolver.dialect.test.ts +137 -0
- package/src/__tests__/AgentRunStatusPublisher.test.ts +49 -0
- package/src/__tests__/AgentSessionResolver.ownership.test.ts +137 -0
- package/src/__tests__/CustomColumnPromoterOnePass.test.ts +15 -1
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityCommunicationsResolver.actingUser.test.ts +151 -0
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/EvolutionActsOnWhatItDetects.test.ts +113 -0
- package/src/__tests__/FetchEntityVectorsResolver.test.ts +369 -18
- package/src/__tests__/FileResolver.fieldSecurity.test.ts +82 -0
- package/src/__tests__/FileResolver.storageAccess.test.ts +562 -0
- package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
- package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
- package/src/__tests__/LoginScreenEngine.test.ts +86 -0
- package/src/__tests__/MJStorageBlobStore.storageGate.test.ts +63 -0
- package/src/__tests__/PipelineProgressResolver.filter.test.ts +58 -0
- package/src/__tests__/QuerySystemUserResolver.fieldSecurity.test.ts +49 -0
- package/src/__tests__/RESTEndpointHandler.test.ts +25 -0
- package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +23 -3
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +324 -46
- package/src/__tests__/RecordingAssemblyScheduler.test.ts +178 -0
- package/src/__tests__/RemoteBrowserActionResolver.ownership.test.ts +98 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +133 -2
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
- package/src/__tests__/ResolverBase.clientOldValues.test.ts +243 -7
- package/src/__tests__/ResolverBase.deleteReturnProjection.test.ts +52 -0
- package/src/__tests__/ResolverBase.entityMessageRelay.test.ts +176 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +5 -2
- package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +23 -1
- package/src/__tests__/ResolverBase.publishStatusUpdate.test.ts +68 -0
- package/src/__tests__/ResolverBase.userViewCacheAndDedup.test.ts +130 -0
- package/src/__tests__/RunAIAgentResolver.branch.test.ts +69 -13
- package/src/__tests__/RunAIAgentResolver.conversationWriter.test.ts +210 -0
- package/src/__tests__/RunAIAgentResolver.fireAndForgetCompletion.test.ts +209 -0
- package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +20 -28
- package/src/__tests__/RunAIAgentResolver.runDataGuard.test.ts +264 -0
- package/src/__tests__/RunAIAgentResolver.serverReply.test.ts +358 -0
- package/src/__tests__/RunAIAgentResolver.stoppedPredecessor.test.ts +4 -1
- package/src/__tests__/RunTemplateResolver.test.ts +35 -2
- package/src/__tests__/RunViewResolver.cacheCheckForwarding.test.ts +11 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
- package/src/__tests__/SearchKnowledgeStreamResolver.test.ts +161 -0
- package/src/__tests__/SessionJanitor.test.ts +365 -10
- package/src/__tests__/SessionManager.test.ts +122 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +25 -0
- package/src/__tests__/TaskGraphAgentRunner.test.ts +266 -0
- package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +468 -23
- package/src/__tests__/TaskGraphFrameResolver.test.ts +22 -0
- package/src/__tests__/TestQuerySQLResolver.test.ts +274 -0
- package/src/__tests__/TransactionGroupResolver.fieldSecurity.test.ts +199 -0
- package/src/__tests__/TransactionGroupResolver.refusals.test.ts +98 -1
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +15 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
- package/src/__tests__/ViewOperationsHandler.test.ts +7 -2
- package/src/__tests__/WidgetSessionService.test.ts +583 -4
- package/src/__tests__/agent-run-data-guard.test.ts +130 -0
- package/src/__tests__/apiRootBoot.test.ts +202 -0
- package/src/__tests__/buildMagicLinkSessionUser.test.ts +96 -0
- package/src/__tests__/cacheBroadcastProviders.test.ts +403 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +109 -0
- package/src/__tests__/conversationRunEvents.test.ts +56 -14
- package/src/__tests__/fieldSecurityFixtures.ts +91 -0
- package/src/__tests__/globalCors.test.ts +217 -0
- package/src/__tests__/localDevAuth.test.ts +196 -0
- package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
- package/src/__tests__/multiTenancy.security.test.ts +51 -0
- package/src/__tests__/multiTenancy.test.ts +1 -1
- package/src/__tests__/orphanedConversationDetailReconciler.test.ts +10 -0
- package/src/__tests__/ownedRealtimeSession.test.ts +39 -0
- package/src/__tests__/pushStatusResolver.filter.test.ts +64 -1
- package/src/__tests__/realtimeRecordingHandler.test.ts +669 -0
- package/src/__tests__/realtimeSessions/RealtimeSessionEventsResolver.test.ts +29 -1
- package/src/__tests__/realtimeSessions/ownedRealtimeSession.anonymous.test.ts +31 -0
- package/src/__tests__/realtimeSessions/ownedRealtimeSession.namedScoped.test.ts +37 -0
- package/src/__tests__/realtimeSessions/sessionAccess.test.ts +146 -7
- package/src/__tests__/realtimeSessions/sessionConfigStamps.test.ts +1 -1
- package/src/__tests__/realtimeSessions/verificationWorkflow.test.ts +25 -4
- package/src/__tests__/resolverBase.cloudEventChangedFields.test.ts +157 -0
- package/src/__tests__/resolverBase.fieldSecurityTransport.test.ts +222 -0
- package/src/__tests__/resolverBase.fls.test.ts +146 -97
- package/src/__tests__/restTransport.fieldSecurity.test.ts +148 -0
- package/src/__tests__/runParamsTimeZone.test.ts +179 -0
- package/src/__tests__/runStreams.fieldSecurity.test.ts +100 -0
- package/src/__tests__/runViewProperties.test.ts +114 -0
- package/src/__tests__/scalabilityBenchmarks.test.ts +765 -0
- package/src/__tests__/scalabilityDatabaseIntegration.test.ts +207 -0
- package/src/__tests__/serverExtensionHostOverlay.test.ts +66 -0
- package/src/__tests__/serverExtensionReservedRoots.test.ts +11 -98
- package/src/__tests__/sessionUserClone.test.ts +5 -1
- package/src/__tests__/sharedCache.test.ts +5 -3
- package/src/__tests__/startupSweepOrder.test.ts +40 -0
- package/src/__tests__/unifiedAuth.test.ts +28 -0
- package/src/__tests__/widgetGuestElevation.test.ts +2 -0
- package/src/agentSessions/SessionJanitor.ts +369 -93
- package/src/agentSessions/SessionManager.ts +45 -10
- package/src/apiRoot.ts +78 -0
- package/src/auth/AuthProviderCatalogRouter.ts +29 -0
- package/src/auth/LoginScreenEngine.ts +50 -0
- package/src/auth/index.ts +57 -12
- package/src/auth/initializeProviders.ts +43 -1
- package/src/auth/localDevAuth.ts +528 -0
- package/src/auth/magicLink/MagicLinkKeys.ts +2 -2
- package/src/auth/magicLink/MagicLinkRouter.ts +3 -2
- package/src/auth/magicLink/types.ts +10 -0
- package/src/auth/sessionUserClone.ts +3 -2
- package/src/broadcast/BaseCacheInvalidationBroadcastProvider.ts +674 -0
- package/src/broadcast/index.ts +5 -0
- package/src/broadcast/providers/AWSIoTCoreBroadcastProvider.ts +180 -0
- package/src/broadcast/providers/AzureWebPubSubBroadcastProvider.ts +182 -0
- package/src/broadcast/providers/GCPPubSubBroadcastProvider.ts +169 -0
- package/src/broadcast/providers/SequencedRoleBucketBroadcastProvider.ts +57 -0
- package/src/cache.ts +2 -1
- package/src/config.ts +115 -5
- package/src/context.ts +32 -8
- package/src/coreRoutes.ts +181 -0
- package/src/generated/graphql-schemas/__mj.ts +3684 -284
- package/src/generic/CacheInvalidationResolver.ts +172 -60
- package/src/generic/OrphanedConversationDetailReconciler.ts +4 -2
- package/src/generic/PubSubManager.ts +54 -1
- package/src/generic/PushStatusResolver.ts +109 -12
- package/src/generic/ResolverBase.ts +540 -431
- package/src/generic/RunViewResolver.ts +97 -15
- package/src/generic/actionOutputFieldSecurity.ts +75 -0
- package/src/generic/fieldSecurityTransport.ts +62 -0
- package/src/globalCors.ts +71 -0
- package/src/index.ts +239 -238
- package/src/integration/CustomColumnPromoter.ts +6 -1
- package/src/logging/variablesLoggingMiddleware.ts +59 -3
- package/src/multiTenancy/index.ts +24 -2
- package/src/realtimeSessions/README.md +7 -3
- package/src/realtimeSessions/RealtimeSessionVerificationService.ts +2 -1
- package/src/realtimeSessions/RealtimeVerifyRouter.ts +2 -1
- package/src/realtimeSessions/RecordingAssemblyScheduler.ts +234 -0
- package/src/realtimeSessions/SessionVerificationPortsBase.ts +1 -0
- package/src/realtimeSessions/index.ts +2 -0
- package/src/realtimeSessions/ownedRealtimeSession.ts +69 -0
- package/src/realtimeSessions/sessionAccess.ts +183 -23
- package/src/realtimeSessions/sessionConfigStamps.ts +1 -1
- package/src/realtimeSessions/subscriptionAuthorization.ts +4 -2
- package/src/realtimeSessions/verificationWorkflow.ts +3 -1
- package/src/realtimeWidget/WidgetRouter.ts +22 -4
- package/src/realtimeWidget/WidgetSessionService.ts +252 -17
- package/src/realtimeWidget/host-identity.ts +1 -1
- package/src/realtimeWidget/widgetCore.ts +30 -1
- package/src/realtimeWidget/widgetGuestElevation.ts +2 -1
- package/src/resolvers/ActionResolver.ts +78 -15
- package/src/resolvers/AdhocQueryResolver.ts +1 -0
- package/src/resolvers/AgentRunStatusPublisher.ts +36 -8
- package/src/resolvers/AgentSessionResolver.ts +46 -18
- package/src/resolvers/ArtifactFileResolver.ts +9 -8
- package/src/resolvers/ConversationRunEventsResolver.ts +15 -11
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityCommunicationsResolver.ts +56 -15
- package/src/resolvers/EntityRecordNameResolver.ts +3 -2
- package/src/resolvers/FetchEntityVectorsResolver.ts +248 -55
- package/src/resolvers/FileCategoryResolver.ts +1 -0
- package/src/resolvers/FileResolver.ts +334 -109
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +6 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +53 -9
- package/src/resolvers/MergeRecordsResolver.ts +23 -2
- package/src/resolvers/PipelineProgressResolver.ts +12 -3
- package/src/resolvers/QueryResolver.ts +31 -6
- package/src/resolvers/QuerySystemUserResolver.ts +15 -6
- package/src/resolvers/RSUResolver.ts +12 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +114 -66
- package/src/resolvers/RemoteBrowserActionResolver.ts +15 -10
- package/src/resolvers/RunAIAgentResolver.ts +296 -85
- package/src/resolvers/RunTemplateResolver.ts +6 -4
- package/src/resolvers/RunTestResolver.ts +3 -1
- package/src/resolvers/SearchKnowledgeStreamResolver.ts +19 -3
- package/src/resolvers/SqlLoggingConfigResolver.ts +2 -4
- package/src/resolvers/SyncDataResolver.ts +4 -0
- package/src/resolvers/TaskGraphFrameResolver.ts +33 -1
- package/src/resolvers/TestQuerySQLResolver.ts +69 -13
- package/src/resolvers/TransactionGroupResolver.ts +185 -30
- package/src/resolvers/UserViewResolver.ts +2 -1
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +43 -0
- package/src/resolvers/agent-run-data-guard.ts +89 -0
- package/src/rest/EntityCRUDHandler.ts +22 -5
- package/src/rest/RESTEndpointHandler.ts +8 -5
- package/src/rest/RealtimeRecordingHandler.ts +467 -0
- package/src/rest/ViewOperationsHandler.ts +52 -18
- package/src/rest/setupRESTEndpoints.ts +6 -0
- package/src/serverExtensionReservedRoots.ts +14 -17
- package/src/serverRoleConfigUnits.ts +48 -0
- package/src/services/MJStorageBlobStore.ts +31 -30
- package/src/services/StartTaskGraphDispatcher.ts +2 -2
- package/src/services/TaskGraphActionRunner.ts +25 -5
- package/src/services/TaskGraphAgentRunner.ts +188 -11
- package/src/services/TaskGraphContinuationDeliverer.ts +738 -16
- package/src/sharedCache.ts +3 -0
- package/src/types.ts +23 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AggregateExpression,
|
|
3
|
+
AssertClientInputWritableForUser,
|
|
3
4
|
BaseEntity,
|
|
4
5
|
BaseEntityEvent,
|
|
5
6
|
CompositeKey,
|
|
@@ -14,8 +15,10 @@ import {
|
|
|
14
15
|
KeyValuePair,
|
|
15
16
|
LogDebug,
|
|
16
17
|
LogError,
|
|
18
|
+
LogErrorEx,
|
|
17
19
|
LogStatus,
|
|
18
20
|
Metadata,
|
|
21
|
+
ProjectEntityValuesForUser,
|
|
19
22
|
ReadableFieldsTransportKey,
|
|
20
23
|
RecordChangeFieldSecurityProjector,
|
|
21
24
|
RecordChangePayloadFields,
|
|
@@ -23,10 +26,19 @@ import {
|
|
|
23
26
|
RunViewParams,
|
|
24
27
|
RunViewResult,
|
|
25
28
|
UserInfo,
|
|
29
|
+
ViewPropertyValues,
|
|
26
30
|
} from '@memberjunction/core';
|
|
27
|
-
import { MJAuditLogEntity, MJErrorLogEntity, MJUserViewEntityExtended } from '@memberjunction/core-entities';
|
|
31
|
+
import { MJAuditLogEntity, MJErrorLogEntity, MJUserViewEntityExtended, UserViewEngine } from '@memberjunction/core-entities';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extended RunViewResult carrying a non-enumerable reference to the resolved view entity
|
|
35
|
+
* so downstream resolvers (e.g. RunViewResolver) do not re-query `MJ: User Views` a third time.
|
|
36
|
+
*/
|
|
37
|
+
export type RunViewResultWithResolvedView<T = unknown> = RunViewResult<T> & {
|
|
38
|
+
_resolvedViewEntity?: MJUserViewEntityExtended;
|
|
39
|
+
};
|
|
28
40
|
import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
|
|
29
|
-
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
41
|
+
import { ClientClauseScreen, UserCache, type EntityBaseViewAllowList } from '@memberjunction/generic-database-provider';
|
|
30
42
|
import { PubSubEngine, AuthorizationError } from 'type-graphql';
|
|
31
43
|
import { GraphQLError } from 'graphql';
|
|
32
44
|
import { RefusalExtensions } from './refusalExtensions.js';
|
|
@@ -37,16 +49,34 @@ import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
|
|
|
37
49
|
import { RunViewGenericParams, UserPayload } from '../types.js';
|
|
38
50
|
import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
|
|
39
51
|
import { DeleteOptionsInput } from './DeleteOptionsInput.js';
|
|
40
|
-
import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, ReplaceByteArraysWithBase64, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
41
|
-
import { SQLParser } from '@memberjunction/sql-parser';
|
|
42
|
-
import {
|
|
52
|
+
import { MJEvent, MJEventType, MJGlobal, UUIDsEqual, ENCRYPTED_SENTINEL, ReplaceByteArraysWithBase64, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
53
|
+
import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
|
|
54
|
+
import type { SQLParserDialect } from '@memberjunction/sql-dialect';
|
|
43
55
|
import { EncryptionEngine, IsEncryptionKeyUnavailable, ReportEncryptionKeyUnavailable } from '@memberjunction/encryption';
|
|
44
|
-
import { PUSH_STATUS_UPDATES_TOPIC, PublishStatusUpdate } from './PushStatusResolver.js';
|
|
56
|
+
import { PUSH_STATUS_UPDATES_TOPIC, PublishStatusUpdate, StatusUpdateOwnerID } from './PushStatusResolver.js';
|
|
45
57
|
import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
|
|
46
58
|
import { PubSubManager } from './PubSubManager.js';
|
|
47
59
|
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
|
|
48
60
|
import { Subscription } from 'rxjs';
|
|
49
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
|
|
64
|
+
* do not track record changes: their server-side write rules compare against the prior state, which a client could
|
|
65
|
+
* otherwise supply itself (lower-case names). See `ResolverBase.MustLoadTruthFromDatabase`.
|
|
66
|
+
*
|
|
67
|
+
* `mj: conversations` and `mj: ai agent sessions` carry the anonymous realtime-session ownership key
|
|
68
|
+
* (`ExternalID` / `ConversationID`), which their server entities refuse an anonymous visitor to change — a rule
|
|
69
|
+
* that decides on `Dirty`, so the baseline must be the stored row, not a client-supplied one (#4931).
|
|
70
|
+
*/
|
|
71
|
+
const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW: ReadonlySet<string> = new Set([
|
|
72
|
+
'mj: record changes',
|
|
73
|
+
'mj: conversation details',
|
|
74
|
+
'mj: data context items',
|
|
75
|
+
'mj: conversations',
|
|
76
|
+
'mj: ai agent sessions',
|
|
77
|
+
'mj: ai agent runs',
|
|
78
|
+
]);
|
|
79
|
+
|
|
50
80
|
export class ResolverBase {
|
|
51
81
|
private static _emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null;
|
|
52
82
|
private static _cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {};
|
|
@@ -86,141 +116,158 @@ export class ResolverBase {
|
|
|
86
116
|
* @param contextUser - Optional user context for decryption (required for encrypted fields)
|
|
87
117
|
* @returns A new object in transport shape, or null when there is nothing to map
|
|
88
118
|
*/
|
|
89
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Precomputed per-entity / per-request field-mapping plan shared across rows in
|
|
121
|
+
* {@link MapFieldNamesToCodeNames} and {@link ArrayMapFieldNamesToCodeNames}.
|
|
122
|
+
*/
|
|
123
|
+
private buildFieldMappingPlan(
|
|
90
124
|
entityName: string,
|
|
91
|
-
dataObject: any,
|
|
92
125
|
contextUser?: UserInfo,
|
|
93
126
|
provider?: IMetadataProvider,
|
|
94
127
|
deniedReadFields?: Set<string>,
|
|
95
128
|
recordChangeProjector?: RecordChangeFieldSecurityProjector
|
|
96
|
-
):
|
|
97
|
-
|
|
129
|
+
): {
|
|
130
|
+
entityInfo: EntityInfo;
|
|
131
|
+
fieldsToProcess: Array<{ name: string; mappedFieldName: string; isDenied: boolean }>;
|
|
132
|
+
readableFields: string[] | null;
|
|
133
|
+
projector: RecordChangeFieldSecurityProjector | undefined;
|
|
134
|
+
encryptedFields: EntityFieldInfo[];
|
|
135
|
+
checkBinary: boolean;
|
|
136
|
+
} {
|
|
137
|
+
const md = provider ?? new Metadata();
|
|
138
|
+
const entityInfo = md.EntityByName(entityName);
|
|
139
|
+
if (!entityInfo) throw new Error(`Entity ${entityName} not found in metadata`);
|
|
140
|
+
|
|
141
|
+
const denied = deniedReadFields ?? (
|
|
142
|
+
entityInfo.FieldLevelSecurityApplies && contextUser
|
|
143
|
+
? entityInfo.GetDeniedReadFields(contextUser)
|
|
144
|
+
: null
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const mapper = new FieldMapper();
|
|
148
|
+
const fieldsToProcess: Array<{ name: string; mappedFieldName: string; isDenied: boolean }> = [];
|
|
149
|
+
for (const f of entityInfo.Fields) {
|
|
150
|
+
const mappedFieldName = mapper.MapFieldName(f.CodeName);
|
|
151
|
+
const isDenied = Boolean(denied?.has(f.Name.trim().toLowerCase()));
|
|
152
|
+
if (isDenied || mappedFieldName !== f.Name) {
|
|
153
|
+
fieldsToProcess.push({ name: f.Name, mappedFieldName, isDenied });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const readableFields = this.BuildReadableFieldsTransportValue(entityInfo, denied);
|
|
158
|
+
const projector = contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)
|
|
159
|
+
? (recordChangeProjector ?? new RecordChangeFieldSecurityProjector(md, contextUser))
|
|
160
|
+
: undefined;
|
|
161
|
+
const encryptedFields = entityInfo.EncryptedFields ?? [];
|
|
162
|
+
const checkBinary = entityInfo.HasBinaryFields !== false;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
entityInfo,
|
|
166
|
+
fieldsToProcess,
|
|
167
|
+
readableFields,
|
|
168
|
+
projector,
|
|
169
|
+
encryptedFields,
|
|
170
|
+
checkBinary,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Applies synchronous field renaming, FLS stripping, readable-fields transport stamping, and
|
|
176
|
+
* Record Changes payload projection for a single row using a precomputed mapping plan.
|
|
177
|
+
*/
|
|
178
|
+
private mapRowSynchronously(
|
|
179
|
+
dataObject: Record<string, unknown> | null | undefined,
|
|
180
|
+
plan: ReturnType<ResolverBase['buildFieldMappingPlan']>
|
|
181
|
+
): Record<string, unknown> | null {
|
|
98
182
|
if (!dataObject || Object.keys(dataObject).length === 0) {
|
|
99
183
|
return null;
|
|
100
184
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
// String scalar cannot serialize a Buffer and would fail the whole response.
|
|
105
|
-
dataObject = ReplaceByteArraysWithBase64({ ...dataObject });
|
|
106
|
-
|
|
107
|
-
// for the given entity name provided, check to see if there are any fields
|
|
108
|
-
// where the code name is different from the field name, and for just those
|
|
109
|
-
// fields, iterate through the dataObject and REPLACE the property that has the field name
|
|
110
|
-
// with the CodeName, because we can't transfer those via GraphQL as they are not
|
|
111
|
-
// valid property names in GraphQL
|
|
112
|
-
{
|
|
113
|
-
const md = provider ?? new Metadata();
|
|
114
|
-
const entityInfo = md.EntityByName(entityName);
|
|
115
|
-
if (!entityInfo) throw new Error(`Entity ${entityName} not found in metadata`);
|
|
116
|
-
// const fields = entityInfo.Fields.filter((f) => f.Name !== f.CodeName || f.Name.startsWith('__mj_'));
|
|
117
|
-
// FIELD-LEVEL SECURITY — this is the authoritative read boundary for every GraphQL
|
|
118
|
-
// return path (single-record resolvers, external-data-source loads, and anything else
|
|
119
|
-
// routed through here), sitting exactly where encryption masking already lives.
|
|
120
|
-
//
|
|
121
|
-
// The denied set is normally computed ONCE per (entity, user) by the caller and passed
|
|
122
|
-
// in: this method runs once per ROW, so resolving it here would cost fields x rows
|
|
123
|
-
// aggregations. We fall back to computing it when a caller doesn't supply it (the
|
|
124
|
-
// single-record resolvers, where "once per row" and "once per request" are the same
|
|
125
|
-
// thing) rather than failing open — a missing argument must never mean missing security.
|
|
126
|
-
const denied = deniedReadFields ?? (
|
|
127
|
-
entityInfo.EnableFieldLevelSecurity && contextUser
|
|
128
|
-
? entityInfo.GetDeniedReadFields(contextUser)
|
|
129
|
-
: null
|
|
130
|
-
);
|
|
185
|
+
let row: Record<string, unknown> = plan.checkBinary
|
|
186
|
+
? ReplaceByteArraysWithBase64({ ...dataObject })
|
|
187
|
+
: { ...dataObject };
|
|
131
188
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
// either way depending on whether mapping has already run.
|
|
142
|
-
delete dataObject[f.Name];
|
|
143
|
-
delete dataObject[mappedFieldName];
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
if (mappedFieldName !== f.Name) {
|
|
147
|
-
dataObject[mappedFieldName] = dataObject[f.Name];
|
|
148
|
-
delete dataObject[f.Name];
|
|
149
|
-
}
|
|
189
|
+
for (let i = 0; i < plan.fieldsToProcess.length; i++) {
|
|
190
|
+
const fp = plan.fieldsToProcess[i];
|
|
191
|
+
if (Object.prototype.hasOwnProperty.call(row, fp.name)) {
|
|
192
|
+
if (fp.isDenied) {
|
|
193
|
+
delete row[fp.name];
|
|
194
|
+
delete row[fp.mappedFieldName];
|
|
195
|
+
} else {
|
|
196
|
+
row[fp.mappedFieldName] = row[fp.name];
|
|
197
|
+
delete row[fp.name];
|
|
150
198
|
}
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
// Field security, second half: say IN-BAND which fields this caller may read. Deleting the
|
|
154
|
-
// key above is not enough on its own — GraphQL emits every SELECTED field regardless, so a
|
|
155
|
-
// denied field the client asked for arrives as an explicit null it cannot tell apart from a
|
|
156
|
-
// genuine one. The client must not settle that from its own metadata: during the window
|
|
157
|
-
// after a permission change its copy disagrees with ours, and once metadata filtering lands
|
|
158
|
-
// (issue #3485) it may not hold the permission rows at all. Null for unrestricted callers,
|
|
159
|
-
// which is nearly every request.
|
|
160
|
-
const readableFields = this.BuildReadableFieldsTransportValue(entityInfo, denied);
|
|
161
|
-
if (readableFields) {
|
|
162
|
-
dataObject[ReadableFieldsTransportKey] = readableFields;
|
|
163
199
|
}
|
|
200
|
+
}
|
|
164
201
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)) {
|
|
176
|
-
const projector = recordChangeProjector ?? new RecordChangeFieldSecurityProjector(md, contextUser);
|
|
177
|
-
dataObject = projector.ProjectRow(dataObject);
|
|
178
|
-
}
|
|
202
|
+
if (plan.readableFields) {
|
|
203
|
+
row[ReadableFieldsTransportKey] = plan.readableFields;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (plan.projector) {
|
|
207
|
+
row = plan.projector.ProjectRow(row);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return row;
|
|
211
|
+
}
|
|
179
212
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
// On decryption failure, use sentinel for safety
|
|
208
|
-
dataObject[fieldName] = ENCRYPTED_SENTINEL;
|
|
209
|
-
}
|
|
213
|
+
/**
|
|
214
|
+
* Decrypts or masks encrypted fields on a mapped row when `plan.encryptedFields.length > 0`.
|
|
215
|
+
*/
|
|
216
|
+
private async decryptRowEncryptedFields(
|
|
217
|
+
row: Record<string, unknown>,
|
|
218
|
+
encryptedFields: EntityFieldInfo[],
|
|
219
|
+
contextUser?: UserInfo
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
for (const field of encryptedFields) {
|
|
222
|
+
const fieldName = field.CodeName;
|
|
223
|
+
const value = row[fieldName];
|
|
224
|
+
|
|
225
|
+
if (value === null || value === undefined) continue;
|
|
226
|
+
|
|
227
|
+
const engine = EncryptionEngine.Instance;
|
|
228
|
+
await engine.Config(false, contextUser);
|
|
229
|
+
const keyMarker = field.EncryptionKeyID ? engine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
|
|
230
|
+
if (typeof value === 'string' && IsValueEncrypted(value, keyMarker)) {
|
|
231
|
+
if (field.AllowDecryptInAPI) {
|
|
232
|
+
if (contextUser) {
|
|
233
|
+
try {
|
|
234
|
+
const decryptedValue = await engine.Decrypt(value, contextUser);
|
|
235
|
+
row[fieldName] = decryptedValue;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (IsEncryptionKeyUnavailable(err)) {
|
|
238
|
+
ReportEncryptionKeyUnavailable(err);
|
|
210
239
|
} else {
|
|
211
|
-
|
|
212
|
-
dataObject[fieldName] = ENCRYPTED_SENTINEL;
|
|
240
|
+
LogError(`Failed to decrypt field ${fieldName} for API response: ${err}`);
|
|
213
241
|
}
|
|
214
|
-
|
|
215
|
-
// AllowDecryptInAPI=false and SendEncryptedValue=false - use sentinel
|
|
216
|
-
dataObject[fieldName] = ENCRYPTED_SENTINEL;
|
|
242
|
+
row[fieldName] = ENCRYPTED_SENTINEL;
|
|
217
243
|
}
|
|
218
|
-
|
|
244
|
+
} else {
|
|
245
|
+
row[fieldName] = ENCRYPTED_SENTINEL;
|
|
219
246
|
}
|
|
247
|
+
} else if (!field.SendEncryptedValue) {
|
|
248
|
+
row[fieldName] = ENCRYPTED_SENTINEL;
|
|
220
249
|
}
|
|
221
250
|
}
|
|
222
251
|
}
|
|
223
|
-
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
protected async MapFieldNamesToCodeNames(
|
|
255
|
+
entityName: string,
|
|
256
|
+
dataObject: any,
|
|
257
|
+
contextUser?: UserInfo,
|
|
258
|
+
provider?: IMetadataProvider,
|
|
259
|
+
deniedReadFields?: Set<string>,
|
|
260
|
+
recordChangeProjector?: RecordChangeFieldSecurityProjector
|
|
261
|
+
): Promise<any> {
|
|
262
|
+
if (!dataObject || Object.keys(dataObject).length === 0) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const plan = this.buildFieldMappingPlan(entityName, contextUser, provider, deniedReadFields, recordChangeProjector);
|
|
266
|
+
const mapped = this.mapRowSynchronously(dataObject as Record<string, unknown>, plan);
|
|
267
|
+
if (mapped && plan.encryptedFields.length > 0) {
|
|
268
|
+
await this.decryptRowEncryptedFields(mapped, plan.encryptedFields, contextUser);
|
|
269
|
+
}
|
|
270
|
+
return mapped;
|
|
224
271
|
}
|
|
225
272
|
|
|
226
273
|
/**
|
|
@@ -280,26 +327,17 @@ export class ResolverBase {
|
|
|
280
327
|
if (!dataObjectArray || dataObjectArray.length === 0) {
|
|
281
328
|
return dataObjectArray;
|
|
282
329
|
}
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
// span many — so the projector, which owns that memo, must outlive the row loop. Built once
|
|
295
|
-
// here for the same reason the denied set above is: GetDeniedReadFields walks every field on
|
|
296
|
-
// an entity and must never be called per row.
|
|
297
|
-
const recordChangeProjector = contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)
|
|
298
|
-
? new RecordChangeFieldSecurityProjector(md, contextUser)
|
|
299
|
-
: undefined;
|
|
300
|
-
const mapped: any[] = [];
|
|
301
|
-
for (const element of dataObjectArray) {
|
|
302
|
-
mapped.push(await this.MapFieldNamesToCodeNames(entityName, element, contextUser, provider, deniedReadFields, recordChangeProjector));
|
|
330
|
+
// Precompute the entity lookup, FieldMapper plan, denied-fields set, readable-fields list,
|
|
331
|
+
// and Record Changes projector ONCE for the whole array rather than per row.
|
|
332
|
+
const plan = this.buildFieldMappingPlan(entityName, contextUser, provider);
|
|
333
|
+
const hasEncryptedFields = plan.encryptedFields.length > 0;
|
|
334
|
+
const mapped: Array<Record<string, unknown> | null> = new Array(dataObjectArray.length);
|
|
335
|
+
for (let i = 0; i < dataObjectArray.length; i++) {
|
|
336
|
+
const row = this.mapRowSynchronously(dataObjectArray[i] as Record<string, unknown>, plan);
|
|
337
|
+
if (row && hasEncryptedFields) {
|
|
338
|
+
await this.decryptRowEncryptedFields(row, plan.encryptedFields, contextUser);
|
|
339
|
+
}
|
|
340
|
+
mapped[i] = row;
|
|
303
341
|
}
|
|
304
342
|
return mapped;
|
|
305
343
|
}
|
|
@@ -350,6 +388,7 @@ export class ResolverBase {
|
|
|
350
388
|
if (!loaded) {
|
|
351
389
|
return null;
|
|
352
390
|
}
|
|
391
|
+
// fls-guard: MapFieldNamesToCodeNames drops the fields the caller may not read
|
|
353
392
|
const mapped = await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), contextUser);
|
|
354
393
|
return mapped as T;
|
|
355
394
|
}
|
|
@@ -366,13 +405,16 @@ export class ResolverBase {
|
|
|
366
405
|
* @param dataObject - The data object containing field values
|
|
367
406
|
* @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
|
|
368
407
|
* @param contextUser - User context for encryption operations
|
|
408
|
+
* @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
|
|
409
|
+
* default) or field Names (raw provider rows, such as dataset item results)
|
|
369
410
|
* @returns The filtered data object
|
|
370
411
|
*/
|
|
371
412
|
protected async FilterEncryptedFieldsForAPI(
|
|
372
413
|
entityName: string,
|
|
373
414
|
dataObject: Record<string, unknown>,
|
|
374
415
|
contextUser: UserInfo,
|
|
375
|
-
provider?: IMetadataProvider
|
|
416
|
+
provider?: IMetadataProvider,
|
|
417
|
+
fieldKey: 'CodeName' | 'Name' = 'CodeName'
|
|
376
418
|
): Promise<Record<string, unknown>> {
|
|
377
419
|
if (!dataObject) return dataObject;
|
|
378
420
|
|
|
@@ -386,7 +428,7 @@ export class ResolverBase {
|
|
|
386
428
|
|
|
387
429
|
// Process each encrypted field
|
|
388
430
|
for (const field of encryptedFields) {
|
|
389
|
-
const fieldName = field.CodeName;
|
|
431
|
+
const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
|
|
390
432
|
const value = dataObject[fieldName];
|
|
391
433
|
|
|
392
434
|
// Skip null/undefined values
|
|
@@ -427,13 +469,15 @@ export class ResolverBase {
|
|
|
427
469
|
}
|
|
428
470
|
|
|
429
471
|
/**
|
|
430
|
-
* Filters encrypted fields for an array of data objects
|
|
472
|
+
* Filters encrypted fields for an array of data objects. `fieldKey` is as for
|
|
473
|
+
* {@link FilterEncryptedFieldsForAPI}.
|
|
431
474
|
*/
|
|
432
475
|
protected async ArrayFilterEncryptedFieldsForAPI(
|
|
433
476
|
entityName: string,
|
|
434
477
|
dataObjectArray: Record<string, unknown>[],
|
|
435
478
|
contextUser: UserInfo,
|
|
436
|
-
provider?: IMetadataProvider
|
|
479
|
+
provider?: IMetadataProvider,
|
|
480
|
+
fieldKey: 'CodeName' | 'Name' = 'CodeName'
|
|
437
481
|
): Promise<Record<string, unknown>[]> {
|
|
438
482
|
if (!dataObjectArray || dataObjectArray.length === 0) return dataObjectArray;
|
|
439
483
|
|
|
@@ -447,7 +491,7 @@ export class ResolverBase {
|
|
|
447
491
|
|
|
448
492
|
// Process each element
|
|
449
493
|
for (const element of dataObjectArray) {
|
|
450
|
-
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
|
|
494
|
+
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
|
|
451
495
|
}
|
|
452
496
|
|
|
453
497
|
return dataObjectArray;
|
|
@@ -535,6 +579,53 @@ export class ResolverBase {
|
|
|
535
579
|
}
|
|
536
580
|
}
|
|
537
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Resolves a User View by name, consulting the in-memory {@link UserViewEngine} cache first
|
|
584
|
+
* and falling back to a database `RunView` only on a cache miss.
|
|
585
|
+
*/
|
|
586
|
+
protected async resolveUserViewByName(
|
|
587
|
+
viewName: string,
|
|
588
|
+
provider: DatabaseProviderBase,
|
|
589
|
+
contextUser?: UserInfo,
|
|
590
|
+
): Promise<MJUserViewEntityExtended | null> {
|
|
591
|
+
if (UserViewEngine.Instance.Loaded) {
|
|
592
|
+
const cached = UserViewEngine.Instance.GetViewByName(viewName);
|
|
593
|
+
if (cached) {
|
|
594
|
+
return cached;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const rv = provider as unknown as IRunViewProvider;
|
|
598
|
+
const result = await rv.RunView<MJUserViewEntityExtended>({
|
|
599
|
+
EntityName: 'MJ: User Views',
|
|
600
|
+
// SECURITY: the view name is client-supplied and lands inside a SQL literal.
|
|
601
|
+
ExtraFilter: "Name='" + EscapeSQLString(viewName) + "'",
|
|
602
|
+
}, contextUser);
|
|
603
|
+
if (result && result.Success && result.Results.length > 0) {
|
|
604
|
+
return result.Results[0];
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Resolves a User View by ID, consulting the in-memory {@link UserViewEngine} cache first
|
|
611
|
+
* and falling back to an entity `Load()` only on a cache miss.
|
|
612
|
+
*/
|
|
613
|
+
protected async resolveUserViewByID(
|
|
614
|
+
viewID: string,
|
|
615
|
+
provider: DatabaseProviderBase,
|
|
616
|
+
contextUser?: UserInfo,
|
|
617
|
+
): Promise<MJUserViewEntityExtended | null> {
|
|
618
|
+
if (UserViewEngine.Instance.Loaded) {
|
|
619
|
+
const cached = UserViewEngine.Instance.GetViewById(viewID);
|
|
620
|
+
if (cached) {
|
|
621
|
+
return cached;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const viewInfo = await provider.GetEntityObject<MJUserViewEntityExtended>('MJ: User Views', contextUser);
|
|
625
|
+
await viewInfo.Load(viewID);
|
|
626
|
+
return viewInfo;
|
|
627
|
+
}
|
|
628
|
+
|
|
538
629
|
async RunViewByNameGeneric(viewInput: RunViewByNameInput, provider: DatabaseProviderBase, userPayload: UserPayload, pubSub: PubSubEngine) {
|
|
539
630
|
try {
|
|
540
631
|
// Log aggregate input for debugging
|
|
@@ -542,14 +633,8 @@ export class ResolverBase {
|
|
|
542
633
|
LogStatus(`[ResolverBase] RunViewByNameGeneric received aggregates: viewName=${viewInput.ViewName}, aggregateCount=${viewInput.Aggregates.length}, aggregates=${JSON.stringify(viewInput.Aggregates.map(a => ({ expression: a.expression, alias: a.alias })))}`);
|
|
543
634
|
}
|
|
544
635
|
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
EntityName: 'MJ: User Views',
|
|
548
|
-
// SECURITY: the view name is client-supplied and lands inside a SQL literal.
|
|
549
|
-
ExtraFilter: "Name='" + EscapeSQLString(viewInput.ViewName) + "'",
|
|
550
|
-
}, userPayload.userRecord);
|
|
551
|
-
if (result && result.Success && result.Results.length > 0) {
|
|
552
|
-
const viewInfo = result.Results[0];
|
|
636
|
+
const viewInfo = await this.resolveUserViewByName(viewInput.ViewName, provider, userPayload.userRecord);
|
|
637
|
+
if (viewInfo) {
|
|
553
638
|
return this.RunViewGenericInternal(
|
|
554
639
|
provider,
|
|
555
640
|
viewInfo,
|
|
@@ -574,7 +659,9 @@ export class ResolverBase {
|
|
|
574
659
|
: undefined,
|
|
575
660
|
viewInput.BypassCache,
|
|
576
661
|
viewInput.DataSource,
|
|
577
|
-
viewInput.IncludeBinaryFields
|
|
662
|
+
viewInput.IncludeBinaryFields,
|
|
663
|
+
viewInput.AlternateBaseView,
|
|
664
|
+
viewInput.Properties
|
|
578
665
|
);
|
|
579
666
|
}
|
|
580
667
|
else {
|
|
@@ -595,8 +682,10 @@ export class ResolverBase {
|
|
|
595
682
|
}
|
|
596
683
|
|
|
597
684
|
const contextUser = this.GetUserFromPayload(userPayload);
|
|
598
|
-
const viewInfo = await
|
|
599
|
-
|
|
685
|
+
const viewInfo = await this.resolveUserViewByID(viewInput.ViewID, provider, contextUser);
|
|
686
|
+
if (!viewInfo) {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
600
689
|
return this.RunViewGenericInternal(
|
|
601
690
|
provider,
|
|
602
691
|
viewInfo,
|
|
@@ -619,7 +708,9 @@ export class ResolverBase {
|
|
|
619
708
|
undefined,
|
|
620
709
|
viewInput.BypassCache,
|
|
621
710
|
viewInput.DataSource,
|
|
622
|
-
viewInput.IncludeBinaryFields
|
|
711
|
+
viewInput.IncludeBinaryFields,
|
|
712
|
+
viewInput.AlternateBaseView,
|
|
713
|
+
viewInput.Properties
|
|
623
714
|
);
|
|
624
715
|
} catch (err) {
|
|
625
716
|
console.log(err);
|
|
@@ -667,7 +758,8 @@ export class ResolverBase {
|
|
|
667
758
|
undefined,
|
|
668
759
|
viewInput.BypassCache,
|
|
669
760
|
viewInput.DataSource,
|
|
670
|
-
viewInput.IncludeBinaryFields
|
|
761
|
+
viewInput.IncludeBinaryFields,
|
|
762
|
+
viewInput.AlternateBaseView
|
|
671
763
|
);
|
|
672
764
|
} catch (err) {
|
|
673
765
|
console.log(err);
|
|
@@ -688,15 +780,14 @@ export class ResolverBase {
|
|
|
688
780
|
let viewInfo: MJUserViewEntityExtended | null = null;
|
|
689
781
|
|
|
690
782
|
if (viewInput.ViewName) {
|
|
691
|
-
viewInfo =
|
|
783
|
+
viewInfo = await this.resolveUserViewByName(viewInput.ViewName, provider, userPayload.userRecord);
|
|
692
784
|
// Populate EntityName on the input so callers (e.g. RunViews resolver) can
|
|
693
785
|
// look up the entity without re-querying the view
|
|
694
786
|
if (viewInfo && !viewInput.EntityName) {
|
|
695
787
|
viewInput.EntityName = viewInfo.Entity;
|
|
696
788
|
}
|
|
697
789
|
} else if (viewInput.ViewID) {
|
|
698
|
-
viewInfo = await
|
|
699
|
-
await viewInfo.Load(viewInput.ViewID);
|
|
790
|
+
viewInfo = await this.resolveUserViewByID(viewInput.ViewID, provider, contextUser);
|
|
700
791
|
// Populate EntityName on the input so callers can look up the entity
|
|
701
792
|
if (viewInfo && !viewInput.EntityName) {
|
|
702
793
|
viewInput.EntityName = viewInfo.Entity;
|
|
@@ -743,6 +834,8 @@ export class ResolverBase {
|
|
|
743
834
|
bypassCache: viewInput.BypassCache,
|
|
744
835
|
dataSource: viewInput.DataSource,
|
|
745
836
|
includeBinaryFields: viewInput.IncludeBinaryFields,
|
|
837
|
+
alternateBaseView: viewInput.AlternateBaseView,
|
|
838
|
+
properties: viewInput.Properties,
|
|
746
839
|
});
|
|
747
840
|
} catch (err) {
|
|
748
841
|
LogError(err);
|
|
@@ -754,6 +847,31 @@ export class ResolverBase {
|
|
|
754
847
|
return results;
|
|
755
848
|
}
|
|
756
849
|
|
|
850
|
+
/**
|
|
851
|
+
* What an entity event may carry to a CloudEvents sink or a log line.
|
|
852
|
+
*
|
|
853
|
+
* The whole row for an entity without field security. For one where it applies (its own flag
|
|
854
|
+
* or an IS-A ancestor's), and for `MJ: Record Changes` (whose payload columns hold other
|
|
855
|
+
* entities' values), only the primary key and the names of the fields that changed: the sink
|
|
856
|
+
* is outside MemberJunction with no reader to project the row for, and these events fire for
|
|
857
|
+
* server-internal saves too, whose objects hold every column.
|
|
858
|
+
*/
|
|
859
|
+
protected EntityEventPayload(baseEntity: BaseEntity, changedFieldNames?: string[]): Record<string, unknown> {
|
|
860
|
+
const entityInfo = baseEntity.EntityInfo;
|
|
861
|
+
if (!entityInfo?.FieldLevelSecurityApplies && !RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)) {
|
|
862
|
+
// fls-guard: entity without field security and not the audit trail; whole row is the documented payload
|
|
863
|
+
return baseEntity.GetAll();
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
// Read through Get() rather than PrimaryKey, which builds every field object of a raw-mode record.
|
|
867
|
+
PrimaryKey: (entityInfo.PrimaryKeys ?? []).map((pk) => ({ FieldName: pk.Name, Value: baseEntity.Get(pk.Name) })),
|
|
868
|
+
// A completed save has already rebased the object, so nothing on it is dirty: the event
|
|
869
|
+
// carries the names the save wrote. Other events (save_started) still read them off the object.
|
|
870
|
+
// GetDirtyFieldNames answers without building field objects for a record still in raw mode.
|
|
871
|
+
ChangedFields: changedFieldNames ?? baseEntity.GetDirtyFieldNames(),
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
757
875
|
private static _priorEmittedData: {Entity: string, PKey: CompositeKey}[] = [];
|
|
758
876
|
protected async EmitCloudEvent({ component, event, eventCode, args }: MJEvent) {
|
|
759
877
|
if (ResolverBase._emit && event === MJEventType.ComponentEvent && eventCode === BaseEntity.BaseEventCode) {
|
|
@@ -761,7 +879,7 @@ export class ResolverBase {
|
|
|
761
879
|
const type = `MemberJunction.${event}${extendedType}`;
|
|
762
880
|
const source = `${process.env.CLOUDEVENTS_SOURCE ?? 'MemberJunction'}`;
|
|
763
881
|
const subject = args instanceof BaseEntityEvent ? args.baseEntity.EntityInfo.CodeName : undefined;
|
|
764
|
-
const data = args?.baseEntity
|
|
882
|
+
const data = args?.baseEntity ? this.EntityEventPayload(args.baseEntity, args.ChangedFieldNames) : {};
|
|
765
883
|
|
|
766
884
|
const cloudEvent = new CloudEvent({ type, source, subject, data });
|
|
767
885
|
|
|
@@ -925,11 +1043,26 @@ export class ResolverBase {
|
|
|
925
1043
|
* This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
|
|
926
1044
|
* wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
|
|
927
1045
|
* allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
|
|
928
|
-
* and catalogs are rejected. Server-internal RunView callers
|
|
1046
|
+
* and catalogs are rejected. Server-internal RunView callers get only the provider's
|
|
1047
|
+
* statement-separator check, which cannot refuse a legitimate filter.
|
|
1048
|
+
*
|
|
1049
|
+
* The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
|
|
1050
|
+
* with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
|
|
1051
|
+
*
|
|
1052
|
+
* Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
|
|
1053
|
+
* provider and the tenant hook splice it inside parentheses and AND their own filters after
|
|
1054
|
+
* it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
|
|
1055
|
+
* security, API-key row filters and tenant scope on one side of an OR the caller controls.
|
|
1056
|
+
* The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
|
|
1057
|
+
* A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
|
|
1058
|
+
* inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
|
|
1059
|
+
* there, so the text after it could be code that the parse-based checks never saw.
|
|
929
1060
|
*
|
|
930
1061
|
* RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
|
|
931
|
-
* into the view
|
|
932
|
-
*
|
|
1062
|
+
* into the view, and RunView removes denied fields from its own result only. A subquery reads a
|
|
1063
|
+
* base view directly with neither, so the screen refuses one over any entity the user reads under
|
|
1064
|
+
* a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
|
|
1065
|
+
* entity included.
|
|
933
1066
|
*/
|
|
934
1067
|
protected assertClientClauseUsesEntityBaseViews(
|
|
935
1068
|
clause: string | undefined | null,
|
|
@@ -940,43 +1073,37 @@ export class ResolverBase {
|
|
|
940
1073
|
): void {
|
|
941
1074
|
if (!clause?.trim()) return;
|
|
942
1075
|
|
|
1076
|
+
// The balance and backslash checks run before the shared screen: it parses the fragment
|
|
1077
|
+
// inside its own parentheses, so it cannot see a fragment that closes the parentheses the
|
|
1078
|
+
// provider and the tenant hook splice it into.
|
|
943
1079
|
const dialect = this.dialectForProvider(provider);
|
|
944
1080
|
if (SQLParser.HasStackedStatements(clause, dialect)) {
|
|
945
1081
|
throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
|
|
946
1082
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
label
|
|
950
|
-
? `SELECT 1 FROM __mj_clause_screen ORDER BY ${clause}`
|
|
951
|
-
: `SELECT 1 FROM __mj_clause_screen WHERE (${clause})`;
|
|
952
|
-
const parser = new SQLParser(wrapped, dialect);
|
|
953
|
-
// A clause the provider checks again on its own (an aggregate expression) leaves text the parser
|
|
954
|
-
// cannot read to that per-item check instead of failing the whole request.
|
|
955
|
-
if (!parser.IsValid && options?.LeaveUnparseableToProvider) return;
|
|
956
|
-
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
957
|
-
throw new Error(
|
|
958
|
-
`Invalid ${label}: not a safe read-only filter fragment — refusing under uncertainty`,
|
|
959
|
-
);
|
|
1083
|
+
const balance = IsBalancedSQLFragment(clause, dialect);
|
|
1084
|
+
if (!balance.IsBalanced) {
|
|
1085
|
+
throw new Error(`Invalid ${label}: ${balance.Reason}`);
|
|
960
1086
|
}
|
|
961
|
-
if (this.
|
|
962
|
-
throw new Error(`Invalid ${label}:
|
|
1087
|
+
if (this.hasBackslashInQuotes(clause, dialect)) {
|
|
1088
|
+
throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
|
|
963
1089
|
}
|
|
964
1090
|
|
|
965
|
-
|
|
966
|
-
const tables = SQLParser.ExtractTableRefs(wrapped, dialect);
|
|
967
|
-
for (const t of tables) {
|
|
968
|
-
const table = this.stripSqlIdent(t.TableName);
|
|
969
|
-
const schema = this.stripSqlIdent(t.SchemaName);
|
|
970
|
-
if (table.toLowerCase() === '__mj_clause_screen') continue;
|
|
971
|
-
this.assertTableRefReadable(allowed, schema, table, label, user);
|
|
972
|
-
}
|
|
1091
|
+
ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
|
|
973
1092
|
}
|
|
974
1093
|
|
|
975
1094
|
/**
|
|
976
1095
|
* SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
|
|
977
1096
|
* to the SELECT-only validation the caller performs, every table reference must resolve to an
|
|
978
|
-
* entity BaseView the acting user can read UNSCOPED.
|
|
979
|
-
*
|
|
1097
|
+
* entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
|
|
1098
|
+
* CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
|
|
1099
|
+
* own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
|
|
1100
|
+
* for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
|
|
1101
|
+
* Fails closed: no user, or an unresolvable reference, refuses.
|
|
1102
|
+
*
|
|
1103
|
+
* The references come from a walk of the whole parse tree
|
|
1104
|
+
* (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
|
|
1105
|
+
* account for every SELECT in the text, or when a FROM item names no table (a table function or
|
|
1106
|
+
* a row list).
|
|
980
1107
|
*
|
|
981
1108
|
* Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
|
|
982
1109
|
* row-level security and denied-field projection, and raw SQL applies neither. Rather than try
|
|
@@ -994,6 +1121,7 @@ export class ResolverBase {
|
|
|
994
1121
|
throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
|
|
995
1122
|
}
|
|
996
1123
|
const dialect = this.dialectForProvider(provider);
|
|
1124
|
+
ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
|
|
997
1125
|
const parser = new SQLParser(sqlText, dialect);
|
|
998
1126
|
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
999
1127
|
throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
|
|
@@ -1001,52 +1129,19 @@ export class ResolverBase {
|
|
|
1001
1129
|
if (this.astContainsWriteNode(parser.AST)) {
|
|
1002
1130
|
throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
|
|
1003
1131
|
}
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1132
|
+
const reads = ClientClauseScreen.CollectStatementReads(parser);
|
|
1133
|
+
ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
|
|
1006
1134
|
const allowed = this.entityBaseViewAllowList(provider);
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
const
|
|
1010
|
-
|
|
1011
|
-
if (this.isUnqualifiedSchema(schema) && cteNames.has(table.toLowerCase())) continue;
|
|
1012
|
-
const entity = this.assertTableRefReadable(allowed, schema, table, label, user);
|
|
1013
|
-
this.assertUnscopedRead(entity, user, label);
|
|
1135
|
+
for (const relation of reads.Relations) {
|
|
1136
|
+
if (relation.IsCTEReference) continue;
|
|
1137
|
+
const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
|
|
1138
|
+
if (entity) this.assertUnscopedRead(entity, user, label);
|
|
1014
1139
|
}
|
|
1015
1140
|
}
|
|
1016
1141
|
|
|
1017
|
-
/** Refuses an entity whose read is narrowed for this user
|
|
1142
|
+
/** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
|
|
1018
1143
|
private assertUnscopedRead(entity: EntityInfo, user: UserInfo, label: string): void {
|
|
1019
|
-
|
|
1020
|
-
throw new Error(
|
|
1021
|
-
`Invalid ${label}: entity '${entity.Name}' is row-level-security filtered for you — ad-hoc SQL cannot apply that filter; use RunView`,
|
|
1022
|
-
);
|
|
1023
|
-
}
|
|
1024
|
-
if (entity.GetDeniedReadFields(user).size > 0) {
|
|
1025
|
-
throw new Error(
|
|
1026
|
-
`Invalid ${label}: entity '${entity.Name}' has fields you are not permitted to read — ad-hoc SQL cannot project them away; use RunView`,
|
|
1027
|
-
);
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
/** Walks a parsed AST collecting the names of CTEs the statement itself defines. */
|
|
1032
|
-
private collectCTENames(node: unknown, names: Set<string>): void {
|
|
1033
|
-
if (!node || typeof node !== 'object') return;
|
|
1034
|
-
if (Array.isArray(node)) {
|
|
1035
|
-
for (const n of node) this.collectCTENames(n, names);
|
|
1036
|
-
return;
|
|
1037
|
-
}
|
|
1038
|
-
const obj = node as Record<string, unknown>;
|
|
1039
|
-
const withClause = obj.with;
|
|
1040
|
-
if (Array.isArray(withClause)) {
|
|
1041
|
-
for (const cte of withClause) {
|
|
1042
|
-
const name = (cte as Record<string, unknown>)?.name;
|
|
1043
|
-
if (typeof name === 'string') names.add(name.toLowerCase());
|
|
1044
|
-
else if (name && typeof name === 'object' && typeof (name as Record<string, unknown>).value === 'string') {
|
|
1045
|
-
names.add(((name as Record<string, unknown>).value as string).toLowerCase());
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
for (const v of Object.values(obj)) this.collectCTENames(v, names);
|
|
1144
|
+
ClientClauseScreen.AssertUnscopedRead(entity, user, label);
|
|
1050
1145
|
}
|
|
1051
1146
|
|
|
1052
1147
|
/**
|
|
@@ -1095,105 +1190,26 @@ export class ResolverBase {
|
|
|
1095
1190
|
}
|
|
1096
1191
|
}
|
|
1097
1192
|
|
|
1098
|
-
/**
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
if (typeof type === 'string' && ResolverBase.WRITE_NODE_TYPES.has(type.toLowerCase())) {
|
|
1105
|
-
return true;
|
|
1106
|
-
}
|
|
1107
|
-
return Object.values(obj).some((v) => this.astContainsWriteNode(v));
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
private static readonly WRITE_NODE_TYPES = new Set<string>([
|
|
1111
|
-
'insert', 'update', 'delete', 'merge', 'replace', 'drop', 'create', 'alter', 'truncate',
|
|
1112
|
-
'rename', 'call', 'exec', 'execute', 'grant', 'revoke', 'use', 'load', 'copy', 'do',
|
|
1113
|
-
]);
|
|
1114
|
-
|
|
1115
|
-
private dialectForProvider(provider?: IMetadataProvider): SQLParserDialect {
|
|
1116
|
-
const name = provider?.constructor?.name ?? '';
|
|
1117
|
-
if (/postgres/i.test(name)) return new PostgreSQLDialect();
|
|
1118
|
-
return new SQLServerDialect();
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
/** True for a reference with no schema — which `SQLParser.ExtractTableRefs` reports as `dbo`. */
|
|
1122
|
-
private isUnqualifiedSchema(schema: string): boolean {
|
|
1123
|
-
return !schema || schema.toLowerCase() === 'dbo';
|
|
1193
|
+
/**
|
|
1194
|
+
* True when a string literal or quoted identifier in `clause`, read the way the database reads
|
|
1195
|
+
* it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
|
|
1196
|
+
*/
|
|
1197
|
+
private hasBackslashInQuotes(clause: string, dialect: SQLParserDialect): boolean {
|
|
1198
|
+
return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
|
|
1124
1199
|
}
|
|
1125
1200
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
return
|
|
1201
|
+
/** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
|
|
1202
|
+
private astContainsWriteNode(node: unknown): boolean {
|
|
1203
|
+
return ClientClauseScreen.AstContainsWriteNode(node);
|
|
1129
1204
|
}
|
|
1130
1205
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
* more than one schema — the database, not this screen, would decide which one runs.
|
|
1134
|
-
*/
|
|
1135
|
-
private entityBaseViewAllowList(provider?: IMetadataProvider): {
|
|
1136
|
-
qualified: Map<string, EntityInfo>;
|
|
1137
|
-
bare: Map<string, EntityInfo | null>;
|
|
1138
|
-
} {
|
|
1139
|
-
const qualified = new Map<string, EntityInfo>();
|
|
1140
|
-
const bare = new Map<string, EntityInfo | null>();
|
|
1141
|
-
const entities = provider?.Entities ?? [];
|
|
1142
|
-
for (const e of entities) {
|
|
1143
|
-
const view = this.stripSqlIdent(e.BaseView);
|
|
1144
|
-
if (!view) continue;
|
|
1145
|
-
const schema = this.stripSqlIdent(e.SchemaName);
|
|
1146
|
-
const bareKey = view.toLowerCase();
|
|
1147
|
-
bare.set(bareKey, bare.has(bareKey) ? null : e);
|
|
1148
|
-
if (schema) qualified.set(`${schema}.${view}`.toLowerCase(), e);
|
|
1149
|
-
}
|
|
1150
|
-
return { qualified, bare };
|
|
1206
|
+
private dialectForProvider(provider?: IMetadataProvider): SQLParserDialect {
|
|
1207
|
+
return ClientClauseScreen.DialectFor(provider);
|
|
1151
1208
|
}
|
|
1152
1209
|
|
|
1153
|
-
/**
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
* entity. Base views do not embed RLS and entity permissions are otherwise checked only on
|
|
1157
|
-
* the TOP entity of a request, so without this check a subquery (or ad-hoc query) could read
|
|
1158
|
-
* entities the caller has no read grant on.
|
|
1159
|
-
*
|
|
1160
|
-
* A schema-qualified reference must match that exact schema — `secret.vwFoo` never resolves
|
|
1161
|
-
* through another schema's `vwFoo`. An unqualified reference resolves only when the view name
|
|
1162
|
-
* is unique across schemas; an ambiguous one must be qualified, because the database's
|
|
1163
|
-
* default-schema resolution, not this screen, would pick which view actually runs.
|
|
1164
|
-
*
|
|
1165
|
-
* `SQLParser.ExtractTableRefs` reports an unqualified reference as schema `dbo`, so `dbo` is
|
|
1166
|
-
* indistinguishable from "no schema" here and both take the unqualified path (an exact
|
|
1167
|
-
* `dbo.<view>` entity wins first).
|
|
1168
|
-
*/
|
|
1169
|
-
private assertTableRefReadable(
|
|
1170
|
-
allowed: { qualified: Map<string, EntityInfo>; bare: Map<string, EntityInfo | null> },
|
|
1171
|
-
schema: string,
|
|
1172
|
-
table: string,
|
|
1173
|
-
label: string,
|
|
1174
|
-
user?: UserInfo,
|
|
1175
|
-
): EntityInfo {
|
|
1176
|
-
const qualified = allowed.qualified.get(`${schema}.${table}`.toLowerCase());
|
|
1177
|
-
const entity = this.isUnqualifiedSchema(schema) ? qualified ?? allowed.bare.get(table.toLowerCase()) : qualified;
|
|
1178
|
-
if (entity === null) {
|
|
1179
|
-
throw new Error(
|
|
1180
|
-
`Invalid ${label}: '${table}' is a base view in more than one schema — qualify it with its schema`,
|
|
1181
|
-
);
|
|
1182
|
-
}
|
|
1183
|
-
if (!entity) {
|
|
1184
|
-
throw new Error(
|
|
1185
|
-
`Invalid ${label}: subquery must use an entity base view, not '${schema ? schema + '.' : ''}${table}'`,
|
|
1186
|
-
);
|
|
1187
|
-
}
|
|
1188
|
-
if (user) {
|
|
1189
|
-
const perms = entity.GetUserPermisions(user);
|
|
1190
|
-
if (!perms.CanRead) {
|
|
1191
|
-
throw new Error(
|
|
1192
|
-
`Invalid ${label}: you do not have read permission on entity '${entity.Name}' referenced by '${schema ? schema + '.' : ''}${table}'`,
|
|
1193
|
-
);
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
return entity;
|
|
1210
|
+
/** The entity base-view allow-list for a provider's entities. */
|
|
1211
|
+
private entityBaseViewAllowList(provider?: IMetadataProvider): EntityBaseViewAllowList {
|
|
1212
|
+
return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
|
|
1197
1213
|
}
|
|
1198
1214
|
|
|
1199
1215
|
/**
|
|
@@ -1223,7 +1239,9 @@ export class ResolverBase {
|
|
|
1223
1239
|
afterKey?: CompositeKey,
|
|
1224
1240
|
bypassCache?: boolean,
|
|
1225
1241
|
dataSource?: 'Live' | 'Materialized',
|
|
1226
|
-
includeBinaryFields?: boolean
|
|
1242
|
+
includeBinaryFields?: boolean,
|
|
1243
|
+
alternateBaseView?: string,
|
|
1244
|
+
properties?: ViewPropertyValues
|
|
1227
1245
|
) {
|
|
1228
1246
|
try {
|
|
1229
1247
|
if (!viewInfo || !userPayload) return null;
|
|
@@ -1285,6 +1303,7 @@ export class ResolverBase {
|
|
|
1285
1303
|
{
|
|
1286
1304
|
ViewID: viewInfo.ID,
|
|
1287
1305
|
ViewName: viewInfo.Name,
|
|
1306
|
+
ViewEntity: viewInfo.ID ? viewInfo : undefined,
|
|
1288
1307
|
EntityName: viewInfo.Entity,
|
|
1289
1308
|
ExtraFilter: extraFilter,
|
|
1290
1309
|
OrderBy: orderBy,
|
|
@@ -1305,10 +1324,22 @@ export class ResolverBase {
|
|
|
1305
1324
|
BypassCache: bypassCache,
|
|
1306
1325
|
DataSource: dataSource,
|
|
1307
1326
|
IncludeBinaryFields: includeBinaryFields,
|
|
1327
|
+
AlternateBaseView: alternateBaseView,
|
|
1328
|
+
Properties: properties,
|
|
1329
|
+
TimeZone: userPayload?.TimeZone,
|
|
1308
1330
|
},
|
|
1309
1331
|
user
|
|
1310
1332
|
);
|
|
1311
1333
|
|
|
1334
|
+
if (result && typeof result === 'object') {
|
|
1335
|
+
Object.defineProperty(result, '_resolvedViewEntity', {
|
|
1336
|
+
value: viewInfo,
|
|
1337
|
+
enumerable: false,
|
|
1338
|
+
configurable: true,
|
|
1339
|
+
writable: true,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1312
1343
|
// Log aggregate results for debugging
|
|
1313
1344
|
if (aggregates?.length) {
|
|
1314
1345
|
LogStatus(`[ResolverBase] RunView result aggregate info: entityName=${viewInfo.Entity}, hasAggregateResults=${!!result?.AggregateResults}, aggregateResultCount=${result?.AggregateResults?.length || 0}, aggregateExecutionTime=${result?.AggregateExecutionTime}, aggregateResults=${JSON.stringify(result?.AggregateResults)}`);
|
|
@@ -1339,7 +1370,8 @@ export class ResolverBase {
|
|
|
1339
1370
|
await this.ArrayFilterEncryptedFieldsForAPI(
|
|
1340
1371
|
viewInfo.Entity,
|
|
1341
1372
|
result.Results as Record<string, unknown>[],
|
|
1342
|
-
user
|
|
1373
|
+
user,
|
|
1374
|
+
md
|
|
1343
1375
|
);
|
|
1344
1376
|
}
|
|
1345
1377
|
|
|
@@ -1435,6 +1467,7 @@ export class ResolverBase {
|
|
|
1435
1467
|
runViewParams.push({
|
|
1436
1468
|
ViewID: param.viewInfo.ID,
|
|
1437
1469
|
ViewName: param.viewInfo.Name,
|
|
1470
|
+
ViewEntity: param.viewInfo.ID ? param.viewInfo : undefined,
|
|
1438
1471
|
EntityName: param.viewInfo.Entity,
|
|
1439
1472
|
ExtraFilter: param.extraFilter,
|
|
1440
1473
|
OrderBy: param.orderBy,
|
|
@@ -1455,6 +1488,9 @@ export class ResolverBase {
|
|
|
1455
1488
|
BypassCache: param.bypassCache,
|
|
1456
1489
|
DataSource: param.dataSource,
|
|
1457
1490
|
IncludeBinaryFields: param.includeBinaryFields,
|
|
1491
|
+
AlternateBaseView: param.alternateBaseView,
|
|
1492
|
+
Properties: param.properties,
|
|
1493
|
+
TimeZone: param.userPayload?.TimeZone,
|
|
1458
1494
|
});
|
|
1459
1495
|
}
|
|
1460
1496
|
|
|
@@ -1476,7 +1512,8 @@ export class ResolverBase {
|
|
|
1476
1512
|
await this.ArrayFilterEncryptedFieldsForAPI(
|
|
1477
1513
|
entityName,
|
|
1478
1514
|
runViewResult.Results as Record<string, unknown>[],
|
|
1479
|
-
contextUser
|
|
1515
|
+
contextUser,
|
|
1516
|
+
md
|
|
1480
1517
|
);
|
|
1481
1518
|
}
|
|
1482
1519
|
}
|
|
@@ -1615,8 +1652,9 @@ export class ResolverBase {
|
|
|
1615
1652
|
* skip redundant re-fetches (it already handled the event locally).
|
|
1616
1653
|
*
|
|
1617
1654
|
* The row itself rides along ONLY for entities opted in via
|
|
1618
|
-
* `cacheSettings.recordDataBroadcastEntities
|
|
1619
|
-
*
|
|
1655
|
+
* `cacheSettings.recordDataBroadcastEntities`, and never for one under field-level security —
|
|
1656
|
+
* this event reaches every connected client unprojected, so the row would otherwise be readable
|
|
1657
|
+
* by sessions that could not read the record or its restricted fields.
|
|
1620
1658
|
*/
|
|
1621
1659
|
protected PublishCacheInvalidation(entityObject: BaseEntity, action: 'save' | 'delete', userPayload: UserPayload): void {
|
|
1622
1660
|
const entityName = entityObject.EntityInfo.Name;
|
|
@@ -1628,7 +1666,8 @@ export class ResolverBase {
|
|
|
1628
1666
|
timestamp: new Date(),
|
|
1629
1667
|
originSessionId: userPayload?.sessionId || null,
|
|
1630
1668
|
recordData:
|
|
1631
|
-
action === 'save' && MayBroadcastRecordData(entityName)
|
|
1669
|
+
action === 'save' && MayBroadcastRecordData(entityName, entityObject.EntityInfo)
|
|
1670
|
+
// fls-guard: MayBroadcastRecordData refuses FLS entities and Record Changes
|
|
1632
1671
|
? JSON.stringify(entityObject.GetAll())
|
|
1633
1672
|
: undefined,
|
|
1634
1673
|
});
|
|
@@ -1636,16 +1675,17 @@ export class ResolverBase {
|
|
|
1636
1675
|
|
|
1637
1676
|
/**
|
|
1638
1677
|
* Publishes a push-status update to the client on {@link PUSH_STATUS_UPDATES_TOPIC}, stamping the
|
|
1639
|
-
*
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
1643
|
-
*
|
|
1678
|
+
* owner key of `userPayload` (`StatusUpdateOwnerID`: the request's user, or the widget guest an
|
|
1679
|
+
* elevated payload stands in for) so the subscription filter can bind delivery to identity (see B49 /
|
|
1680
|
+
* `StatusUpdatesFilter`). The ergonomic wrapper every resolver should use instead of calling
|
|
1681
|
+
* `pubSub.publish` on the topic directly — it makes omitting identity impossible. Non-resolver
|
|
1682
|
+
* publishers (services, the liveness heartbeat) call the shared `PublishStatusUpdate()` function
|
|
1683
|
+
* directly with an explicit `ownerUserId`.
|
|
1644
1684
|
*/
|
|
1645
1685
|
protected PublishStatusUpdate(pubSub: PubSubEngine, sessionId: string, message: string | undefined, userPayload: UserPayload): void {
|
|
1646
1686
|
PublishStatusUpdate(pubSub, {
|
|
1647
1687
|
sessionId,
|
|
1648
|
-
ownerUserId: userPayload
|
|
1688
|
+
ownerUserId: StatusUpdateOwnerID(userPayload),
|
|
1649
1689
|
message,
|
|
1650
1690
|
});
|
|
1651
1691
|
}
|
|
@@ -1669,23 +1709,16 @@ export class ResolverBase {
|
|
|
1669
1709
|
return;
|
|
1670
1710
|
}
|
|
1671
1711
|
|
|
1672
|
-
const baseEntityValues = baseEntity ? baseEntity.
|
|
1712
|
+
const baseEntityValues = baseEntity ? this.EntityEventPayload(baseEntity, event.args instanceof BaseEntityEvent ? event.args.ChangedFieldNames : undefined) : null;
|
|
1673
1713
|
const eventToLog = { entityName: entityObject.EntityInfo.Name, baseEntity: baseEntityValues, event: event.event, eventCode: event.eventCode };
|
|
1674
1714
|
LogDebug(`ResolverBase.ListenForEntityMessages: Received Event from within MJGlobal.Instance.GetEventListener() callback. Will call EmitCloudEvent() next\nEvent data:\n${JSON.stringify(eventToLog)}`);
|
|
1675
1715
|
await this.EmitCloudEvent(event);
|
|
1676
1716
|
LogDebug(`ResolverBase.ListenForEntityMessages: EmitCloudEvent() completed successfully`);
|
|
1677
1717
|
|
|
1678
|
-
if (event.args && event.args instanceof BaseEntityEvent) {
|
|
1679
|
-
|
|
1680
|
-
// message from our entity object, relay it to the client
|
|
1718
|
+
if (event.args && event.args instanceof BaseEntityEvent && this.EntityEventBelongsToSession(event.args, userPayload)) {
|
|
1719
|
+
// message from an entity object of this session's user, relay it to the client
|
|
1681
1720
|
LogDebug('ResolverBase.ListenForEntityMessages: About to publish PUSH_STATUS_UPDATES_TOPIC');
|
|
1682
|
-
this.PublishStatusUpdate(pubSub, userPayload.sessionId,
|
|
1683
|
-
status: 'OK',
|
|
1684
|
-
type: 'EntityObjectStatusMessage',
|
|
1685
|
-
entityName: baseEntityEvent.baseEntity.EntityInfo.Name,
|
|
1686
|
-
primaryKey: baseEntityEvent.baseEntity.PrimaryKey,
|
|
1687
|
-
message: event.args.payload,
|
|
1688
|
-
}), userPayload);
|
|
1721
|
+
this.PublishStatusUpdate(pubSub, userPayload.sessionId, this.EntityStatusMessage(event.args), userPayload);
|
|
1689
1722
|
}
|
|
1690
1723
|
}
|
|
1691
1724
|
});
|
|
@@ -1693,6 +1726,37 @@ export class ResolverBase {
|
|
|
1693
1726
|
}
|
|
1694
1727
|
}
|
|
1695
1728
|
|
|
1729
|
+
/**
|
|
1730
|
+
* Whether an entity event may be relayed to the session whose call created the listener. The
|
|
1731
|
+
* listener is shared by every caller in the process for that entity, so an event raised by an
|
|
1732
|
+
* object loaded for another user (or for the system user) is never relayed to this session.
|
|
1733
|
+
*/
|
|
1734
|
+
protected EntityEventBelongsToSession(event: BaseEntityEvent, userPayload: UserPayload): boolean {
|
|
1735
|
+
const sessionUserId = userPayload?.userRecord?.ID;
|
|
1736
|
+
const objectUserId = event.baseEntity?.ContextCurrentUser?.ID;
|
|
1737
|
+
return Boolean(sessionUserId && objectUserId && UUIDsEqual(sessionUserId, objectUserId));
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/**
|
|
1741
|
+
* The status message relayed for an entity event: the entity, key and event type. The event's
|
|
1742
|
+
* payload is included only when it cannot carry record values: never for a delete (its payload
|
|
1743
|
+
* is the whole deleted row), an entity under field-level security, or `MJ: Record Changes`.
|
|
1744
|
+
*/
|
|
1745
|
+
protected EntityStatusMessage(event: BaseEntityEvent): string {
|
|
1746
|
+
const entityInfo = event.baseEntity.EntityInfo;
|
|
1747
|
+
const mayCarryPayload = event.type !== 'delete'
|
|
1748
|
+
&& !entityInfo.FieldLevelSecurityApplies
|
|
1749
|
+
&& !RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo);
|
|
1750
|
+
return JSON.stringify({
|
|
1751
|
+
status: 'OK',
|
|
1752
|
+
type: 'EntityObjectStatusMessage',
|
|
1753
|
+
entityName: entityInfo.Name,
|
|
1754
|
+
primaryKey: event.baseEntity.PrimaryKey,
|
|
1755
|
+
eventType: event.type,
|
|
1756
|
+
message: mayCarryPayload ? event.payload : undefined,
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1696
1760
|
/**
|
|
1697
1761
|
* IS-A PROMOTION over the wire: an EXISTING parent record gaining a subtype — an Animal that is
|
|
1698
1762
|
* now also a Dog. It arrives as the CHILD's create mutation carrying the existing parent's
|
|
@@ -1769,6 +1833,9 @@ export class ResolverBase {
|
|
|
1769
1833
|
// IS-A promotion: bind the new child to its EXISTING parent row BEFORE the field
|
|
1770
1834
|
// assignments, so values the client sent for parent fields land on the loaded parent
|
|
1771
1835
|
// (as an update) instead of being wiped by the load.
|
|
1836
|
+
// Field-level security: refuse a key the caller may not read, or that names no field, before
|
|
1837
|
+
// anything is applied (the same gate as the update path).
|
|
1838
|
+
AssertClientInputWritableForUser(entityObject.EntityInfo, fieldsForSet, this.GetUserFromPayload(userPayload));
|
|
1772
1839
|
await this.attachToExistingParentIfPromotion(entityObject, fieldsForSet);
|
|
1773
1840
|
entityObject.SetMany(fieldsForSet);
|
|
1774
1841
|
|
|
@@ -1785,6 +1852,7 @@ export class ResolverBase {
|
|
|
1785
1852
|
// Cache invalidation is now handled globally by the MJGlobal listener in index.ts
|
|
1786
1853
|
const contextUser = this.GetUserFromPayload(userPayload);
|
|
1787
1854
|
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
1855
|
+
// fls-guard: MapFieldNamesToCodeNames drops the fields the caller may not read
|
|
1788
1856
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), contextUser);
|
|
1789
1857
|
}
|
|
1790
1858
|
// save failed, throw error with message
|
|
@@ -1825,12 +1893,11 @@ export class ResolverBase {
|
|
|
1825
1893
|
});
|
|
1826
1894
|
|
|
1827
1895
|
// Field-level security: any value the client sends for a field it cannot READ is
|
|
1828
|
-
// fabricated by construction (the field was
|
|
1829
|
-
// received), so
|
|
1830
|
-
// denied we also force the truth-load branch below
|
|
1831
|
-
//
|
|
1832
|
-
|
|
1833
|
-
const hasDeniedReadFields = this.StripDeniedReadFieldsFromClientInput(entityInfo, userInfo, input, clientNewValues);
|
|
1896
|
+
// fabricated by construction (the field was absent from every payload the client ever
|
|
1897
|
+
// received), so the input is refused before anything applies it. When any field is
|
|
1898
|
+
// denied we also force the truth-load branch below, so the denied columns keep what the
|
|
1899
|
+
// database holds rather than anything derived from the client's OldValues.
|
|
1900
|
+
const hasDeniedReadFields = this.AssertClientInputFieldSecurity(entityInfo, userInfo, input, clientNewValues);
|
|
1834
1901
|
// The audit-trail equivalent. Its `true` matters most on THIS entity: 'MJ: Record Changes'
|
|
1835
1902
|
// has TrackRecordChanges off, so without it a client sending OldValues takes the branch below
|
|
1836
1903
|
// that hydrates from those values — never loading what the database actually holds.
|
|
@@ -1893,6 +1960,7 @@ export class ResolverBase {
|
|
|
1893
1960
|
// Cache invalidation is now handled globally by the MJGlobal listener in index.ts
|
|
1894
1961
|
|
|
1895
1962
|
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
1963
|
+
// fls-guard: MapFieldNamesToCodeNames drops the fields the caller may not read
|
|
1896
1964
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
|
|
1897
1965
|
} else {
|
|
1898
1966
|
// CompleteMessage, not Message. A validation refusal puts its reasons in `Errors` and leaves
|
|
@@ -1913,15 +1981,43 @@ export class ResolverBase {
|
|
|
1913
1981
|
});
|
|
1914
1982
|
}
|
|
1915
1983
|
|
|
1984
|
+
/**
|
|
1985
|
+
* Checks whether `oldValues` (`input.OldValues___`) contains every writable in-table field of
|
|
1986
|
+
* `entityInfo` (plus primary keys).
|
|
1987
|
+
*
|
|
1988
|
+
* With the delta-only `Save()` wire protocol, `GraphQLDataProvider.Save()` sends only primary keys
|
|
1989
|
+
* and dirty writable fields in `vars.input` and `OldValues___` on normal updates. Hydrating an
|
|
1990
|
+
* entity directly from a partial-delta `OldValues___` via `entityObject.LoadFromData(oldValues)`
|
|
1991
|
+
* (without first loading the authoritative row via `InnerLoad`) would leave every untouched column
|
|
1992
|
+
* `NotLoaded = true` (or `null`), causing `spUpdate` or custom `Validate()` hooks to see incomplete
|
|
1993
|
+
* state. Returning `false` for partial deltas ensures {@link MustLoadTruthFromDatabase} routes
|
|
1994
|
+
* delta updates through `InnerLoad(cKey)` + {@link TestAndSetClientOldValuesToDBValues}, even on
|
|
1995
|
+
* entities with `TrackRecordChanges === false`.
|
|
1996
|
+
*/
|
|
1997
|
+
protected hasCompleteOldValues(
|
|
1998
|
+
entityInfo: EntityInfo,
|
|
1999
|
+
oldValues?: Array<{ Key: string; Value: unknown }> | null
|
|
2000
|
+
): boolean {
|
|
2001
|
+
if (!oldValues || oldValues.length === 0) {
|
|
2002
|
+
return false;
|
|
2003
|
+
}
|
|
2004
|
+
const providedKeys = new Set(oldValues.map((kv) => kv.Key));
|
|
2005
|
+
return entityInfo.Fields.every(
|
|
2006
|
+
(f) => (!f.IsPrimaryKey && (f.ReadOnly || f.IsVirtual)) || providedKeys.has(f.Name) || providedKeys.has(f.CodeName)
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
1916
2010
|
/**
|
|
1917
2011
|
* Whether `UpdateRecord` must hydrate the entity from the DATABASE rather than from the
|
|
1918
2012
|
* client's `OldValues___`.
|
|
1919
2013
|
*
|
|
1920
|
-
* The `OldValues___` path exists as an optimization
|
|
1921
|
-
*
|
|
1922
|
-
*
|
|
2014
|
+
* The `OldValues___` path exists as an optimization when a client supplies the complete pre-save
|
|
2015
|
+
* record (e.g. `IgnoreDirtyState: true`) and nothing else requires the database's authoritative prior
|
|
2016
|
+
* state. Under the delta-only `Save()` wire protocol, normal updates send only primary keys and dirty
|
|
2017
|
+
* writable fields in `OldValues___`, so {@link hasCompleteOldValues} returns `false` and forces the
|
|
2018
|
+
* database load (`InnerLoad`) so untouched columns retain their stored values.
|
|
1923
2019
|
*
|
|
1924
|
-
* `
|
|
2020
|
+
* `FieldLevelSecurityApplies` (own flag or an IS-A ancestor's) is the security-critical one, and it is NOT redundant with
|
|
1925
2021
|
* `hasDeniedReadFields`. That flag reports READ denials, and the canonical FLS configuration is
|
|
1926
2022
|
* Read Allow + Update Deny — which leaves it false. Such a caller would be hydrated from its own
|
|
1927
2023
|
* `OldValues___`, and a value it supplies there for an update-denied field arrives through
|
|
@@ -1945,6 +2041,16 @@ export class ResolverBase {
|
|
|
1945
2041
|
* caller pin forged audit columns as both old and new values. It doesn't track its own changes, so
|
|
1946
2042
|
* nothing else forces the load.
|
|
1947
2043
|
*
|
|
2044
|
+
* `MJ: Data Context Items` always loads from the database for the same reason: its server class
|
|
2045
|
+
* lets only an administrator save an item that holds SQL, and decides that from the item's `Type`
|
|
2046
|
+
* and `SQL`. Hydrated from OldValues, a client could leave both out, and the stored procedure would
|
|
2047
|
+
* keep the stored SQL item while the rule saw none.
|
|
2048
|
+
*
|
|
2049
|
+
* `MJ: Conversation Details` always loads for the same reason: its write gate decides from the stored
|
|
2050
|
+
* row (who wrote a person's message, its role, the owner's rating), and an OldValues-hydrated entity
|
|
2051
|
+
* would let a grantee claim to be a message's author while rewriting it. See
|
|
2052
|
+
* {@link ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW}.
|
|
2053
|
+
*
|
|
1948
2054
|
* A caller under an Update row filter (a role's Update RLS filter or an API-key row filter) always
|
|
1949
2055
|
* loads from the database too (#4919). The save's post-image check skips its query when no column
|
|
1950
2056
|
* the filter reads is dirty, and dirtiness measured against client OldValues is the caller's to
|
|
@@ -1962,77 +2068,54 @@ export class ResolverBase {
|
|
|
1962
2068
|
): boolean {
|
|
1963
2069
|
return (
|
|
1964
2070
|
entityInfo.TrackRecordChanges ||
|
|
1965
|
-
entityInfo.Name.trim().toLowerCase()
|
|
1966
|
-
!input.OldValues___ ||
|
|
2071
|
+
ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase()) ||
|
|
2072
|
+
!this.hasCompleteOldValues(entityInfo, input.OldValues___) ||
|
|
1967
2073
|
hasDeniedReadFields ||
|
|
1968
2074
|
hasNarrowedAuditPayload ||
|
|
1969
|
-
entityInfo.
|
|
2075
|
+
entityInfo.FieldLevelSecurityApplies ||
|
|
1970
2076
|
!user ||
|
|
1971
2077
|
entityInfo.GetEffectiveRowFilterWhereClause(user, EntityPermissionType.Update, '').length > 0
|
|
1972
2078
|
);
|
|
1973
2079
|
}
|
|
1974
2080
|
|
|
1975
2081
|
/**
|
|
1976
|
-
* Field-level security
|
|
2082
|
+
* Field-level security gate on the update path's client input.
|
|
1977
2083
|
*
|
|
1978
|
-
*
|
|
1979
|
-
*
|
|
1980
|
-
*
|
|
1981
|
-
*
|
|
1982
|
-
* save" safe for a restricted user.
|
|
2084
|
+
* For a caller denied at least one field on the entity, refuses (with a `FieldSecurityError`)
|
|
2085
|
+
* any key in the new values or in `OldValues___` that names a field the caller cannot READ, or
|
|
2086
|
+
* that names no field at all, with the same message for both (see
|
|
2087
|
+
* `AssertClientInputWritableForUser`), so the refusal does not reveal which columns exist.
|
|
1983
2088
|
*
|
|
1984
|
-
*
|
|
1985
|
-
*
|
|
2089
|
+
* Refused rather than dropped: a field the caller cannot read was absent from every payload the
|
|
2090
|
+
* client received, so a value for it can only be fabricated. A well-behaved client never sends
|
|
2091
|
+
* one (it omits fields it did not load), so ordinary traffic is unaffected.
|
|
1986
2092
|
*
|
|
1987
|
-
*
|
|
1988
|
-
*
|
|
1989
|
-
* and is restricted).
|
|
2093
|
+
* There is no split by update permission: Read is required for Update, so a user denied read is
|
|
2094
|
+
* denied update too.
|
|
1990
2095
|
*
|
|
1991
|
-
* Returns true when the user has a non-empty denied-read set on this entity. The caller
|
|
1992
|
-
*
|
|
1993
|
-
*
|
|
2096
|
+
* Returns true when the user has a non-empty denied-read set on this entity. The caller must
|
|
2097
|
+
* then hydrate the entity from the DATABASE (never from client OldValues), so denied fields hold
|
|
2098
|
+
* true values the client input never touches.
|
|
1994
2099
|
*/
|
|
1995
|
-
protected
|
|
2100
|
+
protected AssertClientInputFieldSecurity(
|
|
1996
2101
|
entityInfo: EntityInfo,
|
|
1997
2102
|
userInfo: UserInfo,
|
|
1998
2103
|
input: { OldValues___?: Array<{ Key: string; Value: unknown }> } & Record<string, unknown>,
|
|
1999
2104
|
clientNewValues: Record<string, unknown>
|
|
2000
2105
|
): boolean {
|
|
2001
|
-
if (!entityInfo.
|
|
2106
|
+
if (!entityInfo.FieldLevelSecurityApplies || !userInfo) {
|
|
2002
2107
|
return false;
|
|
2003
2108
|
}
|
|
2004
|
-
|
|
2005
|
-
if (deniedReadNames.size === 0) {
|
|
2109
|
+
if (entityInfo.GetDeniedReadFields(userInfo).size === 0) {
|
|
2006
2110
|
return false;
|
|
2007
2111
|
}
|
|
2008
|
-
|
|
2009
|
-
// Input keys are entity CodeNames (post ReverseMapInputFieldNames); the denied set holds
|
|
2010
|
-
// lowercased field Names — bridge via the field metadata once.
|
|
2011
|
-
const deniedReadCodeNames = new Set<string>();
|
|
2012
|
-
for (const field of entityInfo.Fields) {
|
|
2013
|
-
if (deniedReadNames.has(field.Name.trim().toLowerCase())) {
|
|
2014
|
-
deniedReadCodeNames.add(field.CodeName.trim().toLowerCase());
|
|
2015
|
-
}
|
|
2016
|
-
}
|
|
2017
|
-
|
|
2018
|
-
const stripped: string[] = [];
|
|
2019
|
-
for (const key of Object.keys(clientNewValues)) {
|
|
2020
|
-
if (deniedReadCodeNames.has(key.trim().toLowerCase())) {
|
|
2021
|
-
delete clientNewValues[key];
|
|
2022
|
-
delete input[key];
|
|
2023
|
-
stripped.push(key);
|
|
2024
|
-
}
|
|
2025
|
-
}
|
|
2112
|
+
AssertClientInputWritableForUser(entityInfo, clientNewValues, userInfo);
|
|
2026
2113
|
if (Array.isArray(input.OldValues___)) {
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
LogDebug(
|
|
2033
|
-
`[FieldSecurity] UpdateRecord on '${entityInfo.Name}' for user ${userInfo.Email}: ` +
|
|
2034
|
-
`stripped client-sent value(s) for denied-read field(s) ${stripped.join(', ')}`
|
|
2035
|
-
);
|
|
2114
|
+
const oldValueKeys: Record<string, unknown> = {};
|
|
2115
|
+
for (const item of input.OldValues___) {
|
|
2116
|
+
oldValueKeys[String(item.Key)] = null;
|
|
2117
|
+
}
|
|
2118
|
+
AssertClientInputWritableForUser(entityInfo, oldValueKeys, userInfo);
|
|
2036
2119
|
}
|
|
2037
2120
|
return true;
|
|
2038
2121
|
}
|
|
@@ -2137,43 +2220,62 @@ export class ResolverBase {
|
|
|
2137
2220
|
}
|
|
2138
2221
|
|
|
2139
2222
|
/**
|
|
2140
|
-
*
|
|
2141
|
-
*
|
|
2142
|
-
*
|
|
2223
|
+
* Compares the client-supplied `OldValues___` array in `input` against the authoritative database
|
|
2224
|
+
* values just loaded onto `entityObject` via `InnerLoad`, checks for overlapping concurrent field
|
|
2225
|
+
* modifications, and then applies `clientNewValues` onto `entityObject` via `SetMany`.
|
|
2143
2226
|
*
|
|
2144
|
-
*
|
|
2227
|
+
* Supports both full-record and delta-only `OldValues___` payloads: only keys present in
|
|
2228
|
+
* `input.OldValues___` (e.g. primary keys + dirty writable fields) are compared against `dbValues`,
|
|
2229
|
+
* and `entityObject.SetMany(clientNewValues)` mutates only the fields sent in `clientNewValues`
|
|
2230
|
+
* while preserving all untouched DB-loaded fields on `entityObject`.
|
|
2231
|
+
*
|
|
2232
|
+
* ASSUMES: `input` has an `OldValues___` property that is an array of Key/Value pairs representing
|
|
2233
|
+
* the pre-mutation values of the fields the client is updating.
|
|
2234
|
+
*/
|
|
2235
|
+
/**
|
|
2236
|
+
* Resolves a field on `entityInfo` by `CodeName` or `Name` in $O(1)$ via `FieldByCodeName` /
|
|
2237
|
+
* `FieldByName` when available on `EntityInfo`, falling back to `.Fields.find()` for test stubs.
|
|
2145
2238
|
*/
|
|
2239
|
+
private findEntityFieldByCodeNameOrName(entityInfo: EntityInfo, key: string): EntityFieldInfo | undefined {
|
|
2240
|
+
if (typeof entityInfo.FieldByCodeName === 'function') {
|
|
2241
|
+
return entityInfo.FieldByCodeName(key) ?? entityInfo.FieldByName(key);
|
|
2242
|
+
}
|
|
2243
|
+
return entityInfo.Fields.find((f) => f.CodeName === key || f.Name === key);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2146
2246
|
protected async TestAndSetClientOldValuesToDBValues(input: any, clientNewValues: any, entityObject: BaseEntity, contextUser: UserInfo) {
|
|
2147
2247
|
// we have OldValues, so we need to compare them to the values we just loaded from the DB
|
|
2148
2248
|
const clientOldValues = {};
|
|
2149
2249
|
// for each item in the oldValues array, add it to the clientOldValues object
|
|
2150
2250
|
input.OldValues___.forEach((item) => {
|
|
2151
2251
|
// we need to do a quick transform on the values to make sure they match the TS Type for the given field because item.Value will always be a string
|
|
2152
|
-
const field = entityObject.EntityInfo
|
|
2252
|
+
const field = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, item.Key);
|
|
2153
2253
|
const val = this.ClientOldValueToFieldValue(field, item.Value);
|
|
2154
2254
|
clientOldValues[item.Key] = val;
|
|
2155
2255
|
});
|
|
2156
2256
|
|
|
2157
2257
|
// clientOldValues now has all of the oldValues the CLIENT passed us. Now we need to build the same kind of object
|
|
2158
2258
|
// with the DB values
|
|
2259
|
+
// fls-guard: compared on the server only, never returned
|
|
2159
2260
|
const dbValues = entityObject.GetAll();
|
|
2160
2261
|
|
|
2161
2262
|
// now we need to compare clientOldValues and dbValues and have a new array that has entries for any differences and have FieldName, clientOldValue and dbValue as properties
|
|
2162
2263
|
const dbDifferences = [];
|
|
2163
2264
|
Object.keys(clientOldValues).forEach((key) => {
|
|
2164
|
-
const f = entityObject.EntityInfo
|
|
2265
|
+
const f = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, key);
|
|
2266
|
+
const dbVal = f && f.Name in dbValues ? dbValues[f.Name] : dbValues[key];
|
|
2165
2267
|
let different = false;
|
|
2166
2268
|
switch (typeof clientOldValues[key]) {
|
|
2167
2269
|
case 'number':
|
|
2168
|
-
different = clientOldValues[key] !==
|
|
2270
|
+
different = clientOldValues[key] !== dbVal;
|
|
2169
2271
|
break;
|
|
2170
2272
|
case 'boolean':
|
|
2171
|
-
different = clientOldValues[key] !==
|
|
2273
|
+
different = clientOldValues[key] !== dbVal;
|
|
2172
2274
|
break;
|
|
2173
2275
|
case 'object':
|
|
2174
2276
|
if (clientOldValues[key] instanceof Date) {
|
|
2175
2277
|
const clientDate = clientOldValues[key] as Date;
|
|
2176
|
-
const dbDate =
|
|
2278
|
+
const dbDate = dbVal;
|
|
2177
2279
|
if (dbDate == null || !(dbDate instanceof Date)) {
|
|
2178
2280
|
different = true;
|
|
2179
2281
|
} else if (clientDate.getTime() !== dbDate.getTime()) {
|
|
@@ -2191,11 +2293,11 @@ export class ResolverBase {
|
|
|
2191
2293
|
}
|
|
2192
2294
|
}
|
|
2193
2295
|
} else if (clientOldValues[key] === null) {
|
|
2194
|
-
different =
|
|
2296
|
+
different = dbVal !== null;
|
|
2195
2297
|
}
|
|
2196
2298
|
break;
|
|
2197
2299
|
default:
|
|
2198
|
-
different = clientOldValues[key] !==
|
|
2300
|
+
different = clientOldValues[key] !== dbVal;
|
|
2199
2301
|
break;
|
|
2200
2302
|
}
|
|
2201
2303
|
if (different && f && !f.ReadOnly) {
|
|
@@ -2203,7 +2305,7 @@ export class ResolverBase {
|
|
|
2203
2305
|
dbDifferences.push({
|
|
2204
2306
|
FieldName: key,
|
|
2205
2307
|
ClientOldValue: clientOldValues[key],
|
|
2206
|
-
DBValue:
|
|
2308
|
+
DBValue: dbVal,
|
|
2207
2309
|
});
|
|
2208
2310
|
}
|
|
2209
2311
|
});
|
|
@@ -2213,8 +2315,8 @@ export class ResolverBase {
|
|
|
2213
2315
|
// first step is to get clientNewValues into an object that is like clientOldValues, get the diff and then compare that diff to the differences array that shows diff between DB and ClientOld
|
|
2214
2316
|
const clientDifferences = [];
|
|
2215
2317
|
Object.keys(clientOldValues).forEach((key) => {
|
|
2216
|
-
const f = entityObject.EntityInfo
|
|
2217
|
-
if (clientOldValues[key] !== clientNewValues[key] && f && f.AllowUpdateAPI && !f.IsPrimaryKey) {
|
|
2318
|
+
const f = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, key);
|
|
2319
|
+
if (key in clientNewValues && clientOldValues[key] !== clientNewValues[key] && f && f.AllowUpdateAPI && !f.IsPrimaryKey) {
|
|
2218
2320
|
// only include updateable fields
|
|
2219
2321
|
clientDifferences.push({
|
|
2220
2322
|
FieldName: key,
|
|
@@ -2226,7 +2328,8 @@ export class ResolverBase {
|
|
|
2226
2328
|
|
|
2227
2329
|
// now we have clientDifferences which shows what the client thinks they are changing. And, we have the dbDifferences array that shows changes between the clientOldValues and the dbValues
|
|
2228
2330
|
// if there is ANY overlap in the FIELDS that appear in both arrays, we need to log a warning but allow the save to continue
|
|
2229
|
-
const
|
|
2331
|
+
const dbDiffFieldNames = new Set(dbDifferences.map((dd) => dd.FieldName));
|
|
2332
|
+
const overlap = clientDifferences.filter((cd) => dbDiffFieldNames.has(cd.FieldName));
|
|
2230
2333
|
if (overlap.length > 0) {
|
|
2231
2334
|
const msg = {
|
|
2232
2335
|
Message:
|
|
@@ -2238,16 +2341,16 @@ export class ResolverBase {
|
|
|
2238
2341
|
|
|
2239
2342
|
// Log as warning to console and ErrorLog table instead of throwing error
|
|
2240
2343
|
console.warn('Entity save inconsistency detected but allowing save to continue:', JSON.stringify(msg));
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2344
|
+
LogErrorEx({
|
|
2345
|
+
message: `Entity save inconsistency detected: ${JSON.stringify(msg)}`,
|
|
2346
|
+
severity: 'warning',
|
|
2347
|
+
category: 'ResolverBase.TestAndSetClientOldValuesToDBValues',
|
|
2348
|
+
metadata: {
|
|
2246
2349
|
entityName: entityObject.EntityInfo.Name,
|
|
2247
2350
|
clientDifferences: clientDifferences,
|
|
2248
2351
|
dbDifferences: dbDifferences,
|
|
2249
|
-
overlap: overlap
|
|
2250
|
-
}
|
|
2352
|
+
overlap: overlap,
|
|
2353
|
+
},
|
|
2251
2354
|
});
|
|
2252
2355
|
|
|
2253
2356
|
// Create ErrorLog record in the database — use the entity's bound provider so the
|
|
@@ -2255,7 +2358,8 @@ export class ResolverBase {
|
|
|
2255
2358
|
try {
|
|
2256
2359
|
const md = entityObject.ProviderToUse as unknown as IMetadataProvider;
|
|
2257
2360
|
const errorLogEntity = await md.GetEntityObject<MJErrorLogEntity>('MJ: Error Logs', contextUser);
|
|
2258
|
-
|
|
2361
|
+
// Code is NCHAR(20); a longer value is rejected at the database
|
|
2362
|
+
errorLogEntity.Code = 'SAVE_INCONSISTENCY';
|
|
2259
2363
|
errorLogEntity.Message = `Entity save inconsistency detected for ${entityObject.EntityInfo.Name}: ${JSON.stringify(msg)}`;
|
|
2260
2364
|
errorLogEntity.Status = 'Warning';
|
|
2261
2365
|
errorLogEntity.Category = 'Entity Save';
|
|
@@ -2263,7 +2367,9 @@ export class ResolverBase {
|
|
|
2263
2367
|
|
|
2264
2368
|
const saveResult = await errorLogEntity.Save();
|
|
2265
2369
|
if (!saveResult) {
|
|
2266
|
-
console.error(
|
|
2370
|
+
console.error(
|
|
2371
|
+
`Failed to save ErrorLog record for ${entityObject.EntityInfo.Name}: ${errorLogEntity.LatestResult?.CompleteMessage}`
|
|
2372
|
+
);
|
|
2267
2373
|
}
|
|
2268
2374
|
} catch (errorLogError) {
|
|
2269
2375
|
console.error('Error creating ErrorLog record:', errorLogError);
|
|
@@ -2302,7 +2408,10 @@ export class ResolverBase {
|
|
|
2302
2408
|
extensions: { code: 'LOAD_ENTITY_ERROR', entityName },
|
|
2303
2409
|
});
|
|
2304
2410
|
}
|
|
2305
|
-
|
|
2411
|
+
// Grab the values before we delete so we can return the last state if the delete succeeds,
|
|
2412
|
+
// projected for the caller in case the object holds a column they may not read.
|
|
2413
|
+
// fls-guard: projected by ProjectEntityValuesForUser
|
|
2414
|
+
const returnValue = ProjectEntityValuesForUser(provider, entityObject.EntityInfo, entityObject.GetAll(), this.GetUserFromPayload(userPayload));
|
|
2306
2415
|
|
|
2307
2416
|
this.ListenForEntityMessages(entityObject, pubSub, userPayload);
|
|
2308
2417
|
|