@memberjunction/server 6.1.0-edge.2 → 6.1.0-edge.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/LICENSE +180 -4
  2. package/dist/agentSessions/SessionManager.d.ts +19 -5
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +27 -9
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
  7. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
  8. package/dist/auth/AuthProviderCatalogRouter.js +69 -0
  9. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
  10. package/dist/auth/AuthProviderEngine.d.ts +96 -0
  11. package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
  12. package/dist/auth/AuthProviderEngine.js +245 -0
  13. package/dist/auth/AuthProviderEngine.js.map +1 -0
  14. package/dist/auth/index.d.ts +12 -0
  15. package/dist/auth/index.d.ts.map +1 -1
  16. package/dist/auth/index.js +57 -13
  17. package/dist/auth/index.js.map +1 -1
  18. package/dist/auth/initializeProviders.d.ts +64 -1
  19. package/dist/auth/initializeProviders.d.ts.map +1 -1
  20. package/dist/auth/initializeProviders.js +117 -5
  21. package/dist/auth/initializeProviders.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +89 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +52 -35
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts.map +1 -1
  31. package/dist/context.js +21 -1
  32. package/dist/context.js.map +1 -1
  33. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  34. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
  35. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  36. package/dist/generated/generated.d.ts +619 -1597
  37. package/dist/generated/generated.d.ts.map +1 -1
  38. package/dist/generated/generated.js +3341 -17021
  39. package/dist/generated/generated.js.map +1 -1
  40. package/dist/generic/ResolverBase.d.ts +28 -1
  41. package/dist/generic/ResolverBase.d.ts.map +1 -1
  42. package/dist/generic/ResolverBase.js +57 -8
  43. package/dist/generic/ResolverBase.js.map +1 -1
  44. package/dist/generic/RunViewResolver.d.ts +4 -0
  45. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  46. package/dist/generic/RunViewResolver.js +31 -0
  47. package/dist/generic/RunViewResolver.js.map +1 -1
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +147 -14
  51. package/dist/index.js.map +1 -1
  52. package/dist/integration/CustomColumnPromoter.d.ts +79 -15
  53. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  54. package/dist/integration/CustomColumnPromoter.js +472 -97
  55. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  56. package/dist/integration/InactiveRowWarnings.d.ts +48 -0
  57. package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
  58. package/dist/integration/InactiveRowWarnings.js +57 -0
  59. package/dist/integration/InactiveRowWarnings.js.map +1 -0
  60. package/dist/integration/RSUProgressBridge.d.ts +83 -0
  61. package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
  62. package/dist/integration/RSUProgressBridge.js +230 -0
  63. package/dist/integration/RSUProgressBridge.js.map +1 -0
  64. package/dist/integration/SchemaRefreshLaunch.d.ts +62 -0
  65. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
  66. package/dist/integration/SchemaRefreshLaunch.js +67 -0
  67. package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
  68. package/dist/providerConfigUnits.d.ts +20 -0
  69. package/dist/providerConfigUnits.d.ts.map +1 -0
  70. package/dist/providerConfigUnits.js +22 -0
  71. package/dist/providerConfigUnits.js.map +1 -0
  72. package/dist/resolvers/AdhocQueryResolver.d.ts +4 -0
  73. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  74. package/dist/resolvers/AdhocQueryResolver.js +20 -6
  75. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  76. package/dist/resolvers/FileResolver.d.ts +34 -4
  77. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  78. package/dist/resolvers/FileResolver.js +218 -28
  79. package/dist/resolvers/FileResolver.js.map +1 -1
  80. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts +37 -0
  81. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
  82. package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
  83. package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
  84. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +108 -3
  85. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  86. package/dist/resolvers/IntegrationDiscoveryResolver.js +374 -87
  87. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  88. package/dist/resolvers/QueryResolver.d.ts +5 -4
  89. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  90. package/dist/resolvers/QueryResolver.js +29 -14
  91. package/dist/resolvers/QueryResolver.js.map +1 -1
  92. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
  93. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  94. package/dist/resolvers/QuerySystemUserResolver.js +33 -14
  95. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  96. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
  97. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  98. package/dist/resolvers/RunAIAgentResolver.js +20 -7
  99. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  100. package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
  101. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  102. package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
  103. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  104. package/dist/resolvers/UserResolver.d.ts +19 -17
  105. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  106. package/dist/resolvers/UserResolver.js +66 -67
  107. package/dist/resolvers/UserResolver.js.map +1 -1
  108. package/dist/resolvers/currentUserRoles.d.ts +19 -0
  109. package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
  110. package/dist/resolvers/currentUserRoles.js +36 -0
  111. package/dist/resolvers/currentUserRoles.js.map +1 -0
  112. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
  113. package/dist/resolvers/meetingRecordingRegistration.js +3 -6
  114. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
  115. package/dist/rest/MediaAccessKeys.d.ts +17 -0
  116. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  117. package/dist/rest/MediaAccessKeys.js +42 -1
  118. package/dist/rest/MediaAccessKeys.js.map +1 -1
  119. package/dist/rest/MediaStreamHandler.d.ts +3 -2
  120. package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
  121. package/dist/rest/MediaStreamHandler.js +83 -2
  122. package/dist/rest/MediaStreamHandler.js.map +1 -1
  123. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  124. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  125. package/dist/rest/OAuthCallbackHandler.js +129 -43
  126. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  127. package/dist/rest/UploadTokenManager.d.ts +90 -0
  128. package/dist/rest/UploadTokenManager.d.ts.map +1 -0
  129. package/dist/rest/UploadTokenManager.js +156 -0
  130. package/dist/rest/UploadTokenManager.js.map +1 -0
  131. package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
  132. package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
  133. package/dist/services/IntegrationSyncWorkerService.js +132 -0
  134. package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
  135. package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
  136. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  137. package/dist/services/StartTaskGraphDispatcher.js +21 -1
  138. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  139. package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
  140. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  141. package/dist/services/TaskGraphAgentRunner.js +23 -0
  142. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  143. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  144. package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
  145. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  146. package/dist/types.d.ts +14 -0
  147. package/dist/types.d.ts.map +1 -1
  148. package/dist/types.js.map +1 -1
  149. package/package.json +92 -92
  150. package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
  151. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  152. package/src/__tests__/AuthProviderEngine.test.ts +278 -0
  153. package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
  154. package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
  155. package/src/__tests__/InactiveRowWarnings.test.ts +87 -0
  156. package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
  157. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  158. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  159. package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
  160. package/src/__tests__/RSUProgressBridge.test.ts +250 -0
  161. package/src/__tests__/ReactivateRefreshMessage.test.ts +85 -0
  162. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
  163. package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
  164. package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
  165. package/src/__tests__/SessionManager.test.ts +55 -0
  166. package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
  167. package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
  168. package/src/__tests__/config-units.test.ts +74 -0
  169. package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
  170. package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
  171. package/src/__tests__/newUsers.test.ts +726 -0
  172. package/src/__tests__/uploadTokenManager.test.ts +159 -0
  173. package/src/agentSessions/SessionManager.ts +27 -9
  174. package/src/auth/AuthProviderCatalogRouter.ts +76 -0
  175. package/src/auth/AuthProviderEngine.ts +253 -0
  176. package/src/auth/index.ts +60 -15
  177. package/src/auth/initializeProviders.ts +128 -7
  178. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  179. package/src/config.ts +55 -36
  180. package/src/context.ts +26 -2
  181. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
  182. package/src/generated/generated.ts +2347 -10551
  183. package/src/generic/ResolverBase.ts +67 -8
  184. package/src/generic/RunViewResolver.ts +31 -0
  185. package/src/index.ts +165 -16
  186. package/src/integration/CustomColumnPromoter.ts +520 -98
  187. package/src/integration/InactiveRowWarnings.ts +85 -0
  188. package/src/integration/RSUProgressBridge.ts +248 -0
  189. package/src/integration/SchemaRefreshLaunch.ts +93 -0
  190. package/src/providerConfigUnits.ts +21 -0
  191. package/src/resolvers/AdhocQueryResolver.ts +21 -6
  192. package/src/resolvers/FileResolver.ts +200 -40
  193. package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
  194. package/src/resolvers/IntegrationDiscoveryResolver.ts +390 -71
  195. package/src/resolvers/QueryResolver.ts +25 -11
  196. package/src/resolvers/QuerySystemUserResolver.ts +28 -13
  197. package/src/resolvers/RunAIAgentResolver.ts +21 -6
  198. package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
  199. package/src/resolvers/UserResolver.ts +99 -60
  200. package/src/resolvers/currentUserRoles.ts +47 -0
  201. package/src/resolvers/meetingRecordingRegistration.ts +3 -7
  202. package/src/rest/MediaAccessKeys.ts +47 -1
  203. package/src/rest/MediaStreamHandler.ts +98 -3
  204. package/src/rest/OAuthCallbackHandler.ts +149 -41
  205. package/src/rest/UploadTokenManager.ts +211 -0
  206. package/src/services/IntegrationSyncWorkerService.ts +141 -0
  207. package/src/services/StartTaskGraphDispatcher.ts +21 -1
  208. package/src/services/TaskGraphAgentRunner.ts +23 -0
  209. package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
  210. package/src/types.ts +14 -0
@@ -0,0 +1,475 @@
1
+ /**
2
+ * Tests for the API key scope-authorization layer (auth/APIKeyScopeAuth.ts).
3
+ *
4
+ * Covers every decision branch of CheckAPIKeyScope / CheckAPIKeyScopeAndLog /
5
+ * RequireScope: the "not an API key request" bypass, key/application lookup
6
+ * failures, the application-active ceiling, evaluator allow/deny passthrough,
7
+ * throwOnDenied behavior, audit logging, and adversarial inputs (scope-string
8
+ * passthrough, filter-injection surfaces, case/prefix tricks) in the style of
9
+ * multiTenancy.security.test.ts.
10
+ *
11
+ * Mocking is at the package boundary: @memberjunction/core's RunView (the two
12
+ * metadata lookups) and @memberjunction/api-keys' engine (the scope evaluator +
13
+ * usage logger). The production module under test runs unmodified.
14
+ */
15
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
16
+ import type { RunViewParams, UserInfo } from '@memberjunction/core';
17
+ import type { AuthorizationRequest, AuthorizationResult } from '@memberjunction/api-keys';
18
+
19
+ // ─── Hoisted mocks ──────────────────────────────────────────────────────────
20
+ const { mockRunView, mockEvaluateAccess, mockLogSuccess, mockLogDenied } = vi.hoisted(() => ({
21
+ mockRunView: vi.fn(),
22
+ mockEvaluateAccess: vi.fn(),
23
+ mockLogSuccess: vi.fn(),
24
+ mockLogDenied: vi.fn(),
25
+ }));
26
+
27
+ vi.mock('@memberjunction/core', () => ({
28
+ RunView: class MockRunView {
29
+ public async RunView(params: RunViewParams, contextUser?: UserInfo): Promise<unknown> {
30
+ return mockRunView(params, contextUser);
31
+ }
32
+ },
33
+ }));
34
+
35
+ vi.mock('@memberjunction/api-keys', () => ({
36
+ GetAPIKeyEngine: () => ({
37
+ GetScopeEvaluator: () => ({ EvaluateAccess: mockEvaluateAccess }),
38
+ GetUsageLogger: () => ({ LogSuccess: mockLogSuccess, LogDenied: mockLogDenied }),
39
+ }),
40
+ }));
41
+
42
+ vi.mock('@memberjunction/core-entities', () => ({}));
43
+
44
+ vi.mock('type-graphql', () => ({
45
+ AuthorizationError: class AuthorizationError extends Error {
46
+ constructor(message?: string) {
47
+ super(message);
48
+ this.name = 'AuthorizationError';
49
+ }
50
+ },
51
+ }));
52
+
53
+ import { AuthorizationError } from 'type-graphql';
54
+ import {
55
+ CheckAPIKeyScope,
56
+ CheckAPIKeyScopeAndLog,
57
+ RequireScope,
58
+ RequireViewRun,
59
+ RequireAgentExecute,
60
+ } from '../auth/APIKeyScopeAuth.js';
61
+
62
+ // ─── Fixtures ───────────────────────────────────────────────────────────────
63
+
64
+ const KEY_ID = 'key-0001';
65
+ const KEY_USER_ID = 'key-owner-user-42';
66
+ const APP_ID = 'app-mjapi-id';
67
+
68
+ function makeUser(): UserInfo {
69
+ return { ID: 'ctx-user-1', Name: 'Context User', Email: 'ctx@example.com' } as unknown as UserInfo;
70
+ }
71
+
72
+ interface KeyRow { ID: string; UserID: string }
73
+ interface AppRow { ID: string; Name: string; IsActive: boolean }
74
+ interface LookupResult<T> { Success: boolean; Results: T[] }
75
+
76
+ /** Per-test lookup tables the mocked RunView serves, keyed by EntityName. */
77
+ let keyLookup: LookupResult<KeyRow>;
78
+ let appLookup: LookupResult<AppRow>;
79
+ /** Every RunView invocation, captured for filter-content assertions. */
80
+ let capturedRunViewParams: RunViewParams[];
81
+
82
+ function makeAuthResult(overrides: Partial<AuthorizationResult> = {}): AuthorizationResult {
83
+ return {
84
+ Allowed: true,
85
+ Reason: 'Matched allow rule',
86
+ EvaluatedRules: [],
87
+ ...overrides,
88
+ };
89
+ }
90
+
91
+ const USAGE_DETAILS = { endpoint: '/graphql', method: 'POST' };
92
+
93
+ beforeEach(() => {
94
+ vi.clearAllMocks();
95
+ capturedRunViewParams = [];
96
+ keyLookup = { Success: true, Results: [{ ID: KEY_ID, UserID: KEY_USER_ID }] };
97
+ appLookup = { Success: true, Results: [{ ID: APP_ID, Name: 'MJAPI', IsActive: true }] };
98
+
99
+ mockRunView.mockImplementation((params: RunViewParams) => {
100
+ capturedRunViewParams.push(params);
101
+ if (params.EntityName === 'MJ: API Keys') return Promise.resolve(keyLookup);
102
+ if (params.EntityName === 'MJ: API Applications') return Promise.resolve(appLookup);
103
+ return Promise.resolve({ Success: false, Results: [] });
104
+ });
105
+ mockEvaluateAccess.mockResolvedValue(makeAuthResult());
106
+ mockLogSuccess.mockResolvedValue('log-success-1');
107
+ mockLogDenied.mockResolvedValue('log-denied-1');
108
+ });
109
+
110
+ // ─── CheckAPIKeyScope ───────────────────────────────────────────────────────
111
+
112
+ describe('CheckAPIKeyScope', () => {
113
+ describe('no-API-key bypass', () => {
114
+ it('allows without checking when apiKeyId is undefined (JWT-authenticated request)', async () => {
115
+ const result = await CheckAPIKeyScope(undefined, 'view:run', makeUser());
116
+
117
+ expect(result).toEqual({ Allowed: true, Checked: false, Reason: 'Not authenticated via API key' });
118
+ expect(mockRunView).not.toHaveBeenCalled();
119
+ expect(mockEvaluateAccess).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('treats an empty-string apiKeyId as "not an API key request" (falsy check, not presence check)', async () => {
123
+ // Case/prefix trick: '' is falsy so it takes the bypass. An empty ID can
124
+ // never resolve to a key row, so this is equivalent to the JWT path —
125
+ // pinned here so a refactor to `!== undefined` gets a conscious review.
126
+ const result = await CheckAPIKeyScope('', 'view:run', makeUser());
127
+
128
+ expect(result.Checked).toBe(false);
129
+ expect(result.Allowed).toBe(true);
130
+ expect(mockRunView).not.toHaveBeenCalled();
131
+ });
132
+ });
133
+
134
+ describe('API key lookup', () => {
135
+ it('throws AuthorizationError "API key not found" when the lookup query fails', async () => {
136
+ keyLookup = { Success: false, Results: [] };
137
+
138
+ await expect(CheckAPIKeyScope(KEY_ID, 'view:run', makeUser()))
139
+ .rejects.toThrow('API key not found');
140
+ expect(mockEvaluateAccess).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it('throws AuthorizationError when the key ID matches no row', async () => {
144
+ keyLookup = { Success: true, Results: [] };
145
+
146
+ await expect(CheckAPIKeyScope(KEY_ID, 'view:run', makeUser()))
147
+ .rejects.toBeInstanceOf(AuthorizationError);
148
+ });
149
+
150
+ it('returns a denied result instead of throwing when throwOnDenied is false', async () => {
151
+ keyLookup = { Success: true, Results: [] };
152
+
153
+ const result = await CheckAPIKeyScope(KEY_ID, 'view:run', makeUser(), { throwOnDenied: false });
154
+
155
+ expect(result).toEqual({ Allowed: false, Checked: true, Reason: 'API key not found' });
156
+ });
157
+
158
+ it('KNOWN GAP: the apiKeyId is interpolated verbatim into the RunView ExtraFilter', async () => {
159
+ // The apiKeyId originates server-side (context.userPayload.apiKeyId, set only
160
+ // after key validation), so the trust boundary is upstream — but this layer
161
+ // performs no escaping of its own. Pinned so that if the call site ever
162
+ // starts accepting client-supplied IDs, this assertion is the reminder.
163
+ const hostile = "abc' OR '1'='1";
164
+ keyLookup = { Success: true, Results: [] };
165
+
166
+ await CheckAPIKeyScope(hostile, 'view:run', makeUser(), { throwOnDenied: false });
167
+
168
+ expect(capturedRunViewParams[0].EntityName).toBe('MJ: API Keys');
169
+ expect(capturedRunViewParams[0].ExtraFilter).toBe(`ID='abc' OR '1'='1'`);
170
+ });
171
+ });
172
+
173
+ describe('application lookup and active ceiling', () => {
174
+ it('denies with "Unknown application" when the application name matches no row', async () => {
175
+ appLookup = { Success: true, Results: [] };
176
+
177
+ await expect(CheckAPIKeyScope(KEY_ID, 'view:run', makeUser()))
178
+ .rejects.toThrow('Unknown application: MJAPI');
179
+ });
180
+
181
+ it('names the custom application in the unknown-application denial', async () => {
182
+ appLookup = { Success: false, Results: [] };
183
+
184
+ const result = await CheckAPIKeyScope(KEY_ID, 'view:run', makeUser(), {
185
+ applicationName: 'MCPServer',
186
+ throwOnDenied: false,
187
+ });
188
+
189
+ expect(result.Allowed).toBe(false);
190
+ expect(result.Reason).toBe('Unknown application: MCPServer');
191
+ });
192
+
193
+ it('denies when the application is inactive — BEFORE consulting the scope evaluator', async () => {
194
+ appLookup = { Success: true, Results: [{ ID: APP_ID, Name: 'MJAPI', IsActive: false }] };
195
+
196
+ await expect(CheckAPIKeyScope(KEY_ID, 'view:run', makeUser()))
197
+ .rejects.toThrow('Application is not active: MJAPI');
198
+ expect(mockEvaluateAccess).not.toHaveBeenCalled();
199
+ });
200
+
201
+ it('returns the inactive-application denial without throwing when throwOnDenied is false', async () => {
202
+ appLookup = { Success: true, Results: [{ ID: APP_ID, Name: 'MJAPI', IsActive: false }] };
203
+
204
+ const result = await CheckAPIKeyScope(KEY_ID, 'view:run', makeUser(), { throwOnDenied: false });
205
+
206
+ expect(result).toEqual({ Allowed: false, Checked: true, Reason: 'Application is not active: MJAPI' });
207
+ });
208
+
209
+ it('KNOWN GAP: the applicationName option is interpolated verbatim into the application filter', async () => {
210
+ appLookup = { Success: true, Results: [] };
211
+ const hostile = "MJAPI' OR Name<>'";
212
+
213
+ await CheckAPIKeyScope(KEY_ID, 'view:run', makeUser(), {
214
+ applicationName: hostile,
215
+ throwOnDenied: false,
216
+ });
217
+
218
+ expect(capturedRunViewParams[1].EntityName).toBe('MJ: API Applications');
219
+ expect(capturedRunViewParams[1].ExtraFilter).toBe(`Name='MJAPI' OR Name<>''`);
220
+ });
221
+ });
222
+
223
+ describe('scope evaluation', () => {
224
+ it('builds the AuthorizationRequest from the KEY row (UserId comes from the key, not the context user)', async () => {
225
+ await CheckAPIKeyScope(KEY_ID, 'view:run', makeUser(), { resource: 'Users' });
226
+
227
+ expect(mockEvaluateAccess).toHaveBeenCalledTimes(1);
228
+ const [request, ctxUser] = mockEvaluateAccess.mock.calls[0] as [AuthorizationRequest, UserInfo];
229
+ expect(request).toEqual({
230
+ APIKeyId: KEY_ID,
231
+ UserId: KEY_USER_ID, // the key's owner — NOT ctx-user-1
232
+ ApplicationId: APP_ID,
233
+ ScopePath: 'view:run',
234
+ Resource: 'Users',
235
+ });
236
+ expect(ctxUser.ID).toBe('ctx-user-1');
237
+ });
238
+
239
+ it('defaults the resource to "*" when not provided', async () => {
240
+ await CheckAPIKeyScope(KEY_ID, 'agent:execute', makeUser());
241
+
242
+ const [request] = mockEvaluateAccess.mock.calls[0] as [AuthorizationRequest];
243
+ expect(request.Resource).toBe('*');
244
+ });
245
+
246
+ it('passes the scope path through VERBATIM — no case normalization or prefix expansion at this layer', async () => {
247
+ // Case/prefix confusion belongs to the evaluator. This layer must not
248
+ // "helpfully" lowercase or trim, or two spellings would silently map to
249
+ // different audit trails than what the evaluator actually judged.
250
+ await CheckAPIKeyScope(KEY_ID, ' View:RUN ', makeUser());
251
+ await CheckAPIKeyScope(KEY_ID, 'view:runner', makeUser());
252
+
253
+ const first = mockEvaluateAccess.mock.calls[0][0] as AuthorizationRequest;
254
+ const second = mockEvaluateAccess.mock.calls[1][0] as AuthorizationRequest;
255
+ expect(first.ScopePath).toBe(' View:RUN ');
256
+ expect(second.ScopePath).toBe('view:runner');
257
+ });
258
+
259
+ it('returns the evaluator allow decision with Reason and EvaluatedRules passed through', async () => {
260
+ const rules: AuthorizationResult['EvaluatedRules'] = [{
261
+ Level: 'key',
262
+ Rule: {
263
+ Id: 'r1', ScopeId: 's1', ScopePath: 'view:run', Pattern: '*',
264
+ PatternType: 'Include', IsDeny: false, Priority: 1, RowFilterID: null,
265
+ },
266
+ Matched: true,
267
+ PatternMatched: '*',
268
+ Result: 'Allowed',
269
+ }];
270
+ mockEvaluateAccess.mockResolvedValue(makeAuthResult({ Reason: 'rule r1', EvaluatedRules: rules }));
271
+
272
+ const result = await CheckAPIKeyScope(KEY_ID, 'view:run', makeUser());
273
+
274
+ expect(result).toEqual({ Allowed: true, Reason: 'rule r1', Checked: true, EvaluatedRules: rules });
275
+ });
276
+
277
+ it('throws on evaluator deny, naming the bare scope when resource is the default "*"', async () => {
278
+ mockEvaluateAccess.mockResolvedValue(makeAuthResult({ Allowed: false, Reason: 'No matching scope' }));
279
+
280
+ await expect(CheckAPIKeyScope(KEY_ID, 'view:run', makeUser()))
281
+ .rejects.toThrow('API key does not have permission for scope: view:run. No matching scope');
282
+ });
283
+
284
+ it('includes the resource in the denial message when a resource was requested', async () => {
285
+ mockEvaluateAccess.mockResolvedValue(makeAuthResult({ Allowed: false, Reason: 'Denied by exclude rule' }));
286
+
287
+ await expect(CheckAPIKeyScope(KEY_ID, 'view:run', makeUser(), { resource: 'Users' }))
288
+ .rejects.toThrow('API key does not have permission for scope: view:run (Users). Denied by exclude rule');
289
+ });
290
+
291
+ it('does not leak "undefined" into the denial message when the evaluator gives no reason', async () => {
292
+ mockEvaluateAccess.mockResolvedValue(makeAuthResult({ Allowed: false, Reason: undefined }));
293
+
294
+ const failure = await CheckAPIKeyScope(KEY_ID, 'view:run', makeUser()).catch((e: Error) => e);
295
+
296
+ expect(failure).toBeInstanceOf(AuthorizationError);
297
+ expect((failure as Error).message).not.toContain('undefined');
298
+ });
299
+
300
+ it('returns the deny (default-deny preserved) instead of throwing when throwOnDenied is false', async () => {
301
+ mockEvaluateAccess.mockResolvedValue(makeAuthResult({ Allowed: false, Reason: 'default deny' }));
302
+
303
+ const result = await CheckAPIKeyScope(KEY_ID, 'view:run', makeUser(), { throwOnDenied: false });
304
+
305
+ expect(result.Allowed).toBe(false);
306
+ expect(result.Checked).toBe(true);
307
+ expect(result.Reason).toBe('default deny');
308
+ });
309
+ });
310
+ });
311
+
312
+ // ─── CheckAPIKeyScopeAndLog ─────────────────────────────────────────────────
313
+
314
+ describe('CheckAPIKeyScopeAndLog', () => {
315
+ it('allows without checking or logging when apiKeyId is undefined', async () => {
316
+ const result = await CheckAPIKeyScopeAndLog(undefined, 'view:run', makeUser(), USAGE_DETAILS);
317
+
318
+ expect(result.Checked).toBe(false);
319
+ expect(result.Allowed).toBe(true);
320
+ expect(mockLogSuccess).not.toHaveBeenCalled();
321
+ expect(mockLogDenied).not.toHaveBeenCalled();
322
+ });
323
+
324
+ it('KNOWN GAP: denies "API key not found" WITHOUT writing a usage-log row', async () => {
325
+ // A probe with a bogus key ID leaves no trail in the usage log — only the
326
+ // evaluator-reached denials are logged. Pinned as documentation of the
327
+ // audit-coverage boundary.
328
+ keyLookup = { Success: true, Results: [] };
329
+
330
+ const result = await CheckAPIKeyScopeAndLog(KEY_ID, 'view:run', makeUser(), USAGE_DETAILS, { throwOnDenied: false });
331
+
332
+ expect(result).toEqual({ Allowed: false, Checked: true, Reason: 'API key not found' });
333
+ expect(mockLogDenied).not.toHaveBeenCalled();
334
+ });
335
+
336
+ it('denies unknown applications without logging', async () => {
337
+ appLookup = { Success: true, Results: [] };
338
+
339
+ await expect(CheckAPIKeyScopeAndLog(KEY_ID, 'view:run', makeUser(), USAGE_DETAILS))
340
+ .rejects.toThrow('Unknown application: MJAPI');
341
+ expect(mockLogDenied).not.toHaveBeenCalled();
342
+ });
343
+
344
+ it('SECURITY DIVERGENCE: unlike CheckAPIKeyScope, the logging variant does NOT enforce the application-active ceiling', async () => {
345
+ // CheckAPIKeyScope denies when app.IsActive is false; CheckAPIKeyScopeAndLog has
346
+ // no IsActive branch, so a request through a deactivated application still reaches
347
+ // the evaluator and can be ALLOWED. Deactivating an application therefore does not
348
+ // cut off callers routed through this function. Pinned so a fix flips this test.
349
+ appLookup = { Success: true, Results: [{ ID: APP_ID, Name: 'MJAPI', IsActive: false }] };
350
+
351
+ const result = await CheckAPIKeyScopeAndLog(KEY_ID, 'view:run', makeUser(), USAGE_DETAILS);
352
+
353
+ expect(result.Allowed).toBe(true);
354
+ expect(mockEvaluateAccess).toHaveBeenCalledTimes(1);
355
+ });
356
+
357
+ it('logs an allowed request via LogSuccess with default status 200 and returns the LogId', async () => {
358
+ const rules: AuthorizationResult['EvaluatedRules'] = [];
359
+ mockEvaluateAccess.mockResolvedValue(makeAuthResult({ EvaluatedRules: rules }));
360
+
361
+ const result = await CheckAPIKeyScopeAndLog(KEY_ID, 'view:run', makeUser(), {
362
+ endpoint: '/graphql',
363
+ method: 'POST',
364
+ operationName: 'RunViewOp',
365
+ ipAddress: '10.0.0.9',
366
+ userAgent: 'vitest',
367
+ responseTimeMs: 12,
368
+ }, { resource: 'Users' });
369
+
370
+ expect(result.Allowed).toBe(true);
371
+ expect(result.LogId).toBe('log-success-1');
372
+ expect(mockLogDenied).not.toHaveBeenCalled();
373
+ expect(mockLogSuccess).toHaveBeenCalledTimes(1);
374
+ const args = mockLogSuccess.mock.calls[0];
375
+ expect(args[0]).toBe(KEY_ID);
376
+ expect(args[1]).toBe(APP_ID);
377
+ expect(args[2]).toBe('/graphql');
378
+ expect(args[3]).toBe('RunViewOp');
379
+ expect(args[4]).toBe('POST');
380
+ expect(args[5]).toBe(200); // default success status
381
+ expect(args[6]).toBe(12);
382
+ expect(args[7]).toBe('Users');
383
+ expect(args[8]).toBe(rules);
384
+ expect(args[9]).toBe('10.0.0.9');
385
+ expect(args[10]).toBe('vitest');
386
+ });
387
+
388
+ it('honors an explicit statusCode override in the usage details', async () => {
389
+ await CheckAPIKeyScopeAndLog(KEY_ID, 'view:run', makeUser(), { ...USAGE_DETAILS, statusCode: 207 });
390
+
391
+ expect(mockLogSuccess.mock.calls[0][5]).toBe(207);
392
+ });
393
+
394
+ it('normalizes missing optional usage fields to null in the log call', async () => {
395
+ await CheckAPIKeyScopeAndLog(KEY_ID, 'view:run', makeUser(), USAGE_DETAILS);
396
+
397
+ const args = mockLogSuccess.mock.calls[0];
398
+ expect(args[3]).toBeNull(); // operationName
399
+ expect(args[6]).toBeNull(); // responseTimeMs
400
+ expect(args[9]).toBeNull(); // ipAddress
401
+ expect(args[10]).toBeNull(); // userAgent
402
+ });
403
+
404
+ it('logs a denial via LogDenied (status 403, with the reason) BEFORE throwing', async () => {
405
+ mockEvaluateAccess.mockResolvedValue(makeAuthResult({ Allowed: false, Reason: 'exceeds ceiling' }));
406
+
407
+ await expect(CheckAPIKeyScopeAndLog(KEY_ID, 'view:run', makeUser(), USAGE_DETAILS, { resource: 'Users' }))
408
+ .rejects.toThrow('API key does not have permission for scope: view:run (Users). exceeds ceiling');
409
+
410
+ // The audit row must exist even though the caller sees an exception.
411
+ expect(mockLogDenied).toHaveBeenCalledTimes(1);
412
+ expect(mockLogSuccess).not.toHaveBeenCalled();
413
+ const args = mockLogDenied.mock.calls[0];
414
+ expect(args[5]).toBe(403); // default denied status
415
+ expect(args[9]).toBe('exceeds ceiling'); // DeniedReason
416
+ });
417
+
418
+ it('returns the denial with its LogId when throwOnDenied is false', async () => {
419
+ mockEvaluateAccess.mockResolvedValue(makeAuthResult({ Allowed: false, Reason: 'nope' }));
420
+
421
+ const result = await CheckAPIKeyScopeAndLog(KEY_ID, 'view:run', makeUser(), USAGE_DETAILS, { throwOnDenied: false });
422
+
423
+ expect(result.Allowed).toBe(false);
424
+ expect(result.LogId).toBe('log-denied-1');
425
+ });
426
+
427
+ it('leaves LogId undefined when the usage logger returns null', async () => {
428
+ mockLogSuccess.mockResolvedValue(null);
429
+
430
+ const result = await CheckAPIKeyScopeAndLog(KEY_ID, 'view:run', makeUser(), USAGE_DETAILS);
431
+
432
+ expect(result.Allowed).toBe(true);
433
+ expect(result.LogId).toBeUndefined();
434
+ });
435
+ });
436
+
437
+ // ─── RequireScope + prebuilt checkers ───────────────────────────────────────
438
+
439
+ describe('RequireScope', () => {
440
+ function makeCtx(apiKeyId?: string): { userPayload: { apiKeyId?: string; userRecord: UserInfo } } {
441
+ return { userPayload: { apiKeyId, userRecord: makeUser() } };
442
+ }
443
+
444
+ it('resolves without touching the engine for non-API-key contexts', async () => {
445
+ const requireScope = RequireScope('view:run');
446
+
447
+ await expect(requireScope(makeCtx(undefined))).resolves.toBeUndefined();
448
+ expect(mockRunView).not.toHaveBeenCalled();
449
+ });
450
+
451
+ it('forwards the per-call resource to the evaluator', async () => {
452
+ const requireScope = RequireScope('view:run');
453
+
454
+ await requireScope(makeCtx(KEY_ID), 'Accounts');
455
+
456
+ const [request] = mockEvaluateAccess.mock.calls[0] as [AuthorizationRequest];
457
+ expect(request.ScopePath).toBe('view:run');
458
+ expect(request.Resource).toBe('Accounts');
459
+ });
460
+
461
+ it('throws AuthorizationError through the returned checker on deny', async () => {
462
+ mockEvaluateAccess.mockResolvedValue(makeAuthResult({ Allowed: false, Reason: 'denied' }));
463
+ const requireScope = RequireScope('agent:execute');
464
+
465
+ await expect(requireScope(makeCtx(KEY_ID))).rejects.toBeInstanceOf(AuthorizationError);
466
+ });
467
+
468
+ it('prebuilt checkers carry their canonical scope paths', async () => {
469
+ await RequireViewRun(makeCtx(KEY_ID));
470
+ await RequireAgentExecute(makeCtx(KEY_ID));
471
+
472
+ const scopes = mockEvaluateAccess.mock.calls.map(call => (call[0] as AuthorizationRequest).ScopePath);
473
+ expect(scopes).toEqual(['view:run', 'agent:execute']);
474
+ });
475
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `ExecuteAdhocQuery` runs a raw `SELECT` straight on the read-only pool: no `RunView`, no
3
+ * entity permissions, no row-level security, and therefore no magic-link scope. The
4
+ * confinement a scope-limited session relies on is expressed purely as RLS filter tokens
5
+ * substituted on the entity-read path, so on this path it simply does not exist — a
6
+ * scope-limited principal reaching it would read the whole database.
7
+ *
8
+ * These tests pin the predicate that classifies such principals, and that the resolver
9
+ * consults it before it touches a data source. The resolver itself needs a very heavy mock
10
+ * graph to instantiate (type-graphql decorators, mssql, AppContext, config, providers), so
11
+ * the ordering assertion is a source-shape test — the same hybrid strategy the existing
12
+ * `AdhocQueryResolver.bugs.test.ts` uses for this resolver.
13
+ */
14
+ import { describe, it, expect } from 'vitest';
15
+ import { readFileSync } from 'fs';
16
+ import { resolve } from 'path';
17
+ import { UserInfo } from '@memberjunction/core';
18
+ import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
19
+
20
+ const ADHOC_RESOLVER_PATH = resolve(__dirname, '../resolvers/AdhocQueryResolver.ts');
21
+
22
+ /** A principal with nothing set — the shape of an ordinary authenticated user. */
23
+ function plainUser(): UserInfo {
24
+ const u = new UserInfo();
25
+ u.ID = 'D1F1A0C4-0000-4000-8000-000000000001';
26
+ u.Email = 'user@example.com';
27
+ return u;
28
+ }
29
+
30
+ describe('IsScopeLimitedPrincipal', () => {
31
+ it('does not limit an ordinary authenticated user', () => {
32
+ expect(IsScopeLimitedPrincipal(plainUser())).toBe(false);
33
+ });
34
+
35
+ it('limits an anonymous magic-link guest', () => {
36
+ const guest = plainUser();
37
+ guest.IsMagicLinkAnonymous = true;
38
+ expect(IsScopeLimitedPrincipal(guest)).toBe(true);
39
+ });
40
+
41
+ it('limits a resource-scoped magic-link session (ResourceID)', () => {
42
+ const scoped = plainUser();
43
+ scoped.MagicLinkScope = { ResourceID: 'A0000000-0000-4000-8000-000000000009' };
44
+ expect(IsScopeLimitedPrincipal(scoped)).toBe(true);
45
+ });
46
+
47
+ it('limits a resource-scoped magic-link session carrying only a ResourceType', () => {
48
+ const scoped = plainUser();
49
+ scoped.MagicLinkScope = { ResourceType: 'Dashboards' };
50
+ expect(IsScopeLimitedPrincipal(scoped)).toBe(true);
51
+ });
52
+
53
+ it('does NOT limit a session whose scope object is empty', () => {
54
+ // An empty object carries no confinement. Testing the object's truthiness rather
55
+ // than its contents would lock out a session that is not actually scoped.
56
+ const notReallyScoped = plainUser();
57
+ notReallyScoped.MagicLinkScope = {};
58
+ expect(IsScopeLimitedPrincipal(notReallyScoped)).toBe(false);
59
+ });
60
+
61
+ it('fails closed when there is no principal at all', () => {
62
+ expect(IsScopeLimitedPrincipal(undefined)).toBe(true);
63
+ expect(IsScopeLimitedPrincipal(null)).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe('AdhocQueryResolver scope guard placement', () => {
68
+ const src = readFileSync(ADHOC_RESOLVER_PATH, 'utf8');
69
+
70
+ it('consults IsScopeLimitedPrincipal', () => {
71
+ expect(/\bIsScopeLimitedPrincipal\s*\(/.test(src)).toBe(true);
72
+ });
73
+
74
+ it('refuses the principal BEFORE acquiring a data source', () => {
75
+ // Authorization must gate the work, not run alongside it: if the guard landed after
76
+ // the pool were acquired (or the SQL executed), a scope-limited caller would already
77
+ // have reached the database.
78
+ const guardAt = src.indexOf('IsScopeLimitedPrincipal(');
79
+ const dataSourceAt = src.indexOf('GetReadOnlyDataSource(');
80
+ expect(guardAt).toBeGreaterThan(-1);
81
+ expect(dataSourceAt).toBeGreaterThan(-1);
82
+ expect(guardAt).toBeLessThan(dataSourceAt);
83
+ });
84
+ });