@memberjunction/server 5.51.0 → 6.1.0-edge.1
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 +234 -642
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +1277 -3755
- 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 +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- 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/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/TaskGraphFrameResolver.d.ts +85 -0
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -0
- package/dist/resolvers/TaskGraphFrameResolver.js +163 -0
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -0
- 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/rest/RESTEndpointHandler.d.ts +0 -4
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +1 -15
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.d.ts +33 -0
- package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -0
- package/dist/services/StartTaskGraphDispatcher.js +31 -0
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -0
- package/dist/services/TaskGraphAgentRunner.d.ts +15 -0
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -0
- package/dist/services/TaskGraphAgentRunner.js +74 -0
- package/dist/services/TaskGraphAgentRunner.js.map +1 -0
- package/dist/services/TaskGraphContinuationDeliverer.d.ts +70 -0
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -0
- package/dist/services/TaskGraphContinuationDeliverer.js +162 -0
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -0
- package/dist/services/TaskGraphProviderFactory.d.ts +39 -0
- package/dist/services/TaskGraphProviderFactory.d.ts.map +1 -0
- package/dist/services/TaskGraphProviderFactory.js +28 -0
- package/dist/services/TaskGraphProviderFactory.js.map +1 -0
- package/package.json +103 -100
- package/src/__tests__/RealtimeBridgeResolver.test.ts +6 -0
- package/src/__tests__/SessionManager.test.ts +44 -0
- package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +249 -0
- package/src/__tests__/TaskGraphFrameResolver.test.ts +136 -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 +899 -2555
- 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 +21 -4
- 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/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/TaskGraphFrameResolver.ts +160 -0
- package/src/resolvers/VersionHistoryResolver.ts +10 -13
- package/src/rest/RESTEndpointHandler.ts +1 -15
- package/src/services/StartTaskGraphDispatcher.ts +61 -0
- package/src/services/TaskGraphAgentRunner.ts +81 -0
- package/src/services/TaskGraphContinuationDeliverer.ts +181 -0
- package/src/services/TaskGraphProviderFactory.ts +53 -0
- package/dist/resolvers/ReportResolver.d.ts +0 -24
- package/dist/resolvers/ReportResolver.d.ts.map +0 -1
- package/dist/resolvers/ReportResolver.js +0 -190
- package/dist/resolvers/ReportResolver.js.map +0 -1
- package/dist/resolvers/TaskResolver.d.ts +0 -31
- package/dist/resolvers/TaskResolver.d.ts.map +0 -1
- package/dist/resolvers/TaskResolver.js +0 -157
- package/dist/resolvers/TaskResolver.js.map +0 -1
- package/dist/services/TaskOrchestrator.d.ts +0 -130
- package/dist/services/TaskOrchestrator.d.ts.map +0 -1
- package/dist/services/TaskOrchestrator.js +0 -690
- package/dist/services/TaskOrchestrator.js.map +0 -1
- package/src/resolvers/ReportResolver.ts +0 -157
- package/src/resolvers/TaskResolver.ts +0 -148
- package/src/services/TaskOrchestrator.ts +0 -873
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the task-graph live signal.
|
|
3
|
+
*
|
|
4
|
+
* `taskGraphFrameFilter` is the security-critical gate and is tested in isolation for the same
|
|
5
|
+
* reason `statusUpdatesFilter` is: a `parentTaskId` is a discoverable value, so without an identity
|
|
6
|
+
* check anyone holding one could watch another user's workflow — including its per-step error
|
|
7
|
+
* messages. It must fail **closed**.
|
|
8
|
+
*
|
|
9
|
+
* The broadcaster's contract is that it can never disturb the work it is describing: a frame is
|
|
10
|
+
* commentary on execution, never a step of it.
|
|
11
|
+
*/
|
|
12
|
+
// type-graphql's decorators evaluate at module load, and they need the metadata polyfill. Without
|
|
13
|
+
// this the file fails to COLLECT — which vitest reports as zero tests rather than as a failure.
|
|
14
|
+
import 'reflect-metadata';
|
|
15
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
16
|
+
|
|
17
|
+
vi.mock('@memberjunction/core', () => ({ LogError: vi.fn() }));
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
TASK_GRAPH_FRAMES_TOPIC,
|
|
21
|
+
TaskGraphFrameBroadcaster,
|
|
22
|
+
taskGraphFrameFilter,
|
|
23
|
+
type TaskGraphFramePayload,
|
|
24
|
+
} from '../resolvers/TaskGraphFrameResolver';
|
|
25
|
+
import type { TaskGraphFrame } from '@memberjunction/task-graph';
|
|
26
|
+
import type { PubSubEngine } from 'type-graphql';
|
|
27
|
+
|
|
28
|
+
const OWNER = '11111111-1111-1111-1111-111111111111';
|
|
29
|
+
const OTHER = '22222222-2222-2222-2222-222222222222';
|
|
30
|
+
const GRAPH = '33333333-3333-3333-3333-333333333333';
|
|
31
|
+
|
|
32
|
+
const payload = (over: Partial<TaskGraphFramePayload> = {}): TaskGraphFramePayload => ({
|
|
33
|
+
Kind: 'TaskCompleted',
|
|
34
|
+
ParentTaskID: GRAPH,
|
|
35
|
+
TaskID: 'task-1',
|
|
36
|
+
TaskName: 'Summarize',
|
|
37
|
+
Status: 'Complete',
|
|
38
|
+
ownerUserId: OWNER,
|
|
39
|
+
...over,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// `null` rather than `undefined` for "no identity": passing `undefined` explicitly triggers a
|
|
43
|
+
// default parameter, so the case would silently test the opposite of what it claims.
|
|
44
|
+
const context = (userId: string | null) => (userId ? { userPayload: { userRecord: { ID: userId } } } : {});
|
|
45
|
+
|
|
46
|
+
const check = (over: Partial<TaskGraphFramePayload> = {}, args = GRAPH, userId: string | null = OWNER) =>
|
|
47
|
+
taskGraphFrameFilter({ payload: payload(over), args: { parentTaskId: args }, context: context(userId) });
|
|
48
|
+
|
|
49
|
+
describe('taskGraphFrameFilter — the delivery gate', () => {
|
|
50
|
+
it('delivers a frame for the watched graph to its owner', () => {
|
|
51
|
+
expect(check()).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('matches graph IDs case-insensitively — UUID casing varies by source', () => {
|
|
55
|
+
expect(check({}, GRAPH.toUpperCase())).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('does NOT deliver frames from another graph', () => {
|
|
59
|
+
expect(check({ ParentTaskID: '44444444-4444-4444-4444-444444444444' })).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does NOT deliver another user\'s workflow to a subscriber holding its ID', () => {
|
|
63
|
+
// The whole reason identity is checked: parentTaskId is discoverable, and the frames carry
|
|
64
|
+
// per-step error messages.
|
|
65
|
+
expect(check({}, GRAPH, OTHER)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('fails closed when the connection has no identity', () => {
|
|
69
|
+
expect(check({}, GRAPH, null)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('fails closed when the frame has no owner', () => {
|
|
73
|
+
expect(check({ ownerUserId: '' as unknown as string })).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('fails closed on a missing graph ID on either side', () => {
|
|
77
|
+
expect(check({ ParentTaskID: '' })).toBe(false);
|
|
78
|
+
expect(check({}, '')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('TaskGraphFrameBroadcaster', () => {
|
|
83
|
+
let published: Array<[string, unknown]>;
|
|
84
|
+
let pubSub: PubSubEngine;
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
published = [];
|
|
88
|
+
pubSub = {
|
|
89
|
+
publish: vi.fn().mockImplementation(async (topic: string, p: unknown) => { published.push([topic, p]); }),
|
|
90
|
+
} as unknown as PubSubEngine;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const frame = (over: Partial<TaskGraphFrame> = {}): TaskGraphFrame => ({
|
|
94
|
+
Kind: 'TaskStarted', ParentTaskID: GRAPH, OwnerUserID: OWNER, TaskID: 'task-1', TaskName: 'Gather', ...over,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('publishes the frame with the owner attached as the filter key', () => {
|
|
98
|
+
new TaskGraphFrameBroadcaster(pubSub).OnFrame(frame());
|
|
99
|
+
|
|
100
|
+
expect(published).toHaveLength(1);
|
|
101
|
+
const [topic, p] = published[0];
|
|
102
|
+
expect(topic).toBe(TASK_GRAPH_FRAMES_TOPIC);
|
|
103
|
+
expect((p as TaskGraphFramePayload).ownerUserId).toBe(OWNER);
|
|
104
|
+
expect((p as TaskGraphFramePayload).Kind).toBe('TaskStarted');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('drops a frame with no owner rather than publishing one the filter would refuse', () => {
|
|
108
|
+
// Reachable for graphs submitted before ownership was recorded on the parent.
|
|
109
|
+
new TaskGraphFrameBroadcaster(pubSub).OnFrame(frame({ OwnerUserID: null }));
|
|
110
|
+
expect(published).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('never throws when publishing fails — a frame must not disturb the work it describes', () => {
|
|
114
|
+
const exploding = { publish: vi.fn(() => { throw new Error('pubsub down'); }) } as unknown as PubSubEngine;
|
|
115
|
+
expect(() => new TaskGraphFrameBroadcaster(exploding).OnFrame(frame())).not.toThrow();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('carries the whole frame through, so a viewer can render without re-reading rows', () => {
|
|
119
|
+
new TaskGraphFrameBroadcaster(pubSub).OnFrame(
|
|
120
|
+
frame({ Kind: 'TaskFailed', Status: 'Failed', ErrorMessage: 'model timed out' }),
|
|
121
|
+
);
|
|
122
|
+
const p = published[0][1] as TaskGraphFramePayload;
|
|
123
|
+
expect(p.Status).toBe('Failed');
|
|
124
|
+
expect(p.ErrorMessage).toBe('model timed out');
|
|
125
|
+
expect(p.TaskName).toBe('Gather');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('carries graph-level progress on a settled frame', () => {
|
|
129
|
+
new TaskGraphFrameBroadcaster(pubSub).OnFrame(
|
|
130
|
+
frame({ Kind: 'GraphSettled', TaskID: undefined, Status: 'Complete', CompletedCount: 7, TotalCount: 7 }),
|
|
131
|
+
);
|
|
132
|
+
const p = published[0][1] as TaskGraphFramePayload;
|
|
133
|
+
expect(p.CompletedCount).toBe(7);
|
|
134
|
+
expect(p.TotalCount).toBe(7);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -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
|
|