@memberjunction/server 5.51.2 → 5.51.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +34 -1
  2. package/dist/agentSessions/SessionManager.d.ts +5 -2
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +17 -8
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.js +136 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  10. package/dist/agentSessions/index.d.ts +1 -0
  11. package/dist/agentSessions/index.d.ts.map +1 -1
  12. package/dist/agentSessions/index.js +1 -0
  13. package/dist/agentSessions/index.js.map +1 -1
  14. package/dist/auth/actionAuthorization.d.ts +40 -0
  15. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  16. package/dist/auth/actionAuthorization.js +94 -0
  17. package/dist/auth/actionAuthorization.js.map +1 -0
  18. package/dist/auth/index.d.ts +1 -0
  19. package/dist/auth/index.d.ts.map +1 -1
  20. package/dist/auth/index.js +1 -0
  21. package/dist/auth/index.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +53 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +34 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts +6 -0
  31. package/dist/context.d.ts.map +1 -1
  32. package/dist/context.js +24 -34
  33. package/dist/context.js.map +1 -1
  34. package/dist/generated/generated.d.ts.map +1 -1
  35. package/dist/generated/generated.js +3262 -3273
  36. package/dist/generated/generated.js.map +1 -1
  37. package/dist/generic/CacheInvalidationResolver.d.ts +124 -4
  38. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  39. package/dist/generic/CacheInvalidationResolver.js +170 -15
  40. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  41. package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
  42. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  43. package/dist/generic/FireAndForgetHeartbeat.js +5 -4
  44. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  45. package/dist/generic/PushStatusResolver.d.ts +58 -0
  46. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  47. package/dist/generic/PushStatusResolver.js +42 -3
  48. package/dist/generic/PushStatusResolver.js.map +1 -1
  49. package/dist/generic/ResolverBase.d.ts +148 -3
  50. package/dist/generic/ResolverBase.d.ts.map +1 -1
  51. package/dist/generic/ResolverBase.js +287 -30
  52. package/dist/generic/ResolverBase.js.map +1 -1
  53. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  54. package/dist/generic/RunViewResolver.js +12 -0
  55. package/dist/generic/RunViewResolver.js.map +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +58 -50
  58. package/dist/index.js.map +1 -1
  59. package/dist/multiTenancy/index.d.ts.map +1 -1
  60. package/dist/multiTenancy/index.js +22 -2
  61. package/dist/multiTenancy/index.js.map +1 -1
  62. package/dist/postgresqlCompat.d.ts +8 -0
  63. package/dist/postgresqlCompat.d.ts.map +1 -0
  64. package/dist/postgresqlCompat.js +19 -0
  65. package/dist/postgresqlCompat.js.map +1 -0
  66. package/dist/postgresqlPoolSettings.d.ts +108 -0
  67. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  68. package/dist/postgresqlPoolSettings.js +134 -0
  69. package/dist/postgresqlPoolSettings.js.map +1 -0
  70. package/dist/realtimeWidget/widgetGuestElevation.d.ts +2 -2
  71. package/dist/realtimeWidget/widgetGuestElevation.js +2 -2
  72. package/dist/resolvers/ActionResolver.d.ts +31 -5
  73. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  74. package/dist/resolvers/ActionResolver.js +75 -14
  75. package/dist/resolvers/ActionResolver.js.map +1 -1
  76. package/dist/resolvers/AdhocQueryResolver.d.ts +39 -17
  77. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  78. package/dist/resolvers/AdhocQueryResolver.js +97 -157
  79. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  80. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  81. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  82. package/dist/resolvers/DatasetResolver.js +57 -8
  83. package/dist/resolvers/DatasetResolver.js.map +1 -1
  84. package/dist/resolvers/EntityRecordNameResolver.d.ts +14 -4
  85. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  86. package/dist/resolvers/EntityRecordNameResolver.js +18 -6
  87. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  88. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  89. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  90. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  91. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  92. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  93. package/dist/resolvers/GetDataResolver.js +13 -1
  94. package/dist/resolvers/GetDataResolver.js.map +1 -1
  95. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  96. package/dist/resolvers/MCPResolver.js +13 -16
  97. package/dist/resolvers/MCPResolver.js.map +1 -1
  98. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +26 -14
  99. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  100. package/dist/resolvers/RealtimeClientSessionResolver.js +63 -46
  101. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  102. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  103. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  104. package/dist/resolvers/RemoteBrowserActionResolver.js +24 -27
  105. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  106. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  107. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  108. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  109. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  110. package/dist/resolvers/RunTemplateResolver.js +12 -11
  111. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  112. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  113. package/dist/resolvers/RunTestResolver.js +90 -112
  114. package/dist/resolvers/RunTestResolver.js.map +1 -1
  115. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  116. package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
  117. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  118. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  119. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  120. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  121. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  122. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  123. package/dist/resolvers/adhocTimeout.js +12 -0
  124. package/dist/resolvers/adhocTimeout.js.map +1 -0
  125. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  126. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  127. package/dist/rest/ViewOperationsHandler.js +29 -17
  128. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  129. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  130. package/dist/services/TaskOrchestrator.js +17 -15
  131. package/dist/services/TaskOrchestrator.js.map +1 -1
  132. package/package.json +90 -89
  133. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  134. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  135. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  136. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  137. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  138. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  139. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  140. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  141. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +136 -0
  142. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +318 -0
  143. package/src/__tests__/RealtimeClientSessionResolver.test.ts +66 -27
  144. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +134 -0
  145. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +221 -0
  146. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +173 -0
  147. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  148. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +116 -0
  149. package/src/__tests__/ResolverBase.filterEscaping.test.ts +351 -0
  150. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  151. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +164 -0
  152. package/src/__tests__/SessionManager.test.ts +88 -13
  153. package/src/__tests__/SessionRunIDVerifier.test.ts +255 -0
  154. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  155. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +155 -0
  156. package/src/__tests__/actionAuthorization.test.ts +130 -0
  157. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  158. package/src/__tests__/cacheInvalidationRecordData.test.ts +241 -0
  159. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +180 -0
  160. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  161. package/src/__tests__/multiTenancy.test.ts +1 -1
  162. package/src/__tests__/newUserRolesDefault.test.ts +81 -0
  163. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  164. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  165. package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
  166. package/src/__tests__/resolverBase.rls.test.ts +2 -0
  167. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  168. package/src/agentSessions/SessionManager.ts +24 -25
  169. package/src/agentSessions/SessionRunIDVerifier.ts +179 -0
  170. package/src/agentSessions/index.ts +1 -0
  171. package/src/auth/actionAuthorization.ts +119 -0
  172. package/src/auth/index.ts +1 -0
  173. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  174. package/src/config.ts +34 -1
  175. package/src/context.ts +25 -34
  176. package/src/generated/generated.ts +3262 -3273
  177. package/src/generic/CacheInvalidationResolver.ts +206 -15
  178. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  179. package/src/generic/PushStatusResolver.ts +79 -4
  180. package/src/generic/ResolverBase.ts +339 -31
  181. package/src/generic/RunViewResolver.ts +17 -0
  182. package/src/index.ts +65 -52
  183. package/src/multiTenancy/index.ts +24 -2
  184. package/src/postgresqlCompat.ts +21 -0
  185. package/src/postgresqlPoolSettings.ts +213 -0
  186. package/src/realtimeWidget/widgetGuestElevation.ts +2 -2
  187. package/src/resolvers/ActionResolver.ts +85 -22
  188. package/src/resolvers/AdhocQueryResolver.ts +110 -177
  189. package/src/resolvers/DatasetResolver.ts +70 -10
  190. package/src/resolvers/EntityRecordNameResolver.ts +24 -7
  191. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  192. package/src/resolvers/GetDataResolver.ts +14 -1
  193. package/src/resolvers/MCPResolver.ts +13 -15
  194. package/src/resolvers/RealtimeClientSessionResolver.ts +82 -50
  195. package/src/resolvers/RemoteBrowserActionResolver.ts +30 -26
  196. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  197. package/src/resolvers/RunTemplateResolver.ts +13 -12
  198. package/src/resolvers/RunTestResolver.ts +90 -112
  199. package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
  200. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  201. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  202. package/src/resolvers/__tests__/ReportResolver.test.ts +2 -0
  203. package/src/resolvers/adhocTimeout.ts +12 -0
  204. package/src/rest/ViewOperationsHandler.ts +37 -18
  205. package/src/services/TaskOrchestrator.ts +17 -17
  206. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  207. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  208. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  209. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  210. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  211. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -1,14 +1,17 @@
1
1
  import { Resolver, Mutation, Arg, Ctx } from "type-graphql";
2
2
  import { ActionEngineServer } from "@memberjunction/actions";
3
3
  import { EntityActionEngineServer } from "@memberjunction/actions";
4
- import { Metadata, UserInfo, BaseEntity, CompositeKey, KeyValuePair, LogError } from "@memberjunction/core";
5
- import { ActionParam, ActionResult } from "@memberjunction/actions-base";
4
+ import { Metadata, UserInfo, BaseEntity, CompositeKey, KeyValuePair, LogError, RunView, DatabaseProviderBase } from "@memberjunction/core";
5
+ import { ActionParam, ActionResult, MJActionEntityExtended } from "@memberjunction/actions-base";
6
+ import { UserCache } from "@memberjunction/sqlserver-dataprovider";
6
7
  import { Field, InputType, ObjectType } from "type-graphql";
7
8
  import { KeyValuePairInput } from "../generic/KeyValuePairInput.js";
8
9
  import { AppContext, ProviderInfo } from "../types.js";
9
- import { CopyScalarsAndArrays, UUIDsEqual } from "@memberjunction/global";
10
+ import { CopyScalarsAndArrays, EscapeSQLString, UUIDsEqual } from "@memberjunction/global";
10
11
  import { GetReadOnlyProvider } from "../util.js";
11
12
  import { ResolverBase } from "../generic/ResolverBase.js";
13
+ import { IsScopeLimitedPrincipal } from "../auth/scopeLimitedPrincipal.js";
14
+ import { EvaluateActionRunAuthorization, IsOwner } from "../auth/actionAuthorization.js";
12
15
 
13
16
  /**
14
17
  * Input type for action parameters
@@ -55,8 +58,8 @@ export class RunActionInput {
55
58
  Params?: ActionParamInput[];
56
59
 
57
60
  /**
58
- * Whether to skip logging the action execution
59
- * Defaults to false
61
+ * Ignored. A run started through this API always writes an action execution log row; the field
62
+ * remains so that clients which send it still validate.
60
63
  */
61
64
  @Field(() => Boolean, { nullable: true })
62
65
  SkipActionLog?: boolean;
@@ -174,7 +177,13 @@ export class ActionResultOutput {
174
177
  @Resolver()
175
178
  export class ActionResolver extends ResolverBase {
176
179
  /**
177
- * Mutation for running an action
180
+ * Mutation for running an action.
181
+ *
182
+ * Scope-limited sessions are refused. Any other caller must be authorized for the action — see
183
+ * {@link EvaluateActionRunAuthorization}: an Owner may run any action; anyone else needs a role that
184
+ * holds an authorization linked to the action through `MJ: Action Authorizations`, or, when the
185
+ * action has no such link, an action class that authorizes its caller itself. The run is always
186
+ * logged.
178
187
  * @param input The input parameters for running the action
179
188
  * @param ctx The GraphQL context containing user authentication information
180
189
  * @returns The result of running the action
@@ -185,26 +194,22 @@ export class ActionResolver extends ResolverBase {
185
194
  @Ctx() ctx: AppContext
186
195
  ): Promise<ActionResultOutput> {
187
196
  try {
197
+ const user = this.getRunActionPrincipal(ctx);
198
+
188
199
  // Check API key scope authorization for action execution
189
200
  await this.CheckAPIKeyScopeAuthorization('action:execute', input.ActionID, ctx.userPayload);
190
201
 
191
- // Get the user from context
192
- const user = ctx.userPayload.userRecord;
193
- if (!user) {
194
- throw new Error("User is not authenticated");
195
- }
196
-
197
202
  // Initialize the action engine
198
203
  await ActionEngineServer.Instance.Config(false, user);
199
204
 
200
- // Get the action by ID
201
205
  const action = this.findActionById(input.ActionID);
206
+ await this.assertCanRunAction(action, user, ctx.providers);
202
207
 
203
208
  // Parse the parameters
204
209
  const params = this.parseActionParameters(input.Params);
205
210
 
206
211
  // Run the action
207
- const result = await this.executeAction(action, user, params, input.SkipActionLog);
212
+ const result = await this.executeAction(action, user, params);
208
213
 
209
214
  // Return the result
210
215
  return this.createActionResult(result);
@@ -213,6 +218,66 @@ export class ActionResolver extends ResolverBase {
213
218
  }
214
219
  }
215
220
 
221
+ /**
222
+ * The authenticated caller of RunAction. A scope-limited session is refused: its confinement exists
223
+ * only as row-level-security filters, and an action can reach data and services those filters never
224
+ * cover.
225
+ * @throws Error when there is no user or the session is scope-limited
226
+ */
227
+ private getRunActionPrincipal(ctx: AppContext): UserInfo {
228
+ const user = ctx.userPayload?.userRecord;
229
+ if (!user) {
230
+ throw new Error("User is not authenticated");
231
+ }
232
+ if (IsScopeLimitedPrincipal(user)) {
233
+ throw new Error("RunAction is not permitted for scope-limited sessions");
234
+ }
235
+ return user;
236
+ }
237
+
238
+ /**
239
+ * Refuses the run unless the caller is authorized for the action — see
240
+ * {@link EvaluateActionRunAuthorization}. The `MJ: Action Authorizations` rows, the authorization
241
+ * tree and the role grants all come from the request's provider, or all from the global provider
242
+ * when the request carries none.
243
+ * @throws Error when the caller may not run the action
244
+ */
245
+ private async assertCanRunAction(action: MJActionEntityExtended, user: UserInfo, providers: Array<ProviderInfo>): Promise<void> {
246
+ if (IsOwner(user)) {
247
+ return; // an Owner may run any action, so nothing needs to be read
248
+ }
249
+ const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
250
+ const actionAuthorizationIDs = await this.loadActionAuthorizationIDs(action.ID, provider);
251
+ const metadata = provider ?? Metadata.Provider; // global-provider-ok: fallback only when the request carries no provider
252
+ const actionAuthorizesCaller = ActionEngineServer.Instance.ActionAuthorizesCaller(action);
253
+ const decision = EvaluateActionRunAuthorization(user, actionAuthorizationIDs, metadata, actionAuthorizesCaller);
254
+ if (!decision.Allowed) {
255
+ throw new Error(`Not authorized to run action ${action.ID}: ${decision.Reason}`);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * The AuthorizationID of each `MJ: Action Authorizations` row for the action. Read as the system
261
+ * user: this is the server's own authorization decision, so it must not depend on whether the
262
+ * caller can read that entity.
263
+ */
264
+ private async loadActionAuthorizationIDs(actionID: string, provider: DatabaseProviderBase | null): Promise<string[]> {
265
+ const systemUser = UserCache.Instance.GetSystemUser();
266
+ if (!systemUser) {
267
+ throw new Error("System user not found");
268
+ }
269
+ const result = await new RunView(provider).RunView<{ AuthorizationID: string }>({
270
+ EntityName: 'MJ: Action Authorizations',
271
+ ExtraFilter: `ActionID='${EscapeSQLString(actionID)}'`,
272
+ Fields: ['AuthorizationID'],
273
+ ResultType: 'simple'
274
+ }, systemUser);
275
+ if (!result.Success) {
276
+ throw new Error(`Could not read the authorizations for action ${actionID}: ${result.ErrorMessage}`);
277
+ }
278
+ return result.Results.map(row => row.AuthorizationID);
279
+ }
280
+
216
281
  /**
217
282
  * Finds an action by its ID
218
283
  * @param actionID The ID of the action to find
@@ -220,7 +285,7 @@ export class ActionResolver extends ResolverBase {
220
285
  * @throws Error if the action is not found
221
286
  * @private
222
287
  */
223
- private findActionById(actionID: string): any {
288
+ private findActionById(actionID: string): MJActionEntityExtended {
224
289
  const action = ActionEngineServer.Instance.Actions.find(a => UUIDsEqual(a.ID, actionID));
225
290
  if (!action) {
226
291
  throw new Error(`Action with ID ${actionID} not found`);
@@ -262,25 +327,23 @@ export class ActionResolver extends ResolverBase {
262
327
  }
263
328
 
264
329
  /**
265
- * Executes an action
330
+ * Executes an action. The run is always logged: a client cannot switch off the action execution log.
266
331
  * @param action The action to execute
267
332
  * @param user The user context
268
333
  * @param params The action parameters
269
- * @param skipActionLog Whether to skip action logging
270
334
  * @returns The action result
271
335
  * @private
272
336
  */
273
337
  private async executeAction(
274
- action: any,
275
- user: UserInfo,
276
- params: ActionParam[],
277
- skipActionLog?: boolean
338
+ action: MJActionEntityExtended,
339
+ user: UserInfo,
340
+ params: ActionParam[]
278
341
  ): Promise<ActionResult> {
279
342
  return await ActionEngineServer.Instance.RunAction({
280
343
  Action: action,
281
344
  ContextUser: user,
282
345
  Params: params,
283
- SkipActionLog: skipActionLog,
346
+ SkipActionLog: false,
284
347
  Filters: []
285
348
  });
286
349
  }
@@ -1,13 +1,15 @@
1
1
  import { Arg, Ctx, Query, Resolver, Field, Int, InputType } from 'type-graphql';
2
- import { DatabasePlatform, LogError } from '@memberjunction/core';
3
- import { SQLExpressionValidator } from '@memberjunction/global';
2
+ import { LogError } from '@memberjunction/core';
3
+ import type { DatabaseProviderBase, RunQueryResult, UserInfo } from '@memberjunction/core';
4
4
  import { RenderPipeline } from '@memberjunction/generic-database-provider';
5
+ import type { AdhocSQLAuthorizer } from '@memberjunction/actions';
5
6
  import { AppContext } from '../types.js';
6
- import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
7
+ import { configInfo } from '../config.js';
8
+ import { GetReadOnlyProvider } from '../util.js';
7
9
  import { ResolverBase } from '../generic/ResolverBase.js';
10
+ import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
8
11
  import { RunQueryResultType } from './QueryResolver.js';
9
- import { exactTotalFromPage, resolveAdhocTotalRowCount } from './adhoc-query-helpers.js';
10
- import sql from 'mssql';
12
+ import { ClampAdhocTimeoutSeconds } from './adhocTimeout.js';
11
13
 
12
14
  /**
13
15
  * Input type for executing ad-hoc SQL queries directly.
@@ -31,11 +33,24 @@ class AdhocQueryInput {
31
33
  /**
32
34
  * Resolver for executing ad-hoc (unsaved) SQL queries.
33
35
  *
36
+ * The query runs through the read-only provider's own ad-hoc path (`RunQuery` with `SQL`), the
37
+ * same one in-process callers use, so it behaves identically on every database platform: the SQL
38
+ * is validated and rendered there, paged in the database with a count, run under the caller's
39
+ * timeout, and on PostgreSQL inside a read-only transaction that is rolled back.
40
+ *
34
41
  * Security:
35
- * - SQL validated via SQLExpressionValidator (full_query context) — blocks mutations, dangerous operations
36
- * - Executes on read-only connection pool only (no fallback to read-write)
37
- * - Configurable timeout (default 30s)
42
+ * - SQL validated by the provider (SQLExpressionValidator, single read query) — blocks mutations
43
+ * - Authorizes the RENDERED SQL: every table reference must be an entity base view the caller
44
+ * reads unscoped (CanRead, no row-level filter, no denied fields). Entities with row/field
45
+ * policy for this user are refused — raw SQL cannot apply that policy, so those reads belong
46
+ * on RunView.
47
+ * - Read-only provider only (no fallback to read-write)
48
+ * - Configurable timeout (default 30s), enforced by the database
38
49
  * - Requires authenticated user (standard GraphQL auth, no @RequireSystemUser)
50
+ * - Refuses scope-limited principals (see {@link IsScopeLimitedPrincipal}). Raw SQL bypasses
51
+ * RunView, entity permissions and row-level security, so the RLS scope tokens that confine a
52
+ * magic-link session are never applied on this path — there is no narrower filter to fall back
53
+ * to, only refusal.
39
54
  *
40
55
  * Auto-discovered by MJServer's dynamic resolver import.
41
56
  */
@@ -47,191 +62,94 @@ export class AdhocQueryResolver extends ResolverBase {
47
62
  @Ctx() context: AppContext
48
63
  ): Promise<RunQueryResultType> {
49
64
  const startTime = Date.now();
50
-
65
+ // SECURITY: refuse principals whose read authority is narrower than their role grant.
66
+ // Ad-hoc SQL does not go through RunView, entity permissions or row-level security, so the
67
+ // RLS scope tokens that confine a magic-link session are never substituted. There is no
68
+ // filter to narrow, so the only correct answer for such a principal is no.
69
+ if (IsScopeLimitedPrincipal(context.userPayload?.userRecord)) {
70
+ return this.buildErrorResult('Ad-hoc SQL execution is not permitted for scope-limited sessions.');
71
+ }
72
+ const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
73
+ if (!provider) {
74
+ return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
75
+ }
76
+ const contextUser = context.userPayload?.userRecord;
77
+ const refusal = this.AuthorizeRenderedSQL(input.SQL, provider, contextUser);
78
+ if (refusal) {
79
+ return this.buildErrorResult(refusal, Date.now() - startTime);
80
+ }
81
+ const timeoutSeconds = ClampAdhocTimeoutSeconds(input.TimeoutSeconds, configInfo.databaseSettings.requestTimeout);
82
+ // A negative offset must not reach paging, where it would turn paging off.
83
+ const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
51
84
  try {
52
- // 1. Security: validate SQL using SQLExpressionValidator
53
- const validator = SQLExpressionValidator.Instance;
54
- const validation = validator.validateFullQuery(input.SQL);
55
- if (!validation.valid) {
56
- return this.buildErrorResult(validation.error || 'SQL validation failed');
57
- }
58
-
59
- // 2. Get READ-ONLY data source (no fallback to read-write)
60
- let readOnlyDS: sql.ConnectionPool;
61
- try {
62
- readOnlyDS = GetReadOnlyDataSource(context.dataSources, { allowFallbackToReadWrite: false });
63
- } catch {
64
- return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
65
- }
66
-
67
- // 3. Resolve platform from the read-only provider for the render pipeline.
68
- let platform: DatabasePlatform = 'sqlserver';
69
- try {
70
- const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
71
- if (provider?.PlatformKey) platform = provider.PlatformKey;
72
- } catch {
73
- // Provider not configured — keep the default platform.
74
- }
75
- const contextUser = context.userPayload?.userRecord;
76
-
77
- // 4. Route the SQL through RenderPipeline so composition tokens
78
- // resolve, comments and templates are processed, and the row cap
79
- // is applied at the database (via TOP / LIMIT / OFFSET-FETCH).
80
- //
81
- // Page whenever a positive MaxRows is requested — INCLUDING the first
82
- // page (StartRow 0). This yields OFFSET/FETCH data SQL *and* a
83
- // COUNT(*) query so the response reports the true total row count
84
- // rather than just the returned page size. It mirrors the saved-query
85
- // path (GenericDatabaseProvider.InternalRunQuery → WrapWithPaging).
86
- // (Previously paging was gated on StartRow > 0, so page 1 fell back to
87
- // a TOP-N cap with TotalRowCount = page size — hiding the pager.)
88
- // Clamp StartRow to a non-negative integer. A negative offset must not slip
89
- // past the paging gate (which would then run the query with no row cap at all).
90
- const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
91
- const maxRows = input.MaxRows;
92
- const usePaging = maxRows != null && Number.isInteger(maxRows) && maxRows > 0;
93
-
94
- let dataSQL: string;
95
- let countSQL: string | null = null;
96
- try {
97
- const rendered = RenderPipeline.Run(input.SQL, {
98
- Platform: platform,
99
- ContextUser: contextUser,
100
- ...(usePaging ? { Paging: { StartRow: startRow, MaxRows: maxRows! } } : {}),
101
- });
102
- dataSQL = rendered.FinalSQL;
103
- countSQL = rendered.PagingResult?.CountSQL ?? null;
104
- } catch (renderErr) {
105
- const renderMsg = renderErr instanceof Error ? renderErr.message : String(renderErr);
106
- return this.buildErrorResult(`Ad-hoc query rendering failed: ${renderMsg}`);
107
- }
108
-
109
- // 5. Execute the page (and, only when a full page needs it, the count) under
110
- // a shared wall-clock deadline derived from the request's timeout budget.
111
- const deadline = startTime + (input.TimeoutSeconds ?? 30) * 1000;
112
- const { recordset, totalRowCount } = await this.executeDataAndCount(
113
- readOnlyDS, dataSQL, countSQL, startRow, usePaging ? maxRows! : null, deadline
114
- );
115
- const executionTimeMs = Date.now() - startTime;
116
-
117
- // 6. Return as RunQueryResultType
118
- return {
119
- QueryID: '',
120
- QueryName: 'Ad-Hoc Query',
121
- Success: true,
122
- Results: JSON.stringify(recordset),
123
- RowCount: recordset.length,
124
- TotalRowCount: totalRowCount,
125
- PageNumber: usePaging ? Math.floor(startRow / maxRows!) + 1 : undefined,
126
- PageSize: usePaging ? maxRows! : undefined,
127
- ExecutionTime: executionTimeMs,
128
- ErrorMessage: ''
129
- };
85
+ const result = await provider.RunQuery({
86
+ SQL: input.SQL,
87
+ MaxRows: input.MaxRows,
88
+ StartRow: startRow,
89
+ TimeoutSeconds: timeoutSeconds,
90
+ }, contextUser);
91
+ return this.toResultType(result, startRow, input.MaxRows, timeoutSeconds, Date.now() - startTime);
130
92
  } catch (err: unknown) {
131
- const executionTimeMs = Date.now() - startTime;
132
93
  const errorMessage = err instanceof Error ? err.message : String(err);
133
-
134
- // Handle timeout
135
- if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) {
136
- return {
137
- QueryID: '',
138
- QueryName: 'Ad-Hoc Query',
139
- Success: false,
140
- Results: '[]',
141
- RowCount: 0,
142
- TotalRowCount: 0,
143
- PageNumber: undefined,
144
- PageSize: undefined,
145
- ExecutionTime: executionTimeMs,
146
- ErrorMessage: `Query execution exceeded ${input.TimeoutSeconds ?? 30} second timeout`
147
- };
148
- }
149
-
150
94
  LogError(`Ad-hoc query execution failed: ${errorMessage}`);
151
- return {
152
- QueryID: '',
153
- QueryName: 'Ad-Hoc Query',
154
- Success: false,
155
- Results: '[]',
156
- RowCount: 0,
157
- TotalRowCount: 0,
158
- PageNumber: undefined,
159
- PageSize: undefined,
160
- ExecutionTime: executionTimeMs,
161
- ErrorMessage: `Query execution failed: ${errorMessage}`
162
- };
95
+ return this.buildErrorResult(`Query execution failed: ${errorMessage}`, Date.now() - startTime);
163
96
  }
164
97
  }
165
98
 
166
99
  /**
167
- * Runs the page's data SQL, then — only when the page is FULL (so more rows may
168
- * exist) — a COUNT(*) for the true total. A short (or unpaged) page needs no
169
- * count: the exact total is `startRow + rowsReturned` (see {@link exactTotalFromPage}).
100
+ * SECURITY: authorizes the READ itself, against the SQL that will actually run. The provider
101
+ * validates that the SQL is a single read statement but places no restriction on WHAT is read.
102
+ * Rendering comes first because composition (`{{query:"..."}}`) and templates can introduce
103
+ * table references the raw input never named. Every table reference must be an entity base
104
+ * view the caller can read UNSCOPED — CanRead with no row-level filter and no denied fields —
105
+ * because raw SQL applies neither (see assertFullQueryUsesReadableEntityViews). The provider
106
+ * renders the same input the same way and only wraps it in server-built paging and a count,
107
+ * so the rendered SQL is the full set of reads. Returns the refusal message, or null.
170
108
  *
171
- * The count is NON-FATAL: some queries page fine but cannot be counted — e.g.
172
- * duplicate column names are legal in a result set but rejected inside the COUNT
173
- * CTE wrap. A count failure must never sink the whole result, so we log it and
174
- * report a lower-bound total (`startRow + rowsReturned`) and let the data render.
109
+ * `provider` is the read-only provider the SQL will run on; it supplies the platform and the
110
+ * entity metadata. {@link CreateAdhocSQLAuthorizer} exposes this check to the action engine.
175
111
  */
176
- private async executeDataAndCount(
177
- ds: sql.ConnectionPool,
178
- dataSQL: string,
179
- countSQL: string | null,
180
- startRow: number,
181
- maxRows: number | null,
182
- deadline: number,
183
- ): Promise<{ recordset: Record<string, unknown>[]; totalRowCount: number }> {
184
- const dataResult = await this.runSqlWithDeadline<Record<string, unknown>>(ds, dataSQL, deadline);
185
- const recordset = (dataResult.recordset ?? []) as Record<string, unknown>[];
186
-
187
- // Total already known from the page alone (unpaged, or a short page)? Skip the count.
188
- const exact = exactTotalFromPage(startRow, recordset.length, maxRows);
189
- if (exact != null || !countSQL) {
190
- return { recordset, totalRowCount: exact ?? recordset.length };
191
- }
192
-
193
- // Full page — a COUNT(*) is required to know the true total.
194
- const lowerBound = startRow + recordset.length;
112
+ public AuthorizeRenderedSQL(sqlText: string, provider: DatabaseProviderBase, contextUser: UserInfo | undefined): string | null {
195
113
  try {
196
- const countResult = await this.runSqlWithDeadline<{ TotalRowCount: number }>(ds, countSQL, deadline);
197
- return { recordset, totalRowCount: resolveAdhocTotalRowCount(countResult.recordset, lowerBound) };
198
- } catch (countErr) {
199
- const msg = countErr instanceof Error ? countErr.message : String(countErr);
200
- LogError(`Ad-hoc query row-count failed; reporting a lower-bound total (${lowerBound}). ${msg}`);
201
- return { recordset, totalRowCount: lowerBound };
114
+ const rendered = RenderPipeline.Run(sqlText, { Platform: provider.PlatformKey, ContextUser: contextUser, RequireReadStatement: true });
115
+ this.assertFullQueryUsesReadableEntityViews(rendered.Trace.AfterTemplates, provider, contextUser, 'ad-hoc SQL');
116
+ return null;
117
+ } catch (err) {
118
+ return err instanceof Error ? err.message : String(err);
202
119
  }
203
120
  }
204
121
 
205
- /**
206
- * Executes one SQL statement on the read-only pool, racing it against the shared
207
- * wall-clock `deadline`. The timer is always cleared on completion so a settled
208
- * query never leaves a dangling timeout armed.
209
- */
210
- private async runSqlWithDeadline<T>(
211
- ds: sql.ConnectionPool,
212
- sqlText: string,
213
- deadline: number,
214
- ): Promise<sql.IResult<T>> {
215
- const remaining = deadline - Date.now();
216
- if (remaining <= 0) {
217
- throw new Error('Query timeout exceeded');
218
- }
219
- let timer: ReturnType<typeof setTimeout> | undefined;
220
- try {
221
- return await Promise.race([
222
- new sql.Request(ds).query<T>(sqlText),
223
- new Promise<never>((_, reject) => {
224
- timer = setTimeout(() => reject(new Error('Query timeout exceeded')), remaining);
225
- }),
226
- ]);
227
- } finally {
228
- if (timer) {
229
- clearTimeout(timer);
230
- }
122
+ /** Maps the provider's result to the GraphQL shape, naming a timeout plainly. */
123
+ private toResultType(
124
+ result: RunQueryResult,
125
+ startRow: number,
126
+ maxRows: number | undefined,
127
+ timeoutSeconds: number,
128
+ executionTimeMs: number,
129
+ ): RunQueryResultType {
130
+ if (!result.Success) {
131
+ const message = result.ErrorMessage ?? 'Ad-hoc query failed';
132
+ return this.buildErrorResult(
133
+ /timeout/i.test(message) ? `Query execution exceeded ${timeoutSeconds} second timeout` : message,
134
+ executionTimeMs,
135
+ );
231
136
  }
137
+ const paged = maxRows != null && Number.isInteger(maxRows) && maxRows > 0;
138
+ return {
139
+ QueryID: '',
140
+ QueryName: 'Ad-Hoc Query',
141
+ Success: true,
142
+ Results: JSON.stringify(result.Results),
143
+ RowCount: result.RowCount,
144
+ TotalRowCount: result.TotalRowCount,
145
+ PageNumber: paged ? Math.floor(startRow / maxRows!) + 1 : undefined,
146
+ PageSize: paged ? maxRows : undefined,
147
+ ExecutionTime: executionTimeMs,
148
+ ErrorMessage: ''
149
+ };
232
150
  }
233
151
 
234
- private buildErrorResult(errorMessage: string): RunQueryResultType {
152
+ private buildErrorResult(errorMessage: string, executionTimeMs = 0): RunQueryResultType {
235
153
  return {
236
154
  QueryID: '',
237
155
  QueryName: 'Ad-Hoc Query',
@@ -241,8 +159,23 @@ export class AdhocQueryResolver extends ResolverBase {
241
159
  TotalRowCount: 0,
242
160
  PageNumber: undefined,
243
161
  PageSize: undefined,
244
- ExecutionTime: 0,
162
+ ExecutionTime: executionTimeMs,
245
163
  ErrorMessage: errorMessage
246
164
  };
247
165
  }
248
166
  }
167
+
168
+ /**
169
+ * The ad-hoc SQL checks MJServer registers with the action engine, so that Run Ad-hoc Query applies
170
+ * the same checks as {@link AdhocQueryResolver.ExecuteAdhocQuery}: the rendered SQL must be one read
171
+ * statement that reads only entity views the caller may read in full, and the timeout is limited by
172
+ * the server's request timeout.
173
+ * @param requestTimeoutMs the server's request timeout in milliseconds; 0 or less means no limit
174
+ */
175
+ export function CreateAdhocSQLAuthorizer(requestTimeoutMs: number): AdhocSQLAuthorizer {
176
+ const resolver = new AdhocQueryResolver();
177
+ return {
178
+ Authorize: (sql, provider, contextUser) => resolver.AuthorizeRenderedSQL(sql, provider, contextUser),
179
+ ClampTimeoutSeconds: (requestedSeconds) => ClampAdhocTimeoutSeconds(requestedSeconds, requestTimeoutMs),
180
+ };
181
+ }
@@ -1,6 +1,6 @@
1
1
  import { Arg, Ctx, Field, InputType, Int, ObjectType, Query, Resolver } from 'type-graphql';
2
- import { AppContext } from '../types.js';
3
- import { LogError, Metadata } from '@memberjunction/core';
2
+ import { AppContext, UserPayload } from '../types.js';
3
+ import { DatabaseProviderBase, DatasetItemFilterType, DatasetItemResultType, LogError, Metadata, UserInfo } from '@memberjunction/core';
4
4
  import { GetReadOnlyProvider } from '../util.js';
5
5
  import { ResolverBase } from '../generic/ResolverBase.js';
6
6
 
@@ -34,9 +34,64 @@ export class DatasetItemFilterTypeGQL {
34
34
  Filter: string;
35
35
  }
36
36
 
37
+ /**
38
+ * Request handling shared by the dataset queries. Every read runs as the session's user, so the
39
+ * provider applies that user's entity read permission and row-level security per item, and every
40
+ * client ItemFilter passes the same AST screen as a RunView ExtraFilter before a provider sees it.
41
+ */
42
+ export abstract class DatasetResolverBase extends ResolverBase {
43
+ /** The session's user. A dataset read never runs without one: the provider applies no read checks then. */
44
+ protected RequireDatasetUser(userPayload: UserPayload): UserInfo {
45
+ const user = this.GetUserFromPayload(userPayload);
46
+ if (!user) {
47
+ throw new Error('Dataset request refused: the session resolves to no user');
48
+ }
49
+ return user;
50
+ }
51
+
52
+ /**
53
+ * Screens every client ItemFilter: one statement, read-only, and subqueries only over entity base
54
+ * views the user can read. Throws on the first refusal. Returns the filters without null entries.
55
+ */
56
+ protected ScreenItemFilters(
57
+ itemFilters: DatasetItemFilterTypeGQL[] | undefined,
58
+ provider: DatabaseProviderBase,
59
+ user: UserInfo,
60
+ ): DatasetItemFilterType[] | undefined {
61
+ if (!itemFilters) return undefined;
62
+ itemFilters.forEach((itemFilter, index) => {
63
+ if (itemFilter) {
64
+ this.assertClientClauseUsesEntityBaseViews(itemFilter.Filter, `ItemFilters[${index}].Filter`, provider, user);
65
+ }
66
+ });
67
+ return itemFilters.filter((itemFilter) => itemFilter != null);
68
+ }
69
+
70
+ /**
71
+ * Applies the API encryption policy to each item's rows, as every other GraphQL read path does:
72
+ * an encrypted field the entity does not allow decrypted over the API leaves as ciphertext or the
73
+ * sentinel. Works on copies, because the rows can be the server cache's own frozen objects.
74
+ */
75
+ protected async ProtectEncryptedFields(
76
+ items: DatasetItemResultType[],
77
+ provider: DatabaseProviderBase,
78
+ user: UserInfo,
79
+ ): Promise<DatasetItemResultType[]> {
80
+ return Promise.all(
81
+ items.map(async (item) => {
82
+ const entity = provider.EntityByName(item.EntityName);
83
+ if (!item.Results?.length || !entity?.EncryptedFields.some((field) => !field.AllowDecryptInAPI)) {
84
+ return item;
85
+ }
86
+ const rows = item.Results.map((row: Record<string, unknown>) => ({ ...row }));
87
+ return { ...item, Results: await this.ArrayFilterEncryptedFieldsForAPI(item.EntityName, rows, user, provider, 'Name') };
88
+ }),
89
+ );
90
+ }
91
+ }
37
92
 
38
93
  @Resolver(DatasetResultType)
39
- export class DatasetResolverExtended extends ResolverBase {
94
+ export class DatasetResolverExtended extends DatasetResolverBase {
40
95
  @Query(() => DatasetResultType)
41
96
  async GetDatasetByName(
42
97
  @Arg('DatasetName', () => String) DatasetName: string,
@@ -45,10 +100,12 @@ export class DatasetResolverExtended extends ResolverBase {
45
100
  ) {
46
101
  // Check API key scope authorization for dataset read
47
102
  await this.CheckAPIKeyScopeAuthorization('dataset:read', DatasetName, userPayload);
103
+ const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
104
+ const user = this.RequireDatasetUser(userPayload);
105
+ const itemFilters = this.ScreenItemFilters(ItemFilters, md, user);
48
106
 
49
107
  try {
50
- const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
51
- const result = await md.GetDatasetByName(DatasetName, ItemFilters);
108
+ const result = await md.GetDatasetByName(DatasetName, itemFilters, user);
52
109
  if (result) {
53
110
  return {
54
111
  DatasetID: result.DatasetID,
@@ -56,7 +113,7 @@ export class DatasetResolverExtended extends ResolverBase {
56
113
  Success: result.Success,
57
114
  Status: result.Status,
58
115
  LatestUpdateDate: result.LatestUpdateDate,
59
- Results: JSON.stringify(result.Results),
116
+ Results: JSON.stringify(await this.ProtectEncryptedFields(result.Results, md, user)),
60
117
  };
61
118
  } else {
62
119
  throw new Error('Error retrieving Dataset: ' + DatasetName);
@@ -90,7 +147,7 @@ export class DatasetStatusResultType {
90
147
  }
91
148
 
92
149
  @Resolver(DatasetStatusResultType)
93
- export class DatasetStatusResolver extends ResolverBase {
150
+ export class DatasetStatusResolver extends DatasetResolverBase {
94
151
  @Query(() => DatasetStatusResultType)
95
152
  async GetDatasetStatusByName(
96
153
  @Arg('DatasetName', () => String) DatasetName: string,
@@ -99,10 +156,12 @@ export class DatasetStatusResolver extends ResolverBase {
99
156
  ) {
100
157
  // Check API key scope authorization for dataset read
101
158
  await this.CheckAPIKeyScopeAuthorization('dataset:read', DatasetName, userPayload);
159
+ const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
160
+ const user = this.RequireDatasetUser(userPayload);
161
+ const itemFilters = this.ScreenItemFilters(ItemFilters, md, user);
102
162
 
103
163
  try {
104
- const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
105
- const result = await md.GetDatasetStatusByName(DatasetName, ItemFilters);
164
+ const result = await md.GetDatasetStatusByName(DatasetName, itemFilters, user);
106
165
  if (result) {
107
166
  return {
108
167
  DatasetID: result.DatasetID,
@@ -131,12 +190,13 @@ export class DatasetStatusResolver extends ResolverBase {
131
190
  @Ctx() { providers, userPayload }: AppContext,
132
191
  ): Promise<DatasetStatusResultType[]> {
133
192
  const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
193
+ const user = this.RequireDatasetUser(userPayload);
134
194
  const results: DatasetStatusResultType[] = [];
135
195
 
136
196
  // Execute all status checks in parallel
137
197
  const statusPromises = DatasetNames.map(async (name) => {
138
198
  await this.CheckAPIKeyScopeAuthorization('dataset:read', name, userPayload);
139
- return md.GetDatasetStatusByName(name);
199
+ return md.GetDatasetStatusByName(name, undefined, user);
140
200
  });
141
201
 
142
202
  const statuses = await Promise.all(statusPromises);