@memberjunction/server 6.1.4 → 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 (227) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/SessionManager.d.ts +5 -2
  4. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  5. package/dist/agentSessions/SessionManager.js +17 -8
  6. package/dist/agentSessions/SessionManager.js.map +1 -1
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
  10. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  11. package/dist/agentSessions/index.d.ts +1 -0
  12. package/dist/agentSessions/index.d.ts.map +1 -1
  13. package/dist/agentSessions/index.js +1 -0
  14. package/dist/agentSessions/index.js.map +1 -1
  15. package/dist/auth/actionAuthorization.d.ts +40 -0
  16. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  17. package/dist/auth/actionAuthorization.js +94 -0
  18. package/dist/auth/actionAuthorization.js.map +1 -0
  19. package/dist/auth/exampleNewUserSubClass.js +2 -1
  20. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  21. package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
  22. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  23. package/dist/auth/magicLink/MagicLinkService.js +55 -38
  24. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  25. package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
  26. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
  27. package/dist/auth/magicLink/magicLinkCore.js +20 -52
  28. package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
  29. package/dist/auth/newUsers.d.ts.map +1 -1
  30. package/dist/auth/newUsers.js +15 -11
  31. package/dist/auth/newUsers.js.map +1 -1
  32. package/dist/context.d.ts +6 -0
  33. package/dist/context.d.ts.map +1 -1
  34. package/dist/context.js +24 -34
  35. package/dist/context.js.map +1 -1
  36. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  37. package/dist/generated/graphql-schemas/__mj.js +3417 -3428
  38. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  39. package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
  40. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  41. package/dist/generic/CacheInvalidationResolver.js +59 -19
  42. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  43. package/dist/generic/ResolverBase.d.ts +95 -11
  44. package/dist/generic/ResolverBase.d.ts.map +1 -1
  45. package/dist/generic/ResolverBase.js +186 -76
  46. package/dist/generic/ResolverBase.js.map +1 -1
  47. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  48. package/dist/generic/RunViewResolver.js +12 -0
  49. package/dist/generic/RunViewResolver.js.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +44 -45
  53. package/dist/index.js.map +1 -1
  54. package/dist/isolatedProvider.d.ts +20 -0
  55. package/dist/isolatedProvider.d.ts.map +1 -0
  56. package/dist/isolatedProvider.js +22 -0
  57. package/dist/isolatedProvider.js.map +1 -0
  58. package/dist/multiTenancy/index.d.ts.map +1 -1
  59. package/dist/multiTenancy/index.js +22 -2
  60. package/dist/multiTenancy/index.js.map +1 -1
  61. package/dist/postgresqlCompat.d.ts +8 -0
  62. package/dist/postgresqlCompat.d.ts.map +1 -0
  63. package/dist/postgresqlCompat.js +19 -0
  64. package/dist/postgresqlCompat.js.map +1 -0
  65. package/dist/postgresqlPoolSettings.d.ts +108 -0
  66. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  67. package/dist/postgresqlPoolSettings.js +134 -0
  68. package/dist/postgresqlPoolSettings.js.map +1 -0
  69. package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
  70. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  71. package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
  72. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  73. package/dist/resolvers/ActionResolver.d.ts +31 -5
  74. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  75. package/dist/resolvers/ActionResolver.js +75 -14
  76. package/dist/resolvers/ActionResolver.js.map +1 -1
  77. package/dist/resolvers/AdhocQueryResolver.d.ts +35 -17
  78. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  79. package/dist/resolvers/AdhocQueryResolver.js +92 -166
  80. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  81. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  82. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  83. package/dist/resolvers/DatasetResolver.js +57 -8
  84. package/dist/resolvers/DatasetResolver.js.map +1 -1
  85. package/dist/resolvers/EntityRecordNameResolver.d.ts +8 -23
  86. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  87. package/dist/resolvers/EntityRecordNameResolver.js +11 -48
  88. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  89. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
  90. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  91. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  92. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  93. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  94. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  95. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  96. package/dist/resolvers/GetDataResolver.js +13 -1
  97. package/dist/resolvers/GetDataResolver.js.map +1 -1
  98. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  99. package/dist/resolvers/QuerySystemUserResolver.js +2 -1
  100. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  101. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +83 -22
  102. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  103. package/dist/resolvers/RealtimeClientSessionResolver.js +174 -72
  104. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  105. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  106. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  107. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  108. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  109. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  110. package/dist/resolvers/RunAIAgentResolver.js +2 -1
  111. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  112. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  113. package/dist/resolvers/RunTemplateResolver.js +12 -11
  114. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  115. package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
  116. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  117. package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
  118. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  119. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
  120. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
  121. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
  122. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  123. package/dist/resolvers/SyncDataResolver.js +6 -2
  124. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  125. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  126. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  127. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  128. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  129. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  130. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  131. package/dist/resolvers/adhocTimeout.js +12 -0
  132. package/dist/resolvers/adhocTimeout.js.map +1 -0
  133. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  134. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  135. package/dist/rest/ViewOperationsHandler.js +29 -17
  136. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  137. package/dist/services/TaskGraphActionRunner.d.ts +21 -8
  138. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  139. package/dist/services/TaskGraphActionRunner.js +43 -16
  140. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  141. package/package.json +92 -92
  142. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  143. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  144. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  145. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  146. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  147. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  148. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  149. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  150. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  151. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  152. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  153. package/src/__tests__/RealtimeClientSessionResolver.test.ts +310 -31
  154. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  155. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
  156. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  157. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  158. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  159. package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
  160. package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
  161. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  162. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  163. package/src/__tests__/SessionManager.test.ts +94 -17
  164. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  165. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +131 -0
  166. package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
  167. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  168. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  169. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  170. package/src/__tests__/actionAuthorization.test.ts +130 -0
  171. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  172. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  173. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  174. package/src/__tests__/magicLink.test.ts +15 -76
  175. package/src/__tests__/magicLinkService.consume.test.ts +230 -0
  176. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  177. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  178. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  179. package/src/__tests__/multiTenancy.test.ts +1 -1
  180. package/src/__tests__/newUsers.test.ts +62 -5
  181. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  182. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  183. package/src/agentSessions/SessionManager.ts +23 -25
  184. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  185. package/src/agentSessions/index.ts +1 -0
  186. package/src/auth/actionAuthorization.ts +119 -0
  187. package/src/auth/exampleNewUserSubClass.ts +2 -2
  188. package/src/auth/magicLink/MagicLinkService.ts +61 -39
  189. package/src/auth/magicLink/magicLinkCore.ts +19 -56
  190. package/src/auth/newUsers.ts +15 -11
  191. package/src/context.ts +25 -34
  192. package/src/generated/graphql-schemas/__mj.ts +3417 -3428
  193. package/src/generic/CacheInvalidationResolver.ts +74 -19
  194. package/src/generic/ResolverBase.ts +207 -80
  195. package/src/generic/RunViewResolver.ts +17 -0
  196. package/src/index.ts +49 -46
  197. package/src/isolatedProvider.ts +22 -0
  198. package/src/multiTenancy/index.ts +24 -2
  199. package/src/postgresqlCompat.ts +21 -0
  200. package/src/postgresqlPoolSettings.ts +213 -0
  201. package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
  202. package/src/resolvers/ActionResolver.ts +84 -21
  203. package/src/resolvers/AdhocQueryResolver.ts +105 -187
  204. package/src/resolvers/DatasetResolver.ts +70 -10
  205. package/src/resolvers/EntityRecordNameResolver.ts +11 -52
  206. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
  207. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  208. package/src/resolvers/GetDataResolver.ts +14 -1
  209. package/src/resolvers/QuerySystemUserResolver.ts +2 -1
  210. package/src/resolvers/RealtimeClientSessionResolver.ts +196 -77
  211. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  212. package/src/resolvers/RunAIAgentResolver.ts +4 -1
  213. package/src/resolvers/RunTemplateResolver.ts +13 -12
  214. package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
  215. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
  216. package/src/resolvers/SyncDataResolver.ts +6 -2
  217. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  218. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  219. package/src/resolvers/adhocTimeout.ts +12 -0
  220. package/src/rest/ViewOperationsHandler.ts +37 -18
  221. package/src/services/TaskGraphActionRunner.ts +45 -16
  222. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  223. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  224. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  225. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  226. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  227. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -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
 
@@ -1,9 +1,13 @@
1
1
  import { Arg, Ctx, Field, ObjectType, Query, Resolver } from "type-graphql";
2
+ import type { DatabaseProviderBase, UserInfo } from "@memberjunction/core";
3
+ import { IsValidUUID, UUIDsEqual } from "@memberjunction/global";
4
+ import { DataContext, DataContextItem } from "@memberjunction/data-context";
5
+ import { MJDataContextEntity, MJDataContextItemEntity } from "@memberjunction/core-entities";
6
+ import { RenderPipeline } from "@memberjunction/generic-database-provider";
2
7
  import { AppContext } from "../types.js";
3
- import { DataContext } from "@memberjunction/data-context";
4
- import { GetReadOnlyDataSource, GetReadOnlyProvider } from "../util.js";
5
- import { MJDataContextItemEntity } from "@memberjunction/core-entities";
8
+ import { GetReadOnlyProvider } from "../util.js";
6
9
  import { ResolverBase } from "../generic/ResolverBase.js";
10
+ import { IsScopeLimitedPrincipal } from "../auth/scopeLimitedPrincipal.js";
7
11
 
8
12
  @ObjectType()
9
13
  export class GetDataContextItemDataOutputType {
@@ -39,6 +43,28 @@ export class GetDataContextDataOutputType {
39
43
  }
40
44
 
41
45
 
46
+ /** The outcome of loading one data context item: its data as JSON, or why it has none. */
47
+ interface ItemOutcome {
48
+ Result: string | null;
49
+ Error: string | null;
50
+ }
51
+
52
+ /** Which `sql` items of a data context may run: the read-only provider, and each refused item's reason. */
53
+ interface SQLItemGate {
54
+ Provider: DatabaseProviderBase | null;
55
+ Refusals: Map<DataContextItem, string>;
56
+ }
57
+
58
+ /**
59
+ * Loads the data of data contexts and their items.
60
+ *
61
+ * Items of type `sql` hold raw SQL, which is held to the rules ExecuteAdhocQuery applies to
62
+ * caller-supplied SQL. It runs only for the data context's owner or an administrator (Owner-type
63
+ * user), never for a scope-limited session, only on the read-only provider, and only as a single read
64
+ * statement over entity base views the caller may read in full. A SQL item that may not run is
65
+ * refused on its own, and the other items still load. Items of the other types load through entity
66
+ * permissions for the calling user.
67
+ */
42
68
  @Resolver()
43
69
  export class GetDataContextDataResolver extends ResolverBase {
44
70
  /**
@@ -52,30 +78,25 @@ export class GetDataContextDataResolver extends ResolverBase {
52
78
  ) {
53
79
  // Check API key scope authorization for data context read
54
80
  await this.CheckAPIKeyScopeAuthorization('datacontext:read', DataContextItemID, appCtx.userPayload);
81
+ if (!IsValidUUID(DataContextItemID)) {
82
+ return { Success: false, ErrorMessage: 'DataContextItemID must be a UUID', Result: null };
83
+ }
55
84
 
56
85
  try {
57
- const ds = GetReadOnlyDataSource(appCtx.dataSources, {
58
- allowFallbackToReadWrite: true,
59
- })
86
+ const user: UserInfo = appCtx.userPayload.userRecord;
60
87
  const md = GetReadOnlyProvider(appCtx.providers, {allowFallbackToReadWrite: true});
61
- const dciData = await md.GetEntityObject<MJDataContextItemEntity>("MJ: Data Context Items", appCtx.userPayload.userRecord);
88
+ const dciData = await md.GetEntityObject<MJDataContextItemEntity>("MJ: Data Context Items", user);
62
89
  if (await dciData.Load(DataContextItemID)) {
63
90
  const dci = DataContext.CreateDataContextItem(); // use class factory to get whatever lowest level sub-class is registered
64
- await dci.LoadMetadataFromEntityRecord(dciData, md, appCtx.userPayload.userRecord);
91
+ await dci.LoadMetadataFromEntityRecord(dciData, md, user);
92
+ const ownerID = dci.Type === 'sql' ? await this.loadDataContextOwnerID(dciData.DataContextID, md, user) : null;
93
+ const gate = this.authorizeSQLItems([dci], ownerID, appCtx);
65
94
  // now the metadata is loaded so we can call the regular load function
66
- if (await dci.LoadData(ds)) {
67
- return {
68
- Success: true,
69
- ErrorMessage: null,
70
- Result: JSON.stringify(dci.Data),
71
- }
72
- }
73
- else {
74
- return {
75
- Success: false,
76
- ErrorMessage: 'Error loading data context item data',
77
- Result: null,
78
- }
95
+ const outcome = await this.loadItem(dci, gate, user, false);
96
+ return {
97
+ Success: outcome.Error === null,
98
+ ErrorMessage: outcome.Error,
99
+ Result: outcome.Result,
79
100
  }
80
101
  }
81
102
  else {
@@ -89,14 +110,15 @@ export class GetDataContextDataResolver extends ResolverBase {
89
110
  catch (e) {
90
111
  return {
91
112
  Success: false,
92
- ErrorMessage: e,
113
+ ErrorMessage: e instanceof Error ? e.message : String(e),
93
114
  Result: null,
94
115
  }
95
116
  }
96
117
  }
97
118
 
98
119
  /**
99
- * Returns data for a given data context.
120
+ * Returns data for a given data context. `Results` and `ErrorMessages` hold one entry per item, in
121
+ * item order. `Success` is true, and `ErrorMessages` null, only when every item loaded.
100
122
  * @param DataContextID
101
123
  */
102
124
  @Query(() => GetDataContextDataOutputType)
@@ -106,38 +128,114 @@ export class GetDataContextDataResolver extends ResolverBase {
106
128
  ) {
107
129
  // Check API key scope authorization for data context read
108
130
  await this.CheckAPIKeyScopeAuthorization('datacontext:read', DataContextID, appCtx.userPayload);
131
+ // DataContext.LoadMetadata places this ID in the item filter, so it must be a plain UUID.
132
+ if (!IsValidUUID(DataContextID)) {
133
+ return { Success: false, ErrorMessages: ['DataContextID must be a UUID'], Results: null };
134
+ }
109
135
 
110
136
  try {
111
137
  // our job here is to load the entire data context, so we do that with the Data Context object
138
+ const user: UserInfo = appCtx.userPayload.userRecord;
139
+ const md = GetReadOnlyProvider(appCtx.providers, { allowFallbackToReadWrite: true });
112
140
  const dc = new DataContext();
113
- const ds = GetReadOnlyDataSource(appCtx.dataSources, {
114
- allowFallbackToReadWrite: true,
115
- })
116
- const success = await dc.Load(DataContextID, ds, true, false, 0, appCtx.userPayload.userRecord);
117
- if (success) {
118
- const retVal = {
119
- Success: true,
120
- ErrorMessages: null,
121
- Results: dc.Items.map((item) => {
122
- return JSON.stringify(item.Data);
123
- }),
124
- }
125
- return retVal;
126
- }
127
- else {
141
+ if (!(await dc.LoadMetadata(DataContextID, user, md))) {
128
142
  return {
129
143
  Success: false,
130
144
  ErrorMessages: ['Error loading data context'],
131
- Results: null,
145
+ Results: null,
132
146
  }
133
147
  }
148
+ const gate = this.authorizeSQLItems(dc.Items, dc.MJDataContextEntity.UserID, appCtx);
149
+ const outcomes = await Promise.all(dc.Items.map((item) => this.loadItem(item, gate, user, true)));
150
+ const allLoaded = outcomes.every((outcome) => outcome.Error === null);
151
+ return {
152
+ Success: allLoaded,
153
+ ErrorMessages: allLoaded ? null : outcomes.map((outcome) => outcome.Error),
154
+ Results: outcomes.map((outcome) => outcome.Result),
155
+ }
134
156
  }
135
157
  catch (e) {
136
158
  return {
137
159
  Success: false,
138
- ErrorMessages: [e],
160
+ ErrorMessages: [e instanceof Error ? e.message : String(e)],
139
161
  Results: null,
140
162
  }
141
163
  }
142
164
  }
143
- }
165
+
166
+ /**
167
+ * Decides which `sql` items among `items` may run. Returns the read-only provider they run on, and
168
+ * the reason for each SQL item that may not run.
169
+ */
170
+ private authorizeSQLItems(items: DataContextItem[], ownerID: string | null, appCtx: AppContext): SQLItemGate {
171
+ const refusals = new Map<DataContextItem, string>();
172
+ const sqlItems = items.filter((item) => item.Type === 'sql');
173
+ if (sqlItems.length === 0) {
174
+ return { Provider: null, Refusals: refusals };
175
+ }
176
+ const user: UserInfo | undefined = appCtx.userPayload?.userRecord;
177
+ const provider = GetReadOnlyProvider(appCtx.providers, { allowFallbackToReadWrite: false });
178
+ for (const item of sqlItems) {
179
+ const refusal = this.sqlItemRefusal(item, ownerID, user, provider);
180
+ if (refusal !== null) {
181
+ refusals.set(item, refusal);
182
+ }
183
+ }
184
+ return { Provider: provider, Refusals: refusals };
185
+ }
186
+
187
+ /** Returns why `item` may not run for `user`, or null when it may. */
188
+ private sqlItemRefusal(item: DataContextItem, ownerID: string | null, user: UserInfo | undefined, provider: DatabaseProviderBase | null): string | null {
189
+ if (!user || IsScopeLimitedPrincipal(user)) {
190
+ return 'SQL data context items cannot be run by a scope-limited or unidentified session.';
191
+ }
192
+ if (!this.mayRunDataContextSQL(ownerID, user)) {
193
+ return 'Only the owner of this data context, or an administrator, may run its SQL items.';
194
+ }
195
+ if (!provider) {
196
+ return 'No read-only data source is configured, so SQL data context items cannot run.';
197
+ }
198
+ return this.screenItemSQL(item.SQL ?? '', provider, user);
199
+ }
200
+
201
+ /** True when `user` owns the data context or is an administrator (Owner-type user). */
202
+ private mayRunDataContextSQL(ownerID: string | null, user: UserInfo): boolean {
203
+ const isOwner = !!ownerID && UUIDsEqual(ownerID, user.ID);
204
+ return isOwner || user.Type?.trim().toLowerCase() === 'owner';
205
+ }
206
+
207
+ /**
208
+ * Returns why `sqlText` may not run, or null when it is a single read statement whose every table
209
+ * reference is an entity base view `user` may read without row-level or field-level narrowing. It is
210
+ * the check ExecuteAdhocQuery makes on the rendered SQL.
211
+ */
212
+ private screenItemSQL(sqlText: string, provider: DatabaseProviderBase, user: UserInfo): string | null {
213
+ try {
214
+ const rendered = RenderPipeline.Run(sqlText, { Platform: provider.PlatformKey, ContextUser: user, RequireReadStatement: true });
215
+ this.assertFullQueryUsesReadableEntityViews(rendered.Trace.AfterTemplates, provider, user, 'data context SQL');
216
+ return null;
217
+ }
218
+ catch (e) {
219
+ const reason = e instanceof Error ? e.message : String(e);
220
+ return reason || 'The SQL of this item did not pass the read-only check.';
221
+ }
222
+ }
223
+
224
+ /** Loads one item's data unless `gate` refused it. Returns the data as JSON, or why there is none. */
225
+ private async loadItem(item: DataContextItem, gate: SQLItemGate, user: UserInfo, forceRefresh: boolean): Promise<ItemOutcome> {
226
+ const refusal = gate.Refusals.get(item);
227
+ if (refusal !== undefined) {
228
+ return { Result: null, Error: refusal };
229
+ }
230
+ if (await item.LoadData(gate.Provider, forceRefresh, false, 0, user)) {
231
+ return { Result: JSON.stringify(item.Data), Error: null };
232
+ }
233
+ return { Result: null, Error: item.DataLoadingError || 'Error loading data context item data' };
234
+ }
235
+
236
+ /** Returns the owner (`UserID`) of a data context, or null when the caller cannot load it. */
237
+ private async loadDataContextOwnerID(dataContextID: string, provider: DatabaseProviderBase, user: UserInfo): Promise<string | null> {
238
+ const dataContext = await provider.GetEntityObject<MJDataContextEntity>('MJ: Data Contexts', user);
239
+ return (await dataContext.Load(dataContextID)) ? dataContext.UserID : null;
240
+ }
241
+ }
@@ -1,4 +1,5 @@
1
1
  import { Arg, Ctx, Field, InputType, ObjectType, Query } from 'type-graphql';
2
+ import { SQLExpressionValidator } from '@memberjunction/global';
2
3
  import { AppContext } from '../types.js';
3
4
  import { LogError, LogStatus, LogStatusEx, Metadata } from '@memberjunction/core';
4
5
  import { QueryCompositionEngine } from '@memberjunction/generic-database-provider';
@@ -154,8 +155,20 @@ export class GetDataResolver {
154
155
  resolvedSQL = compositionResult.ResolvedSQL;
155
156
  }
156
157
 
158
+ // Defense-in-depth: even system-token holders get SELECT-only SQL here.
159
+ // The read-only pool is the primary control, but if it is ever
160
+ // misconfigured to a read-write login this validator still refuses DML/DDL
161
+ // (mirrors AdhocQueryResolver). The validator reads exactly the text that
162
+ // runs: it is trimmed here as validate() trims it, and literal \n sequences
163
+ // are not rewritten as validateFullQuery would.
164
+ const sqlToRun = resolvedSQL.trim();
165
+ const validation = SQLExpressionValidator.Instance.validate(sqlToRun, { context: 'full_query', Dialect: platform });
166
+ if (!validation.valid) {
167
+ return { result: null, error: validation.error || 'SQL validation failed' };
168
+ }
169
+
157
170
  const request = new sql.Request(readOnlyDataSource);
158
- const result = await request.query(resolvedSQL);
171
+ const result = await request.query(sqlToRun);
159
172
  return { result: result.recordset, error: null };
160
173
  } catch (err) {
161
174
  // Extract clean SQL error message
@@ -6,6 +6,7 @@ import { MJQueryResolver, MJQuery_, MJQueryField_, MJQueryParameter_, MJQueryEnt
6
6
  import { GetReadWriteProvider } from '../util.js';
7
7
  import { DeleteOptionsInput } from '../generic/DeleteOptionsInput.js';
8
8
  import { MJQueryEntityServer } from '@memberjunction/core-entities-server';
9
+ import { EscapeSQLString } from '@memberjunction/global';
9
10
 
10
11
  /**
11
12
  * Minimal shape of a query row returned by RunView lookups (plain object, not entity instance).
@@ -493,7 +494,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
493
494
  // now make sure there is NO existing query by the same name in the specified category
494
495
  const existingQueryResult = await provider.RunView({
495
496
  EntityName: 'MJ: Queries',
496
- ExtraFilter: `Name='${input.Name}' AND CategoryID='${finalCategoryID}'`
497
+ ExtraFilter: `Name='${EscapeSQLString(input.Name)}' AND CategoryID='${EscapeSQLString(finalCategoryID)}'`
497
498
  }, context.userPayload.userRecord);
498
499
  if (existingQueryResult.Success && existingQueryResult.Results?.length > 0) {
499
500
  // we have a match! Let's return an error