Query complexity validation (#16274)

Validations : 
- relations count (in common api)
- oneToMany relation nested count (in common)
- requested fields count (in gql)
- root resolver count (in gql)
- root resolver duplicates (in gql)
- specific complexity for metadata / nesting count (in gql)
This commit is contained in:
Etienne
2025-12-04 16:33:08 +01:00
committed by GitHub
parent 53d34f4d14
commit d1befa7e35
36 changed files with 1208 additions and 91 deletions
@@ -33,9 +33,9 @@ import {
import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { useSentryTracing } from 'src/engine/core-modules/exception-handler/hooks/use-sentry-tracing';
import { useComputeComplexity } from 'src/engine/core-modules/graphql/hooks/use-compute-complexity.hook';
import { useDisableIntrospectionAndSuggestionsForUnauthenticatedUsers } from 'src/engine/core-modules/graphql/hooks/use-disable-introspection-and-suggestions-for-unauthenticated-users.hook';
import { useGraphQLErrorHandlerHook } from 'src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook';
import { useValidateGraphqlQueryComplexity } from 'src/engine/core-modules/graphql/hooks/use-validate-graphql-query-complexity.hook';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -76,9 +76,14 @@ export class GraphQLConfigService
useDisableIntrospectionAndSuggestionsForUnauthenticatedUsers(
this.twentyConfigService.get('NODE_ENV') === NodeEnvironment.PRODUCTION,
),
useComputeComplexity(
this.twentyConfigService.get('GRAPHQL_MAX_COMPLEXITY'),
),
useValidateGraphqlQueryComplexity({
maximumAllowedFields:
this.twentyConfigService.get('GRAPHQL_MAX_FIELDS'),
maximumAllowedRootResolvers: this.twentyConfigService.get(
'GRAPHQL_MAX_ROOT_RESOLVERS',
),
checkDuplicateRootResolvers: true,
}),
];
if (Sentry.isInitialized()) {
@@ -1,3 +1,4 @@
import { RelationType, type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -22,16 +23,26 @@ export class GraphqlQuerySelectedFieldsRelationParser {
}
parseRelationField(
fieldMetadata: FlatFieldMetadata,
fieldMetadata:
| FlatFieldMetadata<FieldMetadataType.RELATION>
| FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>,
fieldKey: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fieldValue: any,
accumulator: GraphqlQuerySelectedFieldsResult,
isFromOneToManyRelation?: boolean,
): void {
if (!fieldValue || typeof fieldValue !== 'object') {
return;
}
const isOneToManyRelation =
fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY;
if (isFromOneToManyRelation && isOneToManyRelation) {
accumulator.hasAtLeastTwoNestedOneToManyRelations = true;
}
accumulator.relations[fieldKey] = true;
if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) {
@@ -52,6 +63,7 @@ export class GraphqlQuerySelectedFieldsRelationParser {
const relationAccumulator = fieldParser.parse(
fieldValue,
targetObjectMetadata,
isFromOneToManyRelation || isOneToManyRelation,
);
accumulator.select[fieldKey] = {
@@ -60,5 +72,12 @@ export class GraphqlQuerySelectedFieldsRelationParser {
};
accumulator.relations[fieldKey] = relationAccumulator.relations;
accumulator.aggregate[fieldKey] = relationAccumulator.aggregate;
accumulator.relationFieldsCount =
accumulator.relationFieldsCount +
relationAccumulator.relationFieldsCount +
1;
accumulator.hasAtLeastTwoNestedOneToManyRelations =
accumulator.hasAtLeastTwoNestedOneToManyRelations ||
relationAccumulator.hasAtLeastTwoNestedOneToManyRelations;
}
}
@@ -21,6 +21,8 @@ export type GraphqlQuerySelectedFieldsResult = {
relations: Record<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aggregate: Record<string, any>;
relationFieldsCount: number;
hasAtLeastTwoNestedOneToManyRelations: boolean;
};
export class GraphqlQuerySelectedFieldsParser {
@@ -47,11 +49,14 @@ export class GraphqlQuerySelectedFieldsParser {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
graphqlSelectedFields: Partial<Record<string, any>>,
flatObjectMetadata: FlatObjectMetadata,
isFromOneToManyRelation?: boolean,
): GraphqlQuerySelectedFieldsResult {
const accumulator: GraphqlQuerySelectedFieldsResult = {
select: {},
relations: {},
aggregate: {},
relationFieldsCount: 0,
hasAtLeastTwoNestedOneToManyRelations: false,
};
if (this.isRootConnection(graphqlSelectedFields)) {
@@ -59,6 +64,7 @@ export class GraphqlQuerySelectedFieldsParser {
graphqlSelectedFields,
flatObjectMetadata,
accumulator,
isFromOneToManyRelation,
);
return accumulator;
@@ -75,6 +81,7 @@ export class GraphqlQuerySelectedFieldsParser {
graphqlSelectedFields,
flatObjectMetadata,
accumulator,
isFromOneToManyRelation,
);
return accumulator;
@@ -85,6 +92,7 @@ export class GraphqlQuerySelectedFieldsParser {
graphqlSelectedFields: Partial<Record<string, any>>,
flatObjectMetadata: FlatObjectMetadata,
accumulator: GraphqlQuerySelectedFieldsResult,
isFromOneToManyRelation?: boolean,
): void {
for (const fieldMetadataId of flatObjectMetadata.fieldMetadataIds) {
const fieldMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
@@ -116,6 +124,7 @@ export class GraphqlQuerySelectedFieldsParser {
fieldMetadata.name,
graphqlSelectedFieldValue,
accumulator,
isFromOneToManyRelation,
);
continue;
@@ -160,6 +169,7 @@ export class GraphqlQuerySelectedFieldsParser {
fieldMetadata.name,
graphqlSelectedFieldValue,
accumulator,
isFromOneToManyRelation,
);
continue;
@@ -197,6 +207,7 @@ export class GraphqlQuerySelectedFieldsParser {
graphqlSelectedFields: Partial<Record<string, any>>,
flatObjectMetadata: FlatObjectMetadata,
accumulator: GraphqlQuerySelectedFieldsResult,
isFromOneToManyRelation?: boolean,
): void {
this.aggregateParser.parse(
graphqlSelectedFields,
@@ -207,7 +218,12 @@ export class GraphqlQuerySelectedFieldsParser {
const node = graphqlSelectedFields.edges.node;
this.parseRecordFields(node, flatObjectMetadata, accumulator);
this.parseRecordFields(
node,
flatObjectMetadata,
accumulator,
isFromOneToManyRelation,
);
}
private isRootConnection(
@@ -4,7 +4,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import isEmpty from 'lodash.isempty';
import { ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ObjectLiteral } from 'typeorm';
import { FindOptionsRelations, type ObjectLiteral } from 'typeorm';
import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
@@ -12,7 +12,7 @@ import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils
import { CommonResultGettersService } from 'src/engine/api/common/common-result-getters/common-result-getters.service';
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import { type GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
@@ -50,7 +50,7 @@ export class GroupByWithRecordsService {
queryBuilderWithGroupBy: WorkspaceSelectQueryBuilder<ObjectLiteral>;
queryBuilderWithFiltersAndWithoutGroupBy: WorkspaceSelectQueryBuilder<ObjectLiteral>;
groupByDefinitions: GroupByDefinition[];
selectedFieldsResult: GraphqlQuerySelectedFieldsResult;
selectedFieldsResult: CommonSelectedFieldsResult;
queryRunnerContext: CommonExtendedQueryRunnerContext;
orderByForRecords: ObjectRecordOrderBy;
groupLimit?: number;
@@ -110,7 +110,10 @@ export class GroupByWithRecordsService {
parentObjectMetadataItem: flatObjectMetadata,
parentObjectRecords: allRecords,
parentObjectRecordsAggregatedValues: {},
relations: selectedFieldsResult.relations,
relations: selectedFieldsResult.relations as Record<
string,
FindOptionsRelations<ObjectLiteral>
>,
aggregate: selectedFieldsResult.aggregate,
limit: RELATIONS_PER_RECORD_LIMIT,
authContext,
@@ -7,9 +7,9 @@ import { useCachedMetadata } from 'src/engine/api/graphql/graphql-config/hooks/u
import { MetadataGraphQLApiModule } from 'src/engine/api/graphql/metadata-graphql-api.module';
import { type CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { type ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { useComputeComplexity } from 'src/engine/core-modules/graphql/hooks/use-compute-complexity.hook';
import { useDisableIntrospectionAndSuggestionsForUnauthenticatedUsers } from 'src/engine/core-modules/graphql/hooks/use-disable-introspection-and-suggestions-for-unauthenticated-users.hook';
import { useGraphQLErrorHandlerHook } from 'src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook';
import { useValidateGraphqlQueryComplexity } from 'src/engine/core-modules/graphql/hooks/use-validate-graphql-query-complexity.hook';
import { type I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -46,7 +46,12 @@ export const metadataModuleFactory = async (
useDisableIntrospectionAndSuggestionsForUnauthenticatedUsers(
twentyConfigService.get('NODE_ENV') === NodeEnvironment.PRODUCTION,
),
useComputeComplexity(twentyConfigService.get('GRAPHQL_MAX_COMPLEXITY')),
useValidateGraphqlQueryComplexity({
maximumAllowedFields: twentyConfigService.get('GRAPHQL_MAX_FIELDS'),
maximumAllowedRootResolvers: 10,
maximumAllowedNestedFields: 7,
checkDuplicateRootResolvers: true,
}),
],
path: '/metadata',
context: () => ({