@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,104 @@
1
+ // type-graphql decorators call `Reflect.getMetadata`, which only exists once this polyfill is
2
+ // loaded. MUST precede any import that pulls in the resolver file.
3
+ import 'reflect-metadata';
4
+
5
+ /**
6
+ * MJServer registers an ad-hoc SQL authorizer with the action engine so that Run Ad-hoc Query, which
7
+ * agents run as the calling user, applies the same checks as the ExecuteAdhocQuery resolver: the
8
+ * rendered SQL must be one read statement, every table it reads must be an entity base view the
9
+ * caller may read in full (no row-level filter, no denied field), and the timeout is limited by the
10
+ * server's request timeout.
11
+ *
12
+ * Every SQL statement below is harmless.
13
+ */
14
+ import { describe, it, expect, vi } from 'vitest';
15
+ import { readFileSync } from 'fs';
16
+ import { resolve } from 'path';
17
+
18
+ vi.mock('type-graphql', () => {
19
+ const noopDecorator = () => () => undefined;
20
+ return {
21
+ Resolver: noopDecorator, Mutation: noopDecorator, Query: noopDecorator, Subscription: noopDecorator,
22
+ ObjectType: noopDecorator, InputType: noopDecorator, Field: noopDecorator, Arg: noopDecorator,
23
+ Args: noopDecorator, Ctx: noopDecorator, PubSub: noopDecorator, Root: noopDecorator,
24
+ Directive: noopDecorator, Authorized: noopDecorator, UseMiddleware: noopDecorator,
25
+ createUnionType: () => class {},
26
+ AuthorizationError: class AuthorizationError extends Error {},
27
+ Float: class {}, Int: class {}, ID: class {},
28
+ };
29
+ });
30
+
31
+ // The server config is read from mj.config at import; the authorizer takes its timeout as an argument.
32
+ vi.mock('../config.js', () => ({ configInfo: { databaseSettings: { requestTimeout: 120_000 } } }));
33
+
34
+ import type { DatabaseProviderBase, UserInfo } from '@memberjunction/core';
35
+ import { CreateAdhocSQLAuthorizer } from '../resolvers/AdhocQueryResolver.js';
36
+ import { ClampAdhocTimeoutSeconds } from '../resolvers/adhocTimeout.js';
37
+
38
+ type EntityShape = { Name: string; SchemaName: string; BaseView: string; rowFilter?: string };
39
+
40
+ /** A read-only provider with the entity metadata the table check reads. */
41
+ function readOnlyProvider(entities: EntityShape[]): DatabaseProviderBase {
42
+ return {
43
+ PlatformKey: 'sqlserver',
44
+ Entities: entities.map((e) => ({
45
+ Name: e.Name,
46
+ SchemaName: e.SchemaName,
47
+ BaseView: e.BaseView,
48
+ GetUserPermisions: () => ({ CanRead: true }),
49
+ GetEffectiveRowFilterWhereClause: () => e.rowFilter ?? '',
50
+ GetDeniedReadFields: () => new Set<string>(),
51
+ })),
52
+ } as unknown as DatabaseProviderBase;
53
+ }
54
+
55
+ const USER = { ID: 'user-1', Email: 'user@example.com' } as unknown as UserInfo;
56
+ const RUBRIC_EVALUATIONS: EntityShape = { Name: 'MJ: Rubric Evaluations', SchemaName: '__mj', BaseView: 'vwRubricEvaluations' };
57
+ const REQUEST_TIMEOUT_MS = 120_000;
58
+
59
+ describe('CreateAdhocSQLAuthorizer', () => {
60
+ const authorizer = CreateAdhocSQLAuthorizer(REQUEST_TIMEOUT_MS);
61
+
62
+ it('admits SQL that reads only entity views the user may read in full', () => {
63
+ const refusal = authorizer.Authorize('SELECT ID FROM [__mj].[vwRubricEvaluations]', readOnlyProvider([RUBRIC_EVALUATIONS]), USER);
64
+
65
+ expect(refusal).toBeNull();
66
+ });
67
+
68
+ it('refuses SQL that reads an entity the user may read only through a row filter', () => {
69
+ const filtered = { ...RUBRIC_EVALUATIONS, rowFilter: "EvaluatorUserID='user-1'" };
70
+
71
+ const refusal = authorizer.Authorize('SELECT * FROM __mj.vwRubricEvaluations', readOnlyProvider([filtered]), USER);
72
+
73
+ expect(refusal).toMatch(/row-level-security filtered/);
74
+ });
75
+
76
+ it('refuses SQL that reads a table that is not an entity view', () => {
77
+ const refusal = authorizer.Authorize('SELECT * FROM [__mj].[User]', readOnlyProvider([RUBRIC_EVALUATIONS]), USER);
78
+
79
+ expect(refusal).toMatch(/entity base view/);
80
+ });
81
+
82
+ it('refuses a stacked batch hidden behind a bracket identifier', () => {
83
+ const refusal = authorizer.Authorize("SELECT 1 AS [a'] ; SELECT 2 AS [b']", readOnlyProvider([RUBRIC_EVALUATIONS]), USER);
84
+
85
+ expect(refusal).toMatch(/single read query/);
86
+ });
87
+
88
+ it('limits the timeout the way ExecuteAdhocQuery does', () => {
89
+ for (const requested of [undefined, 5, 600]) {
90
+ expect(authorizer.ClampTimeoutSeconds(requested)).toBe(ClampAdhocTimeoutSeconds(requested, REQUEST_TIMEOUT_MS));
91
+ }
92
+ expect(authorizer.ClampTimeoutSeconds(600)).toBe(120);
93
+ });
94
+ });
95
+
96
+ describe('MJServer startup', () => {
97
+ // Startup needs a database, so this one assertion reads the source.
98
+ it('registers the read-only provider factory and this authorizer with the action engine', () => {
99
+ const src = readFileSync(resolve(__dirname, '../index.ts'), 'utf8');
100
+
101
+ expect(/ActionEngineServer\.Instance\.SetReadOnlyProviderFactory\(\s*\(\)\s*=>\s*CreateReadOnlyProvider\(dataSources\)\s*\)/.test(src)).toBe(true);
102
+ expect(/ActionEngineServer\.Instance\.SetAdhocSQLAuthorizer\(\s*CreateAdhocSQLAuthorizer\(configInfo\.databaseSettings\.requestTimeout\)\s*\)/.test(src)).toBe(true);
103
+ });
104
+ });
@@ -0,0 +1,201 @@
1
+ // The resolvers carry type-graphql decorators, which need the Reflect.metadata polyfill at import time.
2
+ import 'reflect-metadata';
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import {
5
+ DatabaseProviderBase,
6
+ DatasetItemFilterType,
7
+ DatasetResultType,
8
+ DatasetStatusResultType,
9
+ EntityInfo,
10
+ UserInfo,
11
+ } from '@memberjunction/core';
12
+ import { ENCRYPTED_SENTINEL } from '@memberjunction/global';
13
+ import { DatasetResolverExtended, DatasetStatusResolver } from '../resolvers/DatasetResolver.js';
14
+ import type { AppContext, UserPayload } from '../types.js';
15
+
16
+ // The encrypted-field policy configures the encryption engine before it inspects a value; there is
17
+ // no key store in a unit test.
18
+ vi.mock('@memberjunction/encryption', () => ({
19
+ EncryptionEngine: {
20
+ Instance: {
21
+ Config: async () => undefined,
22
+ GetKeyByID: () => ({ Marker: '$ENC$' }),
23
+ Encrypt: async (value: string) => `$ENC$${value}`,
24
+ },
25
+ },
26
+ }));
27
+
28
+ /**
29
+ * The GraphQL dataset queries are the network edge of security finding C6.
30
+ *
31
+ * `GetDatasetByName` / `GetDatasetStatusByName` took `ItemFilters[].Filter` from any authenticated
32
+ * principal and handed it to the provider unscreened, and passed no context user, so the provider
33
+ * could neither refuse an unreadable item nor apply row-level security. These tests pin that the
34
+ * resolvers screen every ItemFilter with the same AST screen as ExtraFilter before the provider is
35
+ * reached, pass the session user down, and apply the API encryption policy to the rows they return.
36
+ */
37
+
38
+ const ROLE_ID = 'R0000000-0000-0000-0000-00000000000A';
39
+ const OTHER_ROLE_ID = 'R0000000-0000-0000-0000-00000000000C';
40
+
41
+ function entity(name: string, baseView: string, fields: Record<string, unknown>[] = []): EntityInfo {
42
+ const id = `E-${name}`;
43
+ return new EntityInfo({
44
+ ID: id, Name: name, SchemaName: '__mj', BaseTable: name, BaseView: baseView,
45
+ Permissions: [{ ID: `p-${name}`, EntityID: id, RoleID: ROLE_ID, CanRead: true, Type: 'Allow' }],
46
+ Fields: fields.map((f, i) => ({ ID: `${id}-f${i}`, EntityID: id, Entity: name, Sequence: i + 1, Type: 'nvarchar', ...f })),
47
+ });
48
+ }
49
+
50
+ const THINGS = entity('Things', 'vwThings', [{ Name: 'ID', IsPrimaryKey: true }, { Name: 'Name' }]);
51
+ const SECRETS = entity('Secrets', 'vwSecrets', [
52
+ { Name: 'ID', IsPrimaryKey: true },
53
+ { Name: 'Token', Encrypt: true, EncryptionKeyID: 'K1', AllowDecryptInAPI: false, SendEncryptedValue: false },
54
+ ]);
55
+
56
+ const USER = new UserInfo(undefined, {
57
+ ID: 'U1', Name: 'Reader', Email: 'reader@example.com', IsActive: true,
58
+ UserRoles: [{ UserID: 'U1', RoleID: ROLE_ID, Role: 'Reader' }],
59
+ });
60
+
61
+ type ProviderCall = {
62
+ Method: 'GetDatasetByName' | 'GetDatasetStatusByName';
63
+ DatasetName: string;
64
+ ItemFilters: DatasetItemFilterType[] | undefined;
65
+ ContextUser: UserInfo | undefined;
66
+ };
67
+
68
+ /** A provider double that records what reached it and answers with fixed rows. */
69
+ function fakeProvider(itemEntity: EntityInfo = THINGS, rows: Record<string, unknown>[] = []) {
70
+ const calls: ProviderCall[] = [];
71
+ const provider = {
72
+ Entities: [THINGS, SECRETS],
73
+ EntityByName: (name: string) => [THINGS, SECRETS].find(e => e.Name.toLowerCase() === name?.trim().toLowerCase()),
74
+ GetDatasetByName: async (DatasetName: string, ItemFilters?: DatasetItemFilterType[], ContextUser?: UserInfo): Promise<DatasetResultType> => {
75
+ calls.push({ Method: 'GetDatasetByName', DatasetName, ItemFilters, ContextUser });
76
+ return {
77
+ DatasetID: 'DS1', DatasetName, Success: true, Status: '', LatestUpdateDate: new Date(0),
78
+ Results: [{ Code: itemEntity.Name, EntityName: itemEntity.Name, EntityID: itemEntity.ID, Results: rows, Success: true }],
79
+ };
80
+ },
81
+ GetDatasetStatusByName: async (DatasetName: string, ItemFilters?: DatasetItemFilterType[], ContextUser?: UserInfo): Promise<DatasetStatusResultType> => {
82
+ calls.push({ Method: 'GetDatasetStatusByName', DatasetName, ItemFilters, ContextUser });
83
+ return { DatasetID: 'DS1', DatasetName, Success: true, Status: '', LatestUpdateDate: new Date(0), EntityUpdateDates: [] };
84
+ },
85
+ };
86
+ return { provider: provider as unknown as DatabaseProviderBase, calls };
87
+ }
88
+
89
+ function context(provider: DatabaseProviderBase, userPayload: Partial<UserPayload> = { email: USER.Email, userRecord: USER }): AppContext {
90
+ return {
91
+ providers: [{ provider, type: 'Read-Write' }],
92
+ userPayload: { sessionId: 'S1', ...userPayload } as UserPayload,
93
+ } as AppContext;
94
+ }
95
+
96
+ const STACKED = [{ ItemCode: 'Things', Filter: '1=0; SELECT 1' }];
97
+ // A quote inside a bracket identifier opens a fake literal for the string-literal stripper that
98
+ // guards the provider denylist; the AST screen tokenizes brackets and still sees the second statement.
99
+ const STACKED_BEHIND_BRACKET = [{ ItemCode: 'Things', Filter: "[a'] = 1; SELECT 1 --'" }];
100
+
101
+ describe('GetDatasetByName', () => {
102
+ it('refuses a stacked-statement ItemFilter before the provider is reached', async () => {
103
+ const { provider, calls } = fakeProvider();
104
+
105
+ await expect(new DatasetResolverExtended().GetDatasetByName('MJ_Metadata', context(provider), STACKED))
106
+ .rejects.toThrow(/multiple statements are not permitted/);
107
+ expect(calls).toEqual([]);
108
+ });
109
+
110
+ it('refuses a stacked statement hidden behind a bracket identifier', async () => {
111
+ const { provider, calls } = fakeProvider();
112
+
113
+ await expect(new DatasetResolverExtended().GetDatasetByName('MJ_Metadata', context(provider), STACKED_BEHIND_BRACKET))
114
+ .rejects.toThrow(/multiple statements are not permitted/);
115
+ expect(calls).toEqual([]);
116
+ });
117
+
118
+ it('refuses an ItemFilter whose subquery reads a table that is not an entity base view', async () => {
119
+ const { provider, calls } = fakeProvider();
120
+ const filters = [{ ItemCode: 'Things', Filter: 'ID IN (SELECT ID FROM __mj.[User])' }];
121
+
122
+ await expect(new DatasetResolverExtended().GetDatasetByName('MJ_Metadata', context(provider), filters))
123
+ .rejects.toThrow(/entity base view/);
124
+ expect(calls).toEqual([]);
125
+ });
126
+
127
+ it('passes the session user to the provider, with the screened filters', async () => {
128
+ const { provider, calls } = fakeProvider();
129
+ const filters = [{ ItemCode: 'Things', Filter: "Name = 'x'" }];
130
+
131
+ const result = await new DatasetResolverExtended().GetDatasetByName('Things_Dataset', context(provider), filters);
132
+
133
+ expect(result.Success).toBe(true);
134
+ expect(calls).toHaveLength(1);
135
+ expect(calls[0].ContextUser).toBe(USER);
136
+ expect(calls[0].ItemFilters).toEqual(filters);
137
+ });
138
+
139
+ it('refuses a session that resolves to no user rather than reading as nobody', async () => {
140
+ const { provider, calls } = fakeProvider();
141
+
142
+ await expect(new DatasetResolverExtended().GetDatasetByName('Things_Dataset', context(provider, { email: '', userRecord: null })))
143
+ .rejects.toThrow(/no user/i);
144
+ expect(calls).toEqual([]);
145
+ });
146
+
147
+ it('masks an encrypted field the entity does not allow decrypted over the API, without touching the provider rows', async () => {
148
+ // A context user makes the provider decrypt; the API policy decides what leaves the server.
149
+ const providerRow = Object.freeze({ ID: 'S1', Token: 'plain-secret' });
150
+ const { provider } = fakeProvider(SECRETS, [providerRow]);
151
+
152
+ const result = await new DatasetResolverExtended().GetDatasetByName('Secrets_Dataset', context(provider));
153
+
154
+ const items = JSON.parse(result.Results) as DatasetResultType['Results'];
155
+ expect(items[0].Results[0].Token).toBe(ENCRYPTED_SENTINEL);
156
+ expect(result.Results).not.toContain('plain-secret');
157
+ expect(providerRow.Token).toBe('plain-secret');
158
+ });
159
+ });
160
+
161
+ describe('GetDatasetStatusByName', () => {
162
+ it('refuses a stacked-statement ItemFilter before the provider is reached', async () => {
163
+ const { provider, calls } = fakeProvider();
164
+
165
+ await expect(new DatasetStatusResolver().GetDatasetStatusByName('MJ_Metadata', context(provider), STACKED))
166
+ .rejects.toThrow(/multiple statements are not permitted/);
167
+ expect(calls).toEqual([]);
168
+ });
169
+
170
+ it('passes the session user to the provider', async () => {
171
+ const { provider, calls } = fakeProvider();
172
+
173
+ await new DatasetStatusResolver().GetDatasetStatusByName('Things_Dataset', context(provider), [{ ItemCode: 'Things', Filter: "Name = 'x'" }]);
174
+
175
+ expect(calls).toHaveLength(1);
176
+ expect(calls[0].ContextUser).toBe(USER);
177
+ });
178
+
179
+ it('refuses an ItemFilter that reads an entity the user has no read permission on', async () => {
180
+ const { provider, calls } = fakeProvider();
181
+ const outsider = new UserInfo(undefined, {
182
+ ID: 'U2', Name: 'Outsider', Email: 'outsider@example.com', IsActive: true,
183
+ UserRoles: [{ UserID: 'U2', RoleID: OTHER_ROLE_ID, Role: 'Other' }],
184
+ });
185
+ const filters = [{ ItemCode: 'Things', Filter: 'ID IN (SELECT ID FROM __mj.vwSecrets)' }];
186
+
187
+ await expect(new DatasetStatusResolver().GetDatasetStatusByName('Things_Dataset', context(provider, { email: outsider.Email, userRecord: outsider }), filters))
188
+ .rejects.toThrow(/read permission/);
189
+ expect(calls).toEqual([]);
190
+ });
191
+ });
192
+
193
+ describe('GetMultipleDatasetStatusByName', () => {
194
+ it('passes the session user to the provider for every dataset', async () => {
195
+ const { provider, calls } = fakeProvider();
196
+
197
+ await new DatasetStatusResolver().GetMultipleDatasetStatusByName(['A', 'B'], context(provider));
198
+
199
+ expect(calls.map(c => c.ContextUser)).toEqual([USER, USER]);
200
+ });
201
+ });
@@ -7,43 +7,22 @@ import type { DatabaseProviderBase, EntityInfo, UserInfo } from '@memberjunction
7
7
  import { EntityRecordNameResolver } from '../resolvers/EntityRecordNameResolver.js';
8
8
 
9
9
  /**
10
- * `GetEntityRecordName` must not hand back a name the caller cannot READ.
11
- *
12
- * This resolver predates field-level security and took no user context at all, which made it an
13
- * open side channel: the name never travelled through `MapFieldNamesToCodeNames`, the boundary
14
- * that strips denied fields, so a caller denied READ on an entity's name field could still obtain
15
- * that name from here.
16
- *
17
- * FLS turned the latent gap into a live bypass, and the shape of it is worth remembering: the
18
- * form-field FK control falls through to this query PRECISELY WHEN the joined display column is
19
- * denied. The fallback that exists to handle a denial was the thing that defeated it. Found in
20
- * manual Phase 5 testing — a Contract form showed its Client's name while the grid, correctly,
21
- * did not.
10
+ * `GetEntityRecordName` hands the acting user to the provider, which applies field- and row-level
11
+ * security for them, and reports a withheld name exactly as it reports a missing record. The
12
+ * field-level rule itself is covered by MJCore's DatabaseProviderBase record-name tests.
22
13
  */
23
14
 
24
- const DENIED_USER = { ID: 'u-1', Email: 'restricted@test' } as unknown as UserInfo;
15
+ const ACTING_USER = { ID: 'u-1', Email: 'restricted@test' } as unknown as UserInfo;
25
16
 
26
- /** An entity whose name field is denied or allowed, per the flag. */
27
- function entityInfo(opts: { fls: boolean; deniedNames?: string[]; nameField?: string }): EntityInfo {
28
- const nameFieldName = opts.nameField ?? 'Name';
29
- return {
30
- Name: 'Clients',
31
- EnableFieldLevelSecurity: opts.fls,
32
- Fields: [
33
- { Name: 'ID', IsNameField: false },
34
- { Name: nameFieldName, IsNameField: true },
35
- { Name: 'Industry', IsNameField: false },
36
- ],
37
- GetDeniedReadFields: () => new Set(opts.deniedNames ?? []),
38
- } as unknown as EntityInfo;
39
- }
17
+ const ENTITY = { Name: 'Clients' } as unknown as EntityInfo;
40
18
 
41
- /** A provider that would happily return the name — the resolver must decide not to ask. */
42
- function providerWith(entity: EntityInfo, getName = vi.fn().mockResolvedValue('Acme Manufacturing')) {
19
+ /** A provider whose lookup answers with `name` ('' when it withholds or finds nothing). */
20
+ function providerAnswering(name: string) {
21
+ const getName = vi.fn().mockResolvedValue(name);
43
22
  return {
44
- Entities: [entity],
45
- GetEntityRecordName: getName,
46
- } as unknown as DatabaseProviderBase & { GetEntityRecordName: typeof getName };
23
+ md: { Entities: [ENTITY], GetEntityRecordName: getName } as unknown as DatabaseProviderBase,
24
+ getName,
25
+ };
47
26
  }
48
27
 
49
28
  /** Exposes the inner method so the test does not need a GraphQL context. */
@@ -57,62 +36,38 @@ class Probe extends EntityRecordNameResolver {
57
36
 
58
37
  const KEY = new CompositeKey([{ FieldName: 'ID', Value: 'c-1' }]);
59
38
 
60
- describe('GetEntityRecordName — field-security gate', () => {
61
- it('withholds the name when the name field is read-denied', async () => {
62
- const getName = vi.fn().mockResolvedValue('Acme Manufacturing');
63
- const md = providerWith(entityInfo({ fls: true, deniedNames: ['name'] }), getName);
39
+ describe('GetEntityRecordName — security is applied for the acting user', () => {
40
+ it('passes the acting user to the provider lookup', async () => {
41
+ const { md, getName } = providerAnswering('Acme Manufacturing');
64
42
 
65
- const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
43
+ await new Probe().Inner(md, 'Clients', KEY, ACTING_USER);
66
44
 
67
- expect(result.Success).toBe(false);
68
- expect(result.RecordName).toBeUndefined();
69
- // Not merely filtered after the fact — the lookup must not run at all. The provider's
70
- // record-name cache is keyed by entity + primary key and NOT by user, so a name fetched
71
- // here would be visible to any later caller.
72
- expect(getName).not.toHaveBeenCalled();
45
+ expect(getName).toHaveBeenCalledWith('Clients', expect.anything(), ACTING_USER);
73
46
  });
74
47
 
75
- it('returns the name when the user may read the name field', async () => {
76
- const md = providerWith(entityInfo({ fls: true, deniedNames: ['industry'] }));
48
+ it('returns the name the provider allows', async () => {
49
+ const { md } = providerAnswering('Acme Manufacturing');
77
50
 
78
- const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
51
+ const result = await new Probe().Inner(md, 'Clients', KEY, ACTING_USER);
79
52
 
80
53
  expect(result.Success).toBe(true);
81
54
  expect(result.RecordName).toBe('Acme Manufacturing');
82
55
  });
83
56
 
84
- it('is inert when the entity has field security switched off', async () => {
85
- // Nearly every entity. A denial set that would otherwise match must not apply.
86
- const md = providerWith(entityInfo({ fls: false, deniedNames: ['name'] }));
57
+ it('reports a withheld name as unsuccessful, with no name', async () => {
58
+ const { md } = providerAnswering('');
87
59
 
88
- const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
89
-
90
- expect(result.Success).toBe(true);
91
- expect(result.RecordName).toBe('Acme Manufacturing');
92
- });
93
-
94
- it('honours a designated name field that is not literally called "Name"', async () => {
95
- const md = providerWith(entityInfo({ fls: true, nameField: 'Title', deniedNames: ['title'] }));
96
-
97
- const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
60
+ const result = await new Probe().Inner(md, 'Clients', KEY, ACTING_USER);
98
61
 
99
62
  expect(result.Success).toBe(false);
63
+ expect(result.RecordName).toBeUndefined();
100
64
  });
101
65
 
102
- it('passes the acting user to the provider so row-level security has an identity', async () => {
103
- const getName = vi.fn().mockResolvedValue('Acme Manufacturing');
104
- const md = providerWith(entityInfo({ fls: true }), getName);
105
-
106
- await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
66
+ it('does not disclose which of "missing" or "restricted" applies', async () => {
67
+ const { md } = providerAnswering('');
107
68
 
108
- expect(getName).toHaveBeenCalledWith('Clients', expect.anything(), DENIED_USER);
109
- });
69
+ const result = await new Probe().Inner(md, 'Clients', KEY, ACTING_USER);
110
70
 
111
- it('does not disclose which of "missing" or "restricted" applies', async () => {
112
- // The denial status must read the same as a genuine not-found, so the query cannot be
113
- // used to probe for which entities carry restricted name fields.
114
- const denied = await new Probe().Inner(
115
- providerWith(entityInfo({ fls: true, deniedNames: ['name'] })), 'Clients', KEY, DENIED_USER);
116
- expect(denied.Status).not.toMatch(/denied|permission|restricted|field.security/i);
71
+ expect(result.Status).not.toMatch(/denied|permission|restricted|field.security|RLS/i);
117
72
  });
118
73
  });
@@ -0,0 +1,152 @@
1
+ // ResolverBase transitively pulls in type-graphql decorators, which need the
2
+ // Reflect.metadata polyfill at import time.
3
+ import 'reflect-metadata';
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+ import { CompositeKey, DatabaseProviderBase, EntityInfo, EntityPermissionType, Metadata, RowLevelSecurityFilterInfo, UserInfo } from '@memberjunction/core';
6
+ import type { DeleteSQLResult, IMetadataProvider, QueryExecutionSpec, RunQueryResult, SaveSQLResult } from '@memberjunction/core';
7
+ import { EntityRecordNameResolver } from '../resolvers/EntityRecordNameResolver.js';
8
+ import type { AppContext, UserPayload } from '../types.js';
9
+
10
+ /**
11
+ * The record-name queries against a real provider whose database only records what it is sent.
12
+ * The lookup the resolver causes carries the acting user's read row filter, binds the key value,
13
+ * refuses a key that is not the entity's primary key, and never runs without an acting user.
14
+ */
15
+
16
+ const CLIENTS_ID = 'E0000000-0000-0000-0000-0000000000C1';
17
+ const ACCOUNT_MANAGER_ROLE_ID = 'B0000000-0000-0000-0000-0000000000C1';
18
+ const OWN_CLIENTS_FILTER_ID = 'F0000000-0000-0000-0000-0000000000C1';
19
+ const MANAGER_ID = 'C0000000-0000-0000-0000-0000000000C1';
20
+ const CLIENT_ID_VALUE = '33333333-3333-3333-3333-333333333333';
21
+
22
+ const ROW_FILTERS: RowLevelSecurityFilterInfo[] = [
23
+ new RowLevelSecurityFilterInfo({ ID: OWN_CLIENTS_FILTER_ID, Name: 'Own clients', FilterText: "AccountManagerID = '{{UserID}}'" }),
24
+ ];
25
+
26
+ /** `Clients`: account managers read only the clients they manage. */
27
+ function clientsEntity(): EntityInfo {
28
+ return new EntityInfo({
29
+ ID: CLIENTS_ID, Name: 'Clients', SchemaName: 'dbo', BaseTable: 'Client', BaseView: 'vwClients',
30
+ Permissions: [
31
+ { ID: 'perm-manager', EntityID: CLIENTS_ID, RoleID: ACCOUNT_MANAGER_ROLE_ID, CanRead: true, ReadRLSFilterID: OWN_CLIENTS_FILTER_ID, Type: 'Allow' },
32
+ ],
33
+ Fields: [
34
+ { ID: 'cl-id', EntityID: CLIENTS_ID, Sequence: 1, Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true, NeedsQuotes: true },
35
+ { ID: 'cl-name', EntityID: CLIENTS_ID, Sequence: 2, Name: 'Name', Type: 'nvarchar', IsNameField: true, NeedsQuotes: true },
36
+ { ID: 'cl-tier', EntityID: CLIENTS_ID, Sequence: 3, Name: 'Tier', Type: 'int', NeedsQuotes: false },
37
+ { ID: 'cl-manager', EntityID: CLIENTS_ID, Sequence: 4, Name: 'AccountManagerID', Type: 'uniqueidentifier', NeedsQuotes: true },
38
+ ],
39
+ });
40
+ }
41
+
42
+ interface SentQuery {
43
+ SQL: string;
44
+ Parameters: unknown[] | undefined;
45
+ }
46
+
47
+ /** A SQL Server-shaped provider over `Clients`, whose database answers every query with one row. */
48
+ class RecordingProvider extends DatabaseProviderBase {
49
+ public Sent: SentQuery[] = [];
50
+ private readonly entities = [clientsEntity()];
51
+
52
+ public override get Entities(): EntityInfo[] {
53
+ return this.entities;
54
+ }
55
+
56
+ public override get RowLevelSecurityFilters(): RowLevelSecurityFilterInfo[] {
57
+ return ROW_FILTERS;
58
+ }
59
+
60
+ async ExecuteSQL<T>(sql: string, parameters?: unknown[]): Promise<Array<T>> {
61
+ this.Sent.push({ SQL: sql, Parameters: parameters });
62
+ return [{ Name: 'Acme Manufacturing' }] as T[];
63
+ }
64
+
65
+ public override BuildParameterPlaceholder(index: number): string { return `@p${index}`; }
66
+ protected get UUIDFunctionPattern(): RegExp { return /^newid\(\)$/i; }
67
+ protected get DBDefaultFunctionPattern(): RegExp { return /^getdate\(\)$/i; }
68
+ public QuoteIdentifier(name: string): string { return `[${name}]`; }
69
+ public QuoteSchemaAndView(schema: string, obj: string): string { return `[${schema}].[${obj}]`; }
70
+ protected BuildChildDiscoverySQL(): string { return ''; }
71
+ protected BuildHardLinkDependencySQL(): string { return ''; }
72
+ protected BuildSoftLinkDependencySQL(): string { return ''; }
73
+ protected async GenerateSaveSQL(): Promise<SaveSQLResult> { return { fullSQL: '' }; }
74
+ protected GenerateDeleteSQL(): DeleteSQLResult { return { fullSQL: '' }; }
75
+ protected BuildRecordChangeSQL(): { sql: string; parameters?: unknown[] } | null { return null; }
76
+ protected BuildSiblingRecordChangeSQL(): string { return ''; }
77
+ async BeginTransaction(): Promise<void> {}
78
+ async CommitTransaction(): Promise<void> {}
79
+ async RollbackTransaction(): Promise<void> {}
80
+ protected async InternalExecuteQueryFromSpec(_spec: QueryExecutionSpec): Promise<RunQueryResult> {
81
+ throw new Error('Not supported.');
82
+ }
83
+ }
84
+
85
+ const MANAGER = new UserInfo(null, {
86
+ ID: MANAGER_ID, Name: 'Morgan', Email: 'morgan@example.com', IsActive: true,
87
+ UserRoles: [{ UserID: MANAGER_ID, RoleID: ACCOUNT_MANAGER_ROLE_ID, Role: 'Account Managers' }],
88
+ });
89
+
90
+ function key(fieldName: string, value: string): CompositeKey {
91
+ return new CompositeKey([{ FieldName: fieldName, Value: value }]);
92
+ }
93
+
94
+ describe('EntityRecordNameResolver lookups under row-level security', () => {
95
+ let provider: RecordingProvider;
96
+ let savedProvider: IMetadataProvider;
97
+ let context: AppContext;
98
+ const resolver = new EntityRecordNameResolver();
99
+
100
+ beforeEach(() => {
101
+ provider = new RecordingProvider();
102
+ // Row filters resolve through the global provider.
103
+ savedProvider = Metadata.Provider;
104
+ Metadata.Provider = provider;
105
+ const userPayload: UserPayload = { email: MANAGER.Email, userRecord: MANAGER, sessionId: 'session-1' };
106
+ context = { dataSource: undefined, dataSources: [], providers: [{ provider, type: 'Read-Only' }], userPayload };
107
+ });
108
+
109
+ afterEach(() => {
110
+ Metadata.Provider = savedProvider;
111
+ });
112
+
113
+ function managerRowFilter(): string {
114
+ return provider.EntityByName('Clients').GetEffectiveRowFilterWhereClause(MANAGER, EntityPermissionType.Read, '');
115
+ }
116
+
117
+ it("looks the name up under the acting user's read row filter, with the key value bound", async () => {
118
+ const rowFilter = managerRowFilter();
119
+ expect(rowFilter).toContain(MANAGER_ID);
120
+
121
+ const result = await resolver.GetEntityRecordName('Clients', key('ID', CLIENT_ID_VALUE), context);
122
+
123
+ expect(result.Success).toBe(true);
124
+ expect(provider.Sent).toEqual([
125
+ { SQL: `SELECT [Name] FROM [dbo].[vwClients] WHERE [ID]=@p0 AND (${rowFilter})`, Parameters: [CLIENT_ID_VALUE] },
126
+ ]);
127
+ });
128
+
129
+ it('applies the row filter to each item of a batch', async () => {
130
+ const rowFilter = managerRowFilter();
131
+
132
+ const results = await resolver.GetEntityRecordNames([{ EntityName: 'Clients', CompositeKey: key('ID', CLIENT_ID_VALUE) }], context);
133
+
134
+ expect(results.map(r => r.Success)).toEqual([true]);
135
+ expect(provider.Sent.map(q => q.SQL)).toEqual([`SELECT [Name] FROM [dbo].[vwClients] WHERE [ID]=@p0 AND (${rowFilter})`]);
136
+ });
137
+
138
+ it('refuses a key field that is not a primary key, without querying', async () => {
139
+ const result = await resolver.GetEntityRecordName('Clients', key('Tier', '1'), context);
140
+
141
+ expect(result.Success).toBe(false);
142
+ expect(result.RecordName).toBeUndefined();
143
+ expect(provider.Sent).toHaveLength(0);
144
+ });
145
+
146
+ it('looks no name up when there is no acting user', async () => {
147
+ const result = await resolver.InnerGetEntityRecordName(provider, 'Clients', key('ID', CLIENT_ID_VALUE));
148
+
149
+ expect(result.Success).toBe(false);
150
+ expect(provider.Sent).toHaveLength(0);
151
+ });
152
+ });