@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.
- package/README.md +47 -1
- package/dist/auth/APIKeyScopeAuth.d.ts +51 -0
- package/dist/auth/APIKeyScopeAuth.d.ts.map +1 -0
- package/dist/auth/APIKeyScopeAuth.js +163 -0
- package/dist/auth/APIKeyScopeAuth.js.map +1 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/context.d.ts +8 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +44 -7
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +252 -2
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +1754 -209
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +2 -2
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +22 -4
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +29 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +143 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/APIKeyResolver.d.ts +23 -0
- package/dist/resolvers/APIKeyResolver.d.ts.map +1 -0
- package/dist/resolvers/APIKeyResolver.js +191 -0
- package/dist/resolvers/APIKeyResolver.js.map +1 -0
- package/dist/resolvers/RunAIAgentResolver.js +1 -1
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/UserResolver.d.ts.map +1 -1
- package/dist/resolvers/UserResolver.js +31 -1
- package/dist/resolvers/UserResolver.js.map +1 -1
- package/dist/types.d.ts +4 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +46 -45
- package/src/auth/APIKeyScopeAuth.ts +366 -0
- package/src/auth/index.ts +1 -0
- package/src/context.ts +91 -9
- package/src/generated/generated.ts +987 -14
- package/src/generic/ResolverBase.ts +38 -5
- package/src/generic/RunViewResolver.ts +132 -5
- package/src/index.ts +2 -0
- package/src/resolvers/APIKeyResolver.ts +234 -0
- package/src/resolvers/RunAIAgentResolver.ts +1 -1
- package/src/resolvers/UserResolver.ts +37 -1
- 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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
241
|
+
systemApiKey,
|
|
242
|
+
userApiKey,
|
|
243
|
+
requestContext
|
|
162
244
|
);
|
|
163
245
|
|
|
164
246
|
if (Metadata.Provider.Entities.length === 0 ) {
|