@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,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy that decides who may run an action through a public entry point. It reads the
|
|
3
|
+
* `MJ: Action Authorizations` rows for the action, the caller's roles, one provider's authorizations
|
|
4
|
+
* and role grants, and whether the action's class authorizes its caller itself. It fails closed: an
|
|
5
|
+
* action that no authorization grants, and whose class does not authorize its caller, runs for
|
|
6
|
+
* Owners only.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
9
|
+
import { AuthorizationInfo, AuthorizationRoleInfo, Metadata, UserInfo, UserRoleInfo } from '@memberjunction/core';
|
|
10
|
+
import { EvaluateActionRunAuthorization, IsOwner, type AuthorizationMetadata } from '../auth/actionAuthorization.js';
|
|
11
|
+
|
|
12
|
+
const ANALYST_ROLE = 'A11C0000-0000-4000-8000-0000000000F1';
|
|
13
|
+
const AUDITOR_ROLE = 'A11C0000-0000-4000-8000-0000000000F2';
|
|
14
|
+
const BLOCKED_ROLE = 'A11C0000-0000-4000-8000-0000000000F3';
|
|
15
|
+
|
|
16
|
+
const DATA_TOOLS = new AuthorizationInfo({ ID: 'auth-data-tools', Name: 'Data Tools', IsActive: true });
|
|
17
|
+
const RUN_QUERIES = new AuthorizationInfo({ ID: 'auth-run-queries', Name: 'Run Queries', IsActive: true, ParentID: DATA_TOOLS.ID });
|
|
18
|
+
const RETIRED_TOOL = new AuthorizationInfo({ ID: 'auth-retired-tool', Name: 'Retired Tool', IsActive: false, ParentID: DATA_TOOLS.ID });
|
|
19
|
+
const SWITCHED_OFF = new AuthorizationInfo({ ID: 'auth-switched-off', Name: 'Switched Off', IsActive: false });
|
|
20
|
+
const AUTHORIZATIONS = [DATA_TOOLS, RUN_QUERIES, RETIRED_TOOL, SWITCHED_OFF];
|
|
21
|
+
const AUTHORIZATION_ROLES = [
|
|
22
|
+
new AuthorizationRoleInfo({ ID: 'ar-1', AuthorizationID: RUN_QUERIES.ID, RoleID: ANALYST_ROLE, Type: 'Allow' }),
|
|
23
|
+
new AuthorizationRoleInfo({ ID: 'ar-2', AuthorizationID: DATA_TOOLS.ID, RoleID: AUDITOR_ROLE, Type: 'Allow' }),
|
|
24
|
+
new AuthorizationRoleInfo({ ID: 'ar-3', AuthorizationID: RUN_QUERIES.ID, RoleID: BLOCKED_ROLE, Type: 'Deny' }),
|
|
25
|
+
new AuthorizationRoleInfo({ ID: 'ar-4', AuthorizationID: SWITCHED_OFF.ID, RoleID: ANALYST_ROLE, Type: 'Allow' }),
|
|
26
|
+
];
|
|
27
|
+
/** The provider metadata the decision reads. */
|
|
28
|
+
const METADATA: AuthorizationMetadata = { Authorizations: AUTHORIZATIONS, AuthorizationRoles: AUTHORIZATION_ROLES };
|
|
29
|
+
|
|
30
|
+
const user = (type: string, roleIDs: string[]): UserInfo =>
|
|
31
|
+
new UserInfo(null, { ID: `user-${type}-${roleIDs.join('-')}`, Type: type, UserRoles: roleIDs.map((RoleID) => new UserRoleInfo({ RoleID })) });
|
|
32
|
+
|
|
33
|
+
/** Decides for an action whose class does not authorize its caller. */
|
|
34
|
+
const decide = (caller: UserInfo, linkedAuthorizationIDs: string[]) =>
|
|
35
|
+
EvaluateActionRunAuthorization(caller, linkedAuthorizationIDs, METADATA, false);
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
// The decision reads only the metadata it is given, so any read of the global provider fails the test.
|
|
40
|
+
vi.spyOn(Metadata, 'Provider', 'get').mockImplementation(() => {
|
|
41
|
+
throw new Error('The decision read the global metadata provider');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('IsOwner', () => {
|
|
46
|
+
it('recognises the Owner type, padded as the nchar column stores it', () => {
|
|
47
|
+
expect(IsOwner(user('Owner ', []))).toBe(true);
|
|
48
|
+
expect(IsOwner(user('User', [ANALYST_ROLE]))).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('EvaluateActionRunAuthorization', () => {
|
|
53
|
+
describe('Owners', () => {
|
|
54
|
+
it('lets an Owner run any action, linked or not', () => {
|
|
55
|
+
expect(decide(user('Owner ', []), []).Allowed).toBe(true);
|
|
56
|
+
expect(decide(user('Owner ', []), [RUN_QUERIES.ID]).Allowed).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('an action with no Action Authorization rows', () => {
|
|
61
|
+
it('is refused to any other user when its class does not authorize its caller', () => {
|
|
62
|
+
const decision = decide(user('User', [ANALYST_ROLE]), []);
|
|
63
|
+
|
|
64
|
+
expect(decision.Allowed).toBe(false);
|
|
65
|
+
expect(decision.Reason).toMatch(/no action authorization/i);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('runs for any user when its class authorizes its caller, so the action\'s own check decides', () => {
|
|
69
|
+
expect(EvaluateActionRunAuthorization(user('User', [ANALYST_ROLE]), [], METADATA, true).Allowed).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('an action with Action Authorization rows', () => {
|
|
74
|
+
it('is decided by its links, even when its class authorizes its caller', () => {
|
|
75
|
+
expect(EvaluateActionRunAuthorization(user('User', ['some-other-role']), [RUN_QUERIES.ID], METADATA, true).Allowed).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('runs for a user whose role holds a linked authorization', () => {
|
|
79
|
+
expect(decide(user('User', [ANALYST_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('runs for a user whose role holds an ancestor of a linked authorization', () => {
|
|
83
|
+
expect(decide(user('User', [AUDITOR_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('is refused to a user whose roles hold none of the linked authorizations', () => {
|
|
87
|
+
expect(decide(user('User', ['some-other-role']), [RUN_QUERIES.ID]).Allowed).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('is refused when the linked authorization is inactive', () => {
|
|
91
|
+
expect(decide(user('User', [ANALYST_ROLE]), [SWITCHED_OFF.ID]).Allowed).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('is refused when the linked authorization is inactive, even to a holder of its parent', () => {
|
|
95
|
+
expect(decide(user('User', [AUDITOR_ROLE]), [RETIRED_TOOL.ID]).Allowed).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('is refused when one of the user\'s roles is denied the linked authorization', () => {
|
|
99
|
+
expect(decide(user('User', [ANALYST_ROLE, BLOCKED_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('is refused when a role is denied the linked authorization, even if another role holds its parent', () => {
|
|
103
|
+
expect(decide(user('User', [AUDITOR_ROLE, BLOCKED_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('ignores a linked authorization that no longer exists in metadata', () => {
|
|
107
|
+
expect(decide(user('User', [ANALYST_ROLE]), ['auth-deleted']).Allowed).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('matches authorization IDs regardless of case', () => {
|
|
111
|
+
expect(decide(user('User', [ANALYST_ROLE]), [RUN_QUERIES.ID.toUpperCase()]).Allowed).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('the metadata it reads', () => {
|
|
116
|
+
it('decides from the role grants it is given, never from the global provider', () => {
|
|
117
|
+
const sameTreeWithoutGrants: AuthorizationMetadata = { Authorizations: AUTHORIZATIONS, AuthorizationRoles: [] };
|
|
118
|
+
|
|
119
|
+
expect(decide(user('User', [ANALYST_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(true);
|
|
120
|
+
expect(EvaluateActionRunAuthorization(user('User', [ANALYST_ROLE]), [RUN_QUERIES.ID], sameTreeWithoutGrants, false).Allowed).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('walks to the parent within the authorizations it is given', () => {
|
|
124
|
+
const treeWithoutParent: AuthorizationMetadata = { Authorizations: [RUN_QUERIES], AuthorizationRoles: AUTHORIZATION_ROLES };
|
|
125
|
+
|
|
126
|
+
expect(decide(user('User', [AUDITOR_ROLE]), [RUN_QUERIES.ID]).Allowed).toBe(true);
|
|
127
|
+
expect(EvaluateActionRunAuthorization(user('User', [AUDITOR_ROLE]), [RUN_QUERIES.ID], treeWithoutParent, false).Allowed).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An ad-hoc query's timeout never exceeds the server's requestTimeout, so a caller cannot hold a
|
|
3
|
+
* read-only connection longer than any other request may.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { ClampAdhocTimeoutSeconds } from '../resolvers/adhocTimeout';
|
|
7
|
+
|
|
8
|
+
describe('ClampAdhocTimeoutSeconds', () => {
|
|
9
|
+
it('caps a longer request at requestTimeout', () => {
|
|
10
|
+
expect(ClampAdhocTimeoutSeconds(600, 30000)).toBe(30);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('keeps a shorter request', () => {
|
|
14
|
+
expect(ClampAdhocTimeoutSeconds(5, 30000)).toBe(5);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('uses 30 seconds when the caller names none, within the cap', () => {
|
|
18
|
+
expect(ClampAdhocTimeoutSeconds(undefined, 30000)).toBe(30);
|
|
19
|
+
expect(ClampAdhocTimeoutSeconds(undefined, 10000)).toBe(10);
|
|
20
|
+
expect(ClampAdhocTimeoutSeconds(0, 30000)).toBe(30);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('leaves the request alone when the server sets no limit', () => {
|
|
24
|
+
expect(ClampAdhocTimeoutSeconds(600, 0)).toBe(600);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// The gate lives beside the subscription it protects, and that module carries type-graphql
|
|
2
|
+
// decorators — so the metadata shim has to be in place before it is imported.
|
|
3
|
+
import 'reflect-metadata';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
ConfigureRecordDataBroadcast,
|
|
7
|
+
MayBroadcastRecordData,
|
|
8
|
+
ReadIsRowFiltered,
|
|
9
|
+
ShapeCacheInvalidationEvent,
|
|
10
|
+
} from '../generic/CacheInvalidationResolver.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The cache-invalidation subscription is delivered to every connected client with no per-user
|
|
14
|
+
* filter, so anything `MayBroadcastRecordData` lets through is readable by every signed-in session
|
|
15
|
+
* regardless of row-level security or any tenant scoping a consumer applies on the read path.
|
|
16
|
+
*
|
|
17
|
+
* The important case is the DEFAULT: an untouched deployment must broadcast no row data at all,
|
|
18
|
+
* because that is the configuration everyone upgrades into.
|
|
19
|
+
*/
|
|
20
|
+
describe('MayBroadcastRecordData', () => {
|
|
21
|
+
afterEach(() => ConfigureRecordDataBroadcast([]));
|
|
22
|
+
|
|
23
|
+
it('broadcasts nothing before anything has configured it', () => {
|
|
24
|
+
// No ConfigureRecordDataBroadcast call at all — the state a process starts in, and the state it
|
|
25
|
+
// stays in if bootstrap ever stops calling it. Failing closed there is the whole point.
|
|
26
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
27
|
+
expect(MayBroadcastRecordData('AI Models')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('broadcasts nothing when the deployment has not opted in', () => {
|
|
31
|
+
ConfigureRecordDataBroadcast([]);
|
|
32
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('broadcasts nothing when the config value is absent', () => {
|
|
36
|
+
ConfigureRecordDataBroadcast(undefined);
|
|
37
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('allows exactly the entities named, and no others', () => {
|
|
41
|
+
ConfigureRecordDataBroadcast(['AI Models']);
|
|
42
|
+
expect(MayBroadcastRecordData('AI Models')).toBe(true);
|
|
43
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
44
|
+
expect(MayBroadcastRecordData('AI Model Vendors')).toBe(false);
|
|
45
|
+
// The allowlisted name as a SUBSTRING of another entity must not carry it in. Written this way
|
|
46
|
+
// deliberately: 'AI Model Vendors' above does not contain 'AI Models', so it passes against a
|
|
47
|
+
// loose `includes` match too and proves nothing — a substring mutation survived it.
|
|
48
|
+
expect(MayBroadcastRecordData('Archived AI Models 2024')).toBe(false);
|
|
49
|
+
// ...and the reverse direction, where the entity name is a substring of the allowlisted one.
|
|
50
|
+
expect(MayBroadcastRecordData('AI Model')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('matches entity names case- and whitespace-insensitively', () => {
|
|
54
|
+
ConfigureRecordDataBroadcast([' ai models ']);
|
|
55
|
+
expect(MayBroadcastRecordData('AI Models')).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("restores the previous broadcast-everything behaviour only on an explicit '*'", () => {
|
|
59
|
+
ConfigureRecordDataBroadcast(['*']);
|
|
60
|
+
expect(MayBroadcastRecordData('Users')).toBe(true);
|
|
61
|
+
expect(MayBroadcastRecordData('Organization Person Roles')).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Importing this module must not drag server config loading in with it.
|
|
67
|
+
*
|
|
68
|
+
* The first version of this change read `configInfo` at module scope. `config.ts` runs
|
|
69
|
+
* `loadConfig()` on import, so every test that touched the resolver — directly or through
|
|
70
|
+
* ResolverBase — died at import with "Configuration validation failed". CI caught it; this keeps
|
|
71
|
+
* it caught.
|
|
72
|
+
*/
|
|
73
|
+
describe('CacheInvalidationResolver module surface', () => {
|
|
74
|
+
it('imports without any server configuration present', async () => {
|
|
75
|
+
await expect(import('../generic/CacheInvalidationResolver.js')).resolves.toBeDefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The allowlist is normalised where it enters, not where it is compared.
|
|
81
|
+
*
|
|
82
|
+
* Raised in review: the wildcard was matched raw while entity names were trimmed and lowercased,
|
|
83
|
+
* so a hand-edited `[' * ']` matched neither branch and silently opted nothing in — a config that
|
|
84
|
+
* reads as "broadcast everything" while the gate stayed fully closed.
|
|
85
|
+
*/
|
|
86
|
+
describe('allowlist normalisation', () => {
|
|
87
|
+
afterEach(() => ConfigureRecordDataBroadcast([]));
|
|
88
|
+
|
|
89
|
+
it("honours a wildcard written with surrounding whitespace", () => {
|
|
90
|
+
ConfigureRecordDataBroadcast([' * ']);
|
|
91
|
+
expect(MayBroadcastRecordData('Users')).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('ignores blank entries rather than letting one match something', () => {
|
|
95
|
+
ConfigureRecordDataBroadcast(['', ' ']);
|
|
96
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
97
|
+
// A blank entry must not become a wildcard by accident either.
|
|
98
|
+
expect(MayBroadcastRecordData('')).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('still matches a normal name written with odd casing and padding', () => {
|
|
102
|
+
ConfigureRecordDataBroadcast([' AI MODELS ']);
|
|
103
|
+
expect(MayBroadcastRecordData('ai models')).toBe(true);
|
|
104
|
+
expect(MayBroadcastRecordData('Users')).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Entity-level delivery filtering, raised in review.
|
|
110
|
+
*
|
|
111
|
+
* An event for an entity a session cannot read at all tells that session the record exists, its
|
|
112
|
+
* stable key and when it changed — and the session could never have read it, so withholding costs
|
|
113
|
+
* nothing. This does NOT close the row-level case: two sessions that both hold read permission on
|
|
114
|
+
* an entity still see each other's keys. That is tracked separately and deliberately not faked
|
|
115
|
+
* green here.
|
|
116
|
+
*/
|
|
117
|
+
describe('cacheInvalidationFilter', () => {
|
|
118
|
+
const event = (entityName: string) => ({
|
|
119
|
+
entityName,
|
|
120
|
+
primaryKeyValues: '[{"FieldName":"ID","Value":"r1"}]',
|
|
121
|
+
action: 'save',
|
|
122
|
+
sourceServerId: 's1',
|
|
123
|
+
timestamp: new Date(),
|
|
124
|
+
});
|
|
125
|
+
const session = (userRecord: unknown) => ({ userPayload: { userRecord } });
|
|
126
|
+
|
|
127
|
+
const withEntities = async (entities: Array<{ Name: string; CanRead: boolean }>) => {
|
|
128
|
+
vi.resetModules();
|
|
129
|
+
vi.doMock('@memberjunction/core', () => ({
|
|
130
|
+
Metadata: class {
|
|
131
|
+
get Entities() {
|
|
132
|
+
return entities.map((e) => ({
|
|
133
|
+
Name: e.Name,
|
|
134
|
+
GetUserPermisions: () => ({ CanRead: e.CanRead }),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
// The real one is case-insensitive and trims — the filter relies on that, so the mock does too.
|
|
138
|
+
EntityByName(name: string) {
|
|
139
|
+
const key = name.trim().toLowerCase();
|
|
140
|
+
return this.Entities.find((e) => e.Name.toLowerCase() === key);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
}));
|
|
144
|
+
return (await import('../generic/CacheInvalidationResolver.js')).cacheInvalidationFilter;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
afterEach(() => vi.doUnmock('@memberjunction/core'));
|
|
148
|
+
|
|
149
|
+
it('delivers an event for an entity the session may read', async () => {
|
|
150
|
+
const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
|
|
151
|
+
expect(filter({ payload: event('AI Models') as never, context: session({ ID: 'u1' }) })).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('withholds an event for an entity the session may NOT read', async () => {
|
|
155
|
+
const filter = await withEntities([{ Name: 'Users', CanRead: false }]);
|
|
156
|
+
expect(filter({ payload: event('Users') as never, context: session({ ID: 'u1' }) })).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('fails closed when the connection carries no user', async () => {
|
|
160
|
+
// onConnect rejects a socket whose token does not validate, so this should not arise — and it
|
|
161
|
+
// is the case least deserving of an unfiltered firehose if it does.
|
|
162
|
+
const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
|
|
163
|
+
expect(filter({ payload: event('AI Models') as never, context: undefined })).toBe(false);
|
|
164
|
+
expect(filter({ payload: event('AI Models') as never, context: session(undefined) })).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('delivers when the entity is unknown to this server, rather than silently going stale', async () => {
|
|
168
|
+
// Deliberately the opposite choice: a name absent from metadata cannot be permission-checked,
|
|
169
|
+
// and withholding would stop invalidating a legitimately cacheable entity — a correctness bug
|
|
170
|
+
// in place of a disclosure one.
|
|
171
|
+
const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
|
|
172
|
+
expect(filter({ payload: event('Some Entity This Server Never Heard Of') as never, context: session({ ID: 'u1' }) })).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('withholds an event with no entity name', async () => {
|
|
176
|
+
const filter = await withEntities([{ Name: 'AI Models', CanRead: true }]);
|
|
177
|
+
expect(filter({ payload: { ...event(''), entityName: '' } as never, context: session({ ID: 'u1' }) })).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Row-level narrowing (MJ#5241): a subscriber who reads the entity only through a row-level security filter learns that a
|
|
183
|
+
* row changed, not which one; a subscriber with an unfiltered read keeps the key; a subscriber with no read is the entity-level
|
|
184
|
+
* filter's business and gets nothing here to shape.
|
|
185
|
+
*/
|
|
186
|
+
describe('ReadIsRowFiltered and ShapeCacheInvalidationEvent', () => {
|
|
187
|
+
const FILTERED_ROLE = 'role-participant';
|
|
188
|
+
const OPEN_ROLE = 'role-staff';
|
|
189
|
+
const entity = {
|
|
190
|
+
Permissions: [
|
|
191
|
+
{ RoleID: FILTERED_ROLE, CanRead: true, Type: 'Allow', ReadRLSFilterID: 'filter-1' },
|
|
192
|
+
{ RoleID: OPEN_ROLE, CanRead: true, Type: 'Allow', ReadRLSFilterID: null },
|
|
193
|
+
{ RoleID: 'role-denied', CanRead: true, Type: 'Deny', ReadRLSFilterID: null },
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
const payload = {
|
|
197
|
+
entityName: 'Spaces',
|
|
198
|
+
primaryKeyValues: 'ID|abc',
|
|
199
|
+
action: 'save',
|
|
200
|
+
sourceServerId: 'srv',
|
|
201
|
+
timestamp: new Date('2026-10-07T00:00:00Z'),
|
|
202
|
+
originSessionId: 'sess',
|
|
203
|
+
recordData: '{"Name":"x"}',
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
it('is row-filtered when every read the user holds carries a filter', () => {
|
|
207
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: FILTERED_ROLE }] })).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('is not row-filtered when any read the user holds is unfiltered, whichever role brings it', () => {
|
|
211
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: OPEN_ROLE }] })).toBe(false);
|
|
212
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: FILTERED_ROLE }, { RoleID: OPEN_ROLE }] })).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('is not row-filtered when the user holds no read at all (the entity-level filter owns that case), and a Deny row grants nothing', () => {
|
|
216
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: 'role-stranger' }] })).toBe(false);
|
|
217
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: 'role-denied' }] })).toBe(false);
|
|
218
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: null })).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('matches role ids without regard to case or whitespace', () => {
|
|
222
|
+
expect(ReadIsRowFiltered(entity, { UserRoles: [{ RoleID: ` ${FILTERED_ROLE.toUpperCase()} ` }] })).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('withholds the key and the row from a row-filtered subscriber, and keeps the rest of the event', () => {
|
|
226
|
+
const shaped = ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: FILTERED_ROLE }] }, entity);
|
|
227
|
+
expect(shaped.PrimaryKeyValues).toBeUndefined();
|
|
228
|
+
expect(shaped.RecordData).toBeUndefined();
|
|
229
|
+
expect(shaped.EntityName).toBe('Spaces');
|
|
230
|
+
expect(shaped.Action).toBe('save');
|
|
231
|
+
expect(shaped.SourceServerID).toBe('srv');
|
|
232
|
+
expect(shaped.OriginSessionID).toBe('sess');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('delivers the event whole to an unfiltered reader, and when the entity or the user is unknown', () => {
|
|
236
|
+
expect(ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: OPEN_ROLE }] }, entity).PrimaryKeyValues).toBe('ID|abc');
|
|
237
|
+
expect(ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: OPEN_ROLE }] }, entity).RecordData).toBe('{"Name":"x"}');
|
|
238
|
+
expect(ShapeCacheInvalidationEvent(payload, { UserRoles: [{ RoleID: FILTERED_ROLE }] }, null).PrimaryKeyValues).toBe('ID|abc');
|
|
239
|
+
expect(ShapeCacheInvalidationEvent(payload, undefined, entity).PrimaryKeyValues).toBe('ID|abc');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tenant PreRunView hook checks the caller's ExtraFilter and builds
|
|
3
|
+
* `(<ExtraFilter>) AND (<tenant predicate>)`. On PostgreSQL the provider then rewrites the whole
|
|
4
|
+
* filter (identifier quoting, T-SQL date functions, boolean literals), and that rewrite can change
|
|
5
|
+
* the caller's part after the hook checked it. These tests run the real hook, the real PostgreSQL
|
|
6
|
+
* rewrite and the real view query builder, with only ExecuteSQL recorded, and pin that the tenant
|
|
7
|
+
* predicate stays a separate AND term whatever the rewrite does to the caller's term. The last
|
|
8
|
+
* block runs the same path with no hook and pins that the per-term rewrite keeps a SQL Server
|
|
9
|
+
* bracket name whole.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import {
|
|
13
|
+
ClearAllDataHooks,
|
|
14
|
+
Metadata,
|
|
15
|
+
RegisterDataHook,
|
|
16
|
+
UserInfo,
|
|
17
|
+
type EntityInfo,
|
|
18
|
+
type IMetadataProvider,
|
|
19
|
+
} from '@memberjunction/core';
|
|
20
|
+
import { PostgreSQLDataProvider } from '@memberjunction/postgresql-dataprovider';
|
|
21
|
+
import type { SQLParserDialect } from '@memberjunction/sql-dialect';
|
|
22
|
+
import { IsBalancedSQLFragment, SplitTopLevelAndTerms } from '@memberjunction/sql-parser';
|
|
23
|
+
import { createTenantPreRunViewHook } from '../multiTenancy/index.js';
|
|
24
|
+
import type { MultiTenancyConfig } from '../config.js';
|
|
25
|
+
|
|
26
|
+
const TENANT_TERM = `(("OrganizationID" = 't1'))`;
|
|
27
|
+
|
|
28
|
+
/** The real PostgreSQL provider over one entity fixture, with every executed statement recorded. */
|
|
29
|
+
class RecordingPostgreSQLProvider extends PostgreSQLDataProvider {
|
|
30
|
+
public readonly ExecutedSQL: string[] = [];
|
|
31
|
+
|
|
32
|
+
constructor(private readonly testEntities: EntityInfo[]) {
|
|
33
|
+
super();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public override get Entities(): EntityInfo[] {
|
|
37
|
+
return this.testEntities;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public override async ExecuteSQL<T>(sql: string): Promise<T[]> {
|
|
41
|
+
this.ExecutedSQL.push(sql);
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A tenant-scoped entity with a boolean field, which the PostgreSQL rewrite coerces. */
|
|
47
|
+
function customersEntity(): EntityInfo {
|
|
48
|
+
const field = (name: string, tsType: string) => ({ Name: name, CodeName: name, TSType: tsType, IsVirtual: false });
|
|
49
|
+
const fields = [field('ID', 'string'), field('Name', 'string'), field('OrganizationID', 'string'), field('IsActive', 'boolean')];
|
|
50
|
+
return {
|
|
51
|
+
ID: 'ENT-CUSTOMERS',
|
|
52
|
+
Name: 'Customers',
|
|
53
|
+
SchemaName: 'app',
|
|
54
|
+
BaseView: 'vwCustomers',
|
|
55
|
+
Fields: fields,
|
|
56
|
+
PrimaryKeys: [fields[0]],
|
|
57
|
+
FirstPrimaryKey: fields[0],
|
|
58
|
+
FieldByName: (n: string) => fields.find((f) => f.Name.toLowerCase() === n.trim().toLowerCase()),
|
|
59
|
+
DatetimeFields: [],
|
|
60
|
+
ExternalDataSourceID: null,
|
|
61
|
+
UserViewMaxRows: 0,
|
|
62
|
+
AuditViewRuns: false,
|
|
63
|
+
GetUserPermisions: () => ({ CanRead: true }),
|
|
64
|
+
GetUserRowLevelSecurityWhereClause: () => '',
|
|
65
|
+
} as unknown as EntityInfo;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function tenantConfig(): MultiTenancyConfig {
|
|
69
|
+
return {
|
|
70
|
+
enabled: true,
|
|
71
|
+
contextSource: 'header',
|
|
72
|
+
tenantHeader: 'x-tenant-id',
|
|
73
|
+
scopingStrategy: 'allowlist',
|
|
74
|
+
scopedEntities: ['Customers'],
|
|
75
|
+
autoExcludeCoreEntities: true,
|
|
76
|
+
defaultTenantColumn: 'OrganizationID',
|
|
77
|
+
entityColumnMappings: {},
|
|
78
|
+
adminRoles: ['Admin'],
|
|
79
|
+
writeProtection: 'strict',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The WHERE clause of the one statement the provider ran. */
|
|
84
|
+
function whereOf(provider: RecordingPostgreSQLProvider): string {
|
|
85
|
+
expect(provider.ExecutedSQL).toHaveLength(1);
|
|
86
|
+
const sql = provider.ExecutedSQL[0];
|
|
87
|
+
return sql.substring(sql.indexOf(' WHERE ') + ' WHERE '.length);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether `term` is one of the top-level AND terms inside the one pair of parentheses around
|
|
92
|
+
* `where`. Only then does it narrow every row the query returns.
|
|
93
|
+
*/
|
|
94
|
+
function isTopLevelAndTerm(where: string, term: string, dialect: SQLParserDialect): boolean {
|
|
95
|
+
const inner = where.slice(1, -1);
|
|
96
|
+
const wrapped = where.startsWith('(') && where.endsWith(')') && IsBalancedSQLFragment(inner, dialect).IsBalanced;
|
|
97
|
+
return wrapped && (SplitTopLevelAndTerms(inner, dialect) ?? []).some((t) => t.trim() === term);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('Multi-tenancy on PostgreSQL — the provider rewrite cannot reach the tenant predicate', () => {
|
|
101
|
+
let provider: RecordingPostgreSQLProvider;
|
|
102
|
+
let savedProvider: IMetadataProvider;
|
|
103
|
+
let user: UserInfo;
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
provider = new RecordingPostgreSQLProvider([customersEntity()]);
|
|
107
|
+
savedProvider = Metadata.Provider;
|
|
108
|
+
Metadata.Provider = provider;
|
|
109
|
+
ClearAllDataHooks();
|
|
110
|
+
RegisterDataHook('PreRunView', createTenantPreRunViewHook(tenantConfig()));
|
|
111
|
+
user = new UserInfo(provider, { ID: 'U1', Email: 'u1@example.com' });
|
|
112
|
+
user.TenantContext = { TenantID: 't1', Source: 'header' };
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
ClearAllDataHooks();
|
|
117
|
+
Metadata.Provider = savedProvider;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('keeps the tenant predicate a separate AND term when the rewrite changes the caller term', async () => {
|
|
121
|
+
// The rewrite turns the caller's quoted text into boolean literals and changes where its
|
|
122
|
+
// parentheses close. Each term is rewritten and wrapped on its own, so that change stays
|
|
123
|
+
// inside the caller's term.
|
|
124
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) AND ("IsActive" = 1'` }, user);
|
|
125
|
+
|
|
126
|
+
expect(result.Success).toBe(true);
|
|
127
|
+
expect(whereOf(provider)).toBe(`((("IsActive" = TRUE) AND ("IsActive" = TRUE)) AND ${TENANT_TERM})`);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('keeps the tenant predicate its own AND term after a quote the boolean rewrite drops, followed by a top-level OR', async () => {
|
|
131
|
+
// The rewrite drops the quote, so text the hook read inside a string literal becomes code
|
|
132
|
+
// with a top-level OR. The tenant predicate must still narrow every row.
|
|
133
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) OR (1=1) OR ("IsActive" = 1'` }, user);
|
|
134
|
+
|
|
135
|
+
expect(result.Success).toBe(true);
|
|
136
|
+
const where = whereOf(provider);
|
|
137
|
+
expect(isTopLevelAndTerm(where, TENANT_TERM, provider.Dialect), `tenant predicate is not a top-level AND term of ${where}`).toBe(true);
|
|
138
|
+
expect(where).toBe(`((("IsActive" = TRUE) OR (1=1) OR ("IsActive" = TRUE)) AND ${TENANT_TERM})`);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('refuses a caller term that the rewrite leaves unbalanced, and runs no SQL', async () => {
|
|
142
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) AND (1 = 1'` }, user);
|
|
143
|
+
|
|
144
|
+
expect(provider.ExecutedSQL).toEqual([]);
|
|
145
|
+
expect(result.Success).toBe(false);
|
|
146
|
+
expect(result.ErrorMessage).toMatch(/ExtraFilter/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('leaves an ordinary filter and the tenant predicate as written, each in its own term', async () => {
|
|
150
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = 1 OR "Name" = 'a'` }, user);
|
|
151
|
+
|
|
152
|
+
expect(result.Success).toBe(true);
|
|
153
|
+
expect(whereOf(provider)).toBe(`((("IsActive" = TRUE OR "Name" = 'a')) AND ${TENANT_TERM})`);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('PostgreSQL ExtraFilter rewrite with no PreRunView hook', () => {
|
|
158
|
+
let provider: RecordingPostgreSQLProvider;
|
|
159
|
+
let savedProvider: IMetadataProvider;
|
|
160
|
+
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
provider = new RecordingPostgreSQLProvider([customersEntity()]);
|
|
163
|
+
savedProvider = Metadata.Provider;
|
|
164
|
+
Metadata.Provider = provider;
|
|
165
|
+
ClearAllDataHooks();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
afterEach(() => {
|
|
169
|
+
Metadata.Provider = savedProvider;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('keeps a SQL Server bracket name that contains AND whole when it rewrites each AND term', async () => {
|
|
173
|
+
const user = new UserInfo(provider, { ID: 'U1', Email: 'u1@example.com' });
|
|
174
|
+
|
|
175
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: '[Profit and Loss] > 0 AND [IsActive] = 1' }, user);
|
|
176
|
+
|
|
177
|
+
expect(result.Success).toBe(true);
|
|
178
|
+
expect(whereOf(provider)).toBe('(("Profit and Loss" > 0) AND ("IsActive" = TRUE))');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -286,6 +286,57 @@ describe('Multi-Tenancy Security Edge Cases', () => {
|
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
+
// ─── Client ExtraFilter containment ───────────────────────────────────
|
|
290
|
+
|
|
291
|
+
describe('client ExtraFilter must stay inside its parentheses', () => {
|
|
292
|
+
// The hook builds `(<ExtraFilter>) AND (<tenant>)`. A filter that closes a parenthesis it did
|
|
293
|
+
// not open turns that into `(1=1) OR (1=1) AND (<tenant>)`: AND binds tighter than OR, so the
|
|
294
|
+
// tenant predicate would narrow only one side of an OR the caller controls.
|
|
295
|
+
const escapes = [
|
|
296
|
+
'1=1) OR (1=1',
|
|
297
|
+
"Name = 'a') OR (Name = 'b'",
|
|
298
|
+
'1=1)) OR ((1=1',
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
it.each(escapes)('refuses the ExtraFilter %j instead of ANDing the tenant predicate onto it', (extraFilter) => {
|
|
302
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
303
|
+
const params = { EntityName: 'Customers', ExtraFilter: extraFilter } as RunViewParams;
|
|
304
|
+
|
|
305
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/parenthesis/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('refuses an ExtraFilter that ends inside an unterminated string literal', () => {
|
|
309
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
310
|
+
const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a" } as RunViewParams;
|
|
311
|
+
|
|
312
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/unterminated/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('refuses an ExtraFilter that contains a comment', () => {
|
|
316
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
317
|
+
const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a' -- note\n" } as RunViewParams;
|
|
318
|
+
|
|
319
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/comment/);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('applies the PostgreSQL rules too when the provider dialect is unknown', () => {
|
|
323
|
+
// The mocked Metadata.Provider here is not a database provider, so the hook reads the filter
|
|
324
|
+
// with every supported dialect.
|
|
325
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
326
|
+
const params = { EntityName: 'Customers', ExtraFilter: 'Name = $$x$$' } as RunViewParams;
|
|
327
|
+
|
|
328
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/dollar/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('keeps a balanced ExtraFilter and the tenant predicate as separate parenthesized terms', () => {
|
|
332
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
333
|
+
const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a) OR (b' OR Name = 'c'" } as RunViewParams;
|
|
334
|
+
|
|
335
|
+
const result = hook(params, makeUser('tenant-abc')) as RunViewParams;
|
|
336
|
+
expect(result.ExtraFilter).toBe("(Name = 'a) OR (b' OR Name = 'c') AND ([OrganizationID] = 'tenant-abc')");
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
289
340
|
// ─── PreSave security edge cases ──────────────────────────────────────
|
|
290
341
|
|
|
291
342
|
describe('PreSave security', () => {
|
|
@@ -65,7 +65,7 @@ describe('Multi-Tenancy Hooks', () => {
|
|
|
65
65
|
|
|
66
66
|
const result = hook(params, user);
|
|
67
67
|
expect((result as RunViewParams).ExtraFilter).toBe(
|
|
68
|
-
"(Status = 'Active') AND [OrganizationID] = 'tenant-abc'"
|
|
68
|
+
"(Status = 'Active') AND ([OrganizationID] = 'tenant-abc')"
|
|
69
69
|
);
|
|
70
70
|
});
|
|
71
71
|
|