@memberjunction/server 6.1.5 → 6.1.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.
Files changed (189) hide show
  1. package/dist/.tsbuildinfo +1 -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 +129 -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/exampleNewUserSubClass.js +2 -1
  19. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  20. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  21. package/dist/auth/magicLink/MagicLinkService.js +6 -3
  22. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  23. package/dist/auth/newUsers.d.ts.map +1 -1
  24. package/dist/auth/newUsers.js +15 -11
  25. package/dist/auth/newUsers.js.map +1 -1
  26. package/dist/context.d.ts +6 -0
  27. package/dist/context.d.ts.map +1 -1
  28. package/dist/context.js +13 -11
  29. package/dist/context.js.map +1 -1
  30. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  31. package/dist/generated/graphql-schemas/__mj.js +3417 -3428
  32. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  33. package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
  34. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  35. package/dist/generic/CacheInvalidationResolver.js +59 -19
  36. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  37. package/dist/generic/ResolverBase.d.ts +95 -11
  38. package/dist/generic/ResolverBase.d.ts.map +1 -1
  39. package/dist/generic/ResolverBase.js +186 -76
  40. package/dist/generic/ResolverBase.js.map +1 -1
  41. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  42. package/dist/generic/RunViewResolver.js +12 -0
  43. package/dist/generic/RunViewResolver.js.map +1 -1
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +8 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/isolatedProvider.d.ts +20 -0
  49. package/dist/isolatedProvider.d.ts.map +1 -0
  50. package/dist/isolatedProvider.js +22 -0
  51. package/dist/isolatedProvider.js.map +1 -0
  52. package/dist/multiTenancy/index.d.ts.map +1 -1
  53. package/dist/multiTenancy/index.js +22 -2
  54. package/dist/multiTenancy/index.js.map +1 -1
  55. package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
  56. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  57. package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
  58. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  59. package/dist/resolvers/ActionResolver.d.ts +31 -5
  60. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  61. package/dist/resolvers/ActionResolver.js +75 -14
  62. package/dist/resolvers/ActionResolver.js.map +1 -1
  63. package/dist/resolvers/AdhocQueryResolver.d.ts +28 -0
  64. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  65. package/dist/resolvers/AdhocQueryResolver.js +46 -0
  66. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  67. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  68. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  69. package/dist/resolvers/DatasetResolver.js +57 -8
  70. package/dist/resolvers/DatasetResolver.js.map +1 -1
  71. package/dist/resolvers/EntityRecordNameResolver.d.ts +8 -23
  72. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  73. package/dist/resolvers/EntityRecordNameResolver.js +11 -48
  74. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  75. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
  76. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  77. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  78. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  79. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  80. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  81. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  82. package/dist/resolvers/GetDataResolver.js +13 -1
  83. package/dist/resolvers/GetDataResolver.js.map +1 -1
  84. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  85. package/dist/resolvers/QuerySystemUserResolver.js +2 -1
  86. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  87. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +39 -16
  88. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  89. package/dist/resolvers/RealtimeClientSessionResolver.js +84 -38
  90. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  91. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  92. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  93. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  94. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  95. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  96. package/dist/resolvers/RunTemplateResolver.js +12 -11
  97. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  98. package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
  99. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  100. package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
  101. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  102. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
  103. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
  104. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
  105. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  106. package/dist/resolvers/SyncDataResolver.js +6 -2
  107. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  108. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  109. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  110. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  111. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  112. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  113. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  114. package/dist/rest/ViewOperationsHandler.js +29 -17
  115. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  116. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  117. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  118. package/dist/services/TaskGraphActionRunner.js +27 -1
  119. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  120. package/package.json +92 -92
  121. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  122. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +4 -2
  123. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  124. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  125. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  126. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  127. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  128. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  129. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  130. package/src/__tests__/RealtimeClientSessionResolver.test.ts +106 -41
  131. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  132. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
  133. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  134. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  135. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  136. package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
  137. package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
  138. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  139. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  140. package/src/__tests__/SessionManager.test.ts +94 -17
  141. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  142. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
  143. package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
  144. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  145. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  146. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  147. package/src/__tests__/actionAuthorization.test.ts +130 -0
  148. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  149. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  150. package/src/__tests__/magicLinkService.consume.test.ts +6 -0
  151. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  152. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  153. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  154. package/src/__tests__/multiTenancy.test.ts +1 -1
  155. package/src/__tests__/newUsers.test.ts +62 -5
  156. package/src/agentSessions/SessionManager.ts +23 -25
  157. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  158. package/src/agentSessions/index.ts +1 -0
  159. package/src/auth/actionAuthorization.ts +119 -0
  160. package/src/auth/exampleNewUserSubClass.ts +2 -2
  161. package/src/auth/magicLink/MagicLinkService.ts +6 -3
  162. package/src/auth/newUsers.ts +15 -11
  163. package/src/context.ts +14 -10
  164. package/src/generated/graphql-schemas/__mj.ts +3417 -3428
  165. package/src/generic/CacheInvalidationResolver.ts +74 -19
  166. package/src/generic/ResolverBase.ts +207 -80
  167. package/src/generic/RunViewResolver.ts +17 -0
  168. package/src/index.ts +9 -1
  169. package/src/isolatedProvider.ts +22 -0
  170. package/src/multiTenancy/index.ts +24 -2
  171. package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
  172. package/src/resolvers/ActionResolver.ts +84 -21
  173. package/src/resolvers/AdhocQueryResolver.ts +49 -1
  174. package/src/resolvers/DatasetResolver.ts +70 -10
  175. package/src/resolvers/EntityRecordNameResolver.ts +11 -52
  176. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
  177. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  178. package/src/resolvers/GetDataResolver.ts +14 -1
  179. package/src/resolvers/QuerySystemUserResolver.ts +2 -1
  180. package/src/resolvers/RealtimeClientSessionResolver.ts +103 -42
  181. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  182. package/src/resolvers/RunTemplateResolver.ts +13 -12
  183. package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
  184. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
  185. package/src/resolvers/SyncDataResolver.ts +6 -2
  186. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  187. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  188. package/src/rest/ViewOperationsHandler.ts +37 -18
  189. package/src/services/TaskGraphActionRunner.ts +29 -1
@@ -0,0 +1,22 @@
1
+ import { DatabaseProviderBase, Metadata } from '@memberjunction/core';
2
+
3
+ /**
4
+ * A fresh Read-Write provider with its OWN transaction state, sharing the server's connection pool.
5
+ *
6
+ * A provider's ambient transaction (`BeginTransaction` / `CommitTransaction` / `RollbackTransaction`)
7
+ * is a field on the provider INSTANCE. Two units of work that open transactions on one instance nest
8
+ * into each other, and every write anyone else makes through that instance while one is open lands
9
+ * inside it (MJ#2140; `entityTransactionScope.ts`). The process-global `Metadata.Provider` is shared
10
+ * by every request, scheduled job and background service, so transactional work must never run on
11
+ * it: one transaction left open there silently swallows every later global-provider write until the
12
+ * process restarts.
13
+ *
14
+ * GraphQL resolvers already get a per-request provider from `AppContext.providers`. Code that runs
15
+ * outside one, such as the auth flow before the context exists, calls this.
16
+ *
17
+ * Cheap: it is the global provider's `CreateIndependentInstance()` (SQL Server and PostgreSQL), which
18
+ * reuses the already-loaded metadata and the shared pool.
19
+ */
20
+ export async function CreateIsolatedProvider(): Promise<DatabaseProviderBase> {
21
+ return (Metadata.Provider as DatabaseProviderBase).CreateIndependentInstance(); // global-provider-ok: borrows only its pool and metadata, never its transaction state
22
+ }
@@ -9,7 +9,9 @@
9
9
 
10
10
  import type { RequestHandler } from 'express';
11
11
  import type { PreRunViewHook, PreSaveHook } from '@memberjunction/core';
12
- import { Metadata, type UserInfo, type TenantContext } from '@memberjunction/core';
12
+ import { DatabaseProviderBase, Metadata, type UserInfo, type TenantContext } from '@memberjunction/core';
13
+ import { PostgreSQLDialect, SQLServerDialect, type SQLParserDialect } from '@memberjunction/sql-dialect';
14
+ import { IsBalancedSQLFragment } from '@memberjunction/sql-parser';
13
15
  import type { MultiTenancyConfig } from '../config.js';
14
16
  import type { UserPayload } from '../types.js';
15
17
  import { CloneUserForSessionContext } from '../auth/sessionUserClone.js';
@@ -147,6 +149,25 @@ function isAdminUser(user: UserInfo, adminRoles: string[]): boolean {
147
149
  );
148
150
  }
149
151
 
152
+ /** Dialects to read an ExtraFilter with when the active provider's dialect cannot be resolved. */
153
+ const ALL_FILTER_DIALECTS: SQLParserDialect[] = [new SQLServerDialect(), new PostgreSQLDialect()];
154
+
155
+ /**
156
+ * Throws unless the caller's ExtraFilter stays inside the parentheses the tenant predicate is
157
+ * ANDed after. A filter such as `1=1) OR (1=1` would close them early and leave the tenant
158
+ * predicate on one side of an OR the caller controls.
159
+ */
160
+ function assertExtraFilterBalanced(extraFilter: string): void {
161
+ const provider = Metadata.Provider; // global-provider-ok: same rationale as isEntityScoped above — hooks carry no per-request provider
162
+ const dialects = provider instanceof DatabaseProviderBase ? [provider.Dialect] : ALL_FILTER_DIALECTS;
163
+ for (const dialect of dialects) {
164
+ const check = IsBalancedSQLFragment(extraFilter, dialect);
165
+ if (!check.IsBalanced) {
166
+ throw new Error(`[MultiTenancy] Invalid ExtraFilter: ${check.Reason}`);
167
+ }
168
+ }
169
+ }
170
+
150
171
  /**
151
172
  * Creates a PreRunViewHook that auto-injects tenant WHERE clauses
152
173
  * into RunView queries for scoped entities.
@@ -192,7 +213,8 @@ export function createTenantPreRunViewHook(config: MultiTenancyConfig): PreRunVi
192
213
 
193
214
  // Inject the tenant filter
194
215
  if (params.ExtraFilter && typeof params.ExtraFilter === 'string' && params.ExtraFilter.trim().length > 0) {
195
- params.ExtraFilter = `(${params.ExtraFilter}) AND ${tenantFilter}`;
216
+ assertExtraFilterBalanced(params.ExtraFilter);
217
+ params.ExtraFilter = `(${params.ExtraFilter}) AND (${tenantFilter})`;
196
218
  } else {
197
219
  params.ExtraFilter = tenantFilter;
198
220
  }
@@ -85,8 +85,10 @@ export async function resolveWidgetGuestRunContext(
85
85
  * A scoped anonymous session (`IsMagicLinkAnonymous` + `MagicLinkScope.ResourceID`) holds only the
86
86
  * narrow relay grants its invite's role carries — deliberately NOT the AI run entities, whose rows
87
87
  * leak the rendered system prompt. Unlike {@link resolveWidgetGuestRunContext} no widget instance is
88
- * required: on the realtime path the agent authority is already server-side (the session config's
89
- * `targetAgentID`, `CanRun`-gated at session start), so there is no client-supplied agent id to pin.
88
+ * required: the realtime target is the session config's `targetAgentID`, which the owner can edit, so
89
+ * the relay re-checks `CanRun` on it for the CALLER before this elevation. Direct actions are refused
90
+ * for these callers before elevation, so only the delegated agent run and the run-entity writes
91
+ * execute as the system user.
90
92
  *
91
93
  * PUBLIC WEB-WIDGET guests are deliberately EXCLUDED (returned unchanged): their seeded role writes
92
94
  * run rows under the guest principal, which the `Widget Guest: Own Agent Runs` RLS read filter
@@ -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/generic-database-provider";
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, GetReadWriteProvider } 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,20 +194,16 @@ 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);
@@ -206,7 +211,7 @@ export class ActionResolver extends ResolverBase {
206
211
  // Run the action — thread the request-scoped provider so provider-bound
207
212
  // actions (e.g. the semantic find-* / Search Entity actions, which call
208
213
  // params.Provider.SearchEntity) run against the caller's metadata layer.
209
- const result = await this.executeAction(action, user, params, ctx.providers, input.SkipActionLog);
214
+ const result = await this.executeAction(action, user, params, ctx.providers);
210
215
 
211
216
  // Return the result
212
217
  return this.createActionResult(result);
@@ -215,6 +220,66 @@ export class ActionResolver extends ResolverBase {
215
220
  }
216
221
  }
217
222
 
223
+ /**
224
+ * The authenticated caller of RunAction. A scope-limited session is refused: its confinement exists
225
+ * only as row-level-security filters, and an action can reach data and services those filters never
226
+ * cover.
227
+ * @throws Error when there is no user or the session is scope-limited
228
+ */
229
+ private getRunActionPrincipal(ctx: AppContext): UserInfo {
230
+ const user = ctx.userPayload?.userRecord;
231
+ if (!user) {
232
+ throw new Error("User is not authenticated");
233
+ }
234
+ if (IsScopeLimitedPrincipal(user)) {
235
+ throw new Error("RunAction is not permitted for scope-limited sessions");
236
+ }
237
+ return user;
238
+ }
239
+
240
+ /**
241
+ * Refuses the run unless the caller is authorized for the action — see
242
+ * {@link EvaluateActionRunAuthorization}. The `MJ: Action Authorizations` rows, the authorization
243
+ * tree and the role grants all come from the request's provider, or all from the global provider
244
+ * when the request carries none.
245
+ * @throws Error when the caller may not run the action
246
+ */
247
+ private async assertCanRunAction(action: MJActionEntityExtended, user: UserInfo, providers: Array<ProviderInfo>): Promise<void> {
248
+ if (IsOwner(user)) {
249
+ return; // an Owner may run any action, so nothing needs to be read
250
+ }
251
+ const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
252
+ const actionAuthorizationIDs = await this.loadActionAuthorizationIDs(action.ID, provider);
253
+ const metadata = provider ?? Metadata.Provider; // global-provider-ok: fallback only when the request carries no provider
254
+ const actionAuthorizesCaller = ActionEngineServer.Instance.ActionAuthorizesCaller(action);
255
+ const decision = EvaluateActionRunAuthorization(user, actionAuthorizationIDs, metadata, actionAuthorizesCaller);
256
+ if (!decision.Allowed) {
257
+ throw new Error(`Not authorized to run action ${action.ID}: ${decision.Reason}`);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * The AuthorizationID of each `MJ: Action Authorizations` row for the action. Read as the system
263
+ * user: this is the server's own authorization decision, so it must not depend on whether the
264
+ * caller can read that entity.
265
+ */
266
+ private async loadActionAuthorizationIDs(actionID: string, provider: DatabaseProviderBase | null): Promise<string[]> {
267
+ const systemUser = UserCache.Instance.GetSystemUser();
268
+ if (!systemUser) {
269
+ throw new Error("System user not found");
270
+ }
271
+ const result = await new RunView(provider).RunView<{ AuthorizationID: string }>({
272
+ EntityName: 'MJ: Action Authorizations',
273
+ ExtraFilter: `ActionID='${EscapeSQLString(actionID)}'`,
274
+ Fields: ['AuthorizationID'],
275
+ ResultType: 'simple'
276
+ }, systemUser);
277
+ if (!result.Success) {
278
+ throw new Error(`Could not read the authorizations for action ${actionID}: ${result.ErrorMessage}`);
279
+ }
280
+ return result.Results.map(row => row.AuthorizationID);
281
+ }
282
+
218
283
  /**
219
284
  * Finds an action by its ID
220
285
  * @param actionID The ID of the action to find
@@ -222,7 +287,7 @@ export class ActionResolver extends ResolverBase {
222
287
  * @throws Error if the action is not found
223
288
  * @private
224
289
  */
225
- private findActionById(actionID: string): any {
290
+ private findActionById(actionID: string): MJActionEntityExtended {
226
291
  const action = ActionEngineServer.Instance.Actions.find(a => UUIDsEqual(a.ID, actionID));
227
292
  if (!action) {
228
293
  throw new Error(`Action with ID ${actionID} not found`);
@@ -264,20 +329,18 @@ export class ActionResolver extends ResolverBase {
264
329
  }
265
330
 
266
331
  /**
267
- * Executes an action
332
+ * Executes an action. The run is always logged: a client cannot switch off the action execution log.
268
333
  * @param action The action to execute
269
334
  * @param user The user context
270
335
  * @param params The action parameters
271
- * @param skipActionLog Whether to skip action logging
272
336
  * @returns The action result
273
337
  * @private
274
338
  */
275
339
  private async executeAction(
276
- action: any,
340
+ action: MJActionEntityExtended,
277
341
  user: UserInfo,
278
342
  params: ActionParam[],
279
- providers: Array<ProviderInfo>,
280
- skipActionLog?: boolean
343
+ providers: Array<ProviderInfo>
281
344
  ): Promise<ActionResult> {
282
345
  // Resolve the request-scoped provider. Prefer read-write (actions may mutate);
283
346
  // fall back to read-only, then to the engine's global-Metadata default when no
@@ -287,7 +350,7 @@ export class ActionResolver extends ResolverBase {
287
350
  Action: action,
288
351
  ContextUser: user,
289
352
  Params: params,
290
- SkipActionLog: skipActionLog,
353
+ SkipActionLog: false,
291
354
  Filters: [],
292
355
  Provider: provider
293
356
  });
@@ -1,6 +1,8 @@
1
1
  import { Arg, Ctx, Query, Resolver, Field, Int, InputType } from 'type-graphql';
2
2
  import { LogError } from '@memberjunction/core';
3
- import type { RunQueryResult } from '@memberjunction/core';
3
+ import type { DatabaseProviderBase, RunQueryResult, UserInfo } from '@memberjunction/core';
4
+ import { RenderPipeline } from '@memberjunction/generic-database-provider';
5
+ import type { AdhocSQLAuthorizer } from '@memberjunction/actions';
4
6
  import { AppContext } from '../types.js';
5
7
  import { configInfo } from '../config.js';
6
8
  import { GetReadOnlyProvider } from '../util.js';
@@ -38,6 +40,10 @@ class AdhocQueryInput {
38
40
  *
39
41
  * Security:
40
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.
41
47
  * - Read-only provider only (no fallback to read-write)
42
48
  * - Configurable timeout (default 30s), enforced by the database
43
49
  * - Requires authenticated user (standard GraphQL auth, no @RequireSystemUser)
@@ -68,6 +74,10 @@ export class AdhocQueryResolver extends ResolverBase {
68
74
  return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
69
75
  }
70
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
+ }
71
81
  const timeoutSeconds = ClampAdhocTimeoutSeconds(input.TimeoutSeconds, configInfo.databaseSettings.requestTimeout);
72
82
  // A negative offset must not reach paging, where it would turn paging off.
73
83
  const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
@@ -86,6 +96,29 @@ export class AdhocQueryResolver extends ResolverBase {
86
96
  }
87
97
  }
88
98
 
99
+ /**
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.
108
+ *
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.
111
+ */
112
+ public AuthorizeRenderedSQL(sqlText: string, provider: DatabaseProviderBase, contextUser: UserInfo | undefined): string | null {
113
+ try {
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);
119
+ }
120
+ }
121
+
89
122
  /** Maps the provider's result to the GraphQL shape, naming a timeout plainly. */
90
123
  private toResultType(
91
124
  result: RunQueryResult,
@@ -131,3 +164,18 @@ export class AdhocQueryResolver extends ResolverBase {
131
164
  };
132
165
  }
133
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);
@@ -1,4 +1,4 @@
1
- import { CompositeKey, DatabaseProviderBase, EntityInfo, LogDebug, UserInfo } from '@memberjunction/core';
1
+ import { CompositeKey, DatabaseProviderBase } from '@memberjunction/core';
2
2
  import { Arg, Ctx, Field, InputType, ObjectType, Query, Resolver } from 'type-graphql';
3
3
  import { AppContext, UserPayload } from '../types.js';
4
4
  import { CompositeKeyInputType, CompositeKeyOutputType } from '../generic/KeyInputOutputTypes.js';
@@ -36,20 +36,14 @@ export class EntityRecordNameResult {
36
36
  * Resolves a record's display NAME from its primary key — the lookup behind every foreign-key
37
37
  * link, breadcrumb and picker label in the UI.
38
38
  *
39
- * **This is a read of entity data and is gated like one.** It historically took no user context
40
- * at all, which made it an open side channel: a caller denied READ on an entity's name field
41
- * could still obtain that name here, because the name never travelled through the resolver that
42
- * strips denied fields. Field-level security turned that latent gap into a live bypass — the
43
- * form-field FK control falls through to this query precisely WHEN the joined display column is
44
- * denied, so the one case the fallback exists for was the case that leaked.
39
+ * Entity-level read permission is checked here ({@link ResolverBase.CheckUserReadPermissions}).
40
+ * Field- and row-level security are applied by the provider's lookup, for the acting user passed
41
+ * to it: when any field the name is built from is withheld, it answers with no name, and it reads
42
+ * only rows the user's read row filter allows. Without an acting user there is no lookup.
45
43
  *
46
- * Two gates now apply, matching what the generated single-record resolvers do:
47
- * 1. entity-level read permission ({@link ResolverBase.CheckUserReadPermissions});
48
- * 2. field-level read permission on the entity's own name field.
49
- *
50
- * A field-level denial answers `Success: false` with no name rather than throwing. Callers
51
- * already treat "no name" as "show the primary key", which is the intended degradation — and it
52
- * keeps this query from becoming a probe that distinguishes "restricted" from "missing".
44
+ * A withheld name answers `Success: false` with the same status as a record that does not exist.
45
+ * Callers already treat "no name" as "show the primary key", and the identical answer keeps this
46
+ * query from becoming a probe that distinguishes "restricted" from "missing".
53
47
  */
54
48
  @Resolver(EntityRecordNameResult)
55
49
  export class EntityRecordNameResolver extends ResolverBase {
@@ -91,53 +85,18 @@ export class EntityRecordNameResolver extends ResolverBase {
91
85
  const e = md.Entities.find((e) => e.Name === EntityName);
92
86
  if (e) {
93
87
  const contextUser = userPayload ? this.GetUserFromPayload(userPayload) : undefined;
94
- if (this.IsNameFieldDeniedToUser(e, contextUser)) {
95
- LogDebug(
96
- `[FieldSecurity] Suppressed GetEntityRecordName for '${EntityName}': ` +
97
- `the name field is not readable by this user`
98
- );
99
- return {
100
- Success: false,
101
- Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue`,
102
- CompositeKey: pk,
103
- EntityName,
104
- };
105
- }
106
- // Pass the acting user through: the provider signature accepts it, and omitting it ran the
107
- // underlying lookup with no identity at all — so row-level security had nothing to filter on
108
- // either. The field-level gate above is the column half; this is the row half.
109
- const recordName = await md.GetEntityRecordName(e.Name, pk, contextUser);
88
+ // The acting user is what field- and row-level security are applied for.
89
+ const recordName = contextUser ? await md.GetEntityRecordName(e.Name, pk, contextUser) : '';
110
90
  if (recordName) return { Success: true, Status: 'OK', CompositeKey: pk, RecordName: recordName, EntityName };
111
91
  else
112
92
  return {
113
93
  Success: false,
114
- Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue if user doesn't have Row Level Access (RLS) if RLS is enabled for this entity`,
94
+ Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue`,
115
95
  CompositeKey: pk,
116
96
  EntityName,
117
97
  };
118
98
  } else return { Success: false, Status: `Entity ${EntityName} not found`, CompositeKey: pk, EntityName };
119
99
  }
120
-
121
- /**
122
- * Whether field security withholds this entity's NAME field from the user.
123
- *
124
- * Resolves the name field the same way the provider's own lookup does — the designated
125
- * `IsNameField`, else a field literally called `Name` — so the check cannot drift from the
126
- * value it is guarding. Fails OPEN only where field security does not apply at all (flag off,
127
- * no user, no name field), matching every other FLS gate.
128
- */
129
- protected IsNameFieldDeniedToUser(entity: EntityInfo, contextUser?: UserInfo): boolean {
130
- if (!entity?.EnableFieldLevelSecurity || !contextUser) {
131
- return false;
132
- }
133
- const nameField =
134
- entity.Fields?.find(f => f.IsNameField) ??
135
- entity.Fields?.find(f => f.Name?.trim().toLowerCase() === 'name');
136
- if (!nameField) {
137
- return false;
138
- }
139
- return entity.GetDeniedReadFields(contextUser).has(nameField.Name.trim().toLowerCase());
140
- }
141
100
  }
142
101
 
143
102
  export default EntityRecordNameResolver;
@@ -5,7 +5,7 @@ import { MJEntityDocumentEntity, MJVectorIndexEntity, MJVectorDatabaseEntity } f
5
5
  import { ResolverBase } from '../generic/ResolverBase.js';
6
6
  import { GetAIAPIKey } from '@memberjunction/ai';
7
7
  import { VectorDBBase } from '@memberjunction/ai-vectordb';
8
- import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
8
+ import { EscapeSQLString, MJGlobal, UUIDsEqual } from '@memberjunction/global';
9
9
 
10
10
  /* ───── GraphQL types ───── */
11
11
 
@@ -144,7 +144,7 @@ export class FetchEntityVectorsResolver extends ResolverBase {
144
144
  const rv = new RunView();
145
145
  const result = await rv.RunView<MJEntityDocumentEntity>({
146
146
  EntityName: 'MJ: Entity Documents',
147
- ExtraFilter: `ID='${entityDocumentID}'`,
147
+ ExtraFilter: `ID='${EscapeSQLString(entityDocumentID)}'`,
148
148
  ResultType: 'entity_object',
149
149
  }, contextUser);
150
150