diff --git a/packages/twenty-server/@types/jest.d.ts b/packages/twenty-server/@types/jest.d.ts index 523142b5b..2b06c7722 100644 --- a/packages/twenty-server/@types/jest.d.ts +++ b/packages/twenty-server/@types/jest.d.ts @@ -7,6 +7,7 @@ declare module '@jest/types' { ADMIN_ACCESS_TOKEN: string; EXPIRED_ACCESS_TOKEN: string; MEMBER_ACCESS_TOKEN: string; + GUEST_ACCESS_TOKEN: string; } } } @@ -16,6 +17,7 @@ declare global { const ADMIN_ACCESS_TOKEN: string; const EXPIRED_ACCESS_TOKEN: string; const MEMBER_ACCESS_TOKEN: string; + const GUEST_ACCESS_TOKEN: string; } export {}; diff --git a/packages/twenty-server/jest-integration.config.ts b/packages/twenty-server/jest-integration.config.ts index 0288407ae..f532f9ab1 100644 --- a/packages/twenty-server/jest-integration.config.ts +++ b/packages/twenty-server/jest-integration.config.ts @@ -64,6 +64,8 @@ const jestConfig: JestConfigWithTsJest = { 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzM4MzIzODc5LCJleHAiOjE3MzgzMjU2Nzl9.m73hHVpnw5uGNGrSuKxn6XtKEUK3Wqkp4HsQdYfZiHo', MEMBER_ACCESS_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNzdkNS00Y2I2LWI2MGEtZjRhODM1YTg1ZDYxIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMzk1Ny00OTA4LTljMzYtMjkyOWEyM2Y4MzUzIiwiaWF0IjoxNzM5NDU5NTcwLCJleHAiOjMzMjk3MDU5NTcwfQ.Er7EEU4IP4YlGN79jCLR_6sUBqBfKx2M3G_qGiDpPRo', + GUEST_ACCESS_TOKEN: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC03MTY5LTQyY2YtYmM0Ny0xY2ZlZjE1MjY0YjgiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMTU1My00NWM2LWEwMjgtNWE5MDY0Y2NlMDdmIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtNzE2OS00MmNmLWJjNDctMWNmZWYxNTI2NGIxIiwiaWF0IjoxNzM5ODg4NDcwLCJleHAiOjMzMjk3NDg4NDcwfQ.0NEu-AWGv3l77rs-56Z5Gt0UTU7HDl6qUTHUcMWNrCc', }, }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts index 37f7d340c..7af89bc7d 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts @@ -1,7 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import graphqlFields from 'graphql-fields'; -import { capitalize, SettingsFeatures } from 'twenty-shared'; +import { + capitalize, + isObjectRecordUnderObjectRecordsPermissions, + PermissionsOnAllObjectRecords, + SettingsFeatures, +} from 'twenty-shared'; import { DataSource, ObjectLiteral } from 'typeorm'; import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; @@ -23,6 +28,7 @@ import { QueryResultGettersFactory } from 'src/engine/api/graphql/workspace-quer import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; import { AuthException, AuthExceptionCode, @@ -92,7 +98,20 @@ export abstract class GraphqlQueryBaseResolverService< featureFlagsMap[FeatureFlagKey.IsPermissionsEnabled] && objectMetadataItemWithFieldMaps.isSystem === true ) { - await this.validateSystemObjectPermissions(options); + await this.validateSystemObjectPermissionsOrThrow(options); + } + + if ( + featureFlagsMap[FeatureFlagKey.IsPermissionsEnabled] && + isObjectRecordUnderObjectRecordsPermissions({ + isCustom: objectMetadataItemWithFieldMaps.isCustom, + nameSingular: objectMetadataItemWithFieldMaps.nameSingular, + }) + ) { + await this.validateCustomObjectPermissionsOrThrow({ + operationName, + options, + }); } const hookedArgs = @@ -170,7 +189,7 @@ export abstract class GraphqlQueryBaseResolverService< } } - private async validateSystemObjectPermissions( + private async validateSystemObjectPermissionsOrThrow( options: WorkspaceQueryRunnerOptions, ) { const { authContext, objectMetadataItemWithFieldMaps } = options; @@ -210,6 +229,70 @@ export abstract class GraphqlQueryBaseResolverService< } } + private async validateCustomObjectPermissionsOrThrow({ + operationName, + options, + }: { + operationName: WorkspaceResolverBuilderMethodNames; + options: WorkspaceQueryRunnerOptions; + }) { + if (!options.authContext.apiKey) { + if (!options.authContext.userWorkspaceId) { + throw new AuthException( + 'Missing userWorkspaceId in authContext', + AuthExceptionCode.USER_WORKSPACE_NOT_FOUND, + ); + } + + const requiredPermission = + this.getRequiredPermissionForMethod(operationName); + + const userHasPermission = + await this.permissionsService.userHasObjectRecordsPermission({ + userWorkspaceId: options.authContext.userWorkspaceId, + requiredPermission, + workspaceId: options.authContext.workspace.id, + }); + + if (!userHasPermission) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + } + } + + private getRequiredPermissionForMethod( + operationName: WorkspaceResolverBuilderMethodNames, + ) { + switch (operationName) { + case RESOLVER_METHOD_NAMES.FIND_MANY: + case RESOLVER_METHOD_NAMES.FIND_ONE: + case RESOLVER_METHOD_NAMES.FIND_DUPLICATES: + case RESOLVER_METHOD_NAMES.SEARCH: + return PermissionsOnAllObjectRecords.READ_ALL_OBJECT_RECORDS; + case RESOLVER_METHOD_NAMES.CREATE_MANY: + case RESOLVER_METHOD_NAMES.CREATE_ONE: + case RESOLVER_METHOD_NAMES.UPDATE_MANY: + case RESOLVER_METHOD_NAMES.UPDATE_ONE: + return PermissionsOnAllObjectRecords.UPDATE_ALL_OBJECT_RECORDS; + case RESOLVER_METHOD_NAMES.DELETE_MANY: + case RESOLVER_METHOD_NAMES.DELETE_ONE: + case RESOLVER_METHOD_NAMES.RESTORE_MANY: + case RESOLVER_METHOD_NAMES.RESTORE_ONE: + return PermissionsOnAllObjectRecords.SOFT_DELETE_ALL_OBJECT_RECORDS; + case RESOLVER_METHOD_NAMES.DESTROY_MANY: + case RESOLVER_METHOD_NAMES.DESTROY_ONE: + return PermissionsOnAllObjectRecords.DESTROY_ALL_OBJECT_RECORDS; + default: + throw new PermissionsException( + PermissionsExceptionMessage.UNKNOWN_OPERATION_NAME, + PermissionsExceptionCode.UNKNOWN_OPERATION_NAME, + ); + } + } + protected abstract resolve( executionArgs: GraphqlQueryResolverExecutionArgs, featureFlagsMap: Record, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util.ts new file mode 100644 index 000000000..773f61ff7 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util.ts @@ -0,0 +1,31 @@ +import { + GraphqlQueryRunnerException, + GraphqlQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { + InternalServerError, + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +export const graphqlQueryRunnerExceptionHandler = ( + error: GraphqlQueryRunnerException, +) => { + switch (error.code) { + case GraphqlQueryRunnerExceptionCode.INVALID_ARGS_FIRST: + case GraphqlQueryRunnerExceptionCode.INVALID_ARGS_LAST: + case GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND: + case GraphqlQueryRunnerExceptionCode.MAX_DEPTH_REACHED: + case GraphqlQueryRunnerExceptionCode.INVALID_CURSOR: + case GraphqlQueryRunnerExceptionCode.INVALID_DIRECTION: + case GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR: + case GraphqlQueryRunnerExceptionCode.ARGS_CONFLICT: + case GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND: + case GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT: + throw new UserInputError(error.message); + case GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND: + throw new NotFoundError(error.message); + default: + throw new InternalServerError(error.message); + } +}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts new file mode 100644 index 000000000..2c2254b64 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util.ts @@ -0,0 +1,51 @@ +import { isDefined } from 'twenty-shared'; +import { QueryFailedError } from 'typeorm'; + +import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; + +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +export const handleDuplicateKeyError = ( + error: QueryFailedError, + context: WorkspaceQueryRunnerOptions, +) => { + const indexNameMatch = error.message.match(/"([^"]+)"/); + + if (indexNameMatch) { + const indexName = indexNameMatch[1]; + + const deletedAtFieldMetadata = + context.objectMetadataItemWithFieldMaps.fieldsByName['deletedAt']; + + const affectedColumns = + context.objectMetadataItemWithFieldMaps.indexMetadatas + .find((index) => index.name === indexName) + ?.indexFieldMetadatas?.filter( + (field) => field.fieldMetadataId !== deletedAtFieldMetadata?.id, + ) + .map((indexField) => { + const fieldMetadata = + context.objectMetadataItemWithFieldMaps.fieldsById[ + indexField.fieldMetadataId + ]; + + return fieldMetadata?.label; + }); + + if (!isDefined(affectedColumns)) { + throw new UserInputError(`A duplicate entry was detected`); + } + + const columnNames = affectedColumns.join(', '); + + if (affectedColumns?.length === 1) { + throw new UserInputError( + `Duplicate ${columnNames}. Please set a unique one.`, + ); + } + + throw new UserInputError( + `A duplicate entry was detected. The combination of ${columnNames} must be unique.`, + ); + } +}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util.ts new file mode 100644 index 000000000..69b9b3fa1 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util.ts @@ -0,0 +1,32 @@ +import { + WorkspaceQueryRunnerException, + WorkspaceQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception'; +import { + ForbiddenError, + InternalServerError, + NotFoundError, + TimeoutError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +export const workspaceExceptionHandler = ( + error: WorkspaceQueryRunnerException, +) => { + switch (error.code) { + case WorkspaceQueryRunnerExceptionCode.DATA_NOT_FOUND: + throw new NotFoundError(error.message); + case WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT: + throw new UserInputError(error.message); + case WorkspaceQueryRunnerExceptionCode.QUERY_VIOLATES_UNIQUE_CONSTRAINT: + case WorkspaceQueryRunnerExceptionCode.QUERY_VIOLATES_FOREIGN_KEY_CONSTRAINT: + case WorkspaceQueryRunnerExceptionCode.TOO_MANY_ROWS_AFFECTED: + case WorkspaceQueryRunnerExceptionCode.NO_ROWS_AFFECTED: + throw new ForbiddenError(error.message); + case WorkspaceQueryRunnerExceptionCode.QUERY_TIMEOUT: + throw new TimeoutError(error.message); + case WorkspaceQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR: + default: + throw new InternalServerError(error.message); + } +}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts index cd0002d43..a7636a29d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts @@ -1,114 +1,35 @@ import { QueryFailedError } from 'typeorm'; -import { isDefined } from 'twenty-shared'; import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; -import { - GraphqlQueryRunnerException, - GraphqlQueryRunnerExceptionCode, -} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; -import { - WorkspaceQueryRunnerException, - WorkspaceQueryRunnerExceptionCode, -} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception'; -import { - ForbiddenError, - InternalServerError, - NotFoundError, - TimeoutError, - UserInputError, -} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { GraphqlQueryRunnerException } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { graphqlQueryRunnerExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util'; +import { handleDuplicateKeyError } from 'src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util'; +import { workspaceExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util'; +import { WorkspaceQueryRunnerException } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception'; +import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { permissionGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util'; export const workspaceQueryRunnerGraphqlApiExceptionHandler = ( error: Error, context: WorkspaceQueryRunnerOptions, ) => { - if (error instanceof QueryFailedError) { - if ( - error.message.includes('duplicate key value violates unique constraint') - ) { - const indexNameMatch = error.message.match(/"([^"]+)"/); - - if (indexNameMatch) { - const indexName = indexNameMatch[1]; - - const deletedAtFieldMetadata = - context.objectMetadataItemWithFieldMaps.fieldsByName['deletedAt']; - - const affectedColumns = - context.objectMetadataItemWithFieldMaps.indexMetadatas - .find((index) => index.name === indexName) - ?.indexFieldMetadatas?.filter( - (field) => field.fieldMetadataId !== deletedAtFieldMetadata?.id, - ) - .map((indexField) => { - const fieldMetadata = - context.objectMetadataItemWithFieldMaps.fieldsById[ - indexField.fieldMetadataId - ]; - - return fieldMetadata?.label; - }); - - if (!isDefined(affectedColumns)) { - throw new UserInputError(`A duplicate entry was detected`); - } - - const columnNames = affectedColumns.join(', '); - - if (affectedColumns?.length === 1) { - throw new UserInputError( - `Duplicate ${columnNames}. Please set a unique one.`, - ); - } - - throw new UserInputError( - `A duplicate entry was detected. The combination of ${columnNames} must be unique.`, - ); + switch (true) { + case error instanceof QueryFailedError: { + if ( + error.message.includes('duplicate key value violates unique constraint') + ) { + return handleDuplicateKeyError(error, context); } + throw error; } - - throw error; + case error instanceof PermissionsException: + return permissionGraphqlApiExceptionHandler(error); + case error instanceof WorkspaceQueryRunnerException: + return workspaceExceptionHandler(error); + case error instanceof GraphqlQueryRunnerException: + return graphqlQueryRunnerExceptionHandler(error); + default: + throw error; } - - if (error instanceof WorkspaceQueryRunnerException) { - switch (error.code) { - case WorkspaceQueryRunnerExceptionCode.DATA_NOT_FOUND: - throw new NotFoundError(error.message); - case WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT: - throw new UserInputError(error.message); - case WorkspaceQueryRunnerExceptionCode.QUERY_VIOLATES_UNIQUE_CONSTRAINT: - case WorkspaceQueryRunnerExceptionCode.QUERY_VIOLATES_FOREIGN_KEY_CONSTRAINT: - case WorkspaceQueryRunnerExceptionCode.TOO_MANY_ROWS_AFFECTED: - case WorkspaceQueryRunnerExceptionCode.NO_ROWS_AFFECTED: - throw new ForbiddenError(error.message); - case WorkspaceQueryRunnerExceptionCode.QUERY_TIMEOUT: - throw new TimeoutError(error.message); - case WorkspaceQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR: - default: - throw new InternalServerError(error.message); - } - } - - if (error instanceof GraphqlQueryRunnerException) { - switch (error.code) { - case GraphqlQueryRunnerExceptionCode.INVALID_ARGS_FIRST: - case GraphqlQueryRunnerExceptionCode.INVALID_ARGS_LAST: - case GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND: - case GraphqlQueryRunnerExceptionCode.MAX_DEPTH_REACHED: - case GraphqlQueryRunnerExceptionCode.INVALID_CURSOR: - case GraphqlQueryRunnerExceptionCode.INVALID_DIRECTION: - case GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR: - case GraphqlQueryRunnerExceptionCode.ARGS_CONFLICT: - case GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND: - case GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT: - throw new UserInputError(error.message); - case GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND: - throw new NotFoundError(error.message); - default: - throw new InternalServerError(error.message); - } - } - - throw error; }; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names.ts new file mode 100644 index 000000000..15fde4c0a --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names.ts @@ -0,0 +1,16 @@ +export const RESOLVER_METHOD_NAMES = { + FIND_MANY: 'findMany', + FIND_ONE: 'findOne', + FIND_DUPLICATES: 'findDuplicates', + SEARCH: 'search', + CREATE_MANY: 'createMany', + CREATE_ONE: 'createOne', + UPDATE_MANY: 'updateMany', + UPDATE_ONE: 'updateOne', + DELETE_MANY: 'deleteMany', + DELETE_ONE: 'deleteOne', + RESTORE_MANY: 'restoreMany', + RESTORE_ONE: 'restoreOne', + DESTROY_MANY: 'destroyMany', + DESTROY_ONE: 'destroyOne', +} as const; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts index 6b4211820..147e2866a 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class CreateManyResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'createMany' as const; + public static methodName = RESOLVER_METHOD_NAMES.CREATE_MANY; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryCreateManyResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts index 64bbe6875..18d21b9e6 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryCreateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class CreateOneResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'createOne' as const; + public static methodName = RESOLVER_METHOD_NAMES.CREATE_ONE; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryCreateOneResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts index fab10575a..534f3cc4d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryDeleteManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class DeleteManyResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'deleteMany' as const; + public static methodName = RESOLVER_METHOD_NAMES.DELETE_MANY; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryDeleteManyResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts index 2b14aad99..67cec20eb 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts @@ -9,13 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryDeleteOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-one-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class DeleteOneResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'deleteOne' as const; - + public static methodName = RESOLVER_METHOD_NAMES.DELETE_ONE; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryDeleteOneResolverService, ) {} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts index f4f92e248..c6a07b36d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryDestroyManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class DestroyManyResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'destroyMany' as const; + public static methodName = RESOLVER_METHOD_NAMES.DESTROY_MANY; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryDestroyManyResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts index 4f60eb9b8..25a508d7e 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class DestroyOneResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'destroyOne' as const; + public static methodName = RESOLVER_METHOD_NAMES.DESTROY_ONE; constructor( private readonly graphQLQueryRunnerService: GraphqlQueryDestroyOneResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts index 0ad5cb69a..21576100c 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class FindDuplicatesResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'findDuplicates' as const; + public static methodName = RESOLVER_METHOD_NAMES.FIND_DUPLICATES; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryFindDuplicatesResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts index 08b17ddc1..c6d0662da 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class FindManyResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'findMany' as const; + public static methodName = RESOLVER_METHOD_NAMES.FIND_MANY; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryFindManyResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts index dc2302adc..d7983a67d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class FindOneResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'findOne' as const; + public static methodName = RESOLVER_METHOD_NAMES.FIND_ONE; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryFindOneResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts index 06213a00d..e9385b311 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryRestoreManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class RestoreManyResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'restoreMany' as const; + public static methodName = RESOLVER_METHOD_NAMES.RESTORE_MANY; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryRestoreManyResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts index e9a0f2d7c..25f986f9d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryRestoreOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class RestoreOneResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'restoreOne' as const; + public static methodName = RESOLVER_METHOD_NAMES.RESTORE_ONE; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryRestoreOneResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts index 05f7d6431..60a7eb2b8 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQuerySearchResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class SearchResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'search' as const; + public static methodName = RESOLVER_METHOD_NAMES.SEARCH; constructor( private readonly graphqlQueryRunnerService: GraphqlQuerySearchResolverService, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts index 3830c3f22..efbeeee65 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts @@ -9,13 +9,12 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryUpdateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service'; - +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class UpdateManyResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'updateMany' as const; - + public static methodName = RESOLVER_METHOD_NAMES.UPDATE_MANY; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryUpdateManyResolverService, ) {} diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts index cc4f2d984..401f070c2 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts @@ -9,12 +9,13 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { GraphqlQueryUpdateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service'; +import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; @Injectable() export class UpdateOneResolverFactory implements WorkspaceResolverBuilderFactoryInterface { - public static methodName = 'updateOne' as const; + public static methodName = RESOLVER_METHOD_NAMES.UPDATE_ONE; constructor( private readonly graphqlQueryRunnerService: GraphqlQueryUpdateOneResolverService, diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts index 9dacb79c9..2535b8c36 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts @@ -16,6 +16,8 @@ export enum PermissionsExceptionCode { WORKSPACE_MEMBER_NOT_FOUND = 'WORKSPACE_MEMBER_NOT_FOUND', ROLE_NOT_FOUND = 'ROLE_NOT_FOUND', CANNOT_UNASSIGN_LAST_ADMIN = 'CANNOT_UNASSIGN_LAST_ADMIN', + UNKNOWN_OPERATION_NAME = 'UNKNOWN_OPERATION_NAME', + UNKNOWN_REQUIRED_PERMISSION = 'UNKNOWN_REQUIRED_PERMISSION', } export enum PermissionsExceptionMessage { @@ -28,4 +30,6 @@ export enum PermissionsExceptionMessage { WORKSPACE_MEMBER_NOT_FOUND = 'Workspace member not found', ROLE_NOT_FOUND = 'Role not found', CANNOT_UNASSIGN_LAST_ADMIN = 'Cannot unassign last admin', + UNKNOWN_OPERATION_NAME = 'Unknown operation name, cannot determine required permission', + UNKNOWN_REQUIRED_PERMISSION = 'Unknown required permission', } diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts index c5d9a143b..cb508a70c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts @@ -3,6 +3,12 @@ import { Injectable } from '@nestjs/common'; import { PermissionsOnAllObjectRecords, SettingsFeatures } from 'twenty-shared'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { + PermissionsException, + PermissionsExceptionCode, + PermissionsExceptionMessage, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; @Injectable() @@ -86,7 +92,49 @@ export class PermissionsService { return false; } + public async userHasObjectRecordsPermission({ + userWorkspaceId, + workspaceId, + requiredPermission, + }: { + userWorkspaceId: string; + workspaceId: string; + requiredPermission: PermissionsOnAllObjectRecords; + }): Promise { + const [roleOfUserWorkspace] = await this.userRoleService + .getRolesByUserWorkspaces({ + userWorkspaceIds: [userWorkspaceId], + workspaceId, + }) + .then((roles) => roles?.get(userWorkspaceId) ?? []); + + const roleColumn = + this.getRoleColumnForRequiredPermission(requiredPermission); + + return roleOfUserWorkspace?.[roleColumn] === true; + } + public async isPermissionsEnabled(): Promise { return this.environmentService.get('PERMISSIONS_ENABLED') === true; } + + private getRoleColumnForRequiredPermission( + requiredPermission: PermissionsOnAllObjectRecords, + ): keyof RoleEntity { + switch (requiredPermission) { + case PermissionsOnAllObjectRecords.READ_ALL_OBJECT_RECORDS: + return 'canReadAllObjectRecords'; + case PermissionsOnAllObjectRecords.UPDATE_ALL_OBJECT_RECORDS: + return 'canUpdateAllObjectRecords'; + case PermissionsOnAllObjectRecords.SOFT_DELETE_ALL_OBJECT_RECORDS: + return 'canSoftDeleteAllObjectRecords'; + case PermissionsOnAllObjectRecords.DESTROY_ALL_OBJECT_RECORDS: + return 'canDestroyAllObjectRecords'; + default: + throw new PermissionsException( + PermissionsExceptionMessage.UNKNOWN_REQUIRED_PERMISSION, + PermissionsExceptionCode.UNKNOWN_REQUIRED_PERMISSION, + ); + } + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts new file mode 100644 index 000000000..60614a396 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts @@ -0,0 +1,24 @@ +import { + ForbiddenError, + InternalServerError, + NotFoundError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + PermissionsException, + PermissionsExceptionCode, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; + +export const permissionGraphqlApiExceptionHandler = ( + error: PermissionsException, +) => { + switch (error.code) { + case PermissionsExceptionCode.PERMISSION_DENIED: + case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN: + throw new ForbiddenError(error.message); + case PermissionsExceptionCode.ROLE_NOT_FOUND: + case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND: + throw new NotFoundError(error.message); + default: + throw new InternalServerError(error.message); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter.ts index dbecb22ba..17e5d4121 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter.ts @@ -1,27 +1,11 @@ import { Catch, ExceptionFilter } from '@nestjs/common'; -import { - ForbiddenError, - InternalServerError, - NotFoundError, -} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; -import { - PermissionsException, - PermissionsExceptionCode, -} from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { permissionGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util'; @Catch(PermissionsException) export class PermissionsGraphqlApiExceptionFilter implements ExceptionFilter { catch(exception: PermissionsException) { - switch (exception.code) { - case PermissionsExceptionCode.PERMISSION_DENIED: - case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN: - throw new ForbiddenError(exception.message); - case PermissionsExceptionCode.ROLE_NOT_FOUND: - case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND: - throw new NotFoundError(exception.message); - default: - throw new InternalServerError(exception.message); - } + return permissionGraphqlApiExceptionHandler(exception); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts index d161681ec..0c1adcf54 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts @@ -56,4 +56,23 @@ export class RoleService { workspaceId, }); } + + // Only used for dev seeding and testing + public async createGuestRole({ + workspaceId, + }: { + workspaceId: string; + }): Promise { + return this.roleRepository.save({ + label: 'Guest', + description: 'Guest role', + canUpdateAllSettings: false, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + isEditable: false, + workspaceId, + }); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts index 2547b4fe9..ebc6a6939 100644 --- a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts @@ -109,7 +109,7 @@ export class UserRoleService { }: { userWorkspaceIds: string[]; workspaceId: string; - }): Promise> { + }): Promise> { if (!userWorkspaceIds.length) { return new Map(); } @@ -128,7 +128,7 @@ export class UserRoleService { return new Map(); } - const rolesMap = new Map(); + const rolesMap = new Map(); for (const userWorkspaceId of userWorkspaceIds) { const userWorkspaceRolesOfUserWorkspace = allUserWorkspaceRoles.filter( diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts index 1db51af0d..6cb5d8b33 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts @@ -278,6 +278,17 @@ export class WorkspaceManagerService { if (workspaceId === SEED_APPLE_WORKSPACE_ID) { adminUserWorkspaceId = DEV_SEED_USER_WORKSPACE_IDS.TIM; memberUserWorkspaceId = DEV_SEED_USER_WORKSPACE_IDS.JONY; + + // Create guest role only in this workspace + const guestRole = await this.roleService.createGuestRole({ + workspaceId, + }); + + await this.userRoleService.assignRoleToUserWorkspace({ + workspaceId, + userWorkspaceId: DEV_SEED_USER_WORKSPACE_IDS.PHIL, + roleId: guestRole.id, + }); } else if (workspaceId === SEED_ACME_WORKSPACE_ID) { adminUserWorkspaceId = DEV_SEED_USER_WORKSPACE_IDS.TIM_ACME; } diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/create-many-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/create-many-object-records-permissions.integration-spec.ts new file mode 100644 index 000000000..e6d65ad0e --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/create-many-object-records-permissions.integration-spec.ts @@ -0,0 +1,85 @@ +import { randomUUID } from 'node:crypto'; + +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-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 { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +describe('createManyObjectRecordsPermissions', () => { + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + id: randomUUID(), + }, + { + id: randomUUID(), + }, + ], + }); + + const response = await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ createPeople: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should create multiple object records when user has permission (admin role)', async () => { + const personId1 = randomUUID(); + const personId2 = randomUUID(); + + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + id: personId1, + }, + { + id: personId2, + }, + ], + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.createPeople).toBeDefined(); + expect(response.body.data.createPeople).toHaveLength(2); + expect(response.body.data.createPeople[0].id).toBe(personId1); + expect(response.body.data.createPeople[1].id).toBe(personId2); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/create-one-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/create-one-object-records-permissions.integration-spec.ts new file mode 100644 index 000000000..0da7ae11c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/create-one-object-records-permissions.integration-spec.ts @@ -0,0 +1,69 @@ +import { randomUUID } from 'node:crypto'; + +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-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 { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +describe('createOneObjectRecordsPermissions', () => { + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: randomUUID(), + }, + }); + + const response = await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ createPerson: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should create an object record when user has permission (admin role)', async () => { + const personId = randomUUID(); + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: personId, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.createPerson).toBeDefined(); + expect(response.body.data.createPerson.id).toBe(personId); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/delete-many-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/delete-many-object-records-permissions.integration-spec.ts new file mode 100644 index 000000000..834dddfe7 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/delete-many-object-records-permissions.integration-spec.ts @@ -0,0 +1,96 @@ +import { randomUUID } from 'node:crypto'; + +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util'; +import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-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 { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +describe('deleteManyObjectRecordsPermissions', () => { + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const graphqlOperation = deleteManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + filter: { + id: { + in: [randomUUID(), randomUUID()], + }, + }, + }); + + const response = await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ deletePeople: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should delete multiple object records when user has permission (admin role)', async () => { + const personId1 = randomUUID(); + const personId2 = randomUUID(); + + const createGraphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + id: personId1, + }, + { + id: personId2, + }, + ], + }); + + await makeGraphqlAPIRequest(createGraphqlOperation); + + const deleteGraphqlOperation = deleteManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + filter: { + id: { + in: [personId1, personId2], + }, + }, + }); + + const response = await makeGraphqlAPIRequest(deleteGraphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.deletePeople).toBeDefined(); + expect(response.body.data.deletePeople).toHaveLength(2); + expect(response.body.data.deletePeople[0].id).toBe(personId1); + expect(response.body.data.deletePeople[1].id).toBe(personId2); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/delete-one-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/delete-one-object-records-permissions.integration-spec.ts new file mode 100644 index 000000000..b004dc5b0 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/delete-one-object-records-permissions.integration-spec.ts @@ -0,0 +1,78 @@ +import { randomUUID } from 'node:crypto'; + +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util'; +import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-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 { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +describe('deleteOneObjectRecordsPermissions', () => { + const personId = randomUUID(); + + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + + const createGraphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: personId, + }, + }); + + await makeGraphqlAPIRequest(createGraphqlOperation); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const personId = randomUUID(); + const graphqlOperation = deleteOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + recordId: personId, + }); + + const response = await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ deletePerson: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should delete an object record when user has permission (admin role)', async () => { + const deleteGraphqlOperation = deleteOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + recordId: personId, + }); + + const response = await makeGraphqlAPIRequest(deleteGraphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.deletePerson).toBeDefined(); + expect(response.body.data.deletePerson.id).toBe(personId); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/destroy-many-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/destroy-many-object-records-permissions.integration-spec.ts new file mode 100644 index 000000000..36ae403e2 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/destroy-many-object-records-permissions.integration-spec.ts @@ -0,0 +1,96 @@ +import { randomUUID } from 'node:crypto'; + +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util'; +import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-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 { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +describe('destroyManyObjectRecordsPermissions', () => { + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const graphqlOperation = destroyManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + filter: { + id: { + in: [randomUUID(), randomUUID()], + }, + }, + }); + + const response = await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ destroyPeople: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should destroy multiple object records when user has permission (admin role)', async () => { + const personId1 = randomUUID(); + const personId2 = randomUUID(); + + const createGraphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + id: personId1, + }, + { + id: personId2, + }, + ], + }); + + await makeGraphqlAPIRequest(createGraphqlOperation); + + const graphqlOperation = destroyManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + filter: { + id: { + in: [personId1, personId2], + }, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.destroyPeople).toBeDefined(); + expect(response.body.data.destroyPeople).toHaveLength(2); + expect(response.body.data.destroyPeople[0].id).toBe(personId1); + expect(response.body.data.destroyPeople[1].id).toBe(personId2); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/destroy-one-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/destroy-one-object-records-permissions.integration-spec.ts new file mode 100644 index 000000000..18932e15e --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/destroy-one-object-records-permissions.integration-spec.ts @@ -0,0 +1,78 @@ +import { randomUUID } from 'node:crypto'; + +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util'; +import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-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 { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +describe('destroyOneObjectRecordsPermissions', () => { + const personId = randomUUID(); + + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + + const createGraphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: personId, + }, + }); + + await makeGraphqlAPIRequest(createGraphqlOperation); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const personId = randomUUID(); + const graphqlOperation = destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + recordId: personId, + }); + + const response = await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ destroyPerson: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should destroy an object record when user has permission (admin role)', async () => { + const graphqlOperation = destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + recordId: personId, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.destroyPerson).toBeDefined(); + expect(response.body.data.destroyPerson.id).toBe(personId); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/restore-many-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/restore-many-object-records-permissions.integration-spec.ts new file mode 100644 index 000000000..72abd7223 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/restore-many-object-records-permissions.integration-spec.ts @@ -0,0 +1,112 @@ +import { randomUUID } from 'node:crypto'; + +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util'; +import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-role.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { restoreManyOperationFactory } from 'test/integration/graphql/utils/restore-many-operation-factory.util'; +import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +describe('restoreManyObjectRecordsPermissions', () => { + const personId1 = randomUUID(); + const personId2 = randomUUID(); + + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + + // Create people + const createGraphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + id: personId1, + }, + { + id: personId2, + }, + ], + }); + + await makeGraphqlAPIRequest(createGraphqlOperation); + + // Delete people + const deleteGraphqlOperation = deleteManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + filter: { + id: { + in: [personId1, personId2], + }, + }, + }); + + await makeGraphqlAPIRequest(deleteGraphqlOperation); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const graphqlOperation = restoreManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + filter: { + id: { + in: [personId1, personId2], + }, + }, + }); + + const response = await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ restorePeople: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should restore multiple object records when user has permission (admin role)', async () => { + const graphqlOperation = restoreManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + filter: { + id: { + in: [personId1, personId2], + }, + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.restorePeople).toBeDefined(); + expect(response.body.data.restorePeople).toHaveLength(2); + expect(response.body.data.restorePeople[0].id).toBe(personId1); + expect(response.body.data.restorePeople[1].id).toBe(personId2); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/restore-one-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/restore-one-object-records-permissions.integration-spec.ts new file mode 100644 index 000000000..75ee6c588 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/restore-one-object-records-permissions.integration-spec.ts @@ -0,0 +1,88 @@ +import { randomUUID } from 'node:crypto'; + +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util'; +import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-role.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { restoreOneOperationFactory } from 'test/integration/graphql/utils/restore-one-operation-factory.util'; +import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +describe('restoreOneObjectRecordsPermissions', () => { + const personId = randomUUID(); + + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + + // Create a person + const createGraphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: personId, + }, + }); + + await makeGraphqlAPIRequest(createGraphqlOperation); + + // Delete the person + const deleteGraphqlOperation = deleteOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + recordId: personId, + }); + + await makeGraphqlAPIRequest(deleteGraphqlOperation); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const graphqlOperation = restoreOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + recordId: personId, + }); + + const response = await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ restorePerson: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should restore an object record when user has permission (admin role)', async () => { + const graphqlOperation = restoreOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + recordId: personId, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.restorePerson).toBeDefined(); + expect(response.body.data.restorePerson.id).toBe(personId); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/update-many-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/update-many-object-records-permissions.integration-spec.ts new file mode 100644 index 000000000..3500dd82c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/update-many-object-records-permissions.integration-spec.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto'; + +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-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 { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +describe('updateManyObjectRecordsPermissions', () => { + const personId1 = randomUUID(); + const personId2 = randomUUID(); + + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + + const createGraphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + data: [ + { + id: personId1, + }, + { + id: personId2, + }, + ], + }); + + await makeGraphqlAPIRequest(createGraphqlOperation); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const graphqlOperation = updateManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + filter: { + id: { + in: [randomUUID(), randomUUID()], + }, + }, + data: { + jobTitle: 'Architect', + }, + }); + + const response = await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ updatePeople: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should update multiple object records when user has permission (admin role)', async () => { + const graphqlOperation = updateManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: PERSON_GQL_FIELDS, + filter: { + id: { + in: [personId1, personId2], + }, + }, + data: { + jobTitle: 'Architect', + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.updatePeople).toBeDefined(); + expect(response.body.data.updatePeople).toHaveLength(2); + expect(response.body.data.updatePeople[0].jobTitle).toBe('Architect'); + expect(response.body.data.updatePeople[1].jobTitle).toBe('Architect'); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/update-one-object-records-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/update-one-object-records-permissions.integration-spec.ts new file mode 100644 index 000000000..bb6c6ff00 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/update-one-object-records-permissions.integration-spec.ts @@ -0,0 +1,88 @@ +import { randomUUID } from 'node:crypto'; + +import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { makeGraphqlAPIRequestWithGuestRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-guest-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 { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +describe('updateOneObjectRecordsPermissions', () => { + const personId = randomUUID(); + + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + + const createPersonOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: personId, + jobTitle: 'Software Engineer', + }, + }); + + await makeGraphqlAPIRequest(createPersonOperation); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const personId = randomUUID(); + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + recordId: personId, + data: { + jobTitle: 'Senior Software Engineer', + }, + }); + + const response = await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ updatePerson: null }); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + + it('should update an object record when user has permission (admin role)', async () => { + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + recordId: personId, + data: { + jobTitle: 'Senior Software Engineer', + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.updatePerson).toBeDefined(); + expect(response.body.data.updatePerson.id).toBe(personId); + expect(response.body.data.updatePerson.jobTitle).toBe( + 'Senior Software Engineer', + ); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/workspace.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts similarity index 100% rename from packages/twenty-server/test/integration/graphql/suites/workspace.integration-spec.ts rename to packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts diff --git a/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request-with-guest-role.util.ts b/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request-with-guest-role.util.ts new file mode 100644 index 000000000..9a5f0eeb1 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request-with-guest-role.util.ts @@ -0,0 +1,21 @@ +import { ASTNode, print } from 'graphql'; +import request from 'supertest'; + +type GraphqlOperation = { + query: ASTNode; + variables?: Record; +}; + +export const makeGraphqlAPIRequestWithGuestRole = ( + graphqlOperation: GraphqlOperation, +) => { + const client = request(`http://localhost:${APP_PORT}`); + + return client + .post('/graphql') + .set('Authorization', `Bearer ${GUEST_ACCESS_TOKEN}`) + .send({ + query: print(graphqlOperation.query), + variables: graphqlOperation.variables || {}, + }); +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/restore-many-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/restore-many-operation-factory.util.ts new file mode 100644 index 000000000..29e117727 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/restore-many-operation-factory.util.ts @@ -0,0 +1,27 @@ +import gql from 'graphql-tag'; +import { capitalize } from 'twenty-shared'; + +type RestoreManyOperationFactoryParams = { + objectMetadataSingularName: string; + objectMetadataPluralName: string; + gqlFields: string; + filter: object; +}; + +export const restoreManyOperationFactory = ({ + objectMetadataSingularName, + objectMetadataPluralName, + gqlFields, + filter, +}: RestoreManyOperationFactoryParams) => ({ + query: gql` + mutation Restore${capitalize(objectMetadataPluralName)}($filter: ${capitalize(objectMetadataSingularName)}FilterInput!) { + restore${capitalize(objectMetadataPluralName)}(filter: $filter) { + ${gqlFields} + } + } + `, + variables: { + filter, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/restore-one-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/restore-one-operation-factory.util.ts new file mode 100644 index 000000000..99c216087 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/restore-one-operation-factory.util.ts @@ -0,0 +1,25 @@ +import gql from 'graphql-tag'; +import { capitalize } from 'twenty-shared'; + +type RestoreOneOperationFactoryParams = { + objectMetadataSingularName: string; + gqlFields: string; + recordId: string; +}; + +export const restoreOneOperationFactory = ({ + objectMetadataSingularName, + gqlFields, + recordId, +}: RestoreOneOperationFactoryParams) => ({ + query: gql` + mutation Restore${capitalize(objectMetadataSingularName)}($${objectMetadataSingularName}Id: ID!) { + restore${capitalize(objectMetadataSingularName)}(id: $${objectMetadataSingularName}Id) { + ${gqlFields} + } + } + `, + variables: { + [`${objectMetadataSingularName}Id`]: recordId, + }, +}); diff --git a/packages/twenty-shared/src/constants/StandardObjectRecordsUnderObjectRecordsPermissions.ts b/packages/twenty-shared/src/constants/StandardObjectRecordsUnderObjectRecordsPermissions.ts new file mode 100644 index 000000000..02a27fa07 --- /dev/null +++ b/packages/twenty-shared/src/constants/StandardObjectRecordsUnderObjectRecordsPermissions.ts @@ -0,0 +1,7 @@ +export const STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS = [ + 'person', + 'company', + 'opportunity', + 'note', + 'task', +]; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index 10e872cf4..766e0a2e0 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -1,6 +1,6 @@ export * from './FieldForTotalCountAggregateOperation'; export * from './PermissionsOnAllObjectRecords'; export * from './SettingsFeatures'; +export * from './StandardObjectRecordsUnderObjectRecordsPermissions'; export * from './TwentyCompaniesBaseUrl'; export * from './TwentyIconsBaseUrl'; - diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 0536286be..d43684cac 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -1,5 +1,5 @@ export * from './fieldMetadata'; export * from './image'; +export * from './permissions'; export * from './strings'; export * from './validation'; -export * from './validation'; diff --git a/packages/twenty-shared/src/utils/permissions/index.ts b/packages/twenty-shared/src/utils/permissions/index.ts new file mode 100644 index 000000000..ec044f18b --- /dev/null +++ b/packages/twenty-shared/src/utils/permissions/index.ts @@ -0,0 +1 @@ +export * from './isObjectRecordUnderObjectRecordsPermissions'; diff --git a/packages/twenty-shared/src/utils/permissions/isObjectRecordUnderObjectRecordsPermissions.ts b/packages/twenty-shared/src/utils/permissions/isObjectRecordUnderObjectRecordsPermissions.ts new file mode 100644 index 000000000..fc580a2f3 --- /dev/null +++ b/packages/twenty-shared/src/utils/permissions/isObjectRecordUnderObjectRecordsPermissions.ts @@ -0,0 +1,16 @@ +import { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from 'src/constants'; + +export const isObjectRecordUnderObjectRecordsPermissions = ({ + isCustom, + nameSingular, +}: { + isCustom: boolean; + nameSingular: string; +}) => { + return ( + isCustom || + STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS.includes( + nameSingular, + ) + ); +};