@memberjunction/server 5.51.3 → 5.51.4
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 +34 -1
- package/dist/agentSessions/SessionManager.d.ts +5 -2
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +17 -8
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
- package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
- package/dist/agentSessions/SessionRunIDVerifier.js +136 -0
- package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
- package/dist/agentSessions/index.d.ts +1 -0
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +1 -0
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/auth/actionAuthorization.d.ts +40 -0
- package/dist/auth/actionAuthorization.d.ts.map +1 -0
- package/dist/auth/actionAuthorization.js +94 -0
- package/dist/auth/actionAuthorization.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/scopeLimitedPrincipal.d.ts +39 -0
- package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
- package/dist/auth/scopeLimitedPrincipal.js +49 -0
- package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +34 -1
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +24 -34
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +3262 -3273
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +124 -4
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +170 -15
- package/dist/generic/CacheInvalidationResolver.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 +148 -3
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +287 -30
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +56 -48
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts.map +1 -1
- package/dist/multiTenancy/index.js +22 -2
- package/dist/multiTenancy/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +2 -2
- package/dist/realtimeWidget/widgetGuestElevation.js +2 -2
- package/dist/resolvers/ActionResolver.d.ts +31 -5
- package/dist/resolvers/ActionResolver.d.ts.map +1 -1
- package/dist/resolvers/ActionResolver.js +75 -14
- package/dist/resolvers/ActionResolver.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +39 -17
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +97 -157
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/DatasetResolver.d.ts +24 -3
- package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
- package/dist/resolvers/DatasetResolver.js +57 -8
- package/dist/resolvers/DatasetResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +14 -4
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +18 -6
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
- package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.js +117 -38
- package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +13 -1
- package/dist/resolvers/GetDataResolver.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 +26 -14
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +63 -46
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +24 -27
- 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/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +12 -11
- package/dist/resolvers/RunTemplateResolver.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/SyncRolesUsersResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
- package/dist/resolvers/SyncRolesUsersResolver.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/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts +3 -1
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +29 -17
- package/dist/rest/ViewOperationsHandler.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 +90 -89
- package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
- package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
- package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +136 -0
- package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +318 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +66 -27
- package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +134 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +221 -0
- package/src/__tests__/ResolverBase.aggregateScreen.test.ts +173 -0
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +116 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +351 -0
- package/src/__tests__/RunTemplateResolver.test.ts +132 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +164 -0
- package/src/__tests__/SessionManager.test.ts +88 -13
- package/src/__tests__/SessionRunIDVerifier.test.ts +255 -0
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +155 -0
- package/src/__tests__/actionAuthorization.test.ts +130 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +241 -0
- package/src/__tests__/multiTenancy.postgresRewrite.test.ts +180 -0
- package/src/__tests__/multiTenancy.security.test.ts +51 -0
- package/src/__tests__/multiTenancy.test.ts +1 -1
- package/src/__tests__/newUserRolesDefault.test.ts +81 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
- package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
- package/src/__tests__/resolverBase.rls.test.ts +2 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/agentSessions/SessionManager.ts +24 -25
- package/src/agentSessions/SessionRunIDVerifier.ts +179 -0
- package/src/agentSessions/index.ts +1 -0
- package/src/auth/actionAuthorization.ts +119 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/scopeLimitedPrincipal.ts +50 -0
- package/src/config.ts +34 -1
- package/src/context.ts +25 -34
- package/src/generated/generated.ts +3262 -3273
- package/src/generic/CacheInvalidationResolver.ts +206 -15
- package/src/generic/FireAndForgetHeartbeat.ts +10 -4
- package/src/generic/PushStatusResolver.ts +79 -4
- package/src/generic/ResolverBase.ts +339 -31
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/index.ts +63 -50
- package/src/multiTenancy/index.ts +24 -2
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/realtimeWidget/widgetGuestElevation.ts +2 -2
- package/src/resolvers/ActionResolver.ts +85 -22
- package/src/resolvers/AdhocQueryResolver.ts +110 -177
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +24 -7
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +14 -1
- package/src/resolvers/MCPResolver.ts +13 -15
- package/src/resolvers/RealtimeClientSessionResolver.ts +82 -50
- package/src/resolvers/RemoteBrowserActionResolver.ts +30 -26
- package/src/resolvers/RunAIAgentResolver.ts +30 -40
- package/src/resolvers/RunTemplateResolver.ts +13 -12
- package/src/resolvers/RunTestResolver.ts +90 -112
- package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
- package/src/resolvers/VersionHistoryResolver.ts +10 -13
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/__tests__/ReportResolver.test.ts +2 -0
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/rest/ViewOperationsHandler.ts +37 -18
- package/src/services/TaskOrchestrator.ts +17 -17
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -42
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -50
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// The resolvers carry type-graphql decorators, which need the Reflect.metadata polyfill at import time.
|
|
2
|
+
import 'reflect-metadata';
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
DatabaseProviderBase,
|
|
6
|
+
DatasetItemFilterType,
|
|
7
|
+
DatasetResultType,
|
|
8
|
+
DatasetStatusResultType,
|
|
9
|
+
EntityInfo,
|
|
10
|
+
UserInfo,
|
|
11
|
+
} from '@memberjunction/core';
|
|
12
|
+
import { ENCRYPTED_SENTINEL } from '@memberjunction/global';
|
|
13
|
+
import { DatasetResolverExtended, DatasetStatusResolver } from '../resolvers/DatasetResolver.js';
|
|
14
|
+
import type { AppContext, UserPayload } from '../types.js';
|
|
15
|
+
|
|
16
|
+
// The encrypted-field policy configures the encryption engine before it inspects a value; there is
|
|
17
|
+
// no key store in a unit test.
|
|
18
|
+
vi.mock('@memberjunction/encryption', () => ({
|
|
19
|
+
EncryptionEngine: {
|
|
20
|
+
Instance: {
|
|
21
|
+
Config: async () => undefined,
|
|
22
|
+
GetKeyByID: () => ({ Marker: '$ENC$' }),
|
|
23
|
+
Encrypt: async (value: string) => `$ENC$${value}`,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The GraphQL dataset queries are the network edge of security finding C6.
|
|
30
|
+
*
|
|
31
|
+
* `GetDatasetByName` / `GetDatasetStatusByName` took `ItemFilters[].Filter` from any authenticated
|
|
32
|
+
* principal and handed it to the provider unscreened, and passed no context user, so the provider
|
|
33
|
+
* could neither refuse an unreadable item nor apply row-level security. These tests pin that the
|
|
34
|
+
* resolvers screen every ItemFilter with the same AST screen as ExtraFilter before the provider is
|
|
35
|
+
* reached, pass the session user down, and apply the API encryption policy to the rows they return.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const ROLE_ID = 'R0000000-0000-0000-0000-00000000000A';
|
|
39
|
+
const OTHER_ROLE_ID = 'R0000000-0000-0000-0000-00000000000C';
|
|
40
|
+
|
|
41
|
+
function entity(name: string, baseView: string, fields: Record<string, unknown>[] = []): EntityInfo {
|
|
42
|
+
const id = `E-${name}`;
|
|
43
|
+
return new EntityInfo({
|
|
44
|
+
ID: id, Name: name, SchemaName: '__mj', BaseTable: name, BaseView: baseView,
|
|
45
|
+
Permissions: [{ ID: `p-${name}`, EntityID: id, RoleID: ROLE_ID, CanRead: true, Type: 'Allow' }],
|
|
46
|
+
Fields: fields.map((f, i) => ({ ID: `${id}-f${i}`, EntityID: id, Entity: name, Sequence: i + 1, Type: 'nvarchar', ...f })),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const THINGS = entity('Things', 'vwThings', [{ Name: 'ID', IsPrimaryKey: true }, { Name: 'Name' }]);
|
|
51
|
+
const SECRETS = entity('Secrets', 'vwSecrets', [
|
|
52
|
+
{ Name: 'ID', IsPrimaryKey: true },
|
|
53
|
+
{ Name: 'Token', Encrypt: true, EncryptionKeyID: 'K1', AllowDecryptInAPI: false, SendEncryptedValue: false },
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const USER = new UserInfo(undefined, {
|
|
57
|
+
ID: 'U1', Name: 'Reader', Email: 'reader@example.com', IsActive: true,
|
|
58
|
+
UserRoles: [{ UserID: 'U1', RoleID: ROLE_ID, Role: 'Reader' }],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
type ProviderCall = {
|
|
62
|
+
Method: 'GetDatasetByName' | 'GetDatasetStatusByName';
|
|
63
|
+
DatasetName: string;
|
|
64
|
+
ItemFilters: DatasetItemFilterType[] | undefined;
|
|
65
|
+
ContextUser: UserInfo | undefined;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A provider double that records what reached it and answers with fixed rows. */
|
|
69
|
+
function fakeProvider(itemEntity: EntityInfo = THINGS, rows: Record<string, unknown>[] = []) {
|
|
70
|
+
const calls: ProviderCall[] = [];
|
|
71
|
+
const provider = {
|
|
72
|
+
Entities: [THINGS, SECRETS],
|
|
73
|
+
EntityByName: (name: string) => [THINGS, SECRETS].find(e => e.Name.toLowerCase() === name?.trim().toLowerCase()),
|
|
74
|
+
GetDatasetByName: async (DatasetName: string, ItemFilters?: DatasetItemFilterType[], ContextUser?: UserInfo): Promise<DatasetResultType> => {
|
|
75
|
+
calls.push({ Method: 'GetDatasetByName', DatasetName, ItemFilters, ContextUser });
|
|
76
|
+
return {
|
|
77
|
+
DatasetID: 'DS1', DatasetName, Success: true, Status: '', LatestUpdateDate: new Date(0),
|
|
78
|
+
Results: [{ Code: itemEntity.Name, EntityName: itemEntity.Name, EntityID: itemEntity.ID, Results: rows, Success: true }],
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
GetDatasetStatusByName: async (DatasetName: string, ItemFilters?: DatasetItemFilterType[], ContextUser?: UserInfo): Promise<DatasetStatusResultType> => {
|
|
82
|
+
calls.push({ Method: 'GetDatasetStatusByName', DatasetName, ItemFilters, ContextUser });
|
|
83
|
+
return { DatasetID: 'DS1', DatasetName, Success: true, Status: '', LatestUpdateDate: new Date(0), EntityUpdateDates: [] };
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
return { provider: provider as unknown as DatabaseProviderBase, calls };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function context(provider: DatabaseProviderBase, userPayload: Partial<UserPayload> = { email: USER.Email, userRecord: USER }): AppContext {
|
|
90
|
+
return {
|
|
91
|
+
providers: [{ provider, type: 'Read-Write' }],
|
|
92
|
+
userPayload: { sessionId: 'S1', ...userPayload } as UserPayload,
|
|
93
|
+
} as AppContext;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const STACKED = [{ ItemCode: 'Things', Filter: '1=0; SELECT 1' }];
|
|
97
|
+
// A quote inside a bracket identifier opens a fake literal for the string-literal stripper that
|
|
98
|
+
// guards the provider denylist; the AST screen tokenizes brackets and still sees the second statement.
|
|
99
|
+
const STACKED_BEHIND_BRACKET = [{ ItemCode: 'Things', Filter: "[a'] = 1; SELECT 1 --'" }];
|
|
100
|
+
|
|
101
|
+
describe('GetDatasetByName', () => {
|
|
102
|
+
it('refuses a stacked-statement ItemFilter before the provider is reached', async () => {
|
|
103
|
+
const { provider, calls } = fakeProvider();
|
|
104
|
+
|
|
105
|
+
await expect(new DatasetResolverExtended().GetDatasetByName('MJ_Metadata', context(provider), STACKED))
|
|
106
|
+
.rejects.toThrow(/multiple statements are not permitted/);
|
|
107
|
+
expect(calls).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('refuses a stacked statement hidden behind a bracket identifier', async () => {
|
|
111
|
+
const { provider, calls } = fakeProvider();
|
|
112
|
+
|
|
113
|
+
await expect(new DatasetResolverExtended().GetDatasetByName('MJ_Metadata', context(provider), STACKED_BEHIND_BRACKET))
|
|
114
|
+
.rejects.toThrow(/multiple statements are not permitted/);
|
|
115
|
+
expect(calls).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('refuses an ItemFilter whose subquery reads a table that is not an entity base view', async () => {
|
|
119
|
+
const { provider, calls } = fakeProvider();
|
|
120
|
+
const filters = [{ ItemCode: 'Things', Filter: 'ID IN (SELECT ID FROM __mj.[User])' }];
|
|
121
|
+
|
|
122
|
+
await expect(new DatasetResolverExtended().GetDatasetByName('MJ_Metadata', context(provider), filters))
|
|
123
|
+
.rejects.toThrow(/entity base view/);
|
|
124
|
+
expect(calls).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('passes the session user to the provider, with the screened filters', async () => {
|
|
128
|
+
const { provider, calls } = fakeProvider();
|
|
129
|
+
const filters = [{ ItemCode: 'Things', Filter: "Name = 'x'" }];
|
|
130
|
+
|
|
131
|
+
const result = await new DatasetResolverExtended().GetDatasetByName('Things_Dataset', context(provider), filters);
|
|
132
|
+
|
|
133
|
+
expect(result.Success).toBe(true);
|
|
134
|
+
expect(calls).toHaveLength(1);
|
|
135
|
+
expect(calls[0].ContextUser).toBe(USER);
|
|
136
|
+
expect(calls[0].ItemFilters).toEqual(filters);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('refuses a session that resolves to no user rather than reading as nobody', async () => {
|
|
140
|
+
const { provider, calls } = fakeProvider();
|
|
141
|
+
|
|
142
|
+
await expect(new DatasetResolverExtended().GetDatasetByName('Things_Dataset', context(provider, { email: '', userRecord: null })))
|
|
143
|
+
.rejects.toThrow(/no user/i);
|
|
144
|
+
expect(calls).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('masks an encrypted field the entity does not allow decrypted over the API, without touching the provider rows', async () => {
|
|
148
|
+
// A context user makes the provider decrypt; the API policy decides what leaves the server.
|
|
149
|
+
const providerRow = Object.freeze({ ID: 'S1', Token: 'plain-secret' });
|
|
150
|
+
const { provider } = fakeProvider(SECRETS, [providerRow]);
|
|
151
|
+
|
|
152
|
+
const result = await new DatasetResolverExtended().GetDatasetByName('Secrets_Dataset', context(provider));
|
|
153
|
+
|
|
154
|
+
const items = JSON.parse(result.Results) as DatasetResultType['Results'];
|
|
155
|
+
expect(items[0].Results[0].Token).toBe(ENCRYPTED_SENTINEL);
|
|
156
|
+
expect(result.Results).not.toContain('plain-secret');
|
|
157
|
+
expect(providerRow.Token).toBe('plain-secret');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('GetDatasetStatusByName', () => {
|
|
162
|
+
it('refuses a stacked-statement ItemFilter before the provider is reached', async () => {
|
|
163
|
+
const { provider, calls } = fakeProvider();
|
|
164
|
+
|
|
165
|
+
await expect(new DatasetStatusResolver().GetDatasetStatusByName('MJ_Metadata', context(provider), STACKED))
|
|
166
|
+
.rejects.toThrow(/multiple statements are not permitted/);
|
|
167
|
+
expect(calls).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('passes the session user to the provider', async () => {
|
|
171
|
+
const { provider, calls } = fakeProvider();
|
|
172
|
+
|
|
173
|
+
await new DatasetStatusResolver().GetDatasetStatusByName('Things_Dataset', context(provider), [{ ItemCode: 'Things', Filter: "Name = 'x'" }]);
|
|
174
|
+
|
|
175
|
+
expect(calls).toHaveLength(1);
|
|
176
|
+
expect(calls[0].ContextUser).toBe(USER);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('refuses an ItemFilter that reads an entity the user has no read permission on', async () => {
|
|
180
|
+
const { provider, calls } = fakeProvider();
|
|
181
|
+
const outsider = new UserInfo(undefined, {
|
|
182
|
+
ID: 'U2', Name: 'Outsider', Email: 'outsider@example.com', IsActive: true,
|
|
183
|
+
UserRoles: [{ UserID: 'U2', RoleID: OTHER_ROLE_ID, Role: 'Other' }],
|
|
184
|
+
});
|
|
185
|
+
const filters = [{ ItemCode: 'Things', Filter: 'ID IN (SELECT ID FROM __mj.vwSecrets)' }];
|
|
186
|
+
|
|
187
|
+
await expect(new DatasetStatusResolver().GetDatasetStatusByName('Things_Dataset', context(provider, { email: outsider.Email, userRecord: outsider }), filters))
|
|
188
|
+
.rejects.toThrow(/read permission/);
|
|
189
|
+
expect(calls).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('GetMultipleDatasetStatusByName', () => {
|
|
194
|
+
it('passes the session user to the provider for every dataset', async () => {
|
|
195
|
+
const { provider, calls } = fakeProvider();
|
|
196
|
+
|
|
197
|
+
await new DatasetStatusResolver().GetMultipleDatasetStatusByName(['A', 'B'], context(provider));
|
|
198
|
+
|
|
199
|
+
expect(calls.map(c => c.ContextUser)).toEqual([USER, USER]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// ResolverBase transitively pulls in type-graphql decorators, which need the
|
|
2
|
+
// Reflect.metadata polyfill at import time.
|
|
3
|
+
import 'reflect-metadata';
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { CompositeKey, DatabaseProviderBase, EntityInfo, EntityPermissionType, Metadata, RowLevelSecurityFilterInfo, UserInfo } from '@memberjunction/core';
|
|
6
|
+
import type { DeleteSQLResult, IMetadataProvider, QueryExecutionSpec, RunQueryResult, SaveSQLResult } from '@memberjunction/core';
|
|
7
|
+
import { EntityRecordNameResolver } from '../resolvers/EntityRecordNameResolver.js';
|
|
8
|
+
import type { AppContext, UserPayload } from '../types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The record-name queries against a real provider whose database only records what it is sent.
|
|
12
|
+
* The lookup the resolver causes carries the acting user's read row filter, binds the key value,
|
|
13
|
+
* refuses a key that is not the entity's primary key, and never runs without an acting user.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const CLIENTS_ID = 'E0000000-0000-0000-0000-0000000000C1';
|
|
17
|
+
const ACCOUNT_MANAGER_ROLE_ID = 'B0000000-0000-0000-0000-0000000000C1';
|
|
18
|
+
const OWN_CLIENTS_FILTER_ID = 'F0000000-0000-0000-0000-0000000000C1';
|
|
19
|
+
const MANAGER_ID = 'C0000000-0000-0000-0000-0000000000C1';
|
|
20
|
+
const CLIENT_ID_VALUE = '33333333-3333-3333-3333-333333333333';
|
|
21
|
+
|
|
22
|
+
const ROW_FILTERS: RowLevelSecurityFilterInfo[] = [
|
|
23
|
+
new RowLevelSecurityFilterInfo({ ID: OWN_CLIENTS_FILTER_ID, Name: 'Own clients', FilterText: "AccountManagerID = '{{UserID}}'" }),
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** `Clients`: account managers read only the clients they manage. */
|
|
27
|
+
function clientsEntity(): EntityInfo {
|
|
28
|
+
return new EntityInfo({
|
|
29
|
+
ID: CLIENTS_ID, Name: 'Clients', SchemaName: 'dbo', BaseTable: 'Client', BaseView: 'vwClients',
|
|
30
|
+
Permissions: [
|
|
31
|
+
{ ID: 'perm-manager', EntityID: CLIENTS_ID, RoleID: ACCOUNT_MANAGER_ROLE_ID, CanRead: true, ReadRLSFilterID: OWN_CLIENTS_FILTER_ID, Type: 'Allow' },
|
|
32
|
+
],
|
|
33
|
+
Fields: [
|
|
34
|
+
{ ID: 'cl-id', EntityID: CLIENTS_ID, Sequence: 1, Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true, NeedsQuotes: true },
|
|
35
|
+
{ ID: 'cl-name', EntityID: CLIENTS_ID, Sequence: 2, Name: 'Name', Type: 'nvarchar', IsNameField: true, NeedsQuotes: true },
|
|
36
|
+
{ ID: 'cl-tier', EntityID: CLIENTS_ID, Sequence: 3, Name: 'Tier', Type: 'int', NeedsQuotes: false },
|
|
37
|
+
{ ID: 'cl-manager', EntityID: CLIENTS_ID, Sequence: 4, Name: 'AccountManagerID', Type: 'uniqueidentifier', NeedsQuotes: true },
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SentQuery {
|
|
43
|
+
SQL: string;
|
|
44
|
+
Parameters: unknown[] | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A SQL Server-shaped provider over `Clients`, whose database answers every query with one row. */
|
|
48
|
+
class RecordingProvider extends DatabaseProviderBase {
|
|
49
|
+
public Sent: SentQuery[] = [];
|
|
50
|
+
private readonly entities = [clientsEntity()];
|
|
51
|
+
|
|
52
|
+
public override get Entities(): EntityInfo[] {
|
|
53
|
+
return this.entities;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public override get RowLevelSecurityFilters(): RowLevelSecurityFilterInfo[] {
|
|
57
|
+
return ROW_FILTERS;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async ExecuteSQL<T>(sql: string, parameters?: unknown[]): Promise<Array<T>> {
|
|
61
|
+
this.Sent.push({ SQL: sql, Parameters: parameters });
|
|
62
|
+
return [{ Name: 'Acme Manufacturing' }] as T[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public override BuildParameterPlaceholder(index: number): string { return `@p${index}`; }
|
|
66
|
+
protected get UUIDFunctionPattern(): RegExp { return /^newid\(\)$/i; }
|
|
67
|
+
protected get DBDefaultFunctionPattern(): RegExp { return /^getdate\(\)$/i; }
|
|
68
|
+
public QuoteIdentifier(name: string): string { return `[${name}]`; }
|
|
69
|
+
public QuoteSchemaAndView(schema: string, obj: string): string { return `[${schema}].[${obj}]`; }
|
|
70
|
+
protected BuildChildDiscoverySQL(): string { return ''; }
|
|
71
|
+
protected BuildHardLinkDependencySQL(): string { return ''; }
|
|
72
|
+
protected BuildSoftLinkDependencySQL(): string { return ''; }
|
|
73
|
+
protected async GenerateSaveSQL(): Promise<SaveSQLResult> { return { fullSQL: '' }; }
|
|
74
|
+
protected GenerateDeleteSQL(): DeleteSQLResult { return { fullSQL: '' }; }
|
|
75
|
+
protected BuildRecordChangeSQL(): { sql: string; parameters?: unknown[] } | null { return null; }
|
|
76
|
+
protected BuildSiblingRecordChangeSQL(): string { return ''; }
|
|
77
|
+
async BeginTransaction(): Promise<void> {}
|
|
78
|
+
async CommitTransaction(): Promise<void> {}
|
|
79
|
+
async RollbackTransaction(): Promise<void> {}
|
|
80
|
+
protected async InternalExecuteQueryFromSpec(_spec: QueryExecutionSpec): Promise<RunQueryResult> {
|
|
81
|
+
throw new Error('Not supported.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const MANAGER = new UserInfo(null, {
|
|
86
|
+
ID: MANAGER_ID, Name: 'Morgan', Email: 'morgan@example.com', IsActive: true,
|
|
87
|
+
UserRoles: [{ UserID: MANAGER_ID, RoleID: ACCOUNT_MANAGER_ROLE_ID, Role: 'Account Managers' }],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function key(fieldName: string, value: string): CompositeKey {
|
|
91
|
+
return new CompositeKey([{ FieldName: fieldName, Value: value }]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('EntityRecordNameResolver lookups under row-level security', () => {
|
|
95
|
+
let provider: RecordingProvider;
|
|
96
|
+
let savedProvider: IMetadataProvider;
|
|
97
|
+
let context: AppContext;
|
|
98
|
+
const resolver = new EntityRecordNameResolver();
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
provider = new RecordingProvider();
|
|
102
|
+
// Row filters resolve through the global provider.
|
|
103
|
+
savedProvider = Metadata.Provider;
|
|
104
|
+
Metadata.Provider = provider;
|
|
105
|
+
const userPayload: UserPayload = { email: MANAGER.Email, userRecord: MANAGER, sessionId: 'session-1' };
|
|
106
|
+
context = { dataSource: undefined, dataSources: [], providers: [{ provider, type: 'Read-Only' }], userPayload };
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
Metadata.Provider = savedProvider;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
function managerRowFilter(): string {
|
|
114
|
+
return provider.EntityByName('Clients').GetUserRowLevelSecurityWhereClause(MANAGER, EntityPermissionType.Read, '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
it("looks the name up under the acting user's read row filter, with the key value bound", async () => {
|
|
118
|
+
const rowFilter = managerRowFilter();
|
|
119
|
+
expect(rowFilter).toContain(MANAGER_ID);
|
|
120
|
+
|
|
121
|
+
const result = await resolver.GetEntityRecordName('Clients', key('ID', CLIENT_ID_VALUE), context);
|
|
122
|
+
|
|
123
|
+
expect(result.Success).toBe(true);
|
|
124
|
+
expect(provider.Sent).toEqual([
|
|
125
|
+
{ SQL: `SELECT [Name] FROM [dbo].[vwClients] WHERE [ID]=@p0 AND (${rowFilter})`, Parameters: [CLIENT_ID_VALUE] },
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('applies the row filter to each item of a batch', async () => {
|
|
130
|
+
const rowFilter = managerRowFilter();
|
|
131
|
+
|
|
132
|
+
const results = await resolver.GetEntityRecordNames([{ EntityName: 'Clients', CompositeKey: key('ID', CLIENT_ID_VALUE) }], context);
|
|
133
|
+
|
|
134
|
+
expect(results.map(r => r.Success)).toEqual([true]);
|
|
135
|
+
expect(provider.Sent.map(q => q.SQL)).toEqual([`SELECT [Name] FROM [dbo].[vwClients] WHERE [ID]=@p0 AND (${rowFilter})`]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('refuses a key field that is not a primary key, without querying', async () => {
|
|
139
|
+
const result = await resolver.GetEntityRecordName('Clients', key('Tier', '1'), context);
|
|
140
|
+
|
|
141
|
+
expect(result.Success).toBe(false);
|
|
142
|
+
expect(result.RecordName).toBeUndefined();
|
|
143
|
+
expect(provider.Sent).toHaveLength(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('looks no name up when there is no acting user', async () => {
|
|
147
|
+
const result = await resolver.InnerGetEntityRecordName(provider, 'Clients', key('ID', CLIENT_ID_VALUE));
|
|
148
|
+
|
|
149
|
+
expect(result.Success).toBe(false);
|
|
150
|
+
expect(provider.Sent).toHaveLength(0);
|
|
151
|
+
});
|
|
152
|
+
});
|