@memberjunction/server 3.2.0 → 3.3.0

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 (53) hide show
  1. package/README.md +47 -1
  2. package/dist/auth/APIKeyScopeAuth.d.ts +51 -0
  3. package/dist/auth/APIKeyScopeAuth.d.ts.map +1 -0
  4. package/dist/auth/APIKeyScopeAuth.js +163 -0
  5. package/dist/auth/APIKeyScopeAuth.js.map +1 -0
  6. package/dist/auth/index.d.ts +1 -0
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +1 -0
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/context.d.ts +8 -1
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +44 -7
  13. package/dist/context.js.map +1 -1
  14. package/dist/generated/generated.d.ts +252 -2
  15. package/dist/generated/generated.d.ts.map +1 -1
  16. package/dist/generated/generated.js +1754 -209
  17. package/dist/generated/generated.js.map +1 -1
  18. package/dist/generic/ResolverBase.d.ts +2 -2
  19. package/dist/generic/ResolverBase.d.ts.map +1 -1
  20. package/dist/generic/ResolverBase.js +22 -4
  21. package/dist/generic/ResolverBase.js.map +1 -1
  22. package/dist/generic/RunViewResolver.d.ts +29 -1
  23. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  24. package/dist/generic/RunViewResolver.js +143 -0
  25. package/dist/generic/RunViewResolver.js.map +1 -1
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/resolvers/APIKeyResolver.d.ts +23 -0
  31. package/dist/resolvers/APIKeyResolver.d.ts.map +1 -0
  32. package/dist/resolvers/APIKeyResolver.js +191 -0
  33. package/dist/resolvers/APIKeyResolver.js.map +1 -0
  34. package/dist/resolvers/RunAIAgentResolver.js +1 -1
  35. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  36. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  37. package/dist/resolvers/UserResolver.js +31 -1
  38. package/dist/resolvers/UserResolver.js.map +1 -1
  39. package/dist/types.d.ts +4 -1
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/package.json +46 -45
  43. package/src/auth/APIKeyScopeAuth.ts +366 -0
  44. package/src/auth/index.ts +1 -0
  45. package/src/context.ts +91 -9
  46. package/src/generated/generated.ts +987 -14
  47. package/src/generic/ResolverBase.ts +38 -5
  48. package/src/generic/RunViewResolver.ts +132 -5
  49. package/src/index.ts +2 -0
  50. package/src/resolvers/APIKeyResolver.ts +234 -0
  51. package/src/resolvers/RunAIAgentResolver.ts +1 -1
  52. package/src/resolvers/UserResolver.ts +37 -1
  53. package/src/types.ts +7 -2
@@ -0,0 +1,366 @@
1
+ /**
2
+ * API Key Scope Authorization Utilities
3
+ * Provides utilities for checking API key scopes in resolvers
4
+ * @module @memberjunction/server
5
+ */
6
+
7
+ import { AuthorizationError } from 'type-graphql';
8
+ import { GetAPIKeyEngine, AuthorizationResult, AuthorizationRequest } from '@memberjunction/api-keys';
9
+ import { UserInfo, RunView } from '@memberjunction/core';
10
+ import { APIKeyEntity, APIApplicationEntity } from '@memberjunction/core-entities';
11
+
12
+ /**
13
+ * Application names used by the API Key authorization system
14
+ */
15
+ export type ApplicationName = 'MJAPI' | 'MCPServer' | 'A2AServer' | string;
16
+
17
+ /**
18
+ * Options for scope authorization
19
+ */
20
+ export interface ScopeAuthOptions {
21
+ /** The application making the request (default: 'MJAPI') */
22
+ applicationName?: ApplicationName;
23
+ /** Resource being accessed (e.g., entity name, action name) */
24
+ resource?: string;
25
+ /** Whether to throw an error on denied access (default: true) */
26
+ throwOnDenied?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Result of scope authorization check
31
+ */
32
+ export interface ScopeAuthResult {
33
+ /** Whether access is allowed */
34
+ Allowed: boolean;
35
+ /** Human-readable reason for the decision */
36
+ Reason?: string;
37
+ /** Whether scope checking was performed (false if no API key or enforcement disabled) */
38
+ Checked: boolean;
39
+ /** All rules evaluated during the check */
40
+ EvaluatedRules?: AuthorizationResult['EvaluatedRules'];
41
+ }
42
+
43
+ /**
44
+ * Check if an API key has the required scope for an operation.
45
+ *
46
+ * This function implements the three-tier permission model:
47
+ * 1. User Permissions - What the user can do (already checked by authentication)
48
+ * 2. Application Ceiling - Maximum scope the application allows
49
+ * 3. API Key Scopes - Specific scopes granted to this key
50
+ *
51
+ * @param apiKeyId - The API key ID from context.userPayload.apiKeyId
52
+ * @param scopePath - The scope path required (e.g., 'view:run', 'agent:execute')
53
+ * @param contextUser - The authenticated user from context.userPayload.userRecord
54
+ * @param options - Additional options for scope checking
55
+ * @returns ScopeAuthResult with authorization details
56
+ * @throws AuthorizationError if access is denied and throwOnDenied is true
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * // In a resolver
61
+ * async runView(@Ctx() ctx: AppContext): Promise<ViewResult> {
62
+ * await CheckAPIKeyScope(
63
+ * ctx.userPayload.apiKeyId,
64
+ * 'view:run',
65
+ * ctx.userPayload.userRecord,
66
+ * { resource: 'User' }
67
+ * );
68
+ * // ... proceed with operation
69
+ * }
70
+ * ```
71
+ */
72
+ export async function CheckAPIKeyScope(
73
+ apiKeyId: string | undefined,
74
+ scopePath: string,
75
+ contextUser: UserInfo,
76
+ options: ScopeAuthOptions = {}
77
+ ): Promise<ScopeAuthResult> {
78
+ const {
79
+ applicationName = 'MJAPI',
80
+ resource = '*',
81
+ throwOnDenied = true
82
+ } = options;
83
+
84
+ // If no API key ID, not authenticated via API key - skip scope check
85
+ if (!apiKeyId) {
86
+ return {
87
+ Allowed: true,
88
+ Checked: false,
89
+ Reason: 'Not authenticated via API key'
90
+ };
91
+ }
92
+
93
+ const engine = GetAPIKeyEngine();
94
+
95
+ // Get the API key to find the user ID
96
+ const rv = new RunView();
97
+ const keyResult = await rv.RunView<APIKeyEntity>({
98
+ EntityName: 'MJ: API Keys',
99
+ ExtraFilter: `ID='${apiKeyId}'`,
100
+ ResultType: 'entity_object'
101
+ }, contextUser);
102
+
103
+ if (!keyResult.Success || keyResult.Results.length === 0) {
104
+ const result: ScopeAuthResult = {
105
+ Allowed: false,
106
+ Checked: true,
107
+ Reason: 'API key not found'
108
+ };
109
+ if (throwOnDenied) {
110
+ throw new AuthorizationError(result.Reason);
111
+ }
112
+ return result;
113
+ }
114
+
115
+ const apiKey = keyResult.Results[0];
116
+
117
+ // Get the application by name
118
+ const appResult = await rv.RunView<APIApplicationEntity>({
119
+ EntityName: 'MJ: API Applications',
120
+ ExtraFilter: `Name='${applicationName}'`,
121
+ ResultType: 'entity_object'
122
+ }, contextUser);
123
+
124
+ if (!appResult.Success || appResult.Results.length === 0) {
125
+ const result: ScopeAuthResult = {
126
+ Allowed: false,
127
+ Checked: true,
128
+ Reason: `Unknown application: ${applicationName}`
129
+ };
130
+ if (throwOnDenied) {
131
+ throw new AuthorizationError(result.Reason);
132
+ }
133
+ return result;
134
+ }
135
+
136
+ const app = appResult.Results[0];
137
+
138
+ if (!app.IsActive) {
139
+ const result: ScopeAuthResult = {
140
+ Allowed: false,
141
+ Checked: true,
142
+ Reason: `Application is not active: ${applicationName}`
143
+ };
144
+ if (throwOnDenied) {
145
+ throw new AuthorizationError(result.Reason);
146
+ }
147
+ return result;
148
+ }
149
+
150
+ // Build the authorization request
151
+ const request: AuthorizationRequest = {
152
+ APIKeyId: apiKeyId,
153
+ UserId: apiKey.UserID,
154
+ ApplicationId: app.ID,
155
+ ScopePath: scopePath,
156
+ Resource: resource
157
+ };
158
+
159
+ // Use the scope evaluator directly (since we already have the key ID)
160
+ const scopeEvaluator = engine.GetScopeEvaluator();
161
+ const authResult = await scopeEvaluator.EvaluateAccess(request, contextUser);
162
+
163
+ if (!authResult.Allowed && throwOnDenied) {
164
+ const scopeDisplay = resource !== '*' ? `${scopePath} (${resource})` : scopePath;
165
+ throw new AuthorizationError(
166
+ `API key does not have permission for scope: ${scopeDisplay}. ${authResult.Reason || ''}`
167
+ );
168
+ }
169
+
170
+ return {
171
+ Allowed: authResult.Allowed,
172
+ Reason: authResult.Reason,
173
+ Checked: true,
174
+ EvaluatedRules: authResult.EvaluatedRules
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Check if an API key has the required scope and log usage.
180
+ *
181
+ * Same as CheckAPIKeyScope but also logs the authorization attempt.
182
+ * Use this for operations where you want detailed audit trails.
183
+ *
184
+ * @param apiKeyId - The API key ID from context.userPayload.apiKeyId
185
+ * @param scopePath - The scope path required
186
+ * @param contextUser - The authenticated user
187
+ * @param usageDetails - Details about the request for logging
188
+ * @param options - Additional options for scope checking
189
+ * @returns ScopeAuthResult with authorization details and optional log ID
190
+ */
191
+ export async function CheckAPIKeyScopeAndLog(
192
+ apiKeyId: string | undefined,
193
+ scopePath: string,
194
+ contextUser: UserInfo,
195
+ usageDetails: {
196
+ endpoint: string;
197
+ method: string;
198
+ operationName?: string;
199
+ ipAddress?: string;
200
+ userAgent?: string;
201
+ statusCode?: number;
202
+ responseTimeMs?: number;
203
+ },
204
+ options: ScopeAuthOptions = {}
205
+ ): Promise<ScopeAuthResult & { LogId?: string }> {
206
+ const {
207
+ applicationName = 'MJAPI',
208
+ resource = '*',
209
+ throwOnDenied = true
210
+ } = options;
211
+
212
+ // If no API key ID, not authenticated via API key - skip scope check
213
+ if (!apiKeyId) {
214
+ return {
215
+ Allowed: true,
216
+ Checked: false,
217
+ Reason: 'Not authenticated via API key'
218
+ };
219
+ }
220
+
221
+ const engine = GetAPIKeyEngine();
222
+ const rv = new RunView();
223
+
224
+ // Get the API key
225
+ const keyResult = await rv.RunView<APIKeyEntity>({
226
+ EntityName: 'MJ: API Keys',
227
+ ExtraFilter: `ID='${apiKeyId}'`,
228
+ ResultType: 'entity_object'
229
+ }, contextUser);
230
+
231
+ if (!keyResult.Success || keyResult.Results.length === 0) {
232
+ const result: ScopeAuthResult & { LogId?: string } = {
233
+ Allowed: false,
234
+ Checked: true,
235
+ Reason: 'API key not found'
236
+ };
237
+ if (throwOnDenied) {
238
+ throw new AuthorizationError(result.Reason);
239
+ }
240
+ return result;
241
+ }
242
+
243
+ const apiKey = keyResult.Results[0];
244
+
245
+ // Get the application
246
+ const appResult = await rv.RunView<APIApplicationEntity>({
247
+ EntityName: 'MJ: API Applications',
248
+ ExtraFilter: `Name='${applicationName}'`,
249
+ ResultType: 'entity_object'
250
+ }, contextUser);
251
+
252
+ if (!appResult.Success || appResult.Results.length === 0) {
253
+ const result: ScopeAuthResult & { LogId?: string } = {
254
+ Allowed: false,
255
+ Checked: true,
256
+ Reason: `Unknown application: ${applicationName}`
257
+ };
258
+ if (throwOnDenied) {
259
+ throw new AuthorizationError(result.Reason);
260
+ }
261
+ return result;
262
+ }
263
+
264
+ const app = appResult.Results[0];
265
+
266
+ // Build the authorization request
267
+ const request: AuthorizationRequest = {
268
+ APIKeyId: apiKeyId,
269
+ UserId: apiKey.UserID,
270
+ ApplicationId: app.ID,
271
+ ScopePath: scopePath,
272
+ Resource: resource
273
+ };
274
+
275
+ // Evaluate access
276
+ const scopeEvaluator = engine.GetScopeEvaluator();
277
+ const authResult = await scopeEvaluator.EvaluateAccess(request, contextUser);
278
+
279
+ // Log the usage
280
+ const usageLogger = engine.GetUsageLogger();
281
+ const statusCode = usageDetails.statusCode ?? (authResult.Allowed ? 200 : 403);
282
+
283
+ let logId: string | undefined;
284
+ if (authResult.Allowed) {
285
+ logId = (await usageLogger.LogSuccess(
286
+ apiKeyId,
287
+ app.ID,
288
+ usageDetails.endpoint,
289
+ usageDetails.operationName || null,
290
+ usageDetails.method,
291
+ statusCode,
292
+ usageDetails.responseTimeMs || null,
293
+ resource,
294
+ authResult.EvaluatedRules,
295
+ usageDetails.ipAddress || null,
296
+ usageDetails.userAgent || null,
297
+ contextUser
298
+ )) || undefined;
299
+ } else {
300
+ logId = (await usageLogger.LogDenied(
301
+ apiKeyId,
302
+ app.ID,
303
+ usageDetails.endpoint,
304
+ usageDetails.operationName || null,
305
+ usageDetails.method,
306
+ statusCode,
307
+ usageDetails.responseTimeMs || null,
308
+ resource,
309
+ authResult.EvaluatedRules,
310
+ authResult.Reason,
311
+ usageDetails.ipAddress || null,
312
+ usageDetails.userAgent || null,
313
+ contextUser
314
+ )) || undefined;
315
+ }
316
+
317
+ if (!authResult.Allowed && throwOnDenied) {
318
+ const scopeDisplay = resource !== '*' ? `${scopePath} (${resource})` : scopePath;
319
+ throw new AuthorizationError(
320
+ `API key does not have permission for scope: ${scopeDisplay}. ${authResult.Reason || ''}`
321
+ );
322
+ }
323
+
324
+ return {
325
+ Allowed: authResult.Allowed,
326
+ Reason: authResult.Reason,
327
+ Checked: true,
328
+ EvaluatedRules: authResult.EvaluatedRules,
329
+ LogId: logId
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Decorator-style function for common scope checks.
335
+ * Returns a function that can be used in resolvers.
336
+ *
337
+ * @param scopePath - The scope path required
338
+ * @param options - Additional options
339
+ * @returns A function that performs the scope check
340
+ *
341
+ * @example
342
+ * ```typescript
343
+ * const requireViewRun = RequireScope('view:run');
344
+ *
345
+ * // In resolver
346
+ * async runView(@Ctx() ctx: AppContext): Promise<ViewResult> {
347
+ * await requireViewRun(ctx);
348
+ * // ... proceed
349
+ * }
350
+ * ```
351
+ */
352
+ export function RequireScope(scopePath: string, options: Omit<ScopeAuthOptions, 'resource'> = {}) {
353
+ return async (ctx: { userPayload: { apiKeyId?: string; userRecord: UserInfo } }, resource?: string) => {
354
+ await CheckAPIKeyScope(
355
+ ctx.userPayload.apiKeyId,
356
+ scopePath,
357
+ ctx.userPayload.userRecord,
358
+ { ...options, resource }
359
+ );
360
+ };
361
+ }
362
+
363
+ // Pre-built scope checkers for common operations
364
+ export const RequireViewRun = RequireScope('view:run');
365
+ export const RequireQueryRun = RequireScope('query:run');
366
+ export const RequireAgentExecute = RequireScope('agent:execute');
package/src/auth/index.ts CHANGED
@@ -12,6 +12,7 @@ import { initializeAuthProviders } from './initializeProviders.js';
12
12
  export { TokenExpiredError } from './tokenExpiredError.js';
13
13
  export { IAuthProvider } from './IAuthProvider.js';
14
14
  export { AuthProviderFactory } from './AuthProviderFactory.js';
15
+ export * from './APIKeyScopeAuth.js';
15
16
 
16
17
  // This is a hard-coded forever constant due to internal migrations
17
18
 
package/src/context.ts CHANGED
@@ -13,9 +13,10 @@ import { GetReadOnlyDataSource, GetReadWriteDataSource } from './util.js';
13
13
  import { v4 as uuidv4 } from 'uuid';
14
14
  import e from 'express';
15
15
  import { DatabaseProviderBase } from '@memberjunction/core';
16
- import { SQLServerDataProvider, SQLServerProviderConfigData } from '@memberjunction/sqlserver-dataprovider';
16
+ import { SQLServerDataProvider, SQLServerProviderConfigData, UserCache } from '@memberjunction/sqlserver-dataprovider';
17
17
  import { AuthProviderFactory } from './auth/AuthProviderFactory.js';
18
18
  import { Metadata } from '@memberjunction/core';
19
+ import { GetAPIKeyEngine } from '@memberjunction/api-keys';
19
20
 
20
21
  const verifyAsync = async (issuer: string, token: string): Promise<jwt.JwtPayload> =>
21
22
  new Promise((resolve, reject) => {
@@ -41,20 +42,83 @@ const verifyAsync = async (issuer: string, token: string): Promise<jwt.JwtPayloa
41
42
  });
42
43
  });
43
44
 
45
+ /**
46
+ * Request context for API key usage logging.
47
+ */
48
+ export interface RequestContext {
49
+ /** The API endpoint path (e.g., '/graphql', '/mcp') */
50
+ endpoint: string;
51
+ /** HTTP method (e.g., 'POST', 'GET') */
52
+ method: string;
53
+ /** GraphQL operation name if available */
54
+ operationName?: string;
55
+ /** Client IP address */
56
+ ipAddress?: string;
57
+ /** User-Agent header */
58
+ userAgent?: string;
59
+ }
60
+
44
61
  export const getUserPayload = async (
45
62
  bearerToken: string,
46
63
  sessionId = 'default',
47
64
  dataSources: DataSourceInfo[],
48
65
  requestDomain?: string,
49
- requestApiKey?: string
66
+ systemApiKey?: string,
67
+ userApiKey?: string,
68
+ requestContext?: RequestContext
50
69
  ): Promise<UserPayload> => {
51
70
  try {
52
71
  const readOnlyDataSource = GetReadOnlyDataSource(dataSources, { allowFallbackToReadWrite: true });
53
72
  const readWriteDataSource = GetReadWriteDataSource(dataSources);
54
73
 
55
- if (requestApiKey && requestApiKey != String(undefined)) {
56
- // use requestApiKey for auth
57
- if (requestApiKey === apiKey) {
74
+ // Check for user API key first (X-API-Key header with mj_sk_* format)
75
+ // This authenticates as the specific user who owns the API key
76
+ if (userApiKey && userApiKey !== String(undefined)) {
77
+ // Use system user as context for validation operations
78
+ const systemUser = await getSystemUser(readOnlyDataSource);
79
+ const apiKeyEngine = GetAPIKeyEngine();
80
+ const validationResult = await apiKeyEngine.ValidateAPIKey(
81
+ {
82
+ RawKey: userApiKey,
83
+ ApplicationName: 'MJAPI', // Check if key is bound to this application
84
+ Endpoint: requestContext?.endpoint ?? '/api',
85
+ Method: requestContext?.method ?? 'POST',
86
+ Operation: requestContext?.operationName ?? null,
87
+ StatusCode: 200, // Auth succeeded if we get here
88
+ ResponseTimeMs: undefined, // Not available at auth time
89
+ IPAddress: requestContext?.ipAddress ?? null,
90
+ UserAgent: requestContext?.userAgent ?? null,
91
+ },
92
+ systemUser
93
+ );
94
+
95
+ if (validationResult.IsValid && validationResult.User) {
96
+ // Get the user from UserCache to ensure UserRoles is properly populated
97
+ // The validationResult.User from APIKeyEngine doesn't include UserRoles
98
+ const cachedUser = UserCache.Instance.Users.find(
99
+ u => u.ID === validationResult.User.ID
100
+ );
101
+
102
+ // Use cached user if available, otherwise fall back to the validation result
103
+ const userRecord = cachedUser || validationResult.User;
104
+
105
+ return {
106
+ userRecord,
107
+ email: userRecord.Email,
108
+ sessionId,
109
+ apiKeyId: validationResult.APIKeyId,
110
+ apiKeyHash: validationResult.APIKeyHash,
111
+ };
112
+ }
113
+
114
+ // MJ API key validation failed - use generic message to prevent enumeration
115
+ throw new AuthenticationError('Invalid API key');
116
+ }
117
+
118
+ // Check for system API key (x-mj-api-key header)
119
+ // This authenticates as the system user for system-level operations
120
+ if (systemApiKey && systemApiKey != String(undefined)) {
121
+ if (systemApiKey === apiKey) {
58
122
  const systemUser = await getSystemUser(readOnlyDataSource);
59
123
  return {
60
124
  userRecord: systemUser,
@@ -64,7 +128,7 @@ export const getUserPayload = async (
64
128
  apiKey,
65
129
  };
66
130
  }
67
- throw new AuthenticationError('Invalid API key provided');
131
+ throw new AuthenticationError('Invalid system API key');
68
132
  }
69
133
 
70
134
  const token = bearerToken.replace('Bearer ', '');
@@ -144,8 +208,13 @@ export const contextFunction =
144
208
  const requestDomain = url.parse(req.headers.origin || '');
145
209
  const sessionId = sessionIdRaw ? sessionIdRaw.toString() : '';
146
210
  const bearerToken = req.headers.authorization ?? '';
147
- const apiKey = String(req.headers['x-mj-api-key']);
148
-
211
+
212
+ // Two types of API keys:
213
+ // - x-mj-api-key: System API key for system-level operations (authenticates as system user)
214
+ // - X-API-Key: User API key (mj_sk_*) for user-authenticated operations
215
+ const systemApiKey = String(req.headers['x-mj-api-key']);
216
+ const userApiKey = String(req.headers['x-api-key']);
217
+
149
218
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
150
219
  const reqAny = req as any;
151
220
  const operationName: string | undefined = reqAny.body?.operationName;
@@ -153,12 +222,25 @@ export const contextFunction =
153
222
  console.log({ operationName, variables: reqAny.body?.variables || undefined });
154
223
  }
155
224
 
225
+ // Build request context for API key logging
226
+ // Note: responseTimeMs is not available at auth time, only endpoint/method/ip/ua
227
+ const expressReq = req as e.Request;
228
+ const requestContext: RequestContext = {
229
+ endpoint: expressReq.path || expressReq.url || '/api',
230
+ method: expressReq.method || 'POST',
231
+ operationName: operationName,
232
+ ipAddress: expressReq.ip || expressReq.socket?.remoteAddress || undefined,
233
+ userAgent: req.headers['user-agent'] as string | undefined,
234
+ };
235
+
156
236
  const userPayload = await getUserPayload(
157
237
  bearerToken,
158
238
  sessionId,
159
239
  dataSources,
160
240
  requestDomain?.hostname ? requestDomain.hostname : undefined,
161
- apiKey
241
+ systemApiKey,
242
+ userApiKey,
243
+ requestContext
162
244
  );
163
245
 
164
246
  if (Metadata.Provider.Entities.length === 0 ) {