@memberjunction/server 6.1.4 → 6.1.6
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 +30 -0
- package/dist/.tsbuildinfo +1 -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 +129 -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/exampleNewUserSubClass.js +2 -1
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +55 -38
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.js +20 -52
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +15 -11
- package/dist/auth/newUsers.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/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +3417 -3428
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +59 -19
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +95 -11
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +186 -76
- 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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -45
- package/dist/index.js.map +1 -1
- package/dist/isolatedProvider.d.ts +20 -0
- package/dist/isolatedProvider.d.ts.map +1 -0
- package/dist/isolatedProvider.js +22 -0
- package/dist/isolatedProvider.js.map +1 -0
- 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 +4 -2
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
- 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 +35 -17
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +92 -166
- 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 +8 -23
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +11 -48
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
- package/dist/resolvers/FetchEntityVectorsResolver.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/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +2 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +83 -22
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +174 -72
- 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 +5 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +2 -1
- 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/SearchKnowledgeResolver.d.ts +1 -0
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
- package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncDataResolver.js +6 -2
- package/dist/resolvers/SyncDataResolver.js.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -10
- package/dist/resolvers/VectorizeEntityResolver.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/TaskGraphActionRunner.d.ts +21 -8
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +43 -16
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/package.json +92 -92
- 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 +3 -3
- package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
- 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 +137 -0
- package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +310 -31
- package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
- package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
- package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
- package/src/__tests__/RunTemplateResolver.test.ts +132 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
- package/src/__tests__/SessionManager.test.ts +94 -17
- package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +131 -0
- package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
- package/src/__tests__/actionAuthorization.test.ts +130 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
- package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
- package/src/__tests__/magicLink.test.ts +15 -76
- package/src/__tests__/magicLinkService.consume.test.ts +230 -0
- package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
- package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
- package/src/__tests__/multiTenancy.security.test.ts +51 -0
- package/src/__tests__/multiTenancy.test.ts +1 -1
- package/src/__tests__/newUsers.test.ts +62 -5
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/agentSessions/SessionManager.ts +23 -25
- package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
- package/src/agentSessions/index.ts +1 -0
- package/src/auth/actionAuthorization.ts +119 -0
- package/src/auth/exampleNewUserSubClass.ts +2 -2
- package/src/auth/magicLink/MagicLinkService.ts +61 -39
- package/src/auth/magicLink/magicLinkCore.ts +19 -56
- package/src/auth/newUsers.ts +15 -11
- package/src/context.ts +25 -34
- package/src/generated/graphql-schemas/__mj.ts +3417 -3428
- package/src/generic/CacheInvalidationResolver.ts +74 -19
- package/src/generic/ResolverBase.ts +207 -80
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/index.ts +49 -46
- package/src/isolatedProvider.ts +22 -0
- package/src/multiTenancy/index.ts +24 -2
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
- package/src/resolvers/ActionResolver.ts +84 -21
- package/src/resolvers/AdhocQueryResolver.ts +105 -187
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +11 -52
- package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +14 -1
- package/src/resolvers/QuerySystemUserResolver.ts +2 -1
- package/src/resolvers/RealtimeClientSessionResolver.ts +196 -77
- package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
- package/src/resolvers/RunAIAgentResolver.ts +4 -1
- package/src/resolvers/RunTemplateResolver.ts +13 -12
- package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
- package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
- package/src/resolvers/SyncDataResolver.ts +6 -2
- package/src/resolvers/VectorizeEntityResolver.ts +16 -11
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/rest/ViewOperationsHandler.ts +37 -18
- package/src/services/TaskGraphActionRunner.ts +45 -16
- 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,119 @@
|
|
|
1
|
+
import { AuthorizationInfo, AuthorizationRoleInfo, AuthorizationRoleType, IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
2
|
+
import { NormalizeUUID, UUIDsEqual } from '@memberjunction/global';
|
|
3
|
+
|
|
4
|
+
/** Authorizations keyed by their normalized ID. */
|
|
5
|
+
type AuthorizationsByID = Map<string, AuthorizationInfo>;
|
|
6
|
+
|
|
7
|
+
/** The outcome of {@link EvaluateActionRunAuthorization}. */
|
|
8
|
+
export interface ActionRunAuthorization {
|
|
9
|
+
/** True when the user may run the action. */
|
|
10
|
+
Allowed: boolean;
|
|
11
|
+
/** Why the run is allowed or refused. */
|
|
12
|
+
Reason: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** One provider's authorization tree and the role grants on it. */
|
|
16
|
+
export type AuthorizationMetadata = Pick<IMetadataProvider, 'Authorizations' | 'AuthorizationRoles'>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True when the user is an Owner, MJ's superuser type. The column is nchar, so the stored value is
|
|
20
|
+
* padded.
|
|
21
|
+
*/
|
|
22
|
+
export function IsOwner(user: UserInfo): boolean {
|
|
23
|
+
return user.Type?.trim().toLowerCase() === 'owner';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Decides whether a user may run an action through a public entry point such as the `RunAction`
|
|
28
|
+
* mutation. It fails closed:
|
|
29
|
+
*
|
|
30
|
+
* - An Owner may run any action.
|
|
31
|
+
* - An action with `MJ: Action Authorizations` rows runs for a user who holds at least one of the
|
|
32
|
+
* linked authorizations. A linked authorization counts only when it is active and none of the
|
|
33
|
+
* user's roles is denied it; the user may then hold it directly or through an ancestor.
|
|
34
|
+
* - An action with no such rows runs for any user when its class authorizes its caller itself
|
|
35
|
+
* (`BaseAction.AuthorizesCaller`), so the action's own check decides. Otherwise it runs for
|
|
36
|
+
* Owners only.
|
|
37
|
+
*
|
|
38
|
+
* The authorizations, their parents and their role grants are read only from `metadata`, never
|
|
39
|
+
* from the global provider. Pass the provider the `MJ: Action Authorizations` rows were read from.
|
|
40
|
+
*
|
|
41
|
+
* Refuse scope-limited sessions before calling this; see `IsScopeLimitedPrincipal`.
|
|
42
|
+
*
|
|
43
|
+
* @param user the caller
|
|
44
|
+
* @param actionAuthorizationIDs the AuthorizationID of each `MJ: Action Authorizations` row for the action
|
|
45
|
+
* @param metadata the provider whose authorizations and role grants decide
|
|
46
|
+
* @param actionAuthorizesCaller true when the action's class authorizes its caller itself
|
|
47
|
+
* @returns whether the run is allowed, and why
|
|
48
|
+
*/
|
|
49
|
+
export function EvaluateActionRunAuthorization(
|
|
50
|
+
user: UserInfo,
|
|
51
|
+
actionAuthorizationIDs: readonly string[],
|
|
52
|
+
metadata: AuthorizationMetadata,
|
|
53
|
+
actionAuthorizesCaller: boolean
|
|
54
|
+
): ActionRunAuthorization {
|
|
55
|
+
if (IsOwner(user)) {
|
|
56
|
+
return { Allowed: true, Reason: 'The user is an Owner.' };
|
|
57
|
+
}
|
|
58
|
+
if (actionAuthorizationIDs.length === 0) {
|
|
59
|
+
return actionAuthorizesCaller
|
|
60
|
+
? { Allowed: true, Reason: 'The action authorizes its caller itself.' }
|
|
61
|
+
: { Allowed: false, Reason: 'No Action Authorization grants this action to any role, and the action does not authorize its caller itself; only an Owner may run it.' };
|
|
62
|
+
}
|
|
63
|
+
const authorizationsByID: AuthorizationsByID = new Map(metadata.Authorizations.map((auth) => [NormalizeUUID(auth.ID), auth]));
|
|
64
|
+
const userGrants = grantsToUserRoles(user, metadata.AuthorizationRoles);
|
|
65
|
+
const granted = actionAuthorizationIDs
|
|
66
|
+
.map((id) => authorizationsByID.get(NormalizeUUID(id)))
|
|
67
|
+
.filter((auth): auth is AuthorizationInfo => auth !== undefined)
|
|
68
|
+
.find((auth) => holdsLinkedAuthorization(auth, authorizationsByID, userGrants));
|
|
69
|
+
return granted
|
|
70
|
+
? { Allowed: true, Reason: `The user holds the "${granted.Name}" authorization.` }
|
|
71
|
+
: { Allowed: false, Reason: 'None of your roles holds an authorization this action requires.' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The role grants that name one of the user's roles. */
|
|
75
|
+
function grantsToUserRoles(user: UserInfo, grants: readonly AuthorizationRoleInfo[]): AuthorizationRoleInfo[] {
|
|
76
|
+
const userRoleIDs = new Set((user.UserRoles ?? []).map((userRole) => NormalizeUUID(userRole.RoleID)));
|
|
77
|
+
return grants.filter((grant) => userRoleIDs.has(NormalizeUUID(grant.RoleID)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* True when the user holds a linked authorization: it is active, none of the user's roles is denied
|
|
82
|
+
* it, and the user holds it directly or through an ancestor. The first two are checked on the linked
|
|
83
|
+
* authorization itself, because the ancestor walk would otherwise grant it through the parent.
|
|
84
|
+
*/
|
|
85
|
+
function holdsLinkedAuthorization(auth: AuthorizationInfo, authorizationsByID: AuthorizationsByID, userGrants: readonly AuthorizationRoleInfo[]): boolean {
|
|
86
|
+
if (!auth.IsActive || grantTypes(auth, userGrants).includes(AuthorizationRoleType.Deny)) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return holdsDirectlyOrThroughAncestor(auth, authorizationsByID, userGrants);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* True when the user holds the authorization or one of its ancestors, following `ParentID` within
|
|
94
|
+
* `authorizationsByID`. This is the walk `AuthorizationEvaluator.UserCanExecuteWithAncestors` does,
|
|
95
|
+
* with the role grants taken from the same provider as the authorizations.
|
|
96
|
+
*/
|
|
97
|
+
function holdsDirectlyOrThroughAncestor(auth: AuthorizationInfo, authorizationsByID: AuthorizationsByID, userGrants: readonly AuthorizationRoleInfo[]): boolean {
|
|
98
|
+
const visited = new Set<string>();
|
|
99
|
+
let node: AuthorizationInfo | undefined = auth;
|
|
100
|
+
while (node && !visited.has(NormalizeUUID(node.ID))) {
|
|
101
|
+
if (holdsDirectly(node, userGrants)) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
visited.add(NormalizeUUID(node.ID));
|
|
105
|
+
node = node.ParentID ? authorizationsByID.get(NormalizeUUID(node.ParentID)) : undefined;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True when the authorization is active and the user's roles have an Allow and no Deny on it. */
|
|
111
|
+
function holdsDirectly(auth: AuthorizationInfo, userGrants: readonly AuthorizationRoleInfo[]): boolean {
|
|
112
|
+
const types = grantTypes(auth, userGrants);
|
|
113
|
+
return auth.IsActive && types.includes(AuthorizationRoleType.Allow) && !types.includes(AuthorizationRoleType.Deny);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Allow or Deny, for each of the user's role grants on the authorization. */
|
|
117
|
+
function grantTypes(auth: AuthorizationInfo, userGrants: readonly AuthorizationRoleInfo[]): AuthorizationRoleType[] {
|
|
118
|
+
return userGrants.filter((grant) => UUIDsEqual(grant.AuthorizationID, auth.ID)).map((grant) => grant.AuthorizationType());
|
|
119
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RegisterClass } from '@memberjunction/global';
|
|
1
|
+
import { EscapeSQLString, RegisterClass } from '@memberjunction/global';
|
|
2
2
|
import { Metadata, RunView, LogError, EntitySaveOptions } from '@memberjunction/core';
|
|
3
3
|
import { NewUserBase } from './newUsers.js';
|
|
4
4
|
import { ResolveConfiguredPrincipal } from './principals.js';
|
|
@@ -43,7 +43,7 @@ export class ExampleNewUserSubClass extends NewUserBase {
|
|
|
43
43
|
const viewResults = await rv.RunView(
|
|
44
44
|
{
|
|
45
45
|
EntityName: 'Persons',
|
|
46
|
-
ExtraFilter: `Email = '${email}'`,
|
|
46
|
+
ExtraFilter: `Email = '${EscapeSQLString(email)}'`,
|
|
47
47
|
},
|
|
48
48
|
contextUser
|
|
49
49
|
);
|
|
@@ -34,8 +34,9 @@ import { ResolveConfiguredPrincipal } from '../principals.js';
|
|
|
34
34
|
import { CommunicationEngine } from '@memberjunction/communication-engine';
|
|
35
35
|
import { Message } from '@memberjunction/communication-types';
|
|
36
36
|
import { configInfo, type MagicLinkConfig } from '../../config.js';
|
|
37
|
+
import { CreateIsolatedProvider } from '../../isolatedProvider.js';
|
|
37
38
|
import { MagicLinkKeyManager } from './MagicLinkKeys.js';
|
|
38
|
-
import { generateRawToken, generateSessionId, hashToken, evaluateInvite, buildSessionClaims,
|
|
39
|
+
import { generateRawToken, generateSessionId, hashToken, evaluateInvite, buildSessionClaims, buildConsumeInvitePostgresSQL, canIssueInvites, isRoleGrantable, MAGIC_LINK_TOKEN_PREFIX } from './magicLinkCore.js';
|
|
39
40
|
import type {
|
|
40
41
|
CreateMagicLinkInviteParams,
|
|
41
42
|
CreateMagicLinkInviteResult,
|
|
@@ -48,6 +49,21 @@ const REDEMPTION_ENTITY = 'MJ: Magic Link Redemptions';
|
|
|
48
49
|
/** Shared Anonymous principal — seeded by the Phase-4 migration; must match that UUID. */
|
|
49
50
|
const ANONYMOUS_USER_ID = '273910DF-28F1-45C1-A8F8-6E9AD8E5F008';
|
|
50
51
|
|
|
52
|
+
/** Outcome of the atomic single-use consume — see {@link MagicLinkService.consumeInvite}. */
|
|
53
|
+
type ConsumeOutcome = 'won' | 'lost' | 'failed';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The SQL Server guarded single-use procedure (migration V202609291627__v6.2.x__MagicLink_Consume_Invite_Sproc).
|
|
57
|
+
* A procedure, not raw DML, because on SQL Server MJ grants its runtime roles EXECUTE on procedures
|
|
58
|
+
* and SELECT on views — never DML on base tables — so on any host whose MJAPI login is not db_owner
|
|
59
|
+
* a raw UPDATE is refused (#4753). Ownership chaining lets the procedure update the table on the
|
|
60
|
+
* caller's behalf. PostgreSQL has no counterpart function; see {@link buildConsumeInvitePostgresSQL}.
|
|
61
|
+
*/
|
|
62
|
+
const CONSUME_INVITE_PROC = 'spConsumeMagicLinkInvite';
|
|
63
|
+
|
|
64
|
+
/** MagicLinkInvite base table (core schema) — the PostgreSQL consume updates it directly. */
|
|
65
|
+
const INVITE_TABLE = 'MagicLinkInvite';
|
|
66
|
+
|
|
51
67
|
/** Outcome of provisioning a redeeming user. */
|
|
52
68
|
interface ProvisionResult {
|
|
53
69
|
success: boolean;
|
|
@@ -173,9 +189,9 @@ export class MagicLinkService {
|
|
|
173
189
|
* session JWT. Runs in the public (pre-auth) path, so it uses the global
|
|
174
190
|
* provider and a configured provisioning context user.
|
|
175
191
|
*
|
|
176
|
-
* Single-use is enforced by
|
|
177
|
-
* BEFORE the token is minted — two concurrent
|
|
178
|
-
* link race on the same row and exactly one wins. The consume is the gate:
|
|
192
|
+
* Single-use is enforced by the guarded UPDATE inside the `spConsumeMagicLinkInvite`
|
|
193
|
+
* procedure ({@link consumeInvite}) BEFORE the token is minted — two concurrent
|
|
194
|
+
* redemptions of a single-use link race on the same row and exactly one wins. The consume is the gate:
|
|
179
195
|
* if a later step fails, the use is already burned (fail-closed). That is the
|
|
180
196
|
* correct trade-off for a single-use credential — burning a token on a server
|
|
181
197
|
* error is recoverable (re-issue); allowing replay is not.
|
|
@@ -252,10 +268,14 @@ export class MagicLinkService {
|
|
|
252
268
|
return done({ success: false, errorCode: 'server_error', error: 'Invite role not found.' });
|
|
253
269
|
}
|
|
254
270
|
|
|
255
|
-
// Atomic consume BEFORE minting.
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
271
|
+
// Atomic consume BEFORE minting. A statement that never ran (permissions,
|
|
272
|
+
// missing procedure, connection) is a server fault, not a lost race — report
|
|
273
|
+
// it as such so operators aren't sent hunting a double-redeem (#4753).
|
|
274
|
+
const consume = await this.consumeInvite(invite, provider, contextUser);
|
|
275
|
+
if (consume === 'failed') {
|
|
276
|
+
return done({ success: false, errorCode: 'server_error', error: 'Could not redeem the invite due to a server error.' });
|
|
277
|
+
}
|
|
278
|
+
if (consume === 'lost') {
|
|
259
279
|
// Lost the race or the invite expired between the pre-check and the
|
|
260
280
|
// consume. The in-memory copy still looks eligible if only the DB-side
|
|
261
281
|
// use count changed, so default that case to 'consumed'.
|
|
@@ -502,9 +522,11 @@ export class MagicLinkService {
|
|
|
502
522
|
/** Creates a new user with exactly the restricted role + single app, transactionally. */
|
|
503
523
|
private async createScopedUser(invite: MJMagicLinkInviteEntity, role: RoleInfo, contextUser: UserInfo): Promise<ProvisionResult> {
|
|
504
524
|
// Single provider for BOTH the transaction and the entity writes, so the
|
|
505
|
-
// BeginTransaction/Save/Commit are provably on the same connection.
|
|
506
|
-
//
|
|
507
|
-
|
|
525
|
+
// BeginTransaction/Save/Commit are provably on the same connection. Redemption runs
|
|
526
|
+
// pre-auth, with no per-request provider yet, and the global Metadata.Provider is shared
|
|
527
|
+
// by every concurrent request: a transaction opened there nests into anyone else's and
|
|
528
|
+
// swallows their writes. So it gets its own (see CreateIsolatedProvider).
|
|
529
|
+
const provider = await CreateIsolatedProvider();
|
|
508
530
|
const email = invite.Email;
|
|
509
531
|
// The invite does not persist a name; provision a sensible default. (The
|
|
510
532
|
// create-invite params accept first/last name but there is no column to
|
|
@@ -639,39 +661,39 @@ export class MagicLinkService {
|
|
|
639
661
|
}
|
|
640
662
|
|
|
641
663
|
/**
|
|
642
|
-
* Atomically consumes one use of the invite
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
* (won the race); concurrent redemptions of a single-use link see 0 rows.
|
|
664
|
+
* Atomically consumes one use of the invite with a single guarded UPDATE that re-checks every
|
|
665
|
+
* eligibility condition (Active, not exhausted, not expired) at the DB level, so the increment and
|
|
666
|
+
* the guard are one atomic operation. Concurrent redemptions of a single-use link race on the row
|
|
667
|
+
* and exactly one gets the row back.
|
|
647
668
|
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
669
|
+
* The statement differs per platform because the grants do:
|
|
670
|
+
* - **SQL Server** calls {@link CONSUME_INVITE_PROC} (`EXEC … @ID=@p0`). Runtime roles have
|
|
671
|
+
* EXECUTE on procedures but no DML on base tables (#4753).
|
|
672
|
+
* - **PostgreSQL** runs {@link buildConsumeInvitePostgresSQL}'s `UPDATE … RETURNING` directly.
|
|
673
|
+
* Runtime roles hold table DML there, and no PG counterpart of the procedure exists.
|
|
674
|
+
* The ID is always a bound parameter.
|
|
654
675
|
*
|
|
655
|
-
*
|
|
676
|
+
* Returns `'won'` (this call consumed a use), `'lost'` (the guard matched nothing: consumed,
|
|
677
|
+
* revoked or expired concurrently) or `'failed'` (the statement never ran — permissions, a missing
|
|
678
|
+
* procedure, a connection error). `'failed'` is kept distinct from `'lost'` on purpose: collapsing
|
|
679
|
+
* them is what made #4753's permission error surface to users as "already redeemed".
|
|
656
680
|
*/
|
|
657
|
-
private async consumeInvite(invite: MJMagicLinkInviteEntity, provider: DatabaseProviderBase, contextUser: UserInfo): Promise<
|
|
681
|
+
private async consumeInvite(invite: MJMagicLinkInviteEntity, provider: DatabaseProviderBase, contextUser: UserInfo): Promise<ConsumeOutcome> {
|
|
682
|
+
const isPg = provider.PlatformKey === 'postgresql';
|
|
683
|
+
const statement = isPg ? `PostgreSQL guarded UPDATE on ${INVITE_TABLE}` : CONSUME_INVITE_PROC;
|
|
658
684
|
try {
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
// OUTPUT/RETURNING yields one row iff the WHERE matched — the atomic single-use gate.
|
|
669
|
-
const sql = buildConsumeInviteSQL(table, isPg ? 'postgresql' : 'sqlserver');
|
|
670
|
-
const rows = await provider.ExecuteSQL<{ ID: string }>(sql, [invite.ID], { isMutation: true }, contextUser);
|
|
671
|
-
return Array.isArray(rows) && rows.length === 1;
|
|
685
|
+
const sql = isPg
|
|
686
|
+
? buildConsumeInvitePostgresSQL(`${provider.MJCoreSchemaName}.${INVITE_TABLE}`)
|
|
687
|
+
: provider.Dialect.ProcedureCallSyntax(provider.MJCoreSchemaName, CONSUME_INVITE_PROC, [`@ID=${provider.BuildParameterPlaceholder(0)}`]);
|
|
688
|
+
const rows = await provider.ExecuteSQL<{ ID: string }>(sql, [invite.ID], { isMutation: true, description: `MagicLink ${statement}` }, contextUser);
|
|
689
|
+
if (!Array.isArray(rows)) {
|
|
690
|
+
LogError(`[MagicLink] ${statement} returned no result set for invite ${invite.ID}; treating the consume as failed.`);
|
|
691
|
+
return 'failed';
|
|
692
|
+
}
|
|
693
|
+
return rows.length === 1 ? 'won' : 'lost';
|
|
672
694
|
} catch (e) {
|
|
673
|
-
LogError(`[MagicLink]
|
|
674
|
-
return
|
|
695
|
+
LogError(`[MagicLink] ${statement} FAILED for invite ${invite.ID} — the statement never ran, so this is NOT a lost race: ${e instanceof Error ? e.message : String(e)}`);
|
|
696
|
+
return 'failed';
|
|
675
697
|
}
|
|
676
698
|
}
|
|
677
699
|
|
|
@@ -106,75 +106,38 @@ export function evaluateInvite(invite: InviteEvaluationInput, nowMs: number): {
|
|
|
106
106
|
return { ok: true };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/** SQL dialect for {@link buildConsumeInviteSQL}. */
|
|
110
|
-
export type ConsumeInviteDialect = 'sqlserver' | 'postgresql';
|
|
111
|
-
|
|
112
|
-
/** SQL Server table identifier — bracket-quoted `[schema].[table]` (word chars only). */
|
|
113
|
-
const QUALIFIED_TABLE_PATTERN = /^\[\w+\]\.\[\w+\]$/;
|
|
114
109
|
/** PostgreSQL table identifier — `schema.table` (word chars only); auto-quoted downstream. */
|
|
115
110
|
const QUALIFIED_TABLE_PATTERN_PG = /^\w+\.\w+$/;
|
|
116
111
|
|
|
117
112
|
/**
|
|
118
|
-
* Builds the atomic compare-and-swap UPDATE that consumes one use of an invite
|
|
119
|
-
* in the given SQL dialect.
|
|
120
|
-
*
|
|
121
|
-
* The WHERE clause re-checks every eligibility condition at the DB level, so the
|
|
122
|
-
* increment and the guard are a single atomic operation: concurrent redemptions
|
|
123
|
-
* of a single-use link race on the row and exactly one matches. This is what
|
|
124
|
-
* actually enforces single-use — the JS-side `evaluateInvite` is only a friendly
|
|
125
|
-
* pre-check. The matched row's ID is returned to the caller (via `OUTPUT` on SQL
|
|
126
|
-
* Server, `RETURNING` on PostgreSQL) so it can detect a win (exactly one row).
|
|
127
|
-
*
|
|
128
|
-
* The invite ID MUST be bound as a parameter by the caller (`@p0` on SQL Server,
|
|
129
|
-
* `$1` on PostgreSQL) — it is never interpolated into the string, so this builder
|
|
130
|
-
* is injection-safe regardless of the ID's contents.
|
|
113
|
+
* Builds the PostgreSQL atomic compare-and-swap UPDATE that consumes one use of an invite.
|
|
131
114
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
115
|
+
* PostgreSQL only. SQL Server consumes through the granted `spConsumeMagicLinkInvite` procedure,
|
|
116
|
+
* because its runtime roles are never granted table DML (#4753). PostgreSQL needs neither: its
|
|
117
|
+
* runtime roles hold INSERT/UPDATE/DELETE on every `__mj` table
|
|
118
|
+
* (V202605040300__v5.33.x__Unblock_PostgreSQL_End_To_End.pg-only.sql), and no PG counterpart of
|
|
119
|
+
* the procedure exists — the release-time converter leaves `CREATE PROCEDURE` unhandled.
|
|
136
120
|
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* serialize and
|
|
140
|
-
*
|
|
141
|
-
* `OUTPUT`-into-table win-detect). PascalCase identifiers are auto-quoted by
|
|
142
|
-
* `PostgreSQLDataProvider.ExecuteSQL`, so the table is passed unquoted (`schema.table`).
|
|
121
|
+
* The WHERE re-checks every eligibility condition (the same predicate as the procedure), so the
|
|
122
|
+
* increment and the guard are one atomic statement: concurrent redemptions of a single-use link
|
|
123
|
+
* serialize on the row lock and exactly one matches. `RETURNING ID` yields the row iff the guard
|
|
124
|
+
* matched. The invite ID MUST be bound by the caller as `$1` — it is never interpolated.
|
|
143
125
|
*
|
|
144
|
-
* `qualifiedTable` is
|
|
145
|
-
*
|
|
146
|
-
* this is defense-in-depth
|
|
147
|
-
* an injection vector — a non-conforming table throws.
|
|
148
|
-
*
|
|
149
|
-
* @param qualifiedTable `[schema].[table]` (sqlserver) or `schema.table` (postgresql) for MagicLinkInvite
|
|
150
|
-
* @param dialect target SQL dialect (defaults to `'sqlserver'`)
|
|
126
|
+
* `qualifiedTable` is `schema.table` unquoted (PostgreSQLDataProvider.ExecuteSQL auto-quotes the
|
|
127
|
+
* PascalCase identifiers) and is asserted against a whitelist before interpolation — the caller
|
|
128
|
+
* never passes user input, so this is defense-in-depth.
|
|
151
129
|
*/
|
|
152
|
-
export function
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
throw new Error(`buildConsumeInviteSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
|
|
156
|
-
}
|
|
157
|
-
return (
|
|
158
|
-
`UPDATE ${qualifiedTable} ` +
|
|
159
|
-
`SET UseCount = UseCount + 1, ` +
|
|
160
|
-
`ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc')), ` +
|
|
161
|
-
`Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END ` +
|
|
162
|
-
`WHERE ID = $1 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > (now() AT TIME ZONE 'utc') ` +
|
|
163
|
-
`RETURNING ID;`
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
if (!QUALIFIED_TABLE_PATTERN.test(qualifiedTable)) {
|
|
167
|
-
throw new Error(`buildConsumeInviteSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
|
|
130
|
+
export function buildConsumeInvitePostgresSQL(qualifiedTable: string): string {
|
|
131
|
+
if (!QUALIFIED_TABLE_PATTERN_PG.test(qualifiedTable)) {
|
|
132
|
+
throw new Error(`buildConsumeInvitePostgresSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
|
|
168
133
|
}
|
|
169
134
|
return (
|
|
170
|
-
`DECLARE @consumed TABLE (ID UNIQUEIDENTIFIER); ` +
|
|
171
135
|
`UPDATE ${qualifiedTable} ` +
|
|
172
136
|
`SET UseCount = UseCount + 1, ` +
|
|
173
|
-
`ConsumedAt = COALESCE(ConsumedAt,
|
|
137
|
+
`ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc')), ` +
|
|
174
138
|
`Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END ` +
|
|
175
|
-
`
|
|
176
|
-
`
|
|
177
|
-
`SELECT ID FROM @consumed;`
|
|
139
|
+
`WHERE ID = $1 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > (now() AT TIME ZONE 'utc') ` +
|
|
140
|
+
`RETURNING ID;`
|
|
178
141
|
);
|
|
179
142
|
}
|
|
180
143
|
|
package/src/auth/newUsers.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { ApplicationInfo,
|
|
1
|
+
import { ApplicationInfo, EntitySaveOptions, LogError, LogStatus, RunView, RunViewResult, UserInfo } from "@memberjunction/core";
|
|
2
2
|
import { RegisterClass } from "@memberjunction/global";
|
|
3
3
|
import { ResolveConfiguredPrincipal } from "./principals.js";
|
|
4
4
|
import { configInfo } from "../config.js";
|
|
5
|
+
import { CreateIsolatedProvider } from "../isolatedProvider.js";
|
|
5
6
|
import { MJUserEntity, MJUserRoleEntity, MJUserApplicationEntity, MJUserApplicationEntityEntity, MJApplicationEntityType, MJApplicationEntityEntityType, UserInfoEngine } from "@memberjunction/core-entities";
|
|
6
7
|
|
|
7
8
|
export class NewUserBase {
|
|
@@ -16,8 +17,12 @@ export class NewUserBase {
|
|
|
16
17
|
return null;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// Everything here, metadata lookups, entity objects and the transaction, goes through ONE
|
|
21
|
+
// isolated provider. This runs in the JWT auth flow, before AppContext.providers exists, and
|
|
22
|
+
// the global Metadata.Provider is shared by every concurrent request: a transaction opened
|
|
23
|
+
// there nests into anyone else's and swallows their writes (see CreateIsolatedProvider).
|
|
24
|
+
const provider = await CreateIsolatedProvider();
|
|
25
|
+
const user = await provider.GetEntityObject<MJUserEntity>('MJ: Users', contextUser) // To-Do - change this to be a different defined user for the user creation process
|
|
21
26
|
user.NewRecord();
|
|
22
27
|
user.Name = email;
|
|
23
28
|
user.IsActive = true;
|
|
@@ -38,7 +43,6 @@ export class NewUserBase {
|
|
|
38
43
|
// Create the user and all of its role/application/app-entity records atomically.
|
|
39
44
|
// If any Save fails partway through, the whole provisioning rolls back so we never
|
|
40
45
|
// leave a half-created user with partial roles/applications behind.
|
|
41
|
-
const provider = Metadata.Provider as DatabaseProviderBase; // global-provider-ok: new-user creation runs in the JWT auth flow, before AppContext.providers is built
|
|
42
46
|
await provider.BeginTransaction();
|
|
43
47
|
try {
|
|
44
48
|
if (!await user.Save()) {
|
|
@@ -48,13 +52,13 @@ export class NewUserBase {
|
|
|
48
52
|
if(configInfo.userHandling && configInfo.userHandling.newUserRoles){
|
|
49
53
|
LogStatus(`User ${user.Email} created, assigning roles`);
|
|
50
54
|
for (const role of configInfo.userHandling.newUserRoles) {
|
|
51
|
-
const userRole =
|
|
55
|
+
const userRole = provider.Roles.find(r => r.Name === role);
|
|
52
56
|
if (!userRole) {
|
|
53
57
|
LogError(`Role ${role} not found in the database, cannot assign to new user ${user.Name}`);
|
|
54
58
|
continue;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
const userRoleEntity: MJUserRoleEntity = await
|
|
61
|
+
const userRoleEntity: MJUserRoleEntity = await provider.GetEntityObject<MJUserRoleEntity>('MJ: User Roles', contextUser);
|
|
58
62
|
userRoleEntity.NewRecord();
|
|
59
63
|
userRoleEntity.UserID = user.ID;
|
|
60
64
|
userRoleEntity.RoleID = userRole.ID;
|
|
@@ -73,7 +77,7 @@ export class NewUserBase {
|
|
|
73
77
|
if (configInfo.userHandling.UserApplications && configInfo.userHandling.UserApplications.length > 0) {
|
|
74
78
|
for (const appName of configInfo.userHandling.UserApplications) {
|
|
75
79
|
const toLowerCase: string = appName.trim().toLocaleLowerCase();
|
|
76
|
-
const application: ApplicationInfo | undefined =
|
|
80
|
+
const application: ApplicationInfo | undefined = provider.Applications.find(a => a.Name.trim().toLocaleLowerCase() === toLowerCase);
|
|
77
81
|
if (application) {
|
|
78
82
|
applicationsToCreate.push(application);
|
|
79
83
|
} else {
|
|
@@ -85,12 +89,12 @@ export class NewUserBase {
|
|
|
85
89
|
// Bug F2: use the shared selector, which filters to Active apps flagged
|
|
86
90
|
// DefaultForNewUser (in DefaultSequence order). This path previously omitted the
|
|
87
91
|
// Status = 'Active' check, so an inactive default app could be provisioned here.
|
|
88
|
-
applicationsToCreate = UserInfoEngine.GetDefaultApplicationsForNewUser(
|
|
92
|
+
applicationsToCreate = UserInfoEngine.GetDefaultApplicationsForNewUser(provider);
|
|
89
93
|
LogStatus(`Found ${applicationsToCreate.length} active applications with DefaultForNewUser=true`);
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
for (const [appIndex, application] of applicationsToCreate.entries()) {
|
|
93
|
-
const userApplication: MJUserApplicationEntity = await
|
|
97
|
+
const userApplication: MJUserApplicationEntity = await provider.GetEntityObject<MJUserApplicationEntity>('MJ: User Applications', contextUser);
|
|
94
98
|
userApplication.NewRecord();
|
|
95
99
|
userApplication.UserID = user.ID;
|
|
96
100
|
userApplication.ApplicationID = application.ID;
|
|
@@ -102,7 +106,7 @@ export class NewUserBase {
|
|
|
102
106
|
}
|
|
103
107
|
LogStatus(`Created User Application ${application.Name} for new user ${user.Name}`);
|
|
104
108
|
|
|
105
|
-
const rv: RunView =
|
|
109
|
+
const rv: RunView = RunView.FromMetadataProvider(provider);
|
|
106
110
|
// Boolean literal goes through the active provider's dialect:
|
|
107
111
|
// SQL Server emits `= 1`, PostgreSQL emits `= TRUE`.
|
|
108
112
|
// This keeps the filter server-side (no client-side `.filter()`
|
|
@@ -125,7 +129,7 @@ export class NewUserBase {
|
|
|
125
129
|
LogStatus(`Creating ${defaultForNewUserEntities.length} User Application Entities for User Application ${application.Name} for new user ${user.Name}`);
|
|
126
130
|
|
|
127
131
|
for(const [index, appEntity] of defaultForNewUserEntities.entries()){
|
|
128
|
-
const userAppEntity: MJUserApplicationEntityEntity = await
|
|
132
|
+
const userAppEntity: MJUserApplicationEntityEntity = await provider.GetEntityObject<MJUserApplicationEntityEntity>('MJ: User Application Entities', contextUser);
|
|
129
133
|
userAppEntity.NewRecord();
|
|
130
134
|
userAppEntity.UserApplicationID = userApplication.ID;
|
|
131
135
|
userAppEntity.EntityID = appEntity.EntityID;
|
package/src/context.ts
CHANGED
|
@@ -10,7 +10,8 @@ import { CloneUserForSessionContext } from './auth/sessionUserClone.js';
|
|
|
10
10
|
import { GetAPIKeyActingContextResolver } from './auth/actingContextResolver.js';
|
|
11
11
|
import { TokenExpiredError, AuthProviderFactory } from '@memberjunction/auth-providers';
|
|
12
12
|
import { authCache } from './cache.js';
|
|
13
|
-
import { userEmailMap, apiKey, mj_core_schema } from './config.js';
|
|
13
|
+
import { userEmailMap, apiKey, mj_core_schema, configInfo } from './config.js';
|
|
14
|
+
import { BuildPostgreSQLConnectionConfig, PostgreSQLReadOnlyPool, ResolvePostgreSQLEndpoint, ResolvePostgreSQLReadOnlyCredentials } from './postgresqlPoolSettings.js';
|
|
14
15
|
import { buildBoundaryLogPayload } from './logging/boundaryLogPayload.js';
|
|
15
16
|
import { StartupLogger } from './logging/StartupLogger.js';
|
|
16
17
|
import { DataSourceInfo, UserPayload } from './types.js';
|
|
@@ -797,36 +798,35 @@ async function createPerRequestProviders(
|
|
|
797
798
|
{ provider: p, type: 'Read-Write' }
|
|
798
799
|
];
|
|
799
800
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
providers.push({ provider: rp, type: 'Read-Only' });
|
|
804
|
-
}
|
|
805
|
-
} else {
|
|
806
|
-
const rp = await tryCreateReadOnlyProvider(dataSources);
|
|
807
|
-
if (rp) {
|
|
808
|
-
providers.push({ provider: rp, type: 'Read-Only' });
|
|
809
|
-
}
|
|
801
|
+
const rp = await CreateReadOnlyProvider(dataSources);
|
|
802
|
+
if (rp) {
|
|
803
|
+
providers.push({ provider: rp, type: 'Read-Only' });
|
|
810
804
|
}
|
|
811
805
|
|
|
812
806
|
return providers;
|
|
813
807
|
}
|
|
814
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Opens a provider on the read-only database login, for the current platform. Resolves null when no
|
|
811
|
+
* read-only login is configured; it never falls back to the read-write pool.
|
|
812
|
+
* @param dataSources the data sources MJAPI opened at startup
|
|
813
|
+
*/
|
|
814
|
+
export async function CreateReadOnlyProvider(dataSources: DataSourceInfo[]): Promise<DatabaseProviderBase | null> {
|
|
815
|
+
return resolveDbPlatformFromEnv() === 'postgresql'
|
|
816
|
+
? tryCreateReadOnlyPostgresProvider()
|
|
817
|
+
: tryCreateReadOnlyProvider(dataSources);
|
|
818
|
+
}
|
|
819
|
+
|
|
815
820
|
/**
|
|
816
821
|
* Creates a PostgreSQL per-request provider, sharing the connection pool
|
|
817
822
|
* from the primary provider to avoid pool exhaustion.
|
|
818
823
|
*/
|
|
819
824
|
async function createPostgresProvider(): Promise<DatabaseProviderBase> {
|
|
820
825
|
const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
|
|
821
|
-
const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
|
|
822
|
-
const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
|
|
823
|
-
const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
|
|
824
|
-
const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
|
|
825
|
-
const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
|
|
826
826
|
|
|
827
827
|
const pgProvider = new PostgreSQLDataProvider();
|
|
828
828
|
const pgConfig = new PostgreSQLProviderConfigData(
|
|
829
|
-
|
|
829
|
+
BuildPostgreSQLConnectionConfig(ResolvePostgreSQLEndpoint(), configInfo.databaseSettings, 'api'),
|
|
830
830
|
mj_core_schema,
|
|
831
831
|
0,
|
|
832
832
|
undefined,
|
|
@@ -846,39 +846,30 @@ async function createPostgresProvider(): Promise<DatabaseProviderBase> {
|
|
|
846
846
|
}
|
|
847
847
|
|
|
848
848
|
/**
|
|
849
|
-
*
|
|
850
|
-
*
|
|
849
|
+
* Creates a read-only PostgreSQL per-request provider on the read-only pool MJAPI opened at
|
|
850
|
+
* startup with the read-only login. Returns null when no read-only login is configured, so the
|
|
851
|
+
* caller never falls back to the read-write pool.
|
|
851
852
|
*/
|
|
852
853
|
async function tryCreateReadOnlyPostgresProvider(): Promise<DatabaseProviderBase | null> {
|
|
853
|
-
const
|
|
854
|
-
const
|
|
855
|
-
if (!
|
|
854
|
+
const readOnlyPool = PostgreSQLReadOnlyPool.Instance.Pool;
|
|
855
|
+
const credentials = ResolvePostgreSQLReadOnlyCredentials(configInfo);
|
|
856
|
+
if (!readOnlyPool || !credentials) {
|
|
856
857
|
return null;
|
|
857
858
|
}
|
|
858
859
|
|
|
859
860
|
try {
|
|
860
861
|
const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
|
|
861
|
-
const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
|
|
862
|
-
const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
|
|
863
|
-
const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
|
|
864
862
|
|
|
865
863
|
const roProvider = new PostgreSQLDataProvider();
|
|
866
864
|
const roConfig = new PostgreSQLProviderConfigData(
|
|
867
|
-
{
|
|
865
|
+
BuildPostgreSQLConnectionConfig({ ...ResolvePostgreSQLEndpoint(), ...credentials }, configInfo.databaseSettings, 'read-only'),
|
|
868
866
|
mj_core_schema,
|
|
869
867
|
0,
|
|
870
868
|
undefined,
|
|
871
869
|
undefined,
|
|
872
870
|
false,
|
|
873
871
|
);
|
|
874
|
-
|
|
875
|
-
const primaryProvider = Metadata.Provider as unknown as { DatabaseConnection?: import('pg').Pool }; // global-provider-ok: bootstrap (share primary provider's PG pool with the read-only provider)
|
|
876
|
-
if (primaryProvider?.DatabaseConnection) {
|
|
877
|
-
await roProvider.ConfigWithSharedPool(roConfig, primaryProvider.DatabaseConnection);
|
|
878
|
-
} else {
|
|
879
|
-
await roProvider.Config(roConfig);
|
|
880
|
-
}
|
|
881
|
-
|
|
872
|
+
await roProvider.ConfigWithSharedPool(roConfig, readOnlyPool);
|
|
882
873
|
return roProvider;
|
|
883
874
|
} catch (_err) {
|
|
884
875
|
return null;
|