@memberjunction/server 5.51.0 → 6.1.0-edge.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/LICENSE +7 -0
- package/dist/agentSessions/SessionManager.d.ts +22 -1
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +13 -3
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/auth/actingContextResolver.d.ts +44 -0
- package/dist/auth/actingContextResolver.d.ts.map +1 -0
- package/dist/auth/actingContextResolver.js +18 -0
- package/dist/auth/actingContextResolver.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/auth/sessionUserClone.d.ts +32 -0
- package/dist/auth/sessionUserClone.d.ts.map +1 -0
- package/dist/auth/sessionUserClone.js +50 -0
- package/dist/auth/sessionUserClone.js.map +1 -0
- package/dist/context.d.ts +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +43 -6
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +71 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +383 -3
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
- package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.js +5 -4
- package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
- package/dist/generic/PushStatusResolver.d.ts +58 -0
- package/dist/generic/PushStatusResolver.d.ts.map +1 -1
- package/dist/generic/PushStatusResolver.js +42 -3
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +9 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +31 -13
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts +19 -0
- package/dist/multiTenancy/index.d.ts.map +1 -1
- package/dist/multiTenancy/index.js +82 -5
- package/dist/multiTenancy/index.js.map +1 -1
- package/dist/resolvers/ActionResolver.d.ts.map +1 -1
- package/dist/resolvers/ActionResolver.js +12 -5
- package/dist/resolvers/ActionResolver.js.map +1 -1
- package/dist/resolvers/MCPResolver.d.ts.map +1 -1
- package/dist/resolvers/MCPResolver.js +13 -16
- package/dist/resolvers/MCPResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +15 -14
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +19 -26
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/ReportResolver.d.ts.map +1 -1
- package/dist/resolvers/ReportResolver.js +11 -3
- package/dist/resolvers/ReportResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +30 -40
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTestResolver.js +90 -112
- package/dist/resolvers/RunTestResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +3 -3
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.js +2 -2
- package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
- package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
- package/dist/resolvers/VersionHistoryResolver.js +8 -12
- package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
- package/dist/services/TaskOrchestrator.d.ts.map +1 -1
- package/dist/services/TaskOrchestrator.js +17 -15
- package/dist/services/TaskOrchestrator.js.map +1 -1
- package/package.json +100 -100
- package/src/__tests__/RealtimeBridgeResolver.test.ts +6 -0
- package/src/__tests__/SessionManager.test.ts +44 -0
- package/src/__tests__/multiTenancy.security.test.ts +254 -22
- package/src/__tests__/multiTenancy.test.ts +22 -0
- package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
- package/src/__tests__/resolverBase.rls.test.ts +4 -0
- package/src/__tests__/search-knowledge-tags.test.ts +74 -6
- package/src/__tests__/sessionUserClone.test.ts +161 -0
- package/src/agentSessions/SessionManager.ts +26 -2
- package/src/auth/actingContextResolver.ts +56 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/sessionUserClone.ts +50 -0
- package/src/context.ts +48 -6
- package/src/generated/generated.ts +276 -3
- package/src/generic/FireAndForgetHeartbeat.ts +10 -4
- package/src/generic/PushStatusResolver.ts +79 -4
- package/src/generic/ResolverBase.ts +33 -13
- package/src/index.ts +4 -2
- package/src/multiTenancy/index.ts +92 -5
- package/src/resolvers/ActionResolver.ts +15 -7
- package/src/resolvers/MCPResolver.ts +13 -15
- package/src/resolvers/RealtimeClientSessionResolver.ts +15 -13
- package/src/resolvers/RemoteBrowserActionResolver.ts +19 -25
- package/src/resolvers/ReportResolver.ts +11 -3
- package/src/resolvers/RunAIAgentResolver.ts +30 -40
- package/src/resolvers/RunTestResolver.ts +90 -112
- package/src/resolvers/SearchKnowledgeResolver.ts +3 -3
- package/src/resolvers/SearchKnowledgeStreamResolver.ts +2 -2
- package/src/resolvers/VersionHistoryResolver.ts +10 -13
- package/src/resolvers/__tests__/ReportResolver.test.ts +279 -0
- package/src/services/TaskOrchestrator.ts +17 -17
|
@@ -6,9 +6,16 @@
|
|
|
6
6
|
* in multiTenancy.test.ts.
|
|
7
7
|
*/
|
|
8
8
|
import { describe, it, expect, vi } from 'vitest';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createTenantPreRunViewHook,
|
|
11
|
+
createTenantPreSaveHook,
|
|
12
|
+
createTenantMiddleware,
|
|
13
|
+
IsValidTenantId,
|
|
14
|
+
attachTenantContext,
|
|
15
|
+
} from '../multiTenancy/index.js';
|
|
10
16
|
import type { MultiTenancyConfig } from '../config.js';
|
|
11
|
-
import
|
|
17
|
+
import { Metadata } from '@memberjunction/core';
|
|
18
|
+
import type { RunViewParams, UserInfo, TenantContext } from '@memberjunction/core';
|
|
12
19
|
|
|
13
20
|
// Mock @memberjunction/core Metadata for entity schema lookup.
|
|
14
21
|
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
@@ -20,6 +27,31 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
|
20
27
|
{ Name: 'Orders', SchemaName: 'dbo' },
|
|
21
28
|
{ Name: 'Users', SchemaName: '__mj' },
|
|
22
29
|
];
|
|
30
|
+
|
|
31
|
+
// The hook validates that the tenant column resolves to a real, stored
|
|
32
|
+
// (non-virtual) field before interpolating it as an identifier.
|
|
33
|
+
EntityByName(name: string) {
|
|
34
|
+
const entity = this.Entities.find(
|
|
35
|
+
e => e.Name.trim().toLowerCase() === name.trim().toLowerCase()
|
|
36
|
+
);
|
|
37
|
+
if (!entity) return undefined;
|
|
38
|
+
return {
|
|
39
|
+
...entity,
|
|
40
|
+
Fields: [
|
|
41
|
+
{ Name: 'OrganizationID', IsVirtual: false },
|
|
42
|
+
{ Name: 'TenantID', IsVirtual: false },
|
|
43
|
+
// A view-only column: must be REJECTED as a tenant column target
|
|
44
|
+
{ Name: 'OrganizationName', IsVirtual: true },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// QuoteFilterIdentifier reads the STATIC Metadata.Provider, matching a real
|
|
50
|
+
// server (Metadata.Provider is a DatabaseProviderBase instance). Tests that need
|
|
51
|
+
// to exercise the no-provider-available fail-closed path override this locally.
|
|
52
|
+
static Provider: { QuoteIdentifier: (name: string) => string } | undefined = {
|
|
53
|
+
QuoteIdentifier: (name: string) => `[${name}]`,
|
|
54
|
+
};
|
|
23
55
|
}
|
|
24
56
|
|
|
25
57
|
return {
|
|
@@ -58,41 +90,164 @@ function makeUser(tenantId?: string, roles: string[] = []): MockUser {
|
|
|
58
90
|
|
|
59
91
|
describe('Multi-Tenancy Security Edge Cases', () => {
|
|
60
92
|
describe('SQL injection in tenant ID', () => {
|
|
61
|
-
it('should
|
|
93
|
+
it('should escape quotes so an injection attempt stays INSIDE the string literal', () => {
|
|
62
94
|
const hook = createTenantPreRunViewHook(makeConfig());
|
|
63
95
|
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
64
96
|
const user = makeUser("' OR 1=1 --");
|
|
65
97
|
|
|
66
98
|
const result = hook(params, user) as RunViewParams;
|
|
67
99
|
|
|
68
|
-
// The
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
expect(result.ExtraFilter).toBe("[OrganizationID] = '' OR 1=1 --'");
|
|
100
|
+
// The single quote is doubled at predicate construction, so the payload
|
|
101
|
+
// cannot terminate the literal and rewrite the WHERE clause. (The header
|
|
102
|
+
// boundary rejects this value outright via IsValidTenantId; this is the
|
|
103
|
+
// defense-in-depth layer for TenantContext attached from other sources.)
|
|
104
|
+
expect(result.ExtraFilter).toBe("[OrganizationID] = ''' OR 1=1 --'");
|
|
73
105
|
});
|
|
74
106
|
|
|
75
|
-
it('should
|
|
107
|
+
it('should escape quotes in a UNION attack payload', () => {
|
|
76
108
|
const hook = createTenantPreRunViewHook(makeConfig());
|
|
77
109
|
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
78
110
|
const user = makeUser("' UNION SELECT * FROM Users --");
|
|
79
111
|
|
|
80
112
|
const result = hook(params, user) as RunViewParams;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// Defense-in-depth: the tenant ID should be validated upstream before
|
|
84
|
-
// it reaches TenantContext. Middle-layer middleware (e.g., BCSaaS)
|
|
85
|
-
// should validate that the tenant ID is a valid UUID or org ID.
|
|
113
|
+
// Leading quote doubled → the UNION text is inert literal content
|
|
114
|
+
expect(result.ExtraFilter).toBe("[OrganizationID] = ''' UNION SELECT * FROM Users --'");
|
|
86
115
|
});
|
|
87
116
|
|
|
88
|
-
it('should
|
|
117
|
+
it('should escape quotes in a stacked-statement payload', () => {
|
|
89
118
|
const hook = createTenantPreRunViewHook(makeConfig());
|
|
90
119
|
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
91
120
|
const user = makeUser("abc'; DROP TABLE Customers; --");
|
|
92
121
|
|
|
93
122
|
const result = hook(params, user) as RunViewParams;
|
|
94
|
-
expect(result.ExtraFilter).
|
|
95
|
-
|
|
123
|
+
expect(result.ExtraFilter).toBe("[OrganizationID] = 'abc''; DROP TABLE Customers; --'");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ─── Boundary validation: IsValidTenantId ───────────────────────────────
|
|
128
|
+
|
|
129
|
+
describe('IsValidTenantId', () => {
|
|
130
|
+
it('rejects a quote-based SQL injection payload', () => {
|
|
131
|
+
expect(IsValidTenantId("x' OR '1'='1")).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('rejects the empty string', () => {
|
|
135
|
+
expect(IsValidTenantId('')).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects a 129-character string (max is 128)', () => {
|
|
139
|
+
expect(IsValidTenantId('a'.repeat(129))).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('accepts a 128-character string (boundary)', () => {
|
|
143
|
+
expect(IsValidTenantId('a'.repeat(128))).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('rejects whitespace, semicolons, and comparison operators', () => {
|
|
147
|
+
expect(IsValidTenantId('tenant 1')).toBe(false);
|
|
148
|
+
expect(IsValidTenantId('a;b')).toBe(false);
|
|
149
|
+
expect(IsValidTenantId("1'='1")).toBe(false);
|
|
150
|
+
expect(IsValidTenantId('a=b')).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('accepts a standard GUID', () => {
|
|
154
|
+
expect(IsValidTenantId('550e8400-e29b-41d4-a716-446655440000')).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('accepts conservative identifiers with underscore, dot, and hyphen', () => {
|
|
158
|
+
expect(IsValidTenantId('tenant_1.a-b')).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('KNOWN GAP: accepts consecutive hyphens (SQL line-comment token) because GUIDs need hyphens', () => {
|
|
162
|
+
// 'a--b' passes the character allowlist. This is safe today only because
|
|
163
|
+
// every consumer interpolates the value inside a quote-escaped string
|
|
164
|
+
// literal, where '--' is inert. If a future consumer places the tenant id
|
|
165
|
+
// outside a quoted literal, this becomes a comment-injection vector —
|
|
166
|
+
// flagged here so a tightening of the pattern flips this assertion.
|
|
167
|
+
expect(IsValidTenantId('a--b')).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ─── attachTenantContext validation ─────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe('attachTenantContext', () => {
|
|
174
|
+
function makePlainUser(): UserInfo {
|
|
175
|
+
return { ID: 'user-1' } as unknown as UserInfo;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it('throws on an invalid tenant id instead of attaching it', () => {
|
|
179
|
+
const user = makePlainUser();
|
|
180
|
+
expect(() => attachTenantContext(user, "x' OR '1'='1", 'header')).toThrow(/Invalid tenant identifier/);
|
|
181
|
+
expect(user.TenantContext).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('throws on empty and oversized tenant ids', () => {
|
|
185
|
+
const user = makePlainUser();
|
|
186
|
+
expect(() => attachTenantContext(user, '', 'custom')).toThrow(/Invalid tenant identifier/);
|
|
187
|
+
expect(() => attachTenantContext(user, 'a'.repeat(129), 'custom')).toThrow(/Invalid tenant identifier/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('attaches a valid tenant id with the given source', () => {
|
|
191
|
+
const user = makePlainUser();
|
|
192
|
+
attachTenantContext(user, 'tenant_1.a-b', 'linkedEntity');
|
|
193
|
+
expect(user.TenantContext).toEqual<TenantContext>({ TenantID: 'tenant_1.a-b', Source: 'linkedEntity' });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ─── Tenant column resolution (identifier position) ─────────────────────
|
|
198
|
+
|
|
199
|
+
describe('tenant column must resolve to a stored field', () => {
|
|
200
|
+
it('produces an escaped-value predicate with a quoted identifier for a valid setup', () => {
|
|
201
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
202
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
203
|
+
const user = makeUser("t'1"); // legal only via non-header sources; must still be escaped
|
|
204
|
+
|
|
205
|
+
const result = hook(params, user) as RunViewParams;
|
|
206
|
+
expect(result.ExtraFilter).toBe("[OrganizationID] = 't''1'");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('throws when the configured tenant column does not exist on the entity', () => {
|
|
210
|
+
const hook = createTenantPreRunViewHook(makeConfig({ defaultTenantColumn: 'NoSuchColumn' }));
|
|
211
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
212
|
+
const user = makeUser('tenant-1');
|
|
213
|
+
|
|
214
|
+
expect(() => hook(params, user)).toThrow(/does not resolve to a stored field/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('throws when the tenant column resolves only to a VIRTUAL field', () => {
|
|
218
|
+
const hook = createTenantPreRunViewHook(makeConfig({ defaultTenantColumn: 'OrganizationName' }));
|
|
219
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
220
|
+
const user = makeUser('tenant-1');
|
|
221
|
+
|
|
222
|
+
expect(() => hook(params, user)).toThrow(/does not resolve to a stored field/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('throws when an entityColumnMappings override points at a nonexistent column', () => {
|
|
226
|
+
const hook = createTenantPreRunViewHook(makeConfig({
|
|
227
|
+
entityColumnMappings: { 'Customers': "Bad]Column" },
|
|
228
|
+
}));
|
|
229
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
230
|
+
const user = makeUser('tenant-1');
|
|
231
|
+
|
|
232
|
+
expect(() => hook(params, user)).toThrow(/does not resolve to a stored field/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('throws (fails closed) rather than falling back to hardcoded bracket quoting when no provider is available', () => {
|
|
236
|
+
const savedProvider = Metadata.Provider;
|
|
237
|
+
// @ts-expect-error — simulating the no-provider-available case
|
|
238
|
+
Metadata.Provider = undefined;
|
|
239
|
+
try {
|
|
240
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
241
|
+
const params = { EntityName: 'Customers', ExtraFilter: '' } as RunViewParams;
|
|
242
|
+
const user = makeUser('tenant-1');
|
|
243
|
+
|
|
244
|
+
// A silent bracket-quoting fallback would produce invalid SQL on PostgreSQL
|
|
245
|
+
// without ever surfacing an error — refusing to build the query at all is the
|
|
246
|
+
// correct fail-closed behavior for a security-relevant identifier quote.
|
|
247
|
+
expect(() => hook(params, user)).toThrow(/no database provider available to quote/);
|
|
248
|
+
} finally {
|
|
249
|
+
Metadata.Provider = savedProvider;
|
|
250
|
+
}
|
|
96
251
|
});
|
|
97
252
|
});
|
|
98
253
|
|
|
@@ -232,12 +387,13 @@ describe('Multi-Tenancy Security Edge Cases', () => {
|
|
|
232
387
|
expect(next).toHaveBeenCalledTimes(1);
|
|
233
388
|
});
|
|
234
389
|
|
|
235
|
-
it('should attach TenantContext when userPayload and header are present', () => {
|
|
390
|
+
it('should attach TenantContext to a CLONED userRecord when userPayload and header are present', () => {
|
|
236
391
|
const middleware = createTenantMiddleware(makeConfig());
|
|
237
392
|
const userRecord = { ID: 'u1' } as Record<string, unknown>;
|
|
393
|
+
const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
|
|
238
394
|
const req = {
|
|
239
395
|
headers: { 'x-tenant-id': 'tenant-abc' },
|
|
240
|
-
userPayload
|
|
396
|
+
userPayload,
|
|
241
397
|
} as unknown as Parameters<typeof middleware>[0];
|
|
242
398
|
const res = {} as Parameters<typeof middleware>[1];
|
|
243
399
|
const next = vi.fn();
|
|
@@ -245,26 +401,56 @@ describe('Multi-Tenancy Security Edge Cases', () => {
|
|
|
245
401
|
middleware(req, res, next);
|
|
246
402
|
|
|
247
403
|
expect(next).toHaveBeenCalledTimes(1);
|
|
248
|
-
|
|
404
|
+
// Clone-before-stamp: the SHARED cached record must never be mutated —
|
|
405
|
+
// stamping it in place leaks one session's tenant to concurrent sessions.
|
|
406
|
+
expect(userRecord['TenantContext']).toBeUndefined();
|
|
407
|
+
const stamped = userPayload.userRecord as unknown as UserInfo;
|
|
408
|
+
expect(stamped).not.toBe(userRecord);
|
|
409
|
+
expect(stamped.ID).toBe('u1');
|
|
410
|
+
expect(stamped.TenantContext).toEqual({
|
|
249
411
|
TenantID: 'tenant-abc',
|
|
250
412
|
Source: 'header',
|
|
251
413
|
});
|
|
252
414
|
});
|
|
253
415
|
|
|
416
|
+
it('should reject an invalid tenant header with 400 and never attach or continue', () => {
|
|
417
|
+
const middleware = createTenantMiddleware(makeConfig());
|
|
418
|
+
const userRecord = { ID: 'u1' } as Record<string, unknown>;
|
|
419
|
+
const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
|
|
420
|
+
const req = {
|
|
421
|
+
headers: { 'x-tenant-id': "x' OR '1'='1" },
|
|
422
|
+
userPayload,
|
|
423
|
+
} as unknown as Parameters<typeof middleware>[0];
|
|
424
|
+
const json = vi.fn();
|
|
425
|
+
const status = vi.fn().mockReturnValue({ json });
|
|
426
|
+
const res = { status } as unknown as Parameters<typeof middleware>[1];
|
|
427
|
+
const next = vi.fn();
|
|
428
|
+
|
|
429
|
+
middleware(req, res, next);
|
|
430
|
+
|
|
431
|
+
// Reject the REQUEST — never degrade to an unscoped (fail-open) session
|
|
432
|
+
expect(status).toHaveBeenCalledWith(400);
|
|
433
|
+
expect(json).toHaveBeenCalledWith({ error: 'Invalid tenant identifier' });
|
|
434
|
+
expect(next).not.toHaveBeenCalled();
|
|
435
|
+
expect(userPayload.userRecord).toBe(userRecord);
|
|
436
|
+
expect(userRecord['TenantContext']).toBeUndefined();
|
|
437
|
+
});
|
|
438
|
+
|
|
254
439
|
it('should handle case-insensitive header name', () => {
|
|
255
440
|
const middleware = createTenantMiddleware(makeConfig({ tenantHeader: 'X-Tenant-ID' }));
|
|
256
441
|
const userRecord = { ID: 'u1' } as Record<string, unknown>;
|
|
442
|
+
const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
|
|
257
443
|
const req = {
|
|
258
444
|
// Express normalizes headers to lowercase
|
|
259
445
|
headers: { 'x-tenant-id': 'tenant-xyz' },
|
|
260
|
-
userPayload
|
|
446
|
+
userPayload,
|
|
261
447
|
} as unknown as Parameters<typeof middleware>[0];
|
|
262
448
|
const res = {} as Parameters<typeof middleware>[1];
|
|
263
449
|
const next = vi.fn();
|
|
264
450
|
|
|
265
451
|
middleware(req, res, next);
|
|
266
452
|
|
|
267
|
-
expect(userRecord
|
|
453
|
+
expect((userPayload.userRecord as unknown as UserInfo).TenantContext).toEqual({
|
|
268
454
|
TenantID: 'tenant-xyz',
|
|
269
455
|
Source: 'header',
|
|
270
456
|
});
|
|
@@ -284,6 +470,52 @@ describe('Multi-Tenancy Security Edge Cases', () => {
|
|
|
284
470
|
|
|
285
471
|
expect(userRecord['TenantContext']).toBeUndefined();
|
|
286
472
|
});
|
|
473
|
+
|
|
474
|
+
it('should reject a repeated header (array value) with 400 rather than guessing which value applies', () => {
|
|
475
|
+
const middleware = createTenantMiddleware(makeConfig());
|
|
476
|
+
const userRecord = { ID: 'u1' } as Record<string, unknown>;
|
|
477
|
+
const userPayload = { userRecord, email: 'test@test.com', sessionId: 's1' };
|
|
478
|
+
const req = {
|
|
479
|
+
// Express delivers a repeated header as string[]
|
|
480
|
+
headers: { 'x-tenant-id': ['tenant-A', 'tenant-B'] },
|
|
481
|
+
userPayload,
|
|
482
|
+
} as unknown as Parameters<typeof middleware>[0];
|
|
483
|
+
const json = vi.fn();
|
|
484
|
+
const status = vi.fn().mockReturnValue({ json });
|
|
485
|
+
const res = { status } as unknown as Parameters<typeof middleware>[1];
|
|
486
|
+
const next = vi.fn();
|
|
487
|
+
|
|
488
|
+
middleware(req, res, next);
|
|
489
|
+
|
|
490
|
+
expect(status).toHaveBeenCalledWith(400);
|
|
491
|
+
expect(next).not.toHaveBeenCalled();
|
|
492
|
+
expect(userPayload.userRecord).toBe(userRecord);
|
|
493
|
+
expect(userRecord['TenantContext']).toBeUndefined();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('two concurrent requests for the same shared user with different headers do not cross-contaminate', () => {
|
|
497
|
+
const middleware = createTenantMiddleware(makeConfig());
|
|
498
|
+
const sharedUserRecord = { ID: 'u1', UserRoles: [] } as unknown as Record<string, unknown>;
|
|
499
|
+
|
|
500
|
+
const reqA = {
|
|
501
|
+
headers: { 'x-tenant-id': 'tenant-A' },
|
|
502
|
+
userPayload: { userRecord: sharedUserRecord, email: 'a@test.com', sessionId: 'sA' },
|
|
503
|
+
} as unknown as Parameters<typeof middleware>[0];
|
|
504
|
+
const reqB = {
|
|
505
|
+
headers: { 'x-tenant-id': 'tenant-B' },
|
|
506
|
+
userPayload: { userRecord: sharedUserRecord, email: 'b@test.com', sessionId: 'sB' },
|
|
507
|
+
} as unknown as Parameters<typeof middleware>[0];
|
|
508
|
+
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Parameters<typeof middleware>[1];
|
|
509
|
+
|
|
510
|
+
middleware(reqA, res, vi.fn());
|
|
511
|
+
middleware(reqB, res, vi.fn());
|
|
512
|
+
|
|
513
|
+
const userA = (reqA as unknown as { userPayload: { userRecord: UserInfo } }).userPayload.userRecord;
|
|
514
|
+
const userB = (reqB as unknown as { userPayload: { userRecord: UserInfo } }).userPayload.userRecord;
|
|
515
|
+
expect(userA.TenantContext?.TenantID).toBe('tenant-A');
|
|
516
|
+
expect(userB.TenantContext?.TenantID).toBe('tenant-B');
|
|
517
|
+
expect((sharedUserRecord as unknown as UserInfo).TenantContext).toBeUndefined();
|
|
518
|
+
});
|
|
287
519
|
});
|
|
288
520
|
|
|
289
521
|
// ─── PreSave security edge cases ──────────────────────────────────────
|
|
@@ -15,6 +15,28 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
|
15
15
|
{ Name: 'AI Models', SchemaName: '__mj' },
|
|
16
16
|
{ Name: 'Users', SchemaName: '__mj' },
|
|
17
17
|
];
|
|
18
|
+
|
|
19
|
+
// The hook validates that the tenant column resolves to a real, stored
|
|
20
|
+
// (non-virtual) field on the entity before interpolating it as an identifier.
|
|
21
|
+
EntityByName(name: string) {
|
|
22
|
+
const entity = this.Entities.find(
|
|
23
|
+
e => e.Name.trim().toLowerCase() === name.trim().toLowerCase()
|
|
24
|
+
);
|
|
25
|
+
if (!entity) return undefined;
|
|
26
|
+
return {
|
|
27
|
+
...entity,
|
|
28
|
+
Fields: [
|
|
29
|
+
{ Name: 'OrganizationID', IsVirtual: false },
|
|
30
|
+
{ Name: 'TenantID', IsVirtual: false },
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// QuoteFilterIdentifier reads the STATIC Metadata.Provider, matching a real
|
|
36
|
+
// server (Metadata.Provider is a DatabaseProviderBase instance).
|
|
37
|
+
static Provider = {
|
|
38
|
+
QuoteIdentifier: (name: string) => `[${name}]`,
|
|
39
|
+
};
|
|
18
40
|
}
|
|
19
41
|
|
|
20
42
|
return {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pushStatusResolver.filter.test.ts — locks the security-critical subscription filter for B49.
|
|
3
|
+
*
|
|
4
|
+
* `statusUpdatesFilter` is the gate that fixes the `statusUpdates` session-hijack: a push is
|
|
5
|
+
* delivered only when the push's `sessionId` matches the subscriber's requested session AND the
|
|
6
|
+
* push's `ownerUserId` matches the subscriber CONNECTION's server-authenticated identity. Knowing
|
|
7
|
+
* a victim's client-chosen `sessionId` must no longer be sufficient to receive their pushes.
|
|
8
|
+
*
|
|
9
|
+
* The filter is a pure function (no DB / config / transport), so these are plain unit tests.
|
|
10
|
+
*/
|
|
11
|
+
import 'reflect-metadata';
|
|
12
|
+
import { describe, it, expect } from 'vitest';
|
|
13
|
+
import { statusUpdatesFilter, type PushStatusNotificationPayload, type StatusUpdatesFilterContext } from '../generic/PushStatusResolver';
|
|
14
|
+
import type { UserPayload } from '../types';
|
|
15
|
+
|
|
16
|
+
const OWNER = 'AA11BB22-0000-4000-8000-000000000001';
|
|
17
|
+
const ATTACKER = 'CC33DD44-0000-4000-8000-000000000002';
|
|
18
|
+
const SESSION = 'session-abc-123';
|
|
19
|
+
|
|
20
|
+
function ctxFor(userId: string | undefined): StatusUpdatesFilterContext {
|
|
21
|
+
// userRecord is `any` in UserPayload; a minimal shape with ID is all the filter reads.
|
|
22
|
+
return { userPayload: (userId ? { userRecord: { ID: userId } } : {}) as unknown as UserPayload };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function push(overrides: Partial<PushStatusNotificationPayload> = {}): PushStatusNotificationPayload {
|
|
26
|
+
return { sessionId: SESSION, ownerUserId: OWNER, message: 'progress', ...overrides };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('statusUpdatesFilter (B49 session-hijack gate)', () => {
|
|
30
|
+
it('delivers when sessionId matches AND the connection identity owns the push', () => {
|
|
31
|
+
expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('THE FIX: rejects a matching sessionId when the connection identity is a DIFFERENT user (hijack)', () => {
|
|
35
|
+
// Attacker lifted the victim's sessionId and subscribes with it — but their authenticated
|
|
36
|
+
// connection identity is their own, so delivery must be denied.
|
|
37
|
+
expect(statusUpdatesFilter({ payload: push({ ownerUserId: OWNER }), args: { sessionId: SESSION }, context: ctxFor(ATTACKER) })).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects when the requested sessionId does not match the push', () => {
|
|
41
|
+
expect(statusUpdatesFilter({ payload: push(), args: { sessionId: 'some-other-session' }, context: ctxFor(OWNER) })).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('fails closed when the push carries no ownerUserId', () => {
|
|
45
|
+
expect(statusUpdatesFilter({ payload: push({ ownerUserId: '' }), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('fails closed when the connection has no authenticated identity', () => {
|
|
49
|
+
expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: ctxFor(undefined) })).toBe(false);
|
|
50
|
+
expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: undefined })).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('matches identity case-insensitively (SQL Server upper vs PostgreSQL lower UUIDs)', () => {
|
|
54
|
+
expect(statusUpdatesFilter({ payload: push({ ownerUserId: OWNER.toUpperCase() }), args: { sessionId: SESSION }, context: ctxFor(OWNER.toLowerCase()) })).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -136,7 +136,11 @@ function makeEntity(name: string, opts: {
|
|
|
136
136
|
return {
|
|
137
137
|
Name: name,
|
|
138
138
|
UserExemptFromRowLevelSecurity: () => opts.exempt,
|
|
139
|
+
// ResolverBase now delegates to GetEffectiveRowFilterWhereClause (role RLS
|
|
140
|
+
// + API-key row filters); the deprecated role-only method is kept in the
|
|
141
|
+
// mock shape for completeness but is no longer what the resolver calls.
|
|
139
142
|
GetUserRowLevelSecurityWhereClause: () => opts.exempt ? '' : opts.rlsClause,
|
|
143
|
+
GetEffectiveRowFilterWhereClause: () => opts.exempt ? '' : opts.rlsClause,
|
|
140
144
|
} as unknown as EntityInfo;
|
|
141
145
|
}
|
|
142
146
|
|
|
@@ -10,19 +10,41 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
10
10
|
|
|
11
11
|
// ── Mocks ──────────────────────────────────────────────────────────────
|
|
12
12
|
|
|
13
|
-
const { mockSearch, mockPreviewSearch } =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const { mockSearch, mockPreviewSearch, mockResolveEffectivePermission, mockLogForbiddenSearch } =
|
|
14
|
+
vi.hoisted(() => ({
|
|
15
|
+
mockSearch: vi.fn(),
|
|
16
|
+
mockPreviewSearch: vi.fn(),
|
|
17
|
+
mockResolveEffectivePermission: vi.fn(),
|
|
18
|
+
mockLogForbiddenSearch: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// `SearchKnowledgeResolver` obtains its permission resolver through the pluggable seam —
|
|
22
|
+
// `GetSearchScopePermissionResolver()` — so this mock has to provide that accessor even though
|
|
23
|
+
// no test below currently reaches it: every case here passes `undefined` for scope IDs, and
|
|
24
|
+
// `rejectForbiddenScopes` is guarded by `if (scopeIDs && scopeIDs.length)`.
|
|
25
|
+
//
|
|
26
|
+
// It is here because of how this omission fails. The missing export is simply `undefined`,
|
|
27
|
+
// calling it throws a TypeError, and the resolver's catch-all reports a result code unrelated
|
|
28
|
+
// to the thing under test — the same trap that made 11 failures in the CoreActions suite read
|
|
29
|
+
// like a permissions regression. The first person to add a test case WITH scope IDs would pay
|
|
30
|
+
// that diagnosis cost again, for no reason.
|
|
31
|
+
//
|
|
32
|
+
// The default is permissive (applied in beforeEach, so it survives a reset as well as a clear)
|
|
33
|
+
// so such a test exercises its own subject rather than tripping over an unconfigured double.
|
|
34
|
+
// Override `mockResolveEffectivePermission` in the test to drive the denial paths.
|
|
18
35
|
vi.mock('@memberjunction/search-engine', () => ({
|
|
19
36
|
SearchEngine: {
|
|
20
37
|
Instance: {
|
|
21
38
|
Config: vi.fn().mockResolvedValue(undefined),
|
|
22
39
|
Search: (...args: unknown[]) => mockSearch(...args),
|
|
23
40
|
PreviewSearch: (...args: unknown[]) => mockPreviewSearch(...args),
|
|
41
|
+
// Called only on the denial branch, to log a Status='Forbidden' execution row.
|
|
42
|
+
LogForbiddenSearch: (...args: unknown[]) => mockLogForbiddenSearch(...args),
|
|
24
43
|
}
|
|
25
|
-
}
|
|
44
|
+
},
|
|
45
|
+
GetSearchScopePermissionResolver: () => ({
|
|
46
|
+
ResolveEffectivePermission: (...args: unknown[]) => mockResolveEffectivePermission(...args),
|
|
47
|
+
}),
|
|
26
48
|
}));
|
|
27
49
|
|
|
28
50
|
vi.mock('@memberjunction/global', async (importOriginal) => {
|
|
@@ -120,6 +142,52 @@ const fakeContext = { userPayload: { email: 'test@test.com' } };
|
|
|
120
142
|
describe('SearchKnowledgeResolver', () => {
|
|
121
143
|
beforeEach(() => {
|
|
122
144
|
vi.clearAllMocks();
|
|
145
|
+
mockResolveEffectivePermission.mockResolvedValue({
|
|
146
|
+
Allowed: true, Level: 'Search', Source: 'DirectGrant',
|
|
147
|
+
Reason: 'test double', toSqlPredicate: () => '1=1',
|
|
148
|
+
});
|
|
149
|
+
mockLogForbiddenSearch.mockResolvedValue(undefined);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── Scope authorization ────────────────────────────────────────────
|
|
153
|
+
//
|
|
154
|
+
// This is the branch `rejectForbiddenScopes` guards, and it had no coverage at all — every
|
|
155
|
+
// other case here passes `undefined` for scope IDs, so the whole authorization path was
|
|
156
|
+
// unexercised. These two tests are also what make the resolver entry in the module mock
|
|
157
|
+
// above load-bearing rather than decorative: remove it and both fail.
|
|
158
|
+
describe('scope authorization', () => {
|
|
159
|
+
it('runs the search when every requested scope resolves to Allowed', async () => {
|
|
160
|
+
const resolver = createResolver();
|
|
161
|
+
mockSearch.mockResolvedValue(createMockSearchResult());
|
|
162
|
+
|
|
163
|
+
const result = await resolver.SearchKnowledge(
|
|
164
|
+
'test', 20, undefined, undefined, ['scope-a', 'scope-b'], undefined, undefined,
|
|
165
|
+
fakeContext as never);
|
|
166
|
+
|
|
167
|
+
expect(result.Success).toBe(true);
|
|
168
|
+
expect(mockSearch).toHaveBeenCalled();
|
|
169
|
+
// One resolution per requested scope — the check is per-scope, not per-call.
|
|
170
|
+
expect(mockResolveEffectivePermission).toHaveBeenCalledTimes(2);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('REFUSES the whole call on the first denied scope, and never reaches the search', async () => {
|
|
174
|
+
const resolver = createResolver();
|
|
175
|
+
mockSearch.mockResolvedValue(createMockSearchResult());
|
|
176
|
+
mockResolveEffectivePermission.mockResolvedValue({
|
|
177
|
+
Allowed: false, Level: 'None', Source: 'NoGrant',
|
|
178
|
+
Reason: 'no grant for this scope', toSqlPredicate: () => '1=0',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const result = await resolver.SearchKnowledge(
|
|
182
|
+
'test', 20, undefined, undefined, ['scope-a'], undefined, undefined,
|
|
183
|
+
fakeContext as never);
|
|
184
|
+
|
|
185
|
+
expect(result.Success).toBe(false);
|
|
186
|
+
// The point of the branch: a forbidden scope aborts rather than being silently
|
|
187
|
+
// dropped, because dropping it masks bugs in scope authoring.
|
|
188
|
+
expect(mockSearch).not.toHaveBeenCalled();
|
|
189
|
+
expect(mockLogForbiddenSearch).toHaveBeenCalled();
|
|
190
|
+
});
|
|
123
191
|
});
|
|
124
192
|
|
|
125
193
|
describe('SearchKnowledge mutation', () => {
|