@memberjunction/server 5.8.0 → 5.10.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 +1 -0
- package/dist/agents/skip-agent.d.ts.map +1 -1
- package/dist/agents/skip-agent.js +1 -0
- package/dist/agents/skip-agent.js.map +1 -1
- package/dist/agents/skip-sdk.d.ts +6 -0
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +5 -3
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/apolloServer/index.d.ts +10 -2
- package/dist/apolloServer/index.d.ts.map +1 -1
- package/dist/apolloServer/index.js +22 -8
- package/dist/apolloServer/index.js.map +1 -1
- package/dist/config.d.ts +125 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +144 -62
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +210 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +1049 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +32 -0
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -0
- package/dist/generic/CacheInvalidationResolver.js +80 -0
- package/dist/generic/CacheInvalidationResolver.js.map +1 -0
- package/dist/generic/PubSubManager.d.ts +27 -0
- package/dist/generic/PubSubManager.d.ts.map +1 -0
- package/dist/generic/PubSubManager.js +41 -0
- package/dist/generic/PubSubManager.js.map +1 -0
- package/dist/generic/ResolverBase.d.ts +14 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +66 -4
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/hooks.d.ts +65 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +14 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +173 -45
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts +47 -0
- package/dist/multiTenancy/index.d.ts.map +1 -0
- package/dist/multiTenancy/index.js +152 -0
- package/dist/multiTenancy/index.js.map +1 -0
- package/dist/resolvers/CurrentUserContextResolver.d.ts +18 -0
- package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -0
- package/dist/resolvers/CurrentUserContextResolver.js +54 -0
- package/dist/resolvers/CurrentUserContextResolver.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +123 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js +624 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -0
- package/dist/rest/RESTEndpointHandler.d.ts +3 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +14 -33
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/test-dynamic-plugin.d.ts +6 -0
- package/dist/test-dynamic-plugin.d.ts.map +1 -0
- package/dist/test-dynamic-plugin.js +18 -0
- package/dist/test-dynamic-plugin.js.map +1 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +61 -57
- package/src/__tests__/bcsaas-integration.test.ts +455 -0
- package/src/__tests__/middleware-integration.test.ts +877 -0
- package/src/__tests__/mjapi-bootstrap.test.ts +29 -0
- package/src/__tests__/multiTenancy.security.test.ts +334 -0
- package/src/__tests__/multiTenancy.test.ts +225 -0
- package/src/__tests__/unifiedAuth.test.ts +416 -0
- package/src/agents/skip-agent.ts +1 -0
- package/src/agents/skip-sdk.ts +13 -3
- package/src/apolloServer/index.ts +32 -16
- package/src/config.ts +25 -0
- package/src/context.ts +205 -98
- package/src/generated/generated.ts +746 -1
- package/src/generic/CacheInvalidationResolver.ts +66 -0
- package/src/generic/PubSubManager.ts +46 -0
- package/src/generic/ResolverBase.ts +70 -4
- package/src/hooks.ts +77 -0
- package/src/index.ts +199 -49
- package/src/multiTenancy/index.ts +183 -0
- package/src/resolvers/CurrentUserContextResolver.ts +39 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
- package/src/rest/RESTEndpointHandler.ts +23 -42
- package/src/test-dynamic-plugin.ts +36 -0
- package/src/types.ts +10 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the unified auth middleware (createUnifiedAuthMiddleware).
|
|
3
|
+
*
|
|
4
|
+
* Verifies: OPTIONS passthrough, token handling, userPayload attachment,
|
|
5
|
+
* 401 responses with proper error codes, and backward-compat properties.
|
|
6
|
+
*
|
|
7
|
+
* Because `createUnifiedAuthMiddleware` and `getUserPayload` live in the same
|
|
8
|
+
* module (context.ts), vi.mock cannot intercept the intra-module call.
|
|
9
|
+
* Instead we mock the LOWER-LEVEL dependencies that `getUserPayload` calls.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
12
|
+
|
|
13
|
+
// ─── Hoisted mocks (available inside vi.mock factories) ─────────────────────
|
|
14
|
+
const {
|
|
15
|
+
MockTokenExpiredError,
|
|
16
|
+
mockGetReadOnlyDS,
|
|
17
|
+
mockGetReadWriteDS,
|
|
18
|
+
mockJwtDecode,
|
|
19
|
+
mockJwtVerify,
|
|
20
|
+
mockVerifyUserRecord,
|
|
21
|
+
mockExtractUserInfo,
|
|
22
|
+
mockGetValidationOptions,
|
|
23
|
+
mockGetSigningKeys,
|
|
24
|
+
mockGetByIssuer,
|
|
25
|
+
} = vi.hoisted(() => {
|
|
26
|
+
class _MockTokenExpiredError extends Error {
|
|
27
|
+
constructor(public expiryDate: Date) {
|
|
28
|
+
super('Token expired');
|
|
29
|
+
this.name = 'TokenExpiredError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
MockTokenExpiredError: _MockTokenExpiredError,
|
|
35
|
+
mockGetReadOnlyDS: vi.fn(),
|
|
36
|
+
mockGetReadWriteDS: vi.fn(),
|
|
37
|
+
mockJwtDecode: vi.fn(),
|
|
38
|
+
mockJwtVerify: vi.fn(),
|
|
39
|
+
mockVerifyUserRecord: vi.fn(),
|
|
40
|
+
mockExtractUserInfo: vi.fn(),
|
|
41
|
+
mockGetValidationOptions: vi.fn(),
|
|
42
|
+
mockGetSigningKeys: vi.fn(),
|
|
43
|
+
mockGetByIssuer: vi.fn(),
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── Module mocks ───────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
vi.mock('../util.js', () => ({
|
|
50
|
+
GetReadOnlyDataSource: mockGetReadOnlyDS,
|
|
51
|
+
GetReadWriteDataSource: mockGetReadWriteDS,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('jsonwebtoken', () => ({
|
|
55
|
+
default: {
|
|
56
|
+
decode: mockJwtDecode,
|
|
57
|
+
verify: mockJwtVerify,
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.mock('../auth/index.js', () => ({
|
|
62
|
+
getSigningKeys: mockGetSigningKeys,
|
|
63
|
+
getSystemUser: vi.fn(),
|
|
64
|
+
getValidationOptions: mockGetValidationOptions,
|
|
65
|
+
verifyUserRecord: mockVerifyUserRecord,
|
|
66
|
+
extractUserInfoFromPayload: mockExtractUserInfo,
|
|
67
|
+
TokenExpiredError: MockTokenExpiredError,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock('../cache.js', () => {
|
|
71
|
+
const map = new Map<string, boolean>();
|
|
72
|
+
return { authCache: map };
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
vi.mock('../config.js', () => ({
|
|
76
|
+
configInfo: {
|
|
77
|
+
baseUrl: 'http://localhost',
|
|
78
|
+
graphqlPort: 4001,
|
|
79
|
+
graphqlRootPath: '/',
|
|
80
|
+
databaseSettings: { metadataCacheRefreshInterval: 0 },
|
|
81
|
+
},
|
|
82
|
+
userEmailMap: {},
|
|
83
|
+
apiKey: 'test-api-key',
|
|
84
|
+
mj_core_schema: '__mj',
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
vi.mock('../auth/AuthProviderFactory.js', () => ({
|
|
88
|
+
AuthProviderFactory: {
|
|
89
|
+
getInstance: () => ({ getByIssuer: mockGetByIssuer }),
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
vi.mock('@memberjunction/api-keys', () => ({
|
|
94
|
+
GetAPIKeyEngine: vi.fn(),
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
import { createUnifiedAuthMiddleware } from '../context.js';
|
|
98
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
99
|
+
import type { DataSourceInfo } from '../types.js';
|
|
100
|
+
|
|
101
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function makeMockReq(overrides: Partial<Request> = {}): Request {
|
|
104
|
+
return {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {},
|
|
107
|
+
path: '/',
|
|
108
|
+
url: '/',
|
|
109
|
+
ip: '127.0.0.1',
|
|
110
|
+
socket: { remoteAddress: '127.0.0.1' },
|
|
111
|
+
...overrides,
|
|
112
|
+
} as unknown as Request;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function makeMockRes(): Response & { _status: number; _json: Record<string, unknown> | null } {
|
|
116
|
+
const res = {
|
|
117
|
+
_status: 0,
|
|
118
|
+
_json: null as Record<string, unknown> | null,
|
|
119
|
+
status(code: number) {
|
|
120
|
+
res._status = code;
|
|
121
|
+
return res;
|
|
122
|
+
},
|
|
123
|
+
json(data: Record<string, unknown>) {
|
|
124
|
+
res._json = data;
|
|
125
|
+
return res;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
return res as unknown as Response & { _status: number; _json: Record<string, unknown> | null };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const mockDataSources: DataSourceInfo[] = [];
|
|
132
|
+
const MOCK_POOL = {} as unknown as import('mssql').ConnectionPool;
|
|
133
|
+
|
|
134
|
+
const mockUserRecord = {
|
|
135
|
+
ID: 'user-1',
|
|
136
|
+
Name: 'Test User',
|
|
137
|
+
Email: 'test@example.com',
|
|
138
|
+
IsActive: true,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Configure lower-level mocks so getUserPayload succeeds for a bearer token */
|
|
142
|
+
function setupSuccessfulAuth() {
|
|
143
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
144
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
145
|
+
// Decode returns a valid, non-expired JWT payload
|
|
146
|
+
mockJwtDecode.mockReturnValue({
|
|
147
|
+
iss: 'https://test-issuer.com',
|
|
148
|
+
sub: 'user-1',
|
|
149
|
+
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
|
150
|
+
});
|
|
151
|
+
// AuthProviderFactory finds the issuer
|
|
152
|
+
mockGetByIssuer.mockReturnValue({ name: 'test' });
|
|
153
|
+
// Validation options needed by verifyAsync
|
|
154
|
+
mockGetValidationOptions.mockReturnValue({ audience: ['test-audience'] });
|
|
155
|
+
// Signing keys callback
|
|
156
|
+
mockGetSigningKeys.mockReturnValue((_header: unknown, cb: (err: Error | null, key: string) => void) => {
|
|
157
|
+
cb(null, 'mock-key');
|
|
158
|
+
});
|
|
159
|
+
// jwt.verify calls the callback successfully
|
|
160
|
+
mockJwtVerify.mockImplementation(
|
|
161
|
+
(_token: string, _keyFn: unknown, _options: unknown, callback: (err: Error | null, decoded: unknown) => void) => {
|
|
162
|
+
callback(null, { iss: 'https://test-issuer.com', sub: 'user-1' });
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
// User info extraction
|
|
166
|
+
mockExtractUserInfo.mockReturnValue({
|
|
167
|
+
email: 'test@example.com',
|
|
168
|
+
firstName: 'Test',
|
|
169
|
+
lastName: 'User',
|
|
170
|
+
fullName: 'Test User',
|
|
171
|
+
preferredUsername: 'test@example.com',
|
|
172
|
+
});
|
|
173
|
+
// User record verification
|
|
174
|
+
mockVerifyUserRecord.mockResolvedValue(mockUserRecord);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe('createUnifiedAuthMiddleware', () => {
|
|
180
|
+
let middleware: ReturnType<typeof createUnifiedAuthMiddleware>;
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
vi.clearAllMocks();
|
|
184
|
+
middleware = createUnifiedAuthMiddleware(mockDataSources);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('OPTIONS passthrough', () => {
|
|
188
|
+
it('should call next() without auth for OPTIONS requests', async () => {
|
|
189
|
+
const req = makeMockReq({ method: 'OPTIONS' });
|
|
190
|
+
const res = makeMockRes();
|
|
191
|
+
const next = vi.fn();
|
|
192
|
+
|
|
193
|
+
await middleware(req, res, next);
|
|
194
|
+
|
|
195
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
196
|
+
expect(mockJwtDecode).not.toHaveBeenCalled();
|
|
197
|
+
expect(res._status).toBe(0); // no status set
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should not attach userPayload for OPTIONS requests', async () => {
|
|
201
|
+
const req = makeMockReq({ method: 'OPTIONS' });
|
|
202
|
+
const res = makeMockRes();
|
|
203
|
+
const next = vi.fn();
|
|
204
|
+
|
|
205
|
+
await middleware(req, res, next);
|
|
206
|
+
|
|
207
|
+
expect(req.userPayload).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('Successful authentication', () => {
|
|
212
|
+
beforeEach(() => {
|
|
213
|
+
setupSuccessfulAuth();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should attach userPayload to req on successful auth', async () => {
|
|
217
|
+
const req = makeMockReq({
|
|
218
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
219
|
+
});
|
|
220
|
+
const res = makeMockRes();
|
|
221
|
+
const next = vi.fn();
|
|
222
|
+
|
|
223
|
+
await middleware(req, res, next);
|
|
224
|
+
|
|
225
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
226
|
+
expect(req.userPayload).toBeDefined();
|
|
227
|
+
expect(req.userPayload!.email).toBe('test@example.com');
|
|
228
|
+
expect(req.userPayload!.userRecord).toBe(mockUserRecord);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should set req.user for backward compatibility', async () => {
|
|
232
|
+
const req = makeMockReq({
|
|
233
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
234
|
+
});
|
|
235
|
+
const res = makeMockRes();
|
|
236
|
+
const next = vi.fn();
|
|
237
|
+
|
|
238
|
+
await middleware(req, res, next);
|
|
239
|
+
|
|
240
|
+
const reqRecord = req as unknown as Record<string, unknown>;
|
|
241
|
+
expect(reqRecord['user']).toBeDefined();
|
|
242
|
+
expect((reqRecord['user'] as { email: string }).email).toBe('test@example.com');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should set req.mjUser to the userRecord', async () => {
|
|
246
|
+
const req = makeMockReq({
|
|
247
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
248
|
+
});
|
|
249
|
+
const res = makeMockRes();
|
|
250
|
+
const next = vi.fn();
|
|
251
|
+
|
|
252
|
+
await middleware(req, res, next);
|
|
253
|
+
|
|
254
|
+
const reqRecord = req as unknown as Record<string, unknown>;
|
|
255
|
+
expect(reqRecord['mjUser']).toBe(mockUserRecord);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('Authentication failures', () => {
|
|
260
|
+
it('should return 401 when no authorization header is provided', async () => {
|
|
261
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
262
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
263
|
+
// No auth header → empty bearer token → empty token after strip → "Missing token"
|
|
264
|
+
const req = makeMockReq({ headers: {} });
|
|
265
|
+
const res = makeMockRes();
|
|
266
|
+
const next = vi.fn();
|
|
267
|
+
|
|
268
|
+
await middleware(req, res, next);
|
|
269
|
+
|
|
270
|
+
expect(next).not.toHaveBeenCalled();
|
|
271
|
+
expect(res._status).toBe(401);
|
|
272
|
+
expect(res._json).toEqual({ error: 'Authentication failed' });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should return 401 with JWT_EXPIRED code for expired tokens', async () => {
|
|
276
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
277
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
278
|
+
// jwt.decode returns a payload with expired timestamp
|
|
279
|
+
mockJwtDecode.mockReturnValue({
|
|
280
|
+
iss: 'https://test-issuer.com',
|
|
281
|
+
sub: 'user-1',
|
|
282
|
+
exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const req = makeMockReq({
|
|
286
|
+
headers: { authorization: 'Bearer expired-token' },
|
|
287
|
+
});
|
|
288
|
+
const res = makeMockRes();
|
|
289
|
+
const next = vi.fn();
|
|
290
|
+
|
|
291
|
+
await middleware(req, res, next);
|
|
292
|
+
|
|
293
|
+
expect(next).not.toHaveBeenCalled();
|
|
294
|
+
expect(res._status).toBe(401);
|
|
295
|
+
expect(res._json).toEqual({
|
|
296
|
+
errors: [{
|
|
297
|
+
message: 'Token expired',
|
|
298
|
+
extensions: { code: 'JWT_EXPIRED' }
|
|
299
|
+
}]
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should return 401 when jwt.decode returns null (corrupt token)', async () => {
|
|
304
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
305
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
306
|
+
mockJwtDecode.mockReturnValue(null);
|
|
307
|
+
|
|
308
|
+
const req = makeMockReq({
|
|
309
|
+
headers: { authorization: 'Bearer corrupt-token' },
|
|
310
|
+
});
|
|
311
|
+
const res = makeMockRes();
|
|
312
|
+
const next = vi.fn();
|
|
313
|
+
|
|
314
|
+
await middleware(req, res, next);
|
|
315
|
+
|
|
316
|
+
expect(next).not.toHaveBeenCalled();
|
|
317
|
+
expect(res._status).toBe(401);
|
|
318
|
+
expect(res._json).toEqual({ error: 'Authentication failed' });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should return 401 when verifyUserRecord returns null', async () => {
|
|
322
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
323
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
324
|
+
mockJwtDecode.mockReturnValue({
|
|
325
|
+
iss: 'https://test-issuer.com',
|
|
326
|
+
sub: 'user-1',
|
|
327
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
328
|
+
});
|
|
329
|
+
mockGetByIssuer.mockReturnValue({ name: 'test' });
|
|
330
|
+
mockGetValidationOptions.mockReturnValue({ audience: ['test-audience'] });
|
|
331
|
+
mockGetSigningKeys.mockReturnValue(
|
|
332
|
+
(_h: unknown, cb: (err: Error | null, key: string) => void) => cb(null, 'key')
|
|
333
|
+
);
|
|
334
|
+
mockJwtVerify.mockImplementation(
|
|
335
|
+
(_t: string, _k: unknown, _o: unknown, cb: (err: Error | null, d: unknown) => void) => {
|
|
336
|
+
cb(null, { iss: 'https://test-issuer.com' });
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
mockExtractUserInfo.mockReturnValue({ email: 'unknown@example.com' });
|
|
340
|
+
mockVerifyUserRecord.mockResolvedValue(null); // user not found
|
|
341
|
+
|
|
342
|
+
const req = makeMockReq({
|
|
343
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
344
|
+
});
|
|
345
|
+
const res = makeMockRes();
|
|
346
|
+
const next = vi.fn();
|
|
347
|
+
|
|
348
|
+
await middleware(req, res, next);
|
|
349
|
+
|
|
350
|
+
expect(next).not.toHaveBeenCalled();
|
|
351
|
+
expect(res._status).toBe(401);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should not call next() on auth failure', async () => {
|
|
355
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
356
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
357
|
+
mockJwtDecode.mockReturnValue(null); // invalid token
|
|
358
|
+
|
|
359
|
+
const req = makeMockReq({
|
|
360
|
+
headers: { authorization: 'Bearer bad-token' },
|
|
361
|
+
});
|
|
362
|
+
const res = makeMockRes();
|
|
363
|
+
const next = vi.fn();
|
|
364
|
+
|
|
365
|
+
await middleware(req, res, next);
|
|
366
|
+
|
|
367
|
+
expect(next).not.toHaveBeenCalled();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe('HTTP method handling', () => {
|
|
372
|
+
beforeEach(() => {
|
|
373
|
+
setupSuccessfulAuth();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should authenticate GET requests', async () => {
|
|
377
|
+
const req = makeMockReq({ method: 'GET', headers: { authorization: 'Bearer tok' } });
|
|
378
|
+
const res = makeMockRes();
|
|
379
|
+
const next = vi.fn();
|
|
380
|
+
|
|
381
|
+
await middleware(req, res, next);
|
|
382
|
+
|
|
383
|
+
expect(next).toHaveBeenCalled();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should authenticate POST requests', async () => {
|
|
387
|
+
const req = makeMockReq({ method: 'POST', headers: { authorization: 'Bearer tok' } });
|
|
388
|
+
const res = makeMockRes();
|
|
389
|
+
const next = vi.fn();
|
|
390
|
+
|
|
391
|
+
await middleware(req, res, next);
|
|
392
|
+
|
|
393
|
+
expect(next).toHaveBeenCalled();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should authenticate PUT requests', async () => {
|
|
397
|
+
const req = makeMockReq({ method: 'PUT', headers: { authorization: 'Bearer tok' } });
|
|
398
|
+
const res = makeMockRes();
|
|
399
|
+
const next = vi.fn();
|
|
400
|
+
|
|
401
|
+
await middleware(req, res, next);
|
|
402
|
+
|
|
403
|
+
expect(next).toHaveBeenCalled();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('should authenticate DELETE requests', async () => {
|
|
407
|
+
const req = makeMockReq({ method: 'DELETE', headers: { authorization: 'Bearer tok' } });
|
|
408
|
+
const res = makeMockRes();
|
|
409
|
+
const next = vi.fn();
|
|
410
|
+
|
|
411
|
+
await middleware(req, res, next);
|
|
412
|
+
|
|
413
|
+
expect(next).toHaveBeenCalled();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
});
|
package/src/agents/skip-agent.ts
CHANGED
|
@@ -152,6 +152,7 @@ export class SkipProxyAgent extends BaseAgent {
|
|
|
152
152
|
includeRequests: false,
|
|
153
153
|
forceEntityRefresh: context.forceEntityRefresh || false,
|
|
154
154
|
includeCallbackAuth: true,
|
|
155
|
+
externalReferenceID: this.AgentRun?.ID ?? undefined,
|
|
155
156
|
onStatusUpdate: (message: string, responsePhase?: string) => {
|
|
156
157
|
// Forward Skip status updates to MJ progress callback
|
|
157
158
|
if (params.onProgress) {
|
package/src/agents/skip-sdk.ts
CHANGED
|
@@ -141,6 +141,13 @@ export interface SkipCallOptions {
|
|
|
141
141
|
* the client should pass that payload back in the next request.
|
|
142
142
|
*/
|
|
143
143
|
payload?: Record<string, any>;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Optional reference ID from the calling system. When the MJ API proxies a request
|
|
147
|
+
* to Skip via SkipProxyAgent, this contains the MJ-side Agent Run ID for cross-system
|
|
148
|
+
* correlation and debugging.
|
|
149
|
+
*/
|
|
150
|
+
externalReferenceID?: string;
|
|
144
151
|
}
|
|
145
152
|
|
|
146
153
|
/**
|
|
@@ -320,7 +327,8 @@ export class SkipSDK {
|
|
|
320
327
|
includeRequests = false,
|
|
321
328
|
forceEntityRefresh = false,
|
|
322
329
|
includeCallbackAuth = true,
|
|
323
|
-
payload
|
|
330
|
+
payload,
|
|
331
|
+
externalReferenceID
|
|
324
332
|
} = options;
|
|
325
333
|
|
|
326
334
|
// Build base request with metadata
|
|
@@ -360,7 +368,8 @@ export class SkipSDK {
|
|
|
360
368
|
apiKeys: baseRequest.apiKeys,
|
|
361
369
|
callingServerURL: baseRequest.callingServerURL,
|
|
362
370
|
callingServerAPIKey: baseRequest.callingServerAPIKey,
|
|
363
|
-
callingServerAccessToken: baseRequest.callingServerAccessToken
|
|
371
|
+
callingServerAccessToken: baseRequest.callingServerAccessToken,
|
|
372
|
+
externalReferenceID
|
|
364
373
|
};
|
|
365
374
|
|
|
366
375
|
return request;
|
|
@@ -462,6 +471,7 @@ export class SkipSDK {
|
|
|
462
471
|
EmbeddingVector: q.EmbeddingVector,
|
|
463
472
|
EmbeddingModelID: q.EmbeddingModelID,
|
|
464
473
|
EmbeddingModelName: q.EmbeddingModel,
|
|
474
|
+
TechnicalDescription: q.TechnicalDescription,
|
|
465
475
|
Fields: q.Fields.map((f) => ({
|
|
466
476
|
ID: f.ID,
|
|
467
477
|
QueryID: f.QueryID,
|
|
@@ -497,7 +507,7 @@ export class SkipSDK {
|
|
|
497
507
|
})),
|
|
498
508
|
CacheEnabled: q.CacheEnabled,
|
|
499
509
|
CacheMaxSize: q.CacheMaxSize,
|
|
500
|
-
CacheTTLMinutes: q.
|
|
510
|
+
CacheTTLMinutes: q.CacheTTLMinutes,
|
|
501
511
|
CacheValidationSQL: q.CacheValidationSQL
|
|
502
512
|
}));
|
|
503
513
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApolloServer, ApolloServerOptions } from '@apollo/server';
|
|
1
|
+
import { ApolloServer, ApolloServerOptions, ApolloServerPlugin } from '@apollo/server';
|
|
2
2
|
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
|
|
3
3
|
import { Disposable } from 'graphql-ws';
|
|
4
4
|
import { Server } from 'http';
|
|
@@ -7,27 +7,43 @@ import { AppContext } from '../types.js';
|
|
|
7
7
|
import { Metadata } from '@memberjunction/core';
|
|
8
8
|
import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Creates and configures an Apollo Server instance with built-in plugins
|
|
12
|
+
* for HTTP drain and WebSocket cleanup.
|
|
13
|
+
*
|
|
14
|
+
* @param configOverride - Apollo Server options (typically contains the schema)
|
|
15
|
+
* @param servers - HTTP server and WebSocket cleanup disposable
|
|
16
|
+
* @param additionalPlugins - Optional additional plugins to merge with built-in plugins
|
|
17
|
+
*/
|
|
10
18
|
const buildApolloServer = (
|
|
11
19
|
configOverride: ApolloServerOptions<AppContext>,
|
|
12
|
-
{ httpServer, serverCleanup }: { httpServer: Server; serverCleanup: Disposable }
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
{ httpServer, serverCleanup }: { httpServer: Server; serverCleanup: Disposable },
|
|
21
|
+
additionalPlugins?: ApolloServerPlugin[]
|
|
22
|
+
) => {
|
|
23
|
+
const builtInPlugins: ApolloServerPlugin[] = [
|
|
24
|
+
ApolloServerPluginDrainHttpServer({ httpServer }),
|
|
25
|
+
{
|
|
26
|
+
async serverWillStart() {
|
|
27
|
+
return {
|
|
28
|
+
async drainServer() {
|
|
29
|
+
await serverCleanup.dispose();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const allPlugins = additionalPlugins
|
|
37
|
+
? [...builtInPlugins, ...additionalPlugins]
|
|
38
|
+
: builtInPlugins;
|
|
39
|
+
|
|
40
|
+
return new ApolloServer({
|
|
15
41
|
csrfPrevention: true,
|
|
16
42
|
cache: 'bounded',
|
|
17
|
-
plugins:
|
|
18
|
-
ApolloServerPluginDrainHttpServer({ httpServer }),
|
|
19
|
-
{
|
|
20
|
-
async serverWillStart() {
|
|
21
|
-
return {
|
|
22
|
-
async drainServer() {
|
|
23
|
-
await serverCleanup.dispose();
|
|
24
|
-
},
|
|
25
|
-
};
|
|
26
|
-
},
|
|
27
|
-
}
|
|
28
|
-
],
|
|
43
|
+
plugins: allPlugins,
|
|
29
44
|
introspection: enableIntrospection,
|
|
30
45
|
...configOverride,
|
|
31
46
|
});
|
|
47
|
+
};
|
|
32
48
|
|
|
33
49
|
export default buildApolloServer;
|
package/src/config.ts
CHANGED
|
@@ -147,6 +147,29 @@ const queryDialectSchema = z.object({
|
|
|
147
147
|
targetPlatforms: z.array(z.string()).optional().default([]),
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
const multiTenancySchema = z.object({
|
|
151
|
+
/** Master switch — when false (default), no tenant isolation is applied */
|
|
152
|
+
enabled: zodBooleanWithTransforms().default(false),
|
|
153
|
+
/** How the tenant ID is determined for each request */
|
|
154
|
+
contextSource: z.enum(['header', 'linkedEntity', 'custom']).default('header'),
|
|
155
|
+
/** HTTP header name used when contextSource is 'header' */
|
|
156
|
+
tenantHeader: z.string().default('X-Tenant-ID'),
|
|
157
|
+
/** Whether scopedEntities is an allowlist or denylist of entities to filter */
|
|
158
|
+
scopingStrategy: z.enum(['allowlist', 'denylist']).default('denylist'),
|
|
159
|
+
/** Entities included/excluded from tenant filtering based on scopingStrategy */
|
|
160
|
+
scopedEntities: z.array(z.string()).default([]),
|
|
161
|
+
/** When true, entities in the __mj core schema are never tenant-filtered */
|
|
162
|
+
autoExcludeCoreEntities: zodBooleanWithTransforms().default(true),
|
|
163
|
+
/** Default column name containing the tenant identifier */
|
|
164
|
+
defaultTenantColumn: z.string().default('OrganizationID'),
|
|
165
|
+
/** Per-entity overrides for the tenant column name: { "EntityName": "ColumnName" } */
|
|
166
|
+
entityColumnMappings: z.record(z.string()).default({}),
|
|
167
|
+
/** Roles that bypass tenant filtering entirely */
|
|
168
|
+
adminRoles: z.array(z.string()).default(['Admin', 'System']),
|
|
169
|
+
/** Write protection mode: 'strict' rejects, 'log' warns, 'off' skips validation */
|
|
170
|
+
writeProtection: z.enum(['strict', 'log', 'off']).default('strict'),
|
|
171
|
+
});
|
|
172
|
+
|
|
150
173
|
const telemetrySchema = z.object({
|
|
151
174
|
enabled: zodBooleanWithTransforms().default(
|
|
152
175
|
process.env.MJ_TELEMETRY_ENABLED !== 'false' // Enabled by default unless explicitly disabled
|
|
@@ -166,6 +189,7 @@ const configInfoSchema = z.object({
|
|
|
166
189
|
scheduledJobs: scheduledJobsSchema.optional().default({}),
|
|
167
190
|
telemetry: telemetrySchema.optional().default({}),
|
|
168
191
|
queryDialects: queryDialectSchema.optional().default({}),
|
|
192
|
+
multiTenancy: multiTenancySchema.optional().default({}),
|
|
169
193
|
|
|
170
194
|
apiKey: z.string().optional(),
|
|
171
195
|
baseUrl: z.string().default('http://localhost'),
|
|
@@ -210,6 +234,7 @@ export type ComponentRegistryConfig = z.infer<typeof componentRegistrySchema>;
|
|
|
210
234
|
export type ScheduledJobsConfig = z.infer<typeof scheduledJobsSchema>;
|
|
211
235
|
export type TelemetryConfig = z.infer<typeof telemetrySchema>;
|
|
212
236
|
export type QueryDialectConfig = z.infer<typeof queryDialectSchema>;
|
|
237
|
+
export type MultiTenancyConfig = z.infer<typeof multiTenancySchema>;
|
|
213
238
|
export type ConfigInfo = z.infer<typeof configInfoSchema>;
|
|
214
239
|
|
|
215
240
|
/**
|