diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/__tests__/process-aggregate.helper.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/__tests__/process-aggregate.helper.spec.ts new file mode 100644 index 000000000..dba528e1b --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/__tests__/process-aggregate.helper.spec.ts @@ -0,0 +1,80 @@ +import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; + +describe('ProcessAggregateHelper', () => { + describe('extractColumnNamesFromAggregateExpression', () => { + it('should extract column names from CONCAT expression', () => { + const selection = + 'CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(NULLIF(CONCAT("firstName","lastName")) END, \'\')'; + const result = + ProcessAggregateHelper.extractColumnNamesFromAggregateExpression( + selection, + ); + + expect(result).toEqual(['firstName', 'lastName']); + }); + + it('should extract column names from CONCAT expression - 2', () => { + const selection = + 'CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(NULLIF(CONCAT("firstName")) END, \'\')'; + const result = + ProcessAggregateHelper.extractColumnNamesFromAggregateExpression( + selection, + ); + + expect(result).toEqual(['firstName']); + }); + + it('should extract column names from CONCAT expression - 3', () => { + const selection = + 'CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(NULLIF(CONCAT("firstName","lastName","nickName")) END, \'\')'; + const result = + ProcessAggregateHelper.extractColumnNamesFromAggregateExpression( + selection, + ); + + expect(result).toEqual(['firstName', 'lastName', 'nickName']); + }); + + it('should extract column name from non-CONCAT expression', () => { + const selection = + 'CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT("firstName") END'; + const result = + ProcessAggregateHelper.extractColumnNamesFromAggregateExpression( + selection, + ); + + expect(result).toEqual(['firstName']); + }); + + it('should extract column name from aggregate expression', () => { + const selection = 'AVG("amount")'; + const result = + ProcessAggregateHelper.extractColumnNamesFromAggregateExpression( + selection, + ); + + expect(result).toEqual(['amount']); + }); + + it('should return null when no column names found', () => { + const selection = 'COUNT(*)'; + const result = + ProcessAggregateHelper.extractColumnNamesFromAggregateExpression( + selection, + ); + + expect(result).toBeNull(); + }); + + it('should extract column name from boolean expression', () => { + const selection = + 'CASE WHEN "isActive"::boolean = TRUE THEN 1 ELSE NULL END'; + const result = + ProcessAggregateHelper.extractColumnNamesFromAggregateExpression( + selection, + ); + + expect(result).toEqual(['isActive']); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts index d6afdcf8e..2627ede7a 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper.ts @@ -1,5 +1,3 @@ -import { Injectable } from '@nestjs/common'; - import { isDefined } from 'twenty-shared/utils'; import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; @@ -7,9 +5,8 @@ import { AggregationField } from 'src/engine/api/graphql/workspace-schema-builde import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; -@Injectable() export class ProcessAggregateHelper { - public addSelectedAggregatedFieldsQueriesToQueryBuilder = ({ + public static addSelectedAggregatedFieldsQueriesToQueryBuilder = ({ selectedAggregatedFields, queryBuilder, }: { @@ -110,4 +107,32 @@ export class ProcessAggregateHelper { } } }; + + public static extractColumnNamesFromAggregateExpression = ( + selection: string, + ): string[] | null => { + // Match content between CONCAT(" and ") - handle multiple columns + const concatMatches = selection.match( + /CONCAT\("([^"]+)"(?:,"([^"]+)")*\)/g, + ); + + if (concatMatches) { + // Extract all column names between quotes after CONCAT + const columnNames = selection + .match(/"([^"]+)"/g) + ?.map((match) => match.slice(1, -1)); + + return columnNames || null; + } + + // For non-CONCAT expressions, match content between double quotes + // Using positive lookbehind and lookahead to match content between quotes without including quotes + const columnMatch = selection.match(/(?<=")([^"]+)(?=")/); + + if (columnMatch) { + return [columnMatch[0]]; + } + + return null; + }; } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts index 77c36adca..c6d69fd2c 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts @@ -25,9 +25,7 @@ import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata- @Injectable() export class ProcessNestedRelationsV2Helper { - constructor( - private readonly processAggregateHelper: ProcessAggregateHelper, - ) {} + constructor() {} public async processNestedRelations({ objectMetadataMaps, @@ -324,12 +322,10 @@ export class ProcessNestedRelationsV2Helper { if (aggregateForRelation) { const aggregateQueryBuilder = referenceQueryBuilder.clone(); - this.processAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder( - { - selectedAggregatedFields: aggregateForRelation, - queryBuilder: aggregateQueryBuilder, - }, - ); + ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({ + selectedAggregatedFields: aggregateForRelation, + queryBuilder: aggregateQueryBuilder, + }); const aggregatedFieldsValues = await aggregateQueryBuilder .addSelect(column) diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts index a732d7e2d..ed415b0bc 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts @@ -17,6 +17,7 @@ import { GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; @@ -68,7 +69,19 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol executionArgs: GraphqlQueryResolverExecutionArgs, ): Promise { if (!executionArgs.args.upsert) { - return await executionArgs.repository.insert(executionArgs.args.data); + const { objectMetadataItemWithFieldMaps } = executionArgs.options; + + const selectedColumns = buildColumnsToReturn({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + + return await executionArgs.repository.insert( + executionArgs.args.data, + undefined, + selectedColumns, + ); } return this.performUpsertOperation(executionArgs); @@ -78,12 +91,20 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol executionArgs: GraphqlQueryResolverExecutionArgs, ): Promise { const { objectMetadataItemWithFieldMaps } = executionArgs.options; + + const selectedColumns = buildColumnsToSelect({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const conflictingFields = this.getConflictingFields( objectMetadataItemWithFieldMaps, ); const existingRecords = await this.findExistingRecords( executionArgs, conflictingFields, + selectedColumns, ); const { recordsToUpdate, recordsToInsert } = this.categorizeRecords( @@ -98,17 +119,25 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol raw: [], }; + const columnsToReturn = buildColumnsToReturn({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + await this.processRecordsToUpdate({ partialRecordsToUpdate: recordsToUpdate, repository: executionArgs.repository, objectMetadataItemWithFieldMaps, result, + columnsToReturn, }); await this.processRecordsToInsert({ recordsToInsert, repository: executionArgs.repository, result, + columnsToReturn, }); return result; @@ -161,6 +190,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol fullPath: string; column: string; }[], + selectedColumns: Record, ): Promise[]> { const { objectMetadataItemWithFieldMaps } = executionArgs.options; const queryBuilder = executionArgs.repository.createQueryBuilder( @@ -176,7 +206,11 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol queryBuilder.orWhere(condition); }); - return await queryBuilder.getMany(); + return await queryBuilder + .setFindOptions({ + select: selectedColumns, + }) + .getMany(); } private getValueFromPath( @@ -265,11 +299,13 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol repository, objectMetadataItemWithFieldMaps, result, + columnsToReturn, }: { partialRecordsToUpdate: Partial[]; repository: WorkspaceRepository; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; result: InsertResult; + columnsToReturn: string[]; }): Promise { for (const partialRecordToUpdate of partialRecordsToUpdate) { const recordId = partialRecordToUpdate.id as string; @@ -284,6 +320,8 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol await repository.update( recordId, partialRecordToUpdateWithoutCreatedByUpdate, + undefined, + columnsToReturn, ); result.identifiers.push({ id: recordId }); @@ -295,13 +333,19 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol recordsToInsert, repository, result, + columnsToReturn, }: { recordsToInsert: Partial[]; repository: WorkspaceRepository; result: InsertResult; + columnsToReturn: string[]; }): Promise { if (recordsToInsert.length > 0) { - const insertResult = await repository.insert(recordsToInsert); + const insertResult = await repository.insert( + recordsToInsert, + undefined, + columnsToReturn, + ); result.identifiers.push(...insertResult.identifiers); result.generatedMaps.push(...insertResult.generatedMaps); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts index c85a71aec..e27bbb335 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts @@ -12,6 +12,7 @@ import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-qu import { CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; +import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util'; @@ -29,12 +30,27 @@ export class GraphqlQueryCreateOneResolverService extends GraphqlQueryBaseResolv const { roleId } = executionArgs; + const selectedColumns = buildColumnsToReturn({ + select: executionArgs.graphqlQuerySelectedFieldsResult.select, + relations: executionArgs.graphqlQuerySelectedFieldsResult.relations, + objectMetadataItemWithFieldMaps, + }); + const objectRecords: InsertResult = !executionArgs.args.upsert - ? await executionArgs.repository.insert(executionArgs.args.data) - : await executionArgs.repository.upsert(executionArgs.args.data, { - conflictPaths: ['id'], - skipUpdateIfNoValuesChanged: true, - }); + ? await executionArgs.repository.insert( + executionArgs.args.data, + undefined, + selectedColumns, + ) + : await executionArgs.repository.upsert( + executionArgs.args.data, + { + conflictPaths: ['id'], + skipUpdateIfNoValuesChanged: true, + }, + undefined, + selectedColumns, + ); const queryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts index 14168dc5d..6018ccb9e 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts @@ -35,7 +35,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve FindManyResolverArgs, IConnection > { - constructor(private readonly processAggregateHelper: ProcessAggregateHelper) { + constructor() { super(); } @@ -109,13 +109,11 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve appliedFilters, ); - this.processAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder( - { - selectedAggregatedFields: - executionArgs.graphqlQuerySelectedFieldsResult.aggregate, - queryBuilder: aggregateQueryBuilder, - }, - ); + ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({ + selectedAggregatedFields: + executionArgs.graphqlQuerySelectedFieldsResult.aggregate, + queryBuilder: aggregateQueryBuilder, + }); const limit = executionArgs.args.first ?? executionArgs.args.last ?? QUERY_MAX_RECORDS; diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts index eae6a9367..583ac4861 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts @@ -255,10 +255,15 @@ export class WorkspacePermissionsCacheService { ); for (const fieldPermission of fieldPermissions) { - restrictedFields[fieldPermission.fieldMetadataId] = { - canRead: fieldPermission.canReadFieldValue, - canUpdate: fieldPermission.canUpdateFieldValue, - }; + if ( + isDefined(fieldPermission.canReadFieldValue) || + isDefined(fieldPermission.canUpdateFieldValue) + ) { + restrictedFields[fieldPermission.fieldMetadataId] = { + canRead: fieldPermission.canReadFieldValue, + canUpdate: fieldPermission.canUpdateFieldValue, + }; + } } } } diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 65296508d..6bae0614f 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -22,6 +22,7 @@ const mockedWorkspaceUpdateQueryBuilder = { execute: jest .fn() .mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }), + returning: jest.fn().mockReturnThis(), })), execute: jest .fn() @@ -38,6 +39,7 @@ jest.mock('../repository/workspace-select-query-builder', () => ({ .fn() .mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }), setFindOptions: jest.fn().mockReturnThis(), + returning: jest.fn().mockReturnThis(), update: jest.fn().mockReturnValue(mockedWorkspaceUpdateQueryBuilder), insert: jest.fn().mockReturnThis(), })), @@ -269,17 +271,20 @@ describe('WorkspaceEntityManager', () => { { reload: false }, mockPermissionOptions, ); - expect(entityManager['validatePermissions']).toHaveBeenCalledWith( - 'test-entity', - 'update', - mockPermissionOptions, - ); + expect(entityManager['validatePermissions']).toHaveBeenCalledWith({ + target: 'test-entity', + operationType: 'update', + permissionOptions: mockPermissionOptions, + selectedColumns: [], + }); expect(validateOperationIsPermittedOrThrow).toHaveBeenCalledWith({ entityName: 'test-entity', operationType: 'update', objectMetadataMaps: mockInternalContext.objectMetadataMaps, objectRecordsPermissions: mockPermissionOptions.objectRecordsPermissions, + selectedColumns: [], + allFieldsSelected: false, }); }); }); @@ -299,17 +304,20 @@ describe('WorkspaceEntityManager', () => { describe('Other Methods', () => { it('should call validatePermissions and validateOperationIsPermittedOrThrow for clear', async () => { await entityManager.clear('test-entity', mockPermissionOptions); - expect(entityManager['validatePermissions']).toHaveBeenCalledWith( - 'test-entity', - 'delete', - mockPermissionOptions, - ); + expect(entityManager['validatePermissions']).toHaveBeenCalledWith({ + target: 'test-entity', + operationType: 'delete', + permissionOptions: mockPermissionOptions, + selectedColumns: [], + }); expect(validateOperationIsPermittedOrThrow).toHaveBeenCalledWith({ entityName: 'test-entity', operationType: 'delete', objectMetadataMaps: mockInternalContext.objectMetadataMaps, objectRecordsPermissions: mockPermissionOptions.objectRecordsPermissions, + selectedColumns: [], + allFieldsSelected: false, }); }); }); diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts index bef818409..c32e6d093 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts @@ -164,6 +164,8 @@ export class WorkspaceEntityManager extends EntityManager { options?.objectRecordsPermissions ?? {}, this.internalContext, options?.shouldBypassPermissionChecks ?? false, + undefined, + this.getFeatureFlagMap(), ); } @@ -172,6 +174,7 @@ export class WorkspaceEntityManager extends EntityManager { entity: | QueryDeepPartialEntityWithRelationConnect | QueryDeepPartialEntityWithRelationConnect[], + selectedColumns: string[] = [], permissionOptions?: PermissionOptions, ): Promise { const entityArray = Array.isArray(entity) ? entity : [entity]; @@ -191,6 +194,7 @@ export class WorkspaceEntityManager extends EntityManager { .insert() .into(target) .values(connectedEntities) + .returning(selectedColumns) .execute(); } @@ -204,6 +208,7 @@ export class WorkspaceEntityManager extends EntityManager { shouldBypassPermissionChecks?: boolean; objectRecordsPermissions?: ObjectRecordsPermissions; }, + selectedColumns: string[] = [], ): Promise { const metadata = this.connection.getMetadata(target); let options; @@ -257,6 +262,7 @@ export class WorkspaceEntityManager extends EntityManager { this.connection.driver.supportedUpsertTypes[0], }, ) + .returning(selectedColumns) .execute(); } @@ -274,6 +280,7 @@ export class WorkspaceEntityManager extends EntityManager { | unknown, partialEntity: QueryDeepPartialEntity, permissionOptions?: PermissionOptions, + selectedColumns: string[] = [], ): Promise { const metadata = this.connection.getMetadata(target); @@ -304,6 +311,7 @@ export class WorkspaceEntityManager extends EntityManager { .update() .set(partialEntity) .whereInIds(criteria) + .returning(selectedColumns) .execute(); } else { return this.createQueryBuilder( @@ -315,6 +323,7 @@ export class WorkspaceEntityManager extends EntityManager { .update() .set(partialEntity) .where(criteria) + .returning(selectedColumns) .execute(); } } @@ -325,6 +334,7 @@ export class WorkspaceEntityManager extends EntityManager { propertyPath: string, value: number | string, permissionOptions?: PermissionOptions, + selectedColumns: string[] = [], ): Promise { const metadata = this.connection.getMetadata(target); const column = metadata.findColumnWithPropertyPath(propertyPath); @@ -351,17 +361,24 @@ export class WorkspaceEntityManager extends EntityManager { .update(target as QueryDeepPartialEntity) .set(values) .where(criteria) + .returning(selectedColumns) .execute(); } - validatePermissions( - target: EntityTarget | Entity, - operationType: OperationType, + validatePermissions({ + target, + operationType, + permissionOptions, + selectedColumns, + }: { + target: EntityTarget | Entity; + operationType: OperationType; permissionOptions?: { shouldBypassPermissionChecks?: boolean; objectRecordsPermissions?: ObjectRecordsPermissions; - }, - ): void { + }; + selectedColumns: string[]; + }): void { if (permissionOptions?.shouldBypassPermissionChecks === true) { return; } @@ -377,6 +394,8 @@ export class WorkspaceEntityManager extends EntityManager { objectRecordsPermissions: permissionOptions?.objectRecordsPermissions ?? {}, objectMetadataMaps: this.internalContext.objectMetadataMaps, + selectedColumns, + allFieldsSelected: false, }); } @@ -858,7 +877,12 @@ export class WorkspaceEntityManager extends EntityManager { entityClass: EntityTarget, permissionOptions?: PermissionOptions, ): Promise { - this.validatePermissions(entityClass, 'delete', permissionOptions); + this.validatePermissions({ + target: entityClass, + operationType: 'delete', + permissionOptions, + selectedColumns: [], // TODO + }); return super.clear(entityClass); } @@ -910,6 +934,7 @@ export class WorkspaceEntityManager extends EntityManager { propertyPath: string, value: number | string, permissionOptions?: PermissionOptions, + selectedColumns: string[] = [], ): Promise { const metadata = this.connection.getMetadata(target); const column = metadata.findColumnWithPropertyPath(propertyPath); @@ -935,6 +960,7 @@ export class WorkspaceEntityManager extends EntityManager { .update(target as QueryDeepPartialEntity) .set(values) .where(criteria) + .returning(selectedColumns) .execute(); } @@ -1029,11 +1055,12 @@ export class WorkspaceEntityManager extends EntityManager { ? maybeOptionsOrMaybePermissionOptions : permissionOptions; - this.validatePermissions( - targetOrEntity, - 'update', - permissionOptionsFromArgs, - ); + this.validatePermissions({ + target: targetOrEntity, + operationType: 'update', + permissionOptions: permissionOptionsFromArgs, + selectedColumns: [], // TODO + }); let target = arguments.length > 1 && @@ -1170,11 +1197,12 @@ export class WorkspaceEntityManager extends EntityManager { ? (maybeOptionsOrMaybePermissionOptions as PermissionOptions) : permissionOptions; - this.validatePermissions( - targetOrEntity, - 'delete', - permissionOptionsFromArgs, - ); + this.validatePermissions({ + target: targetOrEntity, + operationType: 'delete', + permissionOptions: permissionOptionsFromArgs, + selectedColumns: [], // TODO + }); const target = arguments.length > 1 && @@ -1281,11 +1309,12 @@ export class WorkspaceEntityManager extends EntityManager { ? (maybeOptionsOrMaybePermissionOptions as PermissionOptions) : permissionOptions; - this.validatePermissions( - targetOrEntityOrEntities, - 'soft-delete', - permissionOptionsFromArgs, - ); + this.validatePermissions({ + target: targetOrEntityOrEntities, + operationType: 'soft-delete', + permissionOptions: permissionOptionsFromArgs, + selectedColumns: [], // TODO + }); let target = arguments.length > 1 && @@ -1387,11 +1416,12 @@ export class WorkspaceEntityManager extends EntityManager { ? (maybeOptionsOrMaybePermissionOptions as PermissionOptions) : permissionOptions; - this.validatePermissions( - targetOrEntityOrEntities, - 'restore', - permissionOptionsFromArgs, - ); + this.validatePermissions({ + target: targetOrEntityOrEntities, + operationType: 'restore', + permissionOptions: permissionOptionsFromArgs, + selectedColumns: [], // TODO + }); let target = arguments.length > 1 && diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/__tests__/workspace.repository.spec.ts b/packages/twenty-server/src/engine/twenty-orm/repository/__tests__/workspace.repository.spec.ts index 0034295c9..bcd2eaa96 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/__tests__/workspace.repository.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/__tests__/workspace.repository.spec.ts @@ -268,6 +268,7 @@ describe('WorkspaceRepository', () => { expect(mockEntityManager.insert).toHaveBeenCalledWith( 'test-entity', { id: 'test-id' }, + undefined, { shouldBypassPermissionChecks: false, objectRecordsPermissions: mockObjectRecordsPermissions, @@ -294,6 +295,7 @@ describe('WorkspaceRepository', () => { shouldBypassPermissionChecks: false, objectRecordsPermissions: mockObjectRecordsPermissions, }, + [], ); }); }); @@ -319,6 +321,7 @@ describe('WorkspaceRepository', () => { shouldBypassPermissionChecks: false, objectRecordsPermissions: mockObjectRecordsPermissions, }, + undefined, ); }); }); @@ -362,6 +365,7 @@ describe('WorkspaceRepository', () => { shouldBypassPermissionChecks: false, objectRecordsPermissions: mockObjectRecordsPermissions, }, + undefined, ); }); }); diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts index e34b90b7d..7e0bb7b76 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts @@ -1,14 +1,21 @@ import { isNonEmptyString } from '@sniptt/guards'; -import { ObjectRecordsPermissions } from 'twenty-shared/types'; +import isEmpty from 'lodash.isempty'; +import { + ObjectRecordsPermissions, + RestrictedFields, +} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { QueryExpressionMap } from 'typeorm/query-builder/QueryExpressionMap'; +import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; +import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { PermissionsException, PermissionsExceptionCode, PermissionsExceptionMessage, } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { getFieldMetadataIdForColumnNameMap } from 'src/engine/twenty-orm/utils/get-field-metadata-id-for-column-name.util'; const getTargetEntityAndOperationType = (expressionMap: QueryExpressionMap) => { const mainEntity = expressionMap.aliases[0].metadata.name; @@ -33,11 +40,17 @@ export const validateOperationIsPermittedOrThrow = ({ operationType, objectRecordsPermissions, objectMetadataMaps, + selectedColumns, + isFieldPermissionsEnabled, + allFieldsSelected, }: { entityName: string; operationType: OperationType; objectRecordsPermissions: ObjectRecordsPermissions; objectMetadataMaps: ObjectMetadataMaps; + selectedColumns: string[]; + isFieldPermissionsEnabled?: boolean; + allFieldsSelected: boolean; }) => { const objectMetadataIdForEntity = objectMetadataMaps.idByNameSingular[entityName]; @@ -64,6 +77,10 @@ export const validateOperationIsPermittedOrThrow = ({ return; } + const fieldMetadataIdForColumnNameMap = isFieldPermissionsEnabled + ? getFieldMetadataIdForColumnNameMap(objectMetadata) + : {}; + const permissionsForEntity = objectRecordsPermissions[objectMetadataIdForEntity]; @@ -75,6 +92,15 @@ export const validateOperationIsPermittedOrThrow = ({ PermissionsExceptionCode.PERMISSION_DENIED, ); } + + if (isFieldPermissionsEnabled) { + validateReadFieldPermissionOrThrow({ + restrictedFields: permissionsForEntity.restrictedFields, + selectedColumns, + fieldMetadataIdForColumnNameMap, + allFieldsSelected, + }); + } break; case 'insert': case 'update': @@ -84,6 +110,14 @@ export const validateOperationIsPermittedOrThrow = ({ PermissionsExceptionCode.PERMISSION_DENIED, ); } + + if (isFieldPermissionsEnabled) { + validateReadFieldPermissionOrThrow({ + restrictedFields: permissionsForEntity.restrictedFields, + selectedColumns, + fieldMetadataIdForColumnNameMap, + }); + } break; case 'delete': if (!permissionsForEntity?.canDestroy) { @@ -92,6 +126,14 @@ export const validateOperationIsPermittedOrThrow = ({ PermissionsExceptionCode.PERMISSION_DENIED, ); } + + if (isFieldPermissionsEnabled) { + validateReadFieldPermissionOrThrow({ + restrictedFields: permissionsForEntity.restrictedFields, + selectedColumns, + fieldMetadataIdForColumnNameMap, + }); + } break; case 'restore': case 'soft-delete': @@ -101,6 +143,14 @@ export const validateOperationIsPermittedOrThrow = ({ PermissionsExceptionCode.PERMISSION_DENIED, ); } + + if (isFieldPermissionsEnabled) { + validateReadFieldPermissionOrThrow({ + restrictedFields: permissionsForEntity.restrictedFields, + selectedColumns, + fieldMetadataIdForColumnNameMap, + }); + } break; default: throw new PermissionsException( @@ -108,14 +158,25 @@ export const validateOperationIsPermittedOrThrow = ({ PermissionsExceptionCode.UNKNOWN_OPERATION_NAME, ); } + + if (isEmpty(permissionsForEntity.restrictedFields)) { + return; + } }; -export const validateQueryIsPermittedOrThrow = ( - expressionMap: QueryExpressionMap, - objectRecordsPermissions: ObjectRecordsPermissions, - objectMetadataMaps: ObjectMetadataMaps, - shouldBypassPermissionChecks: boolean, -) => { +export const validateQueryIsPermittedOrThrow = ({ + expressionMap, + objectRecordsPermissions, + objectMetadataMaps, + shouldBypassPermissionChecks, + isFieldPermissionsEnabled, +}: { + expressionMap: QueryExpressionMap; + objectRecordsPermissions: ObjectRecordsPermissions; + objectMetadataMaps: ObjectMetadataMaps; + shouldBypassPermissionChecks: boolean; + isFieldPermissionsEnabled?: boolean; +}) => { if (shouldBypassPermissionChecks) { return; } @@ -123,10 +184,119 @@ export const validateQueryIsPermittedOrThrow = ( const { mainEntity, operationType } = getTargetEntityAndOperationType(expressionMap); + const allFieldsSelected = expressionMap.selects.some( + (select) => select.selection === mainEntity, + ); + + let selectedColumns: string[] = []; + + if (isFieldPermissionsEnabled) { + selectedColumns = getSelectedColumnsFromExpressionMap({ + operationType, + expressionMap, + allFieldsSelected, + }); + } + validateOperationIsPermittedOrThrow({ entityName: mainEntity, operationType: operationType as OperationType, objectRecordsPermissions, objectMetadataMaps, + selectedColumns: selectedColumns, + isFieldPermissionsEnabled, + allFieldsSelected, }); }; + +const validateReadFieldPermissionOrThrow = ({ + restrictedFields, + selectedColumns, + fieldMetadataIdForColumnNameMap, + allFieldsSelected, +}: { + restrictedFields: RestrictedFields; + selectedColumns: string[]; + fieldMetadataIdForColumnNameMap: Record; + allFieldsSelected?: boolean; +}) => { + if (isEmpty(restrictedFields)) { + return; + } + + if (allFieldsSelected) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + + for (const column of selectedColumns) { + const fieldMetadataId = fieldMetadataIdForColumnNameMap[column]; + + if (!fieldMetadataId) { + throw new InternalServerError( + `Field metadata id not found for column name ${column}`, + ); + } + + if (restrictedFields[fieldMetadataId]?.canRead === false) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + } +}; + +const getSelectedColumnsFromExpressionMap = ({ + operationType, + expressionMap, + allFieldsSelected, +}: { + operationType: string; + expressionMap: QueryExpressionMap; + allFieldsSelected: boolean; +}) => { + let selectedColumns: string[] = []; + + if ( + ['update', 'insert', 'delete', 'soft-delete', 'restore'].includes( + operationType, + ) + ) { + if (isEmpty(expressionMap.returning)) { + throw new InternalServerError( + 'Returning columns are not set for update query', + ); + } + selectedColumns = [expressionMap.returning].flat(); + } else if (!allFieldsSelected) { + selectedColumns = getSelectedColumnsFromExpressionMapSelects( + expressionMap.selects, + ); + } + + return selectedColumns; +}; + +const getSelectedColumnsFromExpressionMapSelects = ( + selects: { selection: string }[], +) => { + return selects + ?.map((select) => { + const columnsFromAggregateExpression = + ProcessAggregateHelper.extractColumnNamesFromAggregateExpression( + select.selection, + ); + + if (columnsFromAggregateExpression) { + return columnsFromAggregateExpression; + } + + const parts = select.selection.split('.'); + + return parts[parts.length - 1]; + }) + .flat(); +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts index 6e0db3a9a..cd9de91d2 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-delete-query-builder.ts @@ -8,10 +8,12 @@ import { } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { TwentyORMException, TwentyORMExceptionCode, @@ -30,18 +32,21 @@ export class WorkspaceDeleteQueryBuilder< private shouldBypassPermissionChecks: boolean; private internalContext: WorkspaceInternalContext; private authContext?: AuthContext; + private featureFlagMap?: FeatureFlagMap; constructor( queryBuilder: DeleteQueryBuilder, objectRecordsPermissions: ObjectRecordsPermissions, internalContext: WorkspaceInternalContext, shouldBypassPermissionChecks: boolean, authContext?: AuthContext, + featureFlagMap?: FeatureFlagMap, ) { super(queryBuilder); this.objectRecordsPermissions = objectRecordsPermissions; this.internalContext = internalContext; this.shouldBypassPermissionChecks = shouldBypassPermissionChecks; this.authContext = authContext; + this.featureFlagMap = featureFlagMap; } override clone(): this { @@ -57,12 +62,14 @@ export class WorkspaceDeleteQueryBuilder< } override async execute(): Promise { - validateQueryIsPermittedOrThrow( - this.expressionMap, - this.objectRecordsPermissions, - this.internalContext.objectMetadataMaps, - this.shouldBypassPermissionChecks, - ); + validateQueryIsPermittedOrThrow({ + expressionMap: this.expressionMap, + objectRecordsPermissions: this.objectRecordsPermissions, + objectMetadataMaps: this.internalContext.objectMetadataMaps, + shouldBypassPermissionChecks: this.shouldBypassPermissionChecks, + isFieldPermissionsEnabled: + this.featureFlagMap?.[FeatureFlagKey.IS_FIELDS_PERMISSIONS_ENABLED], + }); const mainAliasTarget = this.getMainAliasTarget(); diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts index 7cf737d1e..c6ee31bf2 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts @@ -7,10 +7,12 @@ import { } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { TwentyORMException, TwentyORMExceptionCode, @@ -31,6 +33,7 @@ export class WorkspaceInsertQueryBuilder< private shouldBypassPermissionChecks: boolean; private internalContext: WorkspaceInternalContext; private authContext?: AuthContext; + private featureFlagMap?: FeatureFlagMap; constructor( queryBuilder: InsertQueryBuilder, @@ -38,12 +41,14 @@ export class WorkspaceInsertQueryBuilder< internalContext: WorkspaceInternalContext, shouldBypassPermissionChecks: boolean, authContext?: AuthContext, + featureFlagMap?: FeatureFlagMap, ) { super(queryBuilder); this.objectRecordsPermissions = objectRecordsPermissions; this.internalContext = internalContext; this.shouldBypassPermissionChecks = shouldBypassPermissionChecks; this.authContext = authContext; + this.featureFlagMap = featureFlagMap; } override clone(): this { @@ -55,6 +60,7 @@ export class WorkspaceInsertQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ) as this; } @@ -74,12 +80,14 @@ export class WorkspaceInsertQueryBuilder< } override async execute(): Promise { - validateQueryIsPermittedOrThrow( - this.expressionMap, - this.objectRecordsPermissions, - this.internalContext.objectMetadataMaps, - this.shouldBypassPermissionChecks, - ); + validateQueryIsPermittedOrThrow({ + expressionMap: this.expressionMap, + objectRecordsPermissions: this.objectRecordsPermissions, + objectMetadataMaps: this.internalContext.objectMetadataMaps, + shouldBypassPermissionChecks: this.shouldBypassPermissionChecks, + isFieldPermissionsEnabled: + this.featureFlagMap?.[FeatureFlagKey.IS_FIELDS_PERMISSIONS_ENABLED], + }); const mainAliasTarget = this.getMainAliasTarget(); diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts index 13a4c3f9b..6e920dcf6 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts @@ -2,9 +2,11 @@ import { ObjectRecordsPermissions } from 'twenty-shared/types'; import { EntityTarget, ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { PermissionsException, PermissionsExceptionCode, @@ -28,18 +30,21 @@ export class WorkspaceSelectQueryBuilder< shouldBypassPermissionChecks: boolean; internalContext: WorkspaceInternalContext; authContext?: AuthContext; + featureFlagMap?: FeatureFlagMap; constructor( queryBuilder: SelectQueryBuilder, objectRecordsPermissions: ObjectRecordsPermissions, internalContext: WorkspaceInternalContext, shouldBypassPermissionChecks: boolean, authContext?: AuthContext, + featureFlagMap?: FeatureFlagMap, ) { super(queryBuilder); this.objectRecordsPermissions = objectRecordsPermissions; this.internalContext = internalContext; this.shouldBypassPermissionChecks = shouldBypassPermissionChecks; this.authContext = authContext; + this.featureFlagMap = featureFlagMap; } getFindOptions() { @@ -55,6 +60,7 @@ export class WorkspaceSelectQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ) as this; } @@ -204,6 +210,7 @@ export class WorkspaceSelectQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ); } @@ -226,6 +233,7 @@ export class WorkspaceSelectQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ); } @@ -238,6 +246,7 @@ export class WorkspaceSelectQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ); } @@ -250,6 +259,7 @@ export class WorkspaceSelectQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ); } @@ -262,6 +272,7 @@ export class WorkspaceSelectQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ); } @@ -273,12 +284,16 @@ export class WorkspaceSelectQueryBuilder< } private validatePermissions(): void { - validateQueryIsPermittedOrThrow( - this.expressionMap, - this.objectRecordsPermissions, - this.internalContext.objectMetadataMaps, - this.shouldBypassPermissionChecks, - ); + const isFieldPermissionsEnabled = + this.featureFlagMap?.[FeatureFlagKey.IS_FIELDS_PERMISSIONS_ENABLED]; + + validateQueryIsPermittedOrThrow({ + expressionMap: this.expressionMap, + objectRecordsPermissions: this.objectRecordsPermissions, + objectMetadataMaps: this.internalContext.objectMetadataMaps, + shouldBypassPermissionChecks: this.shouldBypassPermissionChecks, + isFieldPermissionsEnabled, + }); } private getMainAliasTarget(): EntityTarget { diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts index 6b9c3543c..efd5e8762 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-soft-delete-query-builder.ts @@ -7,10 +7,12 @@ import { } from 'typeorm'; import { SoftDeleteQueryBuilder } from 'typeorm/query-builder/SoftDeleteQueryBuilder'; +import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { TwentyORMException, TwentyORMExceptionCode, @@ -29,6 +31,7 @@ export class WorkspaceSoftDeleteQueryBuilder< private shouldBypassPermissionChecks: boolean; private internalContext: WorkspaceInternalContext; private authContext?: AuthContext; + private featureFlagMap?: FeatureFlagMap; constructor( queryBuilder: SoftDeleteQueryBuilder, @@ -36,12 +39,14 @@ export class WorkspaceSoftDeleteQueryBuilder< internalContext: WorkspaceInternalContext, shouldBypassPermissionChecks: boolean, authContext?: AuthContext, + featureFlagMap?: FeatureFlagMap, ) { super(queryBuilder); this.objectRecordsPermissions = objectRecordsPermissions; this.internalContext = internalContext; this.shouldBypassPermissionChecks = shouldBypassPermissionChecks; this.authContext = authContext; + this.featureFlagMap = featureFlagMap; } override clone(): this { @@ -57,12 +62,14 @@ export class WorkspaceSoftDeleteQueryBuilder< } override async execute(): Promise { - validateQueryIsPermittedOrThrow( - this.expressionMap, - this.objectRecordsPermissions, - this.internalContext.objectMetadataMaps, - this.shouldBypassPermissionChecks, - ); + validateQueryIsPermittedOrThrow({ + expressionMap: this.expressionMap, + objectRecordsPermissions: this.objectRecordsPermissions, + objectMetadataMaps: this.internalContext.objectMetadataMaps, + shouldBypassPermissionChecks: this.shouldBypassPermissionChecks, + isFieldPermissionsEnabled: + this.featureFlagMap?.[FeatureFlagKey.IS_FIELDS_PERMISSIONS_ENABLED], + }); const mainAliasTarget = this.getMainAliasTarget(); diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts index 610163cb4..ff1ec3443 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts @@ -7,10 +7,12 @@ import { } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { TwentyORMException, TwentyORMExceptionCode, @@ -30,18 +32,21 @@ export class WorkspaceUpdateQueryBuilder< private shouldBypassPermissionChecks: boolean; private internalContext: WorkspaceInternalContext; private authContext?: AuthContext; + private featureFlagMap?: FeatureFlagMap; constructor( queryBuilder: UpdateQueryBuilder, objectRecordsPermissions: ObjectRecordsPermissions, internalContext: WorkspaceInternalContext, shouldBypassPermissionChecks: boolean, authContext?: AuthContext, + featureFlagMap?: FeatureFlagMap, ) { super(queryBuilder); this.objectRecordsPermissions = objectRecordsPermissions; this.internalContext = internalContext; this.shouldBypassPermissionChecks = shouldBypassPermissionChecks; this.authContext = authContext; + this.featureFlagMap = featureFlagMap; } override clone(): this { @@ -53,16 +58,19 @@ export class WorkspaceUpdateQueryBuilder< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ) as this; } override async execute(): Promise { - validateQueryIsPermittedOrThrow( - this.expressionMap, - this.objectRecordsPermissions, - this.internalContext.objectMetadataMaps, - this.shouldBypassPermissionChecks, - ); + validateQueryIsPermittedOrThrow({ + expressionMap: this.expressionMap, + objectRecordsPermissions: this.objectRecordsPermissions, + objectMetadataMaps: this.internalContext.objectMetadataMaps, + shouldBypassPermissionChecks: this.shouldBypassPermissionChecks, + isFieldPermissionsEnabled: + this.featureFlagMap?.[FeatureFlagKey.IS_FIELDS_PERMISSIONS_ENABLED], + }); const mainAliasTarget = this.getMainAliasTarget(); @@ -77,6 +85,7 @@ export class WorkspaceUpdateQueryBuilder< this.internalContext, true, this.authContext, + this.featureFlagMap, ); eventSelectQueryBuilder.expressionMap.wheres = this.expressionMap.wheres; @@ -109,9 +118,15 @@ export class WorkspaceUpdateQueryBuilder< authContext: this.authContext, }); + const formattedResult = formatResult( + result.raw, + objectMetadata, + this.internalContext.objectMetadataMaps, + ); + return { raw: result.raw, - generatedMaps: formattedAfter, + generatedMaps: formattedResult, affected: result.affected, }; } diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts index 9c5c20ec0..75c7fc433 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts @@ -81,6 +81,7 @@ export class WorkspaceRepository< this.internalContext, this.shouldBypassPermissionChecks, this.authContext, + this.featureFlagMap, ); } @@ -538,6 +539,7 @@ export class WorkspaceRepository< | QueryDeepPartialEntityWithRelationConnect | QueryDeepPartialEntityWithRelationConnect[], entityManager?: WorkspaceEntityManager, + selectedColumns?: string[], ): Promise { const manager = entityManager || this.manager; @@ -546,7 +548,12 @@ export class WorkspaceRepository< objectRecordsPermissions: this.objectRecordsPermissions, }; - return manager.insert(this.target, entity, permissionOptions); + return manager.insert( + this.target, + entity, + selectedColumns, + permissionOptions, + ); } /** @@ -565,6 +572,7 @@ export class WorkspaceRepository< | FindOptionsWhere, partialEntity: QueryDeepPartialEntity, entityManager?: WorkspaceEntityManager, + selectedColumns?: string[], ): Promise { const manager = entityManager || this.manager; @@ -582,6 +590,7 @@ export class WorkspaceRepository< criteria, partialEntity, permissionOptions, + selectedColumns, ); } @@ -589,6 +598,7 @@ export class WorkspaceRepository< entityOrEntities: QueryDeepPartialEntity | QueryDeepPartialEntity[], conflictPathsOrOptions: string[] | UpsertOptions, entityManager?: WorkspaceEntityManager, + selectedColumns: string[] = [], ): Promise { const manager = entityManager || this.manager; @@ -602,6 +612,7 @@ export class WorkspaceRepository< entityOrEntities, conflictPathsOrOptions, permissionOptions, + selectedColumns, ); return { @@ -777,6 +788,7 @@ export class WorkspaceRepository< propertyPath: string, value: number | string, entityManager?: WorkspaceEntityManager, + selectedColumns?: string[], ): Promise { const manager = entityManager || this.manager; const computedConditions = await this.transformOptions({ @@ -794,6 +806,7 @@ export class WorkspaceRepository< propertyPath, value, permissionOptions, + selectedColumns, ); } @@ -802,6 +815,7 @@ export class WorkspaceRepository< propertyPath: string, value: number | string, entityManager?: WorkspaceEntityManager, + selectedColumns?: string[], ): Promise { const manager = entityManager || this.manager; const computedConditions = await this.transformOptions({ @@ -819,6 +833,7 @@ export class WorkspaceRepository< propertyPath, value, permissionOptions, + selectedColumns, ); } diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-field-metadata-id-for-column-name.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-field-metadata-id-for-column-name.util.ts new file mode 100644 index 000000000..65bb5e431 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/get-field-metadata-id-for-column-name.util.ts @@ -0,0 +1,47 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; +import { + computeColumnName, + computeCompositeColumnName, +} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; + +export function getFieldMetadataIdForColumnNameMap( + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, +) { + const columnNameToFieldMetadataIdMap: Record = {}; + + for (const [fieldMetadataId, fieldMetadata] of Object.entries( + objectMetadataItemWithFieldMaps.fieldsById, + )) { + if (isCompositeFieldMetadataType(fieldMetadata.type)) { + const compositeType = compositeTypeDefinitions.get(fieldMetadata.type); + + if (!compositeType) { + throw new InternalServerError( + `Composite type not found for field metadata type ${fieldMetadata.type}`, + ); + } + + compositeType.properties.forEach((compositeProperty) => { + const columnName = computeCompositeColumnName( + fieldMetadata.name, + compositeProperty, + ); + + columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId; + }); + } else { + const columnName = computeColumnName(fieldMetadata, { + isForeignKey: fieldMetadata.type === FieldMetadataType.RELATION, + }); + + columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId; + } + } + + return columnNameToFieldMetadataIdMap; +} diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-event-import-exception-handler.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-event-import-exception-handler.service.ts index d318ec991..dab7ecab0 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-event-import-exception-handler.service.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-event-import-exception-handler.service.ts @@ -119,6 +119,8 @@ export class CalendarEventImportErrorHandlerService { }, 'throttleFailureCount', 1, + undefined, + ['throttleFailureCount', 'id'], ); switch (syncStep) { diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-import-exception-handler.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-import-exception-handler.service.ts index 97ab4fb85..38cedd3e4 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-import-exception-handler.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-import-exception-handler.service.ts @@ -141,6 +141,8 @@ export class MessageImportExceptionHandlerService { { id: messageChannel.id }, 'throttleFailureCount', 1, + undefined, + ['throttleFailureCount', 'id'], ); switch (syncStep) { diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts new file mode 100644 index 000000000..6b2ab2724 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts @@ -0,0 +1,497 @@ +import { randomUUID } from 'crypto'; + +import gql from 'graphql-tag'; +import request from 'supertest'; +import { createCustomRoleWithObjectPermissions } from 'test/integration/graphql/utils/create-custom-role-with-object-permissions.util'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util'; +import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util'; +import { deleteRole } from 'test/integration/graphql/utils/delete-one-role.util'; +import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util'; +import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util'; +import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util'; +import { updateManyOperationFactory } from 'test/integration/graphql/utils/update-many-operation-factory.util'; +import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util'; +import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util'; +import { upsertFieldPermissions } from 'test/integration/graphql/utils/upsert-field-permissions.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant'; + +const client = request(`http://localhost:${APP_PORT}`); + +const COMPANY_GQL_FIELDS_WITH_PEOPLE_CITY = ` + id + name + people { + edges { + node { + id + name { + firstName + lastName + } + city + } + } + } +`; + +const COMPANY_GQL_FIELDS_WITH_EMPLOYEES = ` + id + name + employees + people { + edges { + node { + id + name { + firstName + lastName + } + } + } + } +`; + +const COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES_AND_WITHOUT_PEOPLE_CITY = ` + id + name + people { + edges { + node { + id + name { + firstName + lastName + } + } + } + } +`; + +const COMPANY_GQL_FIELDS_WITH_PEOPLE_CITY_AGGREGATE = ` + id + name + people { + percentageEmptyCity + } +`; + +const expectPermissionDeniedError = (response: any) => { + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); +}; + +describe('Field permissions restrictions', () => { + let companyId: string; + let personId: string; + let customRoleId: string; + let companyObjectId: string; + let personObjectId: string; + let restrictedCompanyFieldId: string; + let restrictedPersonFieldId: string; + let originalMemberRoleId: string; + + const restrictAccessToCompanyEmployee = async ( + roleId: string, + companyObjectId: string, + restrictedCompanyFieldId: string, + ) => { + await upsertFieldPermissions({ + roleId, + fieldPermissions: [ + { + objectMetadataId: companyObjectId, + fieldMetadataId: restrictedCompanyFieldId, + canReadFieldValue: false, + }, + ], + }); + }; + + const restrictAccessToPersonCity = async ( + roleId: string, + personObjectId: string, + restrictedPersonFieldId: string, + ) => { + await upsertFieldPermissions({ + roleId, + fieldPermissions: [ + { + objectMetadataId: personObjectId, + fieldMetadataId: restrictedPersonFieldId, + canReadFieldValue: false, + }, + ], + }); + }; + + beforeAll(async () => { + // Enable the feature flag + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IS_FIELDS_PERMISSIONS_ENABLED', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + + // Get the original Member role ID for restoration later + const getRolesQuery = { + query: ` + query GetRoles { + getRoles { + id + label + } + } + `, + }; + const rolesResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send(getRolesQuery); + + originalMemberRoleId = rolesResponse.body.data.getRoles.find( + (role: any) => role.label === 'Member', + ).id; + + // Create a company and a person + companyId = randomUUID(); + personId = randomUUID(); + const createCompanyOp = createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id name', + data: { id: companyId, name: 'TestCompany' }, + }); + + await makeGraphqlAPIRequest(createCompanyOp); + const createPersonOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id city', + data: { id: personId, city: 'Paris', companyId }, + }); + + await makeGraphqlAPIRequest(createPersonOperation); + + // Get object and field metadata IDs + const getObjectMetadataOp = { + query: gql` + query { + objects(paging: { first: 1000 }) { + edges { + node { + id + nameSingular + } + } + } + } + `, + }; + const objectMetadataResponse = + await makeMetadataAPIRequest(getObjectMetadataOp); + const objects = objectMetadataResponse.body.data.objects.edges; + + companyObjectId = objects.find( + (obj: any) => obj.node.nameSingular === 'company', + ).node.id; + personObjectId = objects.find( + (obj: any) => obj.node.nameSingular === 'person', + ).node.id; + + const getFieldMetadataOp = { + query: gql` + query { + fields(paging: { first: 1000 }) { + edges { + node { + id + name + object { + nameSingular + } + } + } + } + } + `, + }; + const fieldMetadataResponse = + await makeMetadataAPIRequest(getFieldMetadataOp); + const fields = fieldMetadataResponse.body.data.fields.edges; + + restrictedCompanyFieldId = fields.find( + (field: any) => + field.node.name === 'employees' && + field.node.object.nameSingular === 'company', + ).node.id; + restrictedPersonFieldId = fields.find( + (field: any) => + field.node.name === 'city' && + field.node.object.nameSingular === 'person', + ).node.id; + }); + + afterAll(async () => { + // Restore the feature flag + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IS_FIELDS_PERMISSIONS_ENABLED', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + + // Restore original role + const restoreMemberRoleQuery = { + query: ` + mutation UpdateWorkspaceMemberRole { + updateWorkspaceMemberRole( + workspaceMemberId: "${WORKSPACE_MEMBER_DATA_SEED_IDS.JONY}" + roleId: "${originalMemberRoleId}" + ) { id } + } + `, + }; + + await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send(restoreMemberRoleQuery); + }); + + beforeEach(async () => { + const { roleId } = await createCustomRoleWithObjectPermissions({ + label: 'CompanyPeopleRole', + canReadCompany: true, + canReadPerson: true, + }); + + customRoleId = roleId; + await updateWorkspaceMemberRole({ + client, + roleId: customRoleId, + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY, + }); + }); + + afterEach(async () => { + if (customRoleId) { + await deleteRole(client, customRoleId); + customRoleId = ''; + } + }); + + describe('should throw an error if requesting a restricted field', () => { + beforeEach(async () => { + await restrictAccessToCompanyEmployee( + customRoleId, + companyObjectId, + restrictedCompanyFieldId, + ); + }); + + it('1. findMany', async () => { + const graphqlOperation = findManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + }); + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('2. findOne', async () => { + const graphqlOperation = findOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + filter: { id: { eq: companyId } }, + }); + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('3. updateMany', async () => { + const graphqlOperation = updateManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('4. updateOne', async () => { + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + recordId: companyId, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('5. createMany', async () => { + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + data: [ + { id: randomUUID(), name: 'TestCompany' }, + { id: randomUUID(), name: 'TestCompany2' }, + ], + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('5. createOne', async () => { + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + data: { id: randomUUID(), name: 'TestCompany3' }, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('6. deleteMany', async () => { + const graphqlOperation = deleteManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('7. deleteOne', async () => { + const graphqlOperation = deleteOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + recordId: companyId, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + }); + + it('2. should throw an error if requesting a restricted field of a related object', async () => { + await restrictAccessToPersonCity( + customRoleId, + personObjectId, + restrictedPersonFieldId, + ); + const graphqlOperation = findManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS_WITH_PEOPLE_CITY, + }); + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('3. should succeed if restricted fields exist but are not requested', async () => { + await restrictAccessToCompanyEmployee( + customRoleId, + companyObjectId, + restrictedCompanyFieldId, + ); + await restrictAccessToPersonCity( + customRoleId, + personObjectId, + restrictedPersonFieldId, + ); + + // Query NOT requesting the restricted field + const graphqlOperation = findManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES_AND_WITHOUT_PEOPLE_CITY, + }); + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data).toBeDefined(); + expect(response.body.data.companies.edges[0].node.id).toBeDefined(); + }); + + describe('Aggregate operations', () => { + it('1. should throw an error if requesting a restricted field through aggregates', async () => { + await restrictAccessToCompanyEmployee( + customRoleId, + companyObjectId, + restrictedCompanyFieldId, + ); + + // Query requesting the aggregate restricted field + const graphqlOperation = { + query: gql` + query Companies { + companies { + countEmptyEmployees + } + } + `, + }; + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('2. should throw an error if requesting a restricted field on related object through aggregates', async () => { + await restrictAccessToPersonCity( + customRoleId, + personObjectId, + restrictedPersonFieldId, + ); + + // Query requesting the aggregate restricted field + const graphqlOperation = findManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS_WITH_PEOPLE_CITY_AGGREGATE, + }); + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + }); +});