@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Field, ObjectType, Resolver, Root, Subscription } from 'type-graphql';
|
|
1
|
+
import { Ctx, Field, ObjectType, Resolver, Root, Subscription } from 'type-graphql';
|
|
2
2
|
import { Metadata, UserInfo } from '@memberjunction/core';
|
|
3
3
|
|
|
4
4
|
export const CACHE_INVALIDATION_TOPIC = 'CACHE_INVALIDATION';
|
|
@@ -151,6 +151,64 @@ export function cacheInvalidationFilter(data: {
|
|
|
151
151
|
return entity.GetUserPermisions(user).CanRead;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/** The part of a permission row and of a user this module reads, so the rule can be tested on plain objects. */
|
|
155
|
+
export interface ReadPermissionRow {
|
|
156
|
+
RoleID: string;
|
|
157
|
+
CanRead: boolean | null;
|
|
158
|
+
/** `Deny` rows subtract; they never carry the read the rule looks at. */
|
|
159
|
+
IsDeny?: boolean;
|
|
160
|
+
Type?: string | null;
|
|
161
|
+
ReadRLSFilterID: string | null;
|
|
162
|
+
}
|
|
163
|
+
export interface RoleHolder {
|
|
164
|
+
UserRoles?: ReadonlyArray<{ RoleID: string }> | null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Is this user's read of the entity row-filtered? True when every read the user holds on the entity comes through a permission
|
|
169
|
+
* row that carries a `ReadRLSFilterID`: there is a row-level security filter between them and the rows, so a record's key, its
|
|
170
|
+
* existence and when it changed are not theirs to learn unless the row passes the filter — which a subscription filter cannot
|
|
171
|
+
* evaluate per event. False when any of their read rows is unfiltered (they could read the row anyway), and false when they hold
|
|
172
|
+
* no read at all (the entity-level filter has already withheld the event).
|
|
173
|
+
*
|
|
174
|
+
* The same in-memory match `GetUserPermisions` does, with no I/O, so it can run per subscriber per event.
|
|
175
|
+
*/
|
|
176
|
+
export function ReadIsRowFiltered(entity: { Permissions: ReadonlyArray<ReadPermissionRow> }, user: RoleHolder): boolean {
|
|
177
|
+
const roles = new Set((user.UserRoles ?? []).map((role) => (role.RoleID ?? '').trim().toLowerCase()));
|
|
178
|
+
let filteredReads = 0;
|
|
179
|
+
for (const row of entity.Permissions) {
|
|
180
|
+
if (!row.CanRead) continue;
|
|
181
|
+
const isDeny = row.IsDeny ?? (row.Type ?? 'Allow').trim().toLowerCase() === 'deny';
|
|
182
|
+
if (isDeny) continue;
|
|
183
|
+
if (!roles.has((row.RoleID ?? '').trim().toLowerCase())) continue;
|
|
184
|
+
if (!row.ReadRLSFilterID) return false;
|
|
185
|
+
filteredReads += 1;
|
|
186
|
+
}
|
|
187
|
+
return filteredReads > 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The event as one subscriber may see it. A subscriber whose read of the entity is row-filtered gets the entity, the action and
|
|
192
|
+
* the time, and neither the key nor the row: "something in this entity changed", which the client already takes as a
|
|
193
|
+
* whole-entity invalidation (a null key on `remote-invalidate`). Everyone else gets the event as published.
|
|
194
|
+
*/
|
|
195
|
+
export function ShapeCacheInvalidationEvent(
|
|
196
|
+
payload: CacheInvalidationPayload,
|
|
197
|
+
user: RoleHolder | undefined,
|
|
198
|
+
entity: { Permissions: ReadonlyArray<ReadPermissionRow> } | null | undefined,
|
|
199
|
+
): CacheInvalidationNotification {
|
|
200
|
+
const withholdKey = !!user && !!entity && ReadIsRowFiltered(entity, user);
|
|
201
|
+
return {
|
|
202
|
+
EntityName: payload.entityName,
|
|
203
|
+
PrimaryKeyValues: withholdKey ? undefined : (payload.primaryKeyValues ?? undefined),
|
|
204
|
+
Action: payload.action,
|
|
205
|
+
SourceServerID: payload.sourceServerId,
|
|
206
|
+
Timestamp: payload.timestamp,
|
|
207
|
+
OriginSessionID: payload.originSessionId ?? undefined,
|
|
208
|
+
RecordData: withholdKey ? undefined : (payload.recordData ?? undefined),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
154
212
|
@Resolver()
|
|
155
213
|
export class CacheInvalidationResolver {
|
|
156
214
|
/**
|
|
@@ -173,30 +231,27 @@ export class CacheInvalidationResolver {
|
|
|
173
231
|
* its stable key, and every time it changes; across entities it learns the cadence and volume
|
|
174
232
|
* of another tenant's activity.
|
|
175
233
|
*
|
|
176
|
-
* The key is kept
|
|
177
|
-
* told about (see `ResolveEntityEventKey` in @memberjunction/core)
|
|
178
|
-
*
|
|
179
|
-
* hottest write paths.
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
234
|
+
* The key is kept for a subscriber who could read the row anyway, because consumers need it to
|
|
235
|
+
* re-read the record they were told about (see `ResolveEntityEventKey` in @memberjunction/core),
|
|
236
|
+
* and the coarse "something in this entity changed" forces whole-entity invalidation on the
|
|
237
|
+
* hottest write paths. It is withheld, with the row, from a subscriber whose every read of the
|
|
238
|
+
* entity goes through a row-level security filter ({@link ReadIsRowFiltered}): a filter the
|
|
239
|
+
* subscription cannot evaluate per event stands between them and the row, so they learn only
|
|
240
|
+
* that a row of the entity changed, and their caches refresh the entity as a whole. That is the
|
|
241
|
+
* same in-memory role match as the entity-level filter, once more per subscriber per event.
|
|
183
242
|
*/
|
|
184
243
|
@Subscription(() => CacheInvalidationNotification, {
|
|
185
244
|
topics: CACHE_INVALIDATION_TOPIC,
|
|
186
245
|
filter: (data: { payload: CacheInvalidationPayload; context: CacheInvalidationFilterContext | undefined }) =>
|
|
187
246
|
cacheInvalidationFilter(data),
|
|
188
247
|
})
|
|
189
|
-
cacheInvalidation(
|
|
190
|
-
@Root() payload: CacheInvalidationPayload
|
|
248
|
+
cacheInvalidation( // case-violation-ok-legacy-back-compat: the property name is the GraphQL schema field name — renaming it breaks every client query
|
|
249
|
+
@Root() payload: CacheInvalidationPayload,
|
|
250
|
+
@Ctx() context: CacheInvalidationFilterContext | undefined
|
|
191
251
|
): CacheInvalidationNotification {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
SourceServerID: payload.sourceServerId,
|
|
197
|
-
Timestamp: payload.timestamp,
|
|
198
|
-
OriginSessionID: payload.originSessionId ?? undefined,
|
|
199
|
-
RecordData: payload.recordData ?? undefined,
|
|
200
|
-
};
|
|
252
|
+
const user = context?.userPayload?.userRecord as UserInfo | undefined;
|
|
253
|
+
// The same server-wide metadata the filter read; an entity unknown here was delivered whole by the filter's own choice
|
|
254
|
+
const entity = payload?.entityName ? new Metadata().EntityByName(payload.entityName) : null; // global-provider-ok: subscription resolver — server-wide metadata, no request-scoped provider exists here
|
|
255
|
+
return ShapeCacheInvalidationEvent(payload, user, entity);
|
|
201
256
|
}
|
|
202
257
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
EntitySaveOptions,
|
|
11
11
|
IMetadataProvider,
|
|
12
12
|
IRunViewProvider,
|
|
13
|
+
KeyValuePair,
|
|
13
14
|
LogDebug,
|
|
14
15
|
LogError,
|
|
15
16
|
LogStatus,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
} from '@memberjunction/core';
|
|
25
26
|
import { MJAuditLogEntity, MJErrorLogEntity, MJUserViewEntityExtended } from '@memberjunction/core-entities';
|
|
26
27
|
import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
|
|
27
|
-
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
28
|
+
import { ClientClauseScreen, UserCache, type EntityBaseViewAllowList } from '@memberjunction/generic-database-provider';
|
|
28
29
|
import { PubSubEngine, AuthorizationError } from 'type-graphql';
|
|
29
30
|
import { GraphQLError } from 'graphql';
|
|
30
31
|
import { RefusalExtensions } from './refusalExtensions.js';
|
|
@@ -36,8 +37,8 @@ import { RunViewGenericParams, UserPayload } from '../types.js';
|
|
|
36
37
|
import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
|
|
37
38
|
import { DeleteOptionsInput } from './DeleteOptionsInput.js';
|
|
38
39
|
import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
39
|
-
import { SQLParser } from '@memberjunction/sql-parser';
|
|
40
|
-
import {
|
|
40
|
+
import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
|
|
41
|
+
import type { SQLParserDialect } from '@memberjunction/sql-dialect';
|
|
41
42
|
import { EncryptionEngine } from '@memberjunction/encryption';
|
|
42
43
|
import { PUSH_STATUS_UPDATES_TOPIC, publishStatusUpdate } from './PushStatusResolver.js';
|
|
43
44
|
import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
|
|
@@ -45,6 +46,15 @@ import { PubSubManager } from './PubSubManager.js';
|
|
|
45
46
|
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
|
|
46
47
|
import { Subscription } from 'rxjs';
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
|
|
51
|
+
* do not track record changes: their server-side write rules compare against the prior state, which a client could
|
|
52
|
+
* otherwise supply itself (lower-case names). See `ResolverBase.MustLoadTruthFromDatabase`.
|
|
53
|
+
*/
|
|
54
|
+
const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW: ReadonlySet<string> = new Set([
|
|
55
|
+
'mj: data context items',
|
|
56
|
+
]);
|
|
57
|
+
|
|
48
58
|
export class ResolverBase {
|
|
49
59
|
private static _emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null;
|
|
50
60
|
private static _cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {};
|
|
@@ -357,13 +367,16 @@ export class ResolverBase {
|
|
|
357
367
|
* @param dataObject - The data object containing field values
|
|
358
368
|
* @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
|
|
359
369
|
* @param contextUser - User context for encryption operations
|
|
370
|
+
* @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
|
|
371
|
+
* default) or field Names (raw provider rows, such as dataset item results)
|
|
360
372
|
* @returns The filtered data object
|
|
361
373
|
*/
|
|
362
374
|
protected async FilterEncryptedFieldsForAPI(
|
|
363
375
|
entityName: string,
|
|
364
376
|
dataObject: Record<string, unknown>,
|
|
365
377
|
contextUser: UserInfo,
|
|
366
|
-
provider?: IMetadataProvider
|
|
378
|
+
provider?: IMetadataProvider,
|
|
379
|
+
fieldKey: 'CodeName' | 'Name' = 'CodeName'
|
|
367
380
|
): Promise<Record<string, unknown>> {
|
|
368
381
|
if (!dataObject) return dataObject;
|
|
369
382
|
|
|
@@ -377,7 +390,7 @@ export class ResolverBase {
|
|
|
377
390
|
|
|
378
391
|
// Process each encrypted field
|
|
379
392
|
for (const field of encryptedFields) {
|
|
380
|
-
const fieldName = field.CodeName;
|
|
393
|
+
const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
|
|
381
394
|
const value = dataObject[fieldName];
|
|
382
395
|
|
|
383
396
|
// Skip null/undefined values
|
|
@@ -418,13 +431,15 @@ export class ResolverBase {
|
|
|
418
431
|
}
|
|
419
432
|
|
|
420
433
|
/**
|
|
421
|
-
* Filters encrypted fields for an array of data objects
|
|
434
|
+
* Filters encrypted fields for an array of data objects. `fieldKey` is as for
|
|
435
|
+
* {@link FilterEncryptedFieldsForAPI}.
|
|
422
436
|
*/
|
|
423
437
|
protected async ArrayFilterEncryptedFieldsForAPI(
|
|
424
438
|
entityName: string,
|
|
425
439
|
dataObjectArray: Record<string, unknown>[],
|
|
426
440
|
contextUser: UserInfo,
|
|
427
|
-
provider?: IMetadataProvider
|
|
441
|
+
provider?: IMetadataProvider,
|
|
442
|
+
fieldKey: 'CodeName' | 'Name' = 'CodeName'
|
|
428
443
|
): Promise<Record<string, unknown>[]> {
|
|
429
444
|
if (!dataObjectArray || dataObjectArray.length === 0) return dataObjectArray;
|
|
430
445
|
|
|
@@ -438,7 +453,7 @@ export class ResolverBase {
|
|
|
438
453
|
|
|
439
454
|
// Process each element
|
|
440
455
|
for (const element of dataObjectArray) {
|
|
441
|
-
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
|
|
456
|
+
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
|
|
442
457
|
}
|
|
443
458
|
|
|
444
459
|
return dataObjectArray;
|
|
@@ -912,53 +927,107 @@ export class ResolverBase {
|
|
|
912
927
|
* This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
|
|
913
928
|
* wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
|
|
914
929
|
* allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
|
|
915
|
-
* and catalogs are rejected. Server-internal RunView callers
|
|
930
|
+
* and catalogs are rejected. Server-internal RunView callers get only the provider's
|
|
931
|
+
* statement-separator check, which cannot refuse a legitimate filter.
|
|
932
|
+
*
|
|
933
|
+
* The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
|
|
934
|
+
* with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
|
|
935
|
+
*
|
|
936
|
+
* Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
|
|
937
|
+
* provider and the tenant hook splice it inside parentheses and AND their own filters after
|
|
938
|
+
* it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
|
|
939
|
+
* security, API-key row filters and tenant scope on one side of an OR the caller controls.
|
|
940
|
+
* The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
|
|
941
|
+
* A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
|
|
942
|
+
* inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
|
|
943
|
+
* there, so the text after it could be code that the parse-based checks never saw.
|
|
916
944
|
*
|
|
917
945
|
* RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
|
|
918
|
-
* into the view
|
|
919
|
-
*
|
|
946
|
+
* into the view, and RunView removes denied fields from its own result only. A subquery reads a
|
|
947
|
+
* base view directly with neither, so the screen refuses one over any entity the user reads under
|
|
948
|
+
* a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
|
|
949
|
+
* entity included.
|
|
920
950
|
*/
|
|
921
951
|
protected assertClientClauseUsesEntityBaseViews(
|
|
922
952
|
clause: string | undefined | null,
|
|
923
953
|
label: string,
|
|
924
954
|
provider?: IMetadataProvider,
|
|
955
|
+
user?: UserInfo,
|
|
956
|
+
options?: { LeaveUnparseableToProvider?: boolean },
|
|
925
957
|
): void {
|
|
926
958
|
if (!clause?.trim()) return;
|
|
927
959
|
|
|
960
|
+
// The balance and backslash checks run before the shared screen: it parses the fragment
|
|
961
|
+
// inside its own parentheses, so it cannot see a fragment that closes the parentheses the
|
|
962
|
+
// provider and the tenant hook splice it into.
|
|
928
963
|
const dialect = this.dialectForProvider(provider);
|
|
929
964
|
if (SQLParser.HasStackedStatements(clause, dialect)) {
|
|
930
965
|
throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
|
|
931
966
|
}
|
|
967
|
+
const balance = IsBalancedSQLFragment(clause, dialect);
|
|
968
|
+
if (!balance.IsBalanced) {
|
|
969
|
+
throw new Error(`Invalid ${label}: ${balance.Reason}`);
|
|
970
|
+
}
|
|
971
|
+
if (this.hasBackslashInQuotes(clause, dialect)) {
|
|
972
|
+
throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
|
|
973
|
+
}
|
|
932
974
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
975
|
+
ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
|
|
980
|
+
* to the SELECT-only validation the caller performs, every table reference must resolve to an
|
|
981
|
+
* entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
|
|
982
|
+
* CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
|
|
983
|
+
* own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
|
|
984
|
+
* for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
|
|
985
|
+
* Fails closed: no user, or an unresolvable reference, refuses.
|
|
986
|
+
*
|
|
987
|
+
* The references come from a walk of the whole parse tree
|
|
988
|
+
* (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
|
|
989
|
+
* account for every SELECT in the text, or when a FROM item names no table (a table function or
|
|
990
|
+
* a row list).
|
|
991
|
+
*
|
|
992
|
+
* Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
|
|
993
|
+
* row-level security and denied-field projection, and raw SQL applies neither. Rather than try
|
|
994
|
+
* to rewrite arbitrary SQL (joins, CTEs) to compose that policy, this refuses any entity that
|
|
995
|
+
* carries a row filter or a denied field for the caller — those reads belong on RunView. For
|
|
996
|
+
* the remaining entities CanRead genuinely means "every row and field of the view".
|
|
997
|
+
*/
|
|
998
|
+
protected assertFullQueryUsesReadableEntityViews(
|
|
999
|
+
sqlText: string,
|
|
1000
|
+
provider: IMetadataProvider | undefined,
|
|
1001
|
+
user: UserInfo | undefined,
|
|
1002
|
+
label = 'SQL',
|
|
1003
|
+
): void {
|
|
1004
|
+
if (!user) {
|
|
1005
|
+
throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
|
|
1006
|
+
}
|
|
1007
|
+
const dialect = this.dialectForProvider(provider);
|
|
1008
|
+
ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
|
|
1009
|
+
const parser = new SQLParser(sqlText, dialect);
|
|
938
1010
|
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
939
|
-
throw new Error(
|
|
940
|
-
`Invalid ${label}: not a safe read-only filter fragment — refusing under uncertainty`,
|
|
941
|
-
);
|
|
1011
|
+
throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
|
|
942
1012
|
}
|
|
943
1013
|
if (this.astContainsWriteNode(parser.AST)) {
|
|
944
1014
|
throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
|
|
945
1015
|
}
|
|
946
|
-
|
|
1016
|
+
const reads = ClientClauseScreen.CollectStatementReads(parser);
|
|
1017
|
+
ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
|
|
947
1018
|
const allowed = this.entityBaseViewAllowList(provider);
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
if (table.toLowerCase() === '__mj_clause_screen') continue;
|
|
953
|
-
const qualified = `${schema}.${table}`.toLowerCase();
|
|
954
|
-
const bare = table.toLowerCase();
|
|
955
|
-
if (allowed.qualified.has(qualified) || allowed.bare.has(bare)) continue;
|
|
956
|
-
throw new Error(
|
|
957
|
-
`Invalid ${label}: subquery must use an entity base view, not '${schema}.${table}'`,
|
|
958
|
-
);
|
|
1019
|
+
for (const relation of reads.Relations) {
|
|
1020
|
+
if (relation.IsCTEReference) continue;
|
|
1021
|
+
const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
|
|
1022
|
+
if (entity) this.assertUnscopedRead(entity, user, label);
|
|
959
1023
|
}
|
|
960
1024
|
}
|
|
961
1025
|
|
|
1026
|
+
/** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
|
|
1027
|
+
private assertUnscopedRead(entity: EntityInfo, user: UserInfo, label: string): void {
|
|
1028
|
+
ClientClauseScreen.AssertUnscopedRead(entity, user, label);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
962
1031
|
/**
|
|
963
1032
|
* Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
|
|
964
1033
|
* a view request can carry that is actually a SQL fragment. GraphQL entry points
|
|
@@ -980,6 +1049,11 @@ export class ResolverBase {
|
|
|
980
1049
|
* The single place that is not true is a field carrying `UserSearchParamFormatAPI`, whose
|
|
981
1050
|
* admin-authored format may splice the term in unquoted; `createViewUserSearchSQL` re-applies
|
|
982
1051
|
* `ValidateUserProvidedSQLClause` for exactly those entities.
|
|
1052
|
+
*
|
|
1053
|
+
* Each `Aggregates[].expression` gets the same screen, except that text the parser cannot read
|
|
1054
|
+
* goes on to the provider, which refuses it per aggregate. The provider never runs the caller's
|
|
1055
|
+
* aggregate text: it runs the SQL that `CheckAggregateExpression` rebuilds from a single
|
|
1056
|
+
* aggregate call over the entity's columns, and refuses everything else per aggregate.
|
|
983
1057
|
*/
|
|
984
1058
|
protected screenClientViewClauses(
|
|
985
1059
|
clauses: {
|
|
@@ -987,57 +1061,39 @@ export class ResolverBase {
|
|
|
987
1061
|
orderBy?: string | null;
|
|
988
1062
|
userSearchString?: string | null;
|
|
989
1063
|
overrideExcludeFilter?: string | null;
|
|
1064
|
+
aggregates?: AggregateExpression[] | null;
|
|
990
1065
|
},
|
|
991
1066
|
provider?: IMetadataProvider,
|
|
1067
|
+
user?: UserInfo,
|
|
992
1068
|
): void {
|
|
993
|
-
this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider);
|
|
994
|
-
this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider);
|
|
995
|
-
this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider);
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
/** Same write-node walk as EDS `sqlReadOnlyScreen.astContainsWriteNode`. */
|
|
999
|
-
private astContainsWriteNode(node: unknown): boolean {
|
|
1000
|
-
if (!node || typeof node !== 'object') return false;
|
|
1001
|
-
if (Array.isArray(node)) return node.some((n) => this.astContainsWriteNode(n));
|
|
1002
|
-
const obj = node as Record<string, unknown>;
|
|
1003
|
-
const type = obj.type;
|
|
1004
|
-
if (typeof type === 'string' && ResolverBase.WRITE_NODE_TYPES.has(type.toLowerCase())) {
|
|
1005
|
-
return true;
|
|
1069
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider, user);
|
|
1070
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider, user);
|
|
1071
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider, user);
|
|
1072
|
+
for (const aggregate of clauses.aggregates ?? []) {
|
|
1073
|
+
this.assertClientClauseUsesEntityBaseViews(aggregate?.expression, 'Aggregate', provider, user, { LeaveUnparseableToProvider: true });
|
|
1006
1074
|
}
|
|
1007
|
-
return Object.values(obj).some((v) => this.astContainsWriteNode(v));
|
|
1008
1075
|
}
|
|
1009
1076
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1077
|
+
/**
|
|
1078
|
+
* True when a string literal or quoted identifier in `clause`, read the way the database reads
|
|
1079
|
+
* it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
|
|
1080
|
+
*/
|
|
1081
|
+
private hasBackslashInQuotes(clause: string, dialect: SQLParserDialect): boolean {
|
|
1082
|
+
return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
|
|
1083
|
+
}
|
|
1014
1084
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
return new SQLServerDialect();
|
|
1085
|
+
/** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
|
|
1086
|
+
private astContainsWriteNode(node: unknown): boolean {
|
|
1087
|
+
return ClientClauseScreen.AstContainsWriteNode(node);
|
|
1019
1088
|
}
|
|
1020
1089
|
|
|
1021
|
-
private
|
|
1022
|
-
|
|
1023
|
-
return name.replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').replace(/^`|`$/g, '');
|
|
1090
|
+
private dialectForProvider(provider?: IMetadataProvider): SQLParserDialect {
|
|
1091
|
+
return ClientClauseScreen.DialectFor(provider);
|
|
1024
1092
|
}
|
|
1025
1093
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
} {
|
|
1030
|
-
const qualified = new Set<string>();
|
|
1031
|
-
const bare = new Set<string>();
|
|
1032
|
-
const entities = provider?.Entities ?? [];
|
|
1033
|
-
for (const e of entities) {
|
|
1034
|
-
const view = this.stripSqlIdent(e.BaseView);
|
|
1035
|
-
if (!view) continue;
|
|
1036
|
-
const schema = this.stripSqlIdent(e.SchemaName);
|
|
1037
|
-
bare.add(view.toLowerCase());
|
|
1038
|
-
if (schema) qualified.add(`${schema}.${view}`.toLowerCase());
|
|
1039
|
-
}
|
|
1040
|
-
return { qualified, bare };
|
|
1094
|
+
/** The entity base-view allow-list for a provider's entities. */
|
|
1095
|
+
private entityBaseViewAllowList(provider?: IMetadataProvider): EntityBaseViewAllowList {
|
|
1096
|
+
return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
|
|
1041
1097
|
}
|
|
1042
1098
|
|
|
1043
1099
|
/**
|
|
@@ -1071,24 +1127,27 @@ export class ResolverBase {
|
|
|
1071
1127
|
try {
|
|
1072
1128
|
if (!viewInfo || !userPayload) return null;
|
|
1073
1129
|
|
|
1130
|
+
// Prefer the authenticated session's payload user — it is the authoritative per-request
|
|
1131
|
+
// identity and (for magic-link sessions) carries the per-session resource scope / synthesized
|
|
1132
|
+
// roles that drive RLS. The cached lookup is a fallback for paths where the payload user
|
|
1133
|
+
// isn't populated. For normal users the two are the same instance, so this is a no-op.
|
|
1134
|
+
const user = this.GetUserFromPayload(userPayload)
|
|
1135
|
+
?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
|
|
1136
|
+
if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
|
|
1137
|
+
|
|
1074
1138
|
// SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
|
|
1075
|
-
// Screen at this boundary: parse, reject writes, allow only entity BaseViews
|
|
1139
|
+
// Screen at this boundary: parse, reject writes, allow only entity BaseViews the
|
|
1140
|
+
// acting user can read (subqueries do not inherit the top entity's permission check).
|
|
1076
1141
|
this.screenClientViewClauses(
|
|
1077
|
-
{ extraFilter, orderBy, userSearchString, overrideExcludeFilter },
|
|
1142
|
+
{ extraFilter, orderBy, userSearchString, overrideExcludeFilter, aggregates },
|
|
1078
1143
|
provider as unknown as IMetadataProvider,
|
|
1144
|
+
user,
|
|
1079
1145
|
);
|
|
1080
1146
|
|
|
1081
1147
|
// Check API key scope authorization for view operations
|
|
1082
1148
|
await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
|
|
1083
1149
|
|
|
1084
1150
|
const md = provider
|
|
1085
|
-
// Prefer the authenticated session's payload user — it is the authoritative per-request
|
|
1086
|
-
// identity and (for magic-link sessions) carries the per-session resource scope / synthesized
|
|
1087
|
-
// roles that drive RLS. The cached lookup is a fallback for paths where the payload user
|
|
1088
|
-
// isn't populated. For normal users the two are the same instance, so this is a no-op.
|
|
1089
|
-
const user = this.GetUserFromPayload(userPayload)
|
|
1090
|
-
?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
|
|
1091
|
-
if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
|
|
1092
1151
|
|
|
1093
1152
|
const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
|
|
1094
1153
|
if (!entityInfo) throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
|
|
@@ -1246,8 +1305,10 @@ export class ResolverBase {
|
|
|
1246
1305
|
orderBy: param.orderBy,
|
|
1247
1306
|
userSearchString: param.userSearchString,
|
|
1248
1307
|
overrideExcludeFilter: param.overrideExcludeFilter,
|
|
1308
|
+
aggregates: param.aggregates,
|
|
1249
1309
|
},
|
|
1250
1310
|
md,
|
|
1311
|
+
contextUser ?? undefined,
|
|
1251
1312
|
);
|
|
1252
1313
|
|
|
1253
1314
|
if (param.viewInfo) {
|
|
@@ -1529,6 +1590,62 @@ export class ResolverBase {
|
|
|
1529
1590
|
}
|
|
1530
1591
|
}
|
|
1531
1592
|
|
|
1593
|
+
/**
|
|
1594
|
+
* IS-A PROMOTION over the wire: an EXISTING parent record gaining a subtype — an Animal that is
|
|
1595
|
+
* now also a Dog. It arrives as the CHILD's create mutation carrying the existing parent's
|
|
1596
|
+
* primary key, because in IS-A the shared key is the relationship and the child has no key of
|
|
1597
|
+
* its own to mint. `NewRecord()` has just reset the whole chain to "new", so left alone the
|
|
1598
|
+
* parent would save as a CREATE: a second copy of a row that already exists (a unique-constraint
|
|
1599
|
+
* violation at best, a silently duplicated parent at worst).
|
|
1600
|
+
*
|
|
1601
|
+
* When a child create carries a complete primary key, bind the new child to the existing parent
|
|
1602
|
+
* row with `AttachToParent` (#3825): it loads the parent chain, so the parent saves as an UPDATE
|
|
1603
|
+
* and only the child is INSERTed. A key that matches no row leaves the fresh chain exactly as
|
|
1604
|
+
* `NewRecord()` built it — the ordinary whole-chain create. A non-IS-A entity, or a create with
|
|
1605
|
+
* no key, never gets past the guards.
|
|
1606
|
+
*
|
|
1607
|
+
* @returns true when the child was attached to an existing parent row.
|
|
1608
|
+
*/
|
|
1609
|
+
protected async attachToExistingParentIfPromotion(entityObject: BaseEntity, input: Record<string, unknown>): Promise<boolean> {
|
|
1610
|
+
if (!entityObject.EntityInfo.IsChildType) {
|
|
1611
|
+
return false;
|
|
1612
|
+
}
|
|
1613
|
+
const key = this.primaryKeyFromInput(entityObject.EntityInfo, input);
|
|
1614
|
+
if (!key) {
|
|
1615
|
+
return false;
|
|
1616
|
+
}
|
|
1617
|
+
const attached = await entityObject.AttachToParent(key);
|
|
1618
|
+
if (!attached) {
|
|
1619
|
+
// No parent row under that key: this is a whole-chain create on the CALLER's key (the
|
|
1620
|
+
// client mints the shared key at the root and sends it, so honoring it keeps the client's
|
|
1621
|
+
// in-memory chain and the stored rows on one key). AttachToParent restored the fresh chain,
|
|
1622
|
+
// but the root's ReadOnly key slot is no longer writable — a later SetMany would silently
|
|
1623
|
+
// keep the server-minted value — so re-seed the chain with the supplied key explicitly.
|
|
1624
|
+
entityObject.NewRecord(key);
|
|
1625
|
+
}
|
|
1626
|
+
return attached;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* The entity's primary key as supplied on a mutation input, or null when any part of it is
|
|
1631
|
+
* missing. Composite keys are all-or-nothing: a half-specified key identifies nothing. An empty
|
|
1632
|
+
* string counts as missing, the same as null — it is never a valid key value, and a GUID or
|
|
1633
|
+
* string key that is "" identifies no row, so the create proceeds as a whole-chain create rather
|
|
1634
|
+
* than failing the lookup. A malformed non-empty key (e.g. a non-GUID for a uniqueidentifier)
|
|
1635
|
+
* is NOT swallowed: the load throws and the mutation fails loudly.
|
|
1636
|
+
*/
|
|
1637
|
+
protected primaryKeyFromInput(entityInfo: EntityInfo, input: Record<string, unknown>): CompositeKey | null {
|
|
1638
|
+
const pairs: KeyValuePair[] = [];
|
|
1639
|
+
for (const pk of entityInfo.PrimaryKeys) {
|
|
1640
|
+
const value = input[pk.Name] ?? input[pk.CodeName];
|
|
1641
|
+
if (value === null || value === undefined || value === '') {
|
|
1642
|
+
return null;
|
|
1643
|
+
}
|
|
1644
|
+
pairs.push(new KeyValuePair(pk.Name, value));
|
|
1645
|
+
}
|
|
1646
|
+
return pairs.length > 0 ? CompositeKey.FromKeyValuePairs(pairs) : null;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1532
1649
|
protected async CreateRecord(entityName: string, input: any, provider: DatabaseProviderBase, userPayload: UserPayload, pubSub: PubSubEngine) {
|
|
1533
1650
|
// Check API key scope authorization for entity create operations
|
|
1534
1651
|
await this.CheckAPIKeyScopeAuthorization('entity:create', entityName, userPayload);
|
|
@@ -1546,6 +1663,10 @@ export class ResolverBase {
|
|
|
1546
1663
|
for (const key of Object.keys(input)) {
|
|
1547
1664
|
if (key !== 'RestoreContext___') fieldsForSet[key] = input[key];
|
|
1548
1665
|
}
|
|
1666
|
+
// IS-A promotion: bind the new child to its EXISTING parent row BEFORE the field
|
|
1667
|
+
// assignments, so values the client sent for parent fields land on the loaded parent
|
|
1668
|
+
// (as an update) instead of being wiped by the load.
|
|
1669
|
+
await this.attachToExistingParentIfPromotion(entityObject, fieldsForSet);
|
|
1549
1670
|
entityObject.SetMany(fieldsForSet);
|
|
1550
1671
|
|
|
1551
1672
|
// Reconstruct the client-side restore context, if any, on this server
|
|
@@ -1711,6 +1832,11 @@ export class ResolverBase {
|
|
|
1711
1832
|
*
|
|
1712
1833
|
* Ordered so the boolean flag is evaluated last: the extra load lands only on entities that have
|
|
1713
1834
|
* the feature switched on, which is almost none of them.
|
|
1835
|
+
*
|
|
1836
|
+
* `MJ: Data Context Items` always loads from the database: its server class lets only an
|
|
1837
|
+
* administrator save an item that holds SQL, and decides that from the item's `Type` and `SQL`.
|
|
1838
|
+
* Hydrated from OldValues, a client could leave both out, and the stored procedure would keep the
|
|
1839
|
+
* stored SQL item while the rule saw none. See {@link ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW}.
|
|
1714
1840
|
*/
|
|
1715
1841
|
protected MustLoadTruthFromDatabase(
|
|
1716
1842
|
entityInfo: EntityInfo,
|
|
@@ -1720,6 +1846,7 @@ export class ResolverBase {
|
|
|
1720
1846
|
): boolean {
|
|
1721
1847
|
return (
|
|
1722
1848
|
entityInfo.TrackRecordChanges ||
|
|
1849
|
+
ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase()) ||
|
|
1723
1850
|
!input.OldValues___ ||
|
|
1724
1851
|
hasDeniedReadFields ||
|
|
1725
1852
|
hasNarrowedAuditPayload ||
|
|
@@ -1184,6 +1184,8 @@ export class RunViewResolver extends ResolverBase {
|
|
|
1184
1184
|
@Ctx() { providers, userPayload }: AppContext
|
|
1185
1185
|
): Promise<RunViewsWithCacheCheckOutput> {
|
|
1186
1186
|
try {
|
|
1187
|
+
// Same scope as RunViews: this is the transport RunViews uses for a CacheLocal batch.
|
|
1188
|
+
await this.CheckAPIKeyScopeAuthorization('view:batch', '*', userPayload);
|
|
1187
1189
|
const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
|
|
1188
1190
|
|
|
1189
1191
|
// Cast provider to SQLServerDataProvider to access RunViewsWithCacheCheck method
|
|
@@ -1192,6 +1194,21 @@ export class RunViewResolver extends ResolverBase {
|
|
|
1192
1194
|
throw new Error('Provider does not support RunViewsWithCacheCheck');
|
|
1193
1195
|
}
|
|
1194
1196
|
|
|
1197
|
+
// SECURITY: same GraphQL-boundary clause screen as RunViews, before the provider runs.
|
|
1198
|
+
const user = this.GetUserFromPayload(userPayload);
|
|
1199
|
+
for (const item of input) {
|
|
1200
|
+
this.screenClientViewClauses(
|
|
1201
|
+
{
|
|
1202
|
+
extraFilter: item.params.ExtraFilter,
|
|
1203
|
+
orderBy: item.params.OrderBy,
|
|
1204
|
+
userSearchString: item.params.UserSearchString,
|
|
1205
|
+
overrideExcludeFilter: item.params.OverrideExcludeFilter,
|
|
1206
|
+
},
|
|
1207
|
+
provider,
|
|
1208
|
+
user,
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1195
1212
|
// Convert GraphQL input types to core types
|
|
1196
1213
|
const coreParams: RunViewWithCacheCheckParams[] = input.map(item => ({
|
|
1197
1214
|
params: {
|