@memberjunction/server 6.1.5 → 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/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.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +6 -3
- package/dist/auth/magicLink/MagicLinkService.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 +13 -11
- 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 +8 -1
- 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/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 +28 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +46 -0
- 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 +39 -16
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +84 -38
- 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/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/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 +8 -0
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -1
- 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 +4 -2
- 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 +106 -41
- 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 +2 -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__/cacheInvalidationRecordData.test.ts +64 -0
- package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
- package/src/__tests__/magicLinkService.consume.test.ts +6 -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/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 +6 -3
- package/src/auth/newUsers.ts +15 -11
- package/src/context.ts +14 -10
- 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 +9 -1
- package/src/isolatedProvider.ts +22 -0
- package/src/multiTenancy/index.ts +24 -2
- package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
- package/src/resolvers/ActionResolver.ts +84 -21
- package/src/resolvers/AdhocQueryResolver.ts +49 -1
- 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 +103 -42
- package/src/resolvers/RemoteBrowserActionResolver.ts +11 -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/rest/ViewOperationsHandler.ts +37 -18
- package/src/services/TaskGraphActionRunner.ts +29 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { BaseEntity, BaseEntityEvent, CompositeKey, EntityFieldTSType, LogDebug, LogError, LogStatus, Metadata, ReadableFieldsTransportKey, RecordChangeFieldSecurityProjector, RecordChangePayloadFields, } from '@memberjunction/core';
|
|
2
|
-
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
1
|
+
import { BaseEntity, BaseEntityEvent, CompositeKey, EntityFieldTSType, KeyValuePair, LogDebug, LogError, LogStatus, Metadata, ReadableFieldsTransportKey, RecordChangeFieldSecurityProjector, RecordChangePayloadFields, } from '@memberjunction/core';
|
|
2
|
+
import { ClientClauseScreen, UserCache } from '@memberjunction/generic-database-provider';
|
|
3
3
|
import { AuthorizationError } from 'type-graphql';
|
|
4
4
|
import { GraphQLError } from 'graphql';
|
|
5
5
|
import { RefusalExtensions } from './refusalExtensions.js';
|
|
@@ -7,13 +7,20 @@ import { GetAPIKeyEngine } from '@memberjunction/api-keys';
|
|
|
7
7
|
import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
|
|
8
8
|
import { DeleteOptionsInput } from './DeleteOptionsInput.js';
|
|
9
9
|
import { MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
10
|
-
import { SQLParser } from '@memberjunction/sql-parser';
|
|
11
|
-
import { PostgreSQLDialect, SQLServerDialect } from '@memberjunction/sql-dialect';
|
|
10
|
+
import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
|
|
12
11
|
import { EncryptionEngine } from '@memberjunction/encryption';
|
|
13
12
|
import { publishStatusUpdate } from './PushStatusResolver.js';
|
|
14
13
|
import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
|
|
15
14
|
import { PubSubManager } from './PubSubManager.js';
|
|
16
15
|
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
|
|
16
|
+
/**
|
|
17
|
+
* Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
|
|
18
|
+
* do not track record changes: their server-side write rules compare against the prior state, which a client could
|
|
19
|
+
* otherwise supply itself (lower-case names). See `ResolverBase.MustLoadTruthFromDatabase`.
|
|
20
|
+
*/
|
|
21
|
+
const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW = new Set([
|
|
22
|
+
'mj: data context items',
|
|
23
|
+
]);
|
|
17
24
|
export class ResolverBase {
|
|
18
25
|
static { this._emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null; }
|
|
19
26
|
static { this._cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {}; }
|
|
@@ -299,9 +306,11 @@ export class ResolverBase {
|
|
|
299
306
|
* @param dataObject - The data object containing field values
|
|
300
307
|
* @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
|
|
301
308
|
* @param contextUser - User context for encryption operations
|
|
309
|
+
* @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
|
|
310
|
+
* default) or field Names (raw provider rows, such as dataset item results)
|
|
302
311
|
* @returns The filtered data object
|
|
303
312
|
*/
|
|
304
|
-
async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider) {
|
|
313
|
+
async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider, fieldKey = 'CodeName') {
|
|
305
314
|
if (!dataObject)
|
|
306
315
|
return dataObject;
|
|
307
316
|
const md = provider ?? new Metadata();
|
|
@@ -314,7 +323,7 @@ export class ResolverBase {
|
|
|
314
323
|
return dataObject;
|
|
315
324
|
// Process each encrypted field
|
|
316
325
|
for (const field of encryptedFields) {
|
|
317
|
-
const fieldName = field.CodeName;
|
|
326
|
+
const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
|
|
318
327
|
const value = dataObject[fieldName];
|
|
319
328
|
// Skip null/undefined values
|
|
320
329
|
if (value === null || value === undefined)
|
|
@@ -350,9 +359,10 @@ export class ResolverBase {
|
|
|
350
359
|
return dataObject;
|
|
351
360
|
}
|
|
352
361
|
/**
|
|
353
|
-
* Filters encrypted fields for an array of data objects
|
|
362
|
+
* Filters encrypted fields for an array of data objects. `fieldKey` is as for
|
|
363
|
+
* {@link FilterEncryptedFieldsForAPI}.
|
|
354
364
|
*/
|
|
355
|
-
async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider) {
|
|
365
|
+
async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider, fieldKey = 'CodeName') {
|
|
356
366
|
if (!dataObjectArray || dataObjectArray.length === 0)
|
|
357
367
|
return dataObjectArray;
|
|
358
368
|
// Check if entity has any encrypted fields first to avoid unnecessary processing
|
|
@@ -365,7 +375,7 @@ export class ResolverBase {
|
|
|
365
375
|
return dataObjectArray;
|
|
366
376
|
// Process each element
|
|
367
377
|
for (const element of dataObjectArray) {
|
|
368
|
-
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
|
|
378
|
+
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
|
|
369
379
|
}
|
|
370
380
|
return dataObjectArray;
|
|
371
381
|
}
|
|
@@ -723,43 +733,94 @@ export class ResolverBase {
|
|
|
723
733
|
* This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
|
|
724
734
|
* wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
|
|
725
735
|
* allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
|
|
726
|
-
* and catalogs are rejected. Server-internal RunView callers
|
|
736
|
+
* and catalogs are rejected. Server-internal RunView callers get only the provider's
|
|
737
|
+
* statement-separator check, which cannot refuse a legitimate filter.
|
|
738
|
+
*
|
|
739
|
+
* The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
|
|
740
|
+
* with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
|
|
741
|
+
*
|
|
742
|
+
* Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
|
|
743
|
+
* provider and the tenant hook splice it inside parentheses and AND their own filters after
|
|
744
|
+
* it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
|
|
745
|
+
* security, API-key row filters and tenant scope on one side of an OR the caller controls.
|
|
746
|
+
* The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
|
|
747
|
+
* A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
|
|
748
|
+
* inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
|
|
749
|
+
* there, so the text after it could be code that the parse-based checks never saw.
|
|
727
750
|
*
|
|
728
751
|
* RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
|
|
729
|
-
* into the view
|
|
730
|
-
*
|
|
752
|
+
* into the view, and RunView removes denied fields from its own result only. A subquery reads a
|
|
753
|
+
* base view directly with neither, so the screen refuses one over any entity the user reads under
|
|
754
|
+
* a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
|
|
755
|
+
* entity included.
|
|
731
756
|
*/
|
|
732
|
-
assertClientClauseUsesEntityBaseViews(clause, label, provider) {
|
|
757
|
+
assertClientClauseUsesEntityBaseViews(clause, label, provider, user, options) {
|
|
733
758
|
if (!clause?.trim())
|
|
734
759
|
return;
|
|
760
|
+
// The balance and backslash checks run before the shared screen: it parses the fragment
|
|
761
|
+
// inside its own parentheses, so it cannot see a fragment that closes the parentheses the
|
|
762
|
+
// provider and the tenant hook splice it into.
|
|
735
763
|
const dialect = this.dialectForProvider(provider);
|
|
736
764
|
if (SQLParser.HasStackedStatements(clause, dialect)) {
|
|
737
765
|
throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
|
|
738
766
|
}
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
767
|
+
const balance = IsBalancedSQLFragment(clause, dialect);
|
|
768
|
+
if (!balance.IsBalanced) {
|
|
769
|
+
throw new Error(`Invalid ${label}: ${balance.Reason}`);
|
|
770
|
+
}
|
|
771
|
+
if (this.hasBackslashInQuotes(clause, dialect)) {
|
|
772
|
+
throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
|
|
773
|
+
}
|
|
774
|
+
ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
|
|
778
|
+
* to the SELECT-only validation the caller performs, every table reference must resolve to an
|
|
779
|
+
* entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
|
|
780
|
+
* CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
|
|
781
|
+
* own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
|
|
782
|
+
* for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
|
|
783
|
+
* Fails closed: no user, or an unresolvable reference, refuses.
|
|
784
|
+
*
|
|
785
|
+
* The references come from a walk of the whole parse tree
|
|
786
|
+
* (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
|
|
787
|
+
* account for every SELECT in the text, or when a FROM item names no table (a table function or
|
|
788
|
+
* a row list).
|
|
789
|
+
*
|
|
790
|
+
* Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
|
|
791
|
+
* row-level security and denied-field projection, and raw SQL applies neither. Rather than try
|
|
792
|
+
* to rewrite arbitrary SQL (joins, CTEs) to compose that policy, this refuses any entity that
|
|
793
|
+
* carries a row filter or a denied field for the caller — those reads belong on RunView. For
|
|
794
|
+
* the remaining entities CanRead genuinely means "every row and field of the view".
|
|
795
|
+
*/
|
|
796
|
+
assertFullQueryUsesReadableEntityViews(sqlText, provider, user, label = 'SQL') {
|
|
797
|
+
if (!user) {
|
|
798
|
+
throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
|
|
799
|
+
}
|
|
800
|
+
const dialect = this.dialectForProvider(provider);
|
|
801
|
+
ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
|
|
802
|
+
const parser = new SQLParser(sqlText, dialect);
|
|
743
803
|
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
744
|
-
throw new Error(`Invalid ${label}: not a safe read-only
|
|
804
|
+
throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
|
|
745
805
|
}
|
|
746
806
|
if (this.astContainsWriteNode(parser.AST)) {
|
|
747
807
|
throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
|
|
748
808
|
}
|
|
809
|
+
const reads = ClientClauseScreen.CollectStatementReads(parser);
|
|
810
|
+
ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
|
|
749
811
|
const allowed = this.entityBaseViewAllowList(provider);
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
const table = this.stripSqlIdent(t.TableName);
|
|
753
|
-
const schema = this.stripSqlIdent(t.SchemaName);
|
|
754
|
-
if (table.toLowerCase() === '__mj_clause_screen')
|
|
755
|
-
continue;
|
|
756
|
-
const qualified = `${schema}.${table}`.toLowerCase();
|
|
757
|
-
const bare = table.toLowerCase();
|
|
758
|
-
if (allowed.qualified.has(qualified) || allowed.bare.has(bare))
|
|
812
|
+
for (const relation of reads.Relations) {
|
|
813
|
+
if (relation.IsCTEReference)
|
|
759
814
|
continue;
|
|
760
|
-
|
|
815
|
+
const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
|
|
816
|
+
if (entity)
|
|
817
|
+
this.assertUnscopedRead(entity, user, label);
|
|
761
818
|
}
|
|
762
819
|
}
|
|
820
|
+
/** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
|
|
821
|
+
assertUnscopedRead(entity, user, label) {
|
|
822
|
+
ClientClauseScreen.AssertUnscopedRead(entity, user, label);
|
|
823
|
+
}
|
|
763
824
|
/**
|
|
764
825
|
* Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
|
|
765
826
|
* a view request can carry that is actually a SQL fragment. GraphQL entry points
|
|
@@ -781,54 +842,37 @@ export class ResolverBase {
|
|
|
781
842
|
* The single place that is not true is a field carrying `UserSearchParamFormatAPI`, whose
|
|
782
843
|
* admin-authored format may splice the term in unquoted; `createViewUserSearchSQL` re-applies
|
|
783
844
|
* `ValidateUserProvidedSQLClause` for exactly those entities.
|
|
845
|
+
*
|
|
846
|
+
* Each `Aggregates[].expression` gets the same screen, except that text the parser cannot read
|
|
847
|
+
* goes on to the provider, which refuses it per aggregate. The provider never runs the caller's
|
|
848
|
+
* aggregate text: it runs the SQL that `CheckAggregateExpression` rebuilds from a single
|
|
849
|
+
* aggregate call over the entity's columns, and refuses everything else per aggregate.
|
|
850
|
+
*/
|
|
851
|
+
screenClientViewClauses(clauses, provider, user) {
|
|
852
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider, user);
|
|
853
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider, user);
|
|
854
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider, user);
|
|
855
|
+
for (const aggregate of clauses.aggregates ?? []) {
|
|
856
|
+
this.assertClientClauseUsesEntityBaseViews(aggregate?.expression, 'Aggregate', provider, user, { LeaveUnparseableToProvider: true });
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* True when a string literal or quoted identifier in `clause`, read the way the database reads
|
|
861
|
+
* it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
|
|
784
862
|
*/
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider);
|
|
788
|
-
this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider);
|
|
863
|
+
hasBackslashInQuotes(clause, dialect) {
|
|
864
|
+
return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
|
|
789
865
|
}
|
|
790
|
-
/**
|
|
866
|
+
/** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
|
|
791
867
|
astContainsWriteNode(node) {
|
|
792
|
-
|
|
793
|
-
return false;
|
|
794
|
-
if (Array.isArray(node))
|
|
795
|
-
return node.some((n) => this.astContainsWriteNode(n));
|
|
796
|
-
const obj = node;
|
|
797
|
-
const type = obj.type;
|
|
798
|
-
if (typeof type === 'string' && ResolverBase.WRITE_NODE_TYPES.has(type.toLowerCase())) {
|
|
799
|
-
return true;
|
|
800
|
-
}
|
|
801
|
-
return Object.values(obj).some((v) => this.astContainsWriteNode(v));
|
|
868
|
+
return ClientClauseScreen.AstContainsWriteNode(node);
|
|
802
869
|
}
|
|
803
|
-
static { this.WRITE_NODE_TYPES = new Set([
|
|
804
|
-
'insert', 'update', 'delete', 'merge', 'replace', 'drop', 'create', 'alter', 'truncate',
|
|
805
|
-
'rename', 'call', 'exec', 'execute', 'grant', 'revoke', 'use', 'load', 'copy', 'do',
|
|
806
|
-
]); }
|
|
807
870
|
dialectForProvider(provider) {
|
|
808
|
-
|
|
809
|
-
if (/postgres/i.test(name))
|
|
810
|
-
return new PostgreSQLDialect();
|
|
811
|
-
return new SQLServerDialect();
|
|
812
|
-
}
|
|
813
|
-
stripSqlIdent(name) {
|
|
814
|
-
if (!name)
|
|
815
|
-
return '';
|
|
816
|
-
return name.replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').replace(/^`|`$/g, '');
|
|
871
|
+
return ClientClauseScreen.DialectFor(provider);
|
|
817
872
|
}
|
|
873
|
+
/** The entity base-view allow-list for a provider's entities. */
|
|
818
874
|
entityBaseViewAllowList(provider) {
|
|
819
|
-
|
|
820
|
-
const bare = new Set();
|
|
821
|
-
const entities = provider?.Entities ?? [];
|
|
822
|
-
for (const e of entities) {
|
|
823
|
-
const view = this.stripSqlIdent(e.BaseView);
|
|
824
|
-
if (!view)
|
|
825
|
-
continue;
|
|
826
|
-
const schema = this.stripSqlIdent(e.SchemaName);
|
|
827
|
-
bare.add(view.toLowerCase());
|
|
828
|
-
if (schema)
|
|
829
|
-
qualified.add(`${schema}.${view}`.toLowerCase());
|
|
830
|
-
}
|
|
831
|
-
return { qualified, bare };
|
|
875
|
+
return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
|
|
832
876
|
}
|
|
833
877
|
/**
|
|
834
878
|
* Optimized RunViewGenericInternal implementation with:
|
|
@@ -839,12 +883,6 @@ export class ResolverBase {
|
|
|
839
883
|
try {
|
|
840
884
|
if (!viewInfo || !userPayload)
|
|
841
885
|
return null;
|
|
842
|
-
// SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
|
|
843
|
-
// Screen at this boundary: parse, reject writes, allow only entity BaseViews.
|
|
844
|
-
this.screenClientViewClauses({ extraFilter, orderBy, userSearchString, overrideExcludeFilter }, provider);
|
|
845
|
-
// Check API key scope authorization for view operations
|
|
846
|
-
await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
|
|
847
|
-
const md = provider;
|
|
848
886
|
// Prefer the authenticated session's payload user — it is the authoritative per-request
|
|
849
887
|
// identity and (for magic-link sessions) carries the per-session resource scope / synthesized
|
|
850
888
|
// roles that drive RLS. The cached lookup is a fallback for paths where the payload user
|
|
@@ -853,6 +891,13 @@ export class ResolverBase {
|
|
|
853
891
|
?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
|
|
854
892
|
if (!user)
|
|
855
893
|
throw new Error(`User ${userPayload?.email} not found in metadata`);
|
|
894
|
+
// SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
|
|
895
|
+
// Screen at this boundary: parse, reject writes, allow only entity BaseViews the
|
|
896
|
+
// acting user can read (subqueries do not inherit the top entity's permission check).
|
|
897
|
+
this.screenClientViewClauses({ extraFilter, orderBy, userSearchString, overrideExcludeFilter, aggregates }, provider, user);
|
|
898
|
+
// Check API key scope authorization for view operations
|
|
899
|
+
await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
|
|
900
|
+
const md = provider;
|
|
856
901
|
const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
|
|
857
902
|
if (!entityInfo)
|
|
858
903
|
throw new Error(`Entity ${viewInfo.Entity} not found in metadata`);
|
|
@@ -988,7 +1033,8 @@ export class ResolverBase {
|
|
|
988
1033
|
orderBy: param.orderBy,
|
|
989
1034
|
userSearchString: param.userSearchString,
|
|
990
1035
|
overrideExcludeFilter: param.overrideExcludeFilter,
|
|
991
|
-
|
|
1036
|
+
aggregates: param.aggregates,
|
|
1037
|
+
}, md, contextUser ?? undefined);
|
|
992
1038
|
if (param.viewInfo) {
|
|
993
1039
|
// Validate entity only once per entity type
|
|
994
1040
|
const entityName = param.viewInfo.Entity;
|
|
@@ -1233,6 +1279,60 @@ export class ResolverBase {
|
|
|
1233
1279
|
this.EventSubscriptions.set(uniqueKey, theSub);
|
|
1234
1280
|
}
|
|
1235
1281
|
}
|
|
1282
|
+
/**
|
|
1283
|
+
* IS-A PROMOTION over the wire: an EXISTING parent record gaining a subtype — an Animal that is
|
|
1284
|
+
* now also a Dog. It arrives as the CHILD's create mutation carrying the existing parent's
|
|
1285
|
+
* primary key, because in IS-A the shared key is the relationship and the child has no key of
|
|
1286
|
+
* its own to mint. `NewRecord()` has just reset the whole chain to "new", so left alone the
|
|
1287
|
+
* parent would save as a CREATE: a second copy of a row that already exists (a unique-constraint
|
|
1288
|
+
* violation at best, a silently duplicated parent at worst).
|
|
1289
|
+
*
|
|
1290
|
+
* When a child create carries a complete primary key, bind the new child to the existing parent
|
|
1291
|
+
* row with `AttachToParent` (#3825): it loads the parent chain, so the parent saves as an UPDATE
|
|
1292
|
+
* and only the child is INSERTed. A key that matches no row leaves the fresh chain exactly as
|
|
1293
|
+
* `NewRecord()` built it — the ordinary whole-chain create. A non-IS-A entity, or a create with
|
|
1294
|
+
* no key, never gets past the guards.
|
|
1295
|
+
*
|
|
1296
|
+
* @returns true when the child was attached to an existing parent row.
|
|
1297
|
+
*/
|
|
1298
|
+
async attachToExistingParentIfPromotion(entityObject, input) {
|
|
1299
|
+
if (!entityObject.EntityInfo.IsChildType) {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
const key = this.primaryKeyFromInput(entityObject.EntityInfo, input);
|
|
1303
|
+
if (!key) {
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1306
|
+
const attached = await entityObject.AttachToParent(key);
|
|
1307
|
+
if (!attached) {
|
|
1308
|
+
// No parent row under that key: this is a whole-chain create on the CALLER's key (the
|
|
1309
|
+
// client mints the shared key at the root and sends it, so honoring it keeps the client's
|
|
1310
|
+
// in-memory chain and the stored rows on one key). AttachToParent restored the fresh chain,
|
|
1311
|
+
// but the root's ReadOnly key slot is no longer writable — a later SetMany would silently
|
|
1312
|
+
// keep the server-minted value — so re-seed the chain with the supplied key explicitly.
|
|
1313
|
+
entityObject.NewRecord(key);
|
|
1314
|
+
}
|
|
1315
|
+
return attached;
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* The entity's primary key as supplied on a mutation input, or null when any part of it is
|
|
1319
|
+
* missing. Composite keys are all-or-nothing: a half-specified key identifies nothing. An empty
|
|
1320
|
+
* string counts as missing, the same as null — it is never a valid key value, and a GUID or
|
|
1321
|
+
* string key that is "" identifies no row, so the create proceeds as a whole-chain create rather
|
|
1322
|
+
* than failing the lookup. A malformed non-empty key (e.g. a non-GUID for a uniqueidentifier)
|
|
1323
|
+
* is NOT swallowed: the load throws and the mutation fails loudly.
|
|
1324
|
+
*/
|
|
1325
|
+
primaryKeyFromInput(entityInfo, input) {
|
|
1326
|
+
const pairs = [];
|
|
1327
|
+
for (const pk of entityInfo.PrimaryKeys) {
|
|
1328
|
+
const value = input[pk.Name] ?? input[pk.CodeName];
|
|
1329
|
+
if (value === null || value === undefined || value === '') {
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
pairs.push(new KeyValuePair(pk.Name, value));
|
|
1333
|
+
}
|
|
1334
|
+
return pairs.length > 0 ? CompositeKey.FromKeyValuePairs(pairs) : null;
|
|
1335
|
+
}
|
|
1236
1336
|
async CreateRecord(entityName, input, provider, userPayload, pubSub) {
|
|
1237
1337
|
// Check API key scope authorization for entity create operations
|
|
1238
1338
|
await this.CheckAPIKeyScopeAuthorization('entity:create', entityName, userPayload);
|
|
@@ -1249,6 +1349,10 @@ export class ResolverBase {
|
|
|
1249
1349
|
if (key !== 'RestoreContext___')
|
|
1250
1350
|
fieldsForSet[key] = input[key];
|
|
1251
1351
|
}
|
|
1352
|
+
// IS-A promotion: bind the new child to its EXISTING parent row BEFORE the field
|
|
1353
|
+
// assignments, so values the client sent for parent fields land on the loaded parent
|
|
1354
|
+
// (as an update) instead of being wiped by the load.
|
|
1355
|
+
await this.attachToExistingParentIfPromotion(entityObject, fieldsForSet);
|
|
1252
1356
|
entityObject.SetMany(fieldsForSet);
|
|
1253
1357
|
// Reconstruct the client-side restore context, if any, on this server
|
|
1254
1358
|
// entity so the data provider writes the lineage columns on Save().
|
|
@@ -1402,9 +1506,15 @@ export class ResolverBase {
|
|
|
1402
1506
|
*
|
|
1403
1507
|
* Ordered so the boolean flag is evaluated last: the extra load lands only on entities that have
|
|
1404
1508
|
* the feature switched on, which is almost none of them.
|
|
1509
|
+
*
|
|
1510
|
+
* `MJ: Data Context Items` always loads from the database: its server class lets only an
|
|
1511
|
+
* administrator save an item that holds SQL, and decides that from the item's `Type` and `SQL`.
|
|
1512
|
+
* Hydrated from OldValues, a client could leave both out, and the stored procedure would keep the
|
|
1513
|
+
* stored SQL item while the rule saw none. See {@link ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW}.
|
|
1405
1514
|
*/
|
|
1406
1515
|
MustLoadTruthFromDatabase(entityInfo, input, hasDeniedReadFields, hasNarrowedAuditPayload) {
|
|
1407
1516
|
return (entityInfo.TrackRecordChanges ||
|
|
1517
|
+
ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase()) ||
|
|
1408
1518
|
!input.OldValues___ ||
|
|
1409
1519
|
hasDeniedReadFields ||
|
|
1410
1520
|
hasNarrowedAuditPayload ||
|