From 04dd0e50bb89ef69c3d46776b1ddd03cc8889680 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Wed, 11 Jun 2025 18:47:29 +0200 Subject: [PATCH] [permissions] permissions and workflows (#12436) In this PR - Determine object record permissions on workflows objects (workflow, workflowVersion, workflowRun) base on settings permissions @Weiko - Add Workflow permission guards on workflow resolvers @thomtrp . **Any method within a resolver that has the SettingsPermission Guard is only callable by a apiKey or a user that has the permission** (so not by external parties). - Add checks bypass in workflow services since 1) for actions gated by settings permissions, the gate should be done at resolver level, so it will have been done before the call to the service 2) some service methods may be called by workflowTriggerController which is callable by external parties without permissions (ex: workflowCommonWorkspaceService.getWorkflowVersionOrFail). This is something we may want to change in the future (still to discuss), by removing the guard at resolver-level and relying on shouldBypassPermissionChecks at getRepository and made in a way that we only bypass for external parties. - Add checks bypass for actions performed by workflows since they should not be restricted in our current vision - Add tests --- ...with-settings-permissions-requirements.ts} | 2 +- .../interfaces/base-resolver-service.ts | 10 +- .../workspace-query-hook.explorer.ts | 4 + .../workflow-trigger.controller.ts | 18 +- .../resolvers/workflow-builder.resolver.ts | 14 +- .../resolvers/workflow-step.resolver.ts | 14 +- .../resolvers/workflow-trigger.resolver.ts | 16 +- .../resolvers/workflow-version.resolver.ts | 12 +- .../workflow/workflow-api.module.ts | 2 + .../setting-permission-type.constants.ts | 1 + .../remote-server/remote-server.service.ts | 2 +- .../workspace-permissions-cache.service.ts | 91 +++-- ...kflow-version-delete-one.pre-query.hook.ts | 5 +- ...kflow-version-update-one.pre-query.hook.ts | 9 +- .../workflow-common.workspace-service.ts | 32 +- ...ow-version-validation.workspace-service.ts | 42 +- ...workflow-version-step.workspace-service.ts | 32 +- .../workflow-version.workspace-service.ts | 8 +- .../create-record.workflow-action.ts | 13 +- .../delete-record.workflow-action.ts | 15 +- .../find-records.workflow-action.ts | 15 +- .../update-record.workflow-action.ts | 15 +- ...orkflow-executor.workspace-service.spec.ts | 32 +- .../workflow-executor.workspace-service.ts | 31 +- .../workflow-runner/jobs/run-workflow.job.ts | 32 +- .../workflow-run.workspace-service.ts | 80 ++-- .../workflow-statuses-update.job.spec.ts | 34 +- .../jobs/workflow-statuses-update.job.ts | 20 +- .../database-event-trigger.listener.ts | 2 + .../jobs/workflow-trigger.job.ts | 12 +- .../workflow-trigger.workspace-service.ts | 32 +- .../workflow-gql-fields.constants.ts | 15 + ...-settings-permissions.integration-spec.ts} | 47 ++- .../workflows.integration-spec.ts | 370 ++++++++++++++++++ 34 files changed, 864 insertions(+), 215 deletions(-) rename packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/{system-objects-permissions-requirements.constant.ts => objects-with-settings-permissions-requirements.ts} (79%) create mode 100644 packages/twenty-server/test/integration/constants/workflow-gql-fields.constants.ts rename packages/twenty-server/test/integration/graphql/suites/settings-permissions/{granular-settings-permissions.ts => granular-settings-permissions.integration-spec.ts} (91%) create mode 100644 packages/twenty-server/test/integration/graphql/suites/settings-permissions/workflows.integration-spec.ts diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/system-objects-permissions-requirements.constant.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/objects-with-settings-permissions-requirements.ts similarity index 79% rename from packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/system-objects-permissions-requirements.constant.ts rename to packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/objects-with-settings-permissions-requirements.ts index 27b0bab71..603fad32d 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/system-objects-permissions-requirements.constant.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/objects-with-settings-permissions-requirements.ts @@ -1,6 +1,6 @@ import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; -export const SYSTEM_OBJECTS_PERMISSIONS_REQUIREMENTS = { +export const OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS = { apiKey: SettingPermissionType.API_KEYS_AND_WEBHOOKS, webhook: SettingPermissionType.API_KEYS_AND_WEBHOOKS, } as const; 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 e1dfd4e90..b6b885fe3 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 @@ -15,7 +15,7 @@ import { WorkspaceResolverBuilderMethodNames, } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; -import { SYSTEM_OBJECTS_PERMISSIONS_REQUIREMENTS } from 'src/engine/api/graphql/graphql-query-runner/constants/system-objects-permissions-requirements.constant'; +import { OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS } from 'src/engine/api/graphql/graphql-query-runner/constants/objects-with-settings-permissions-requirements'; import { GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; @@ -97,7 +97,7 @@ export abstract class GraphqlQueryBaseResolverService< featureFlagsMap[FeatureFlagKey.IS_PERMISSIONS_V2_ENABLED]; if (objectMetadataItemWithFieldMaps.isSystem === true) { - await this.validateSystemObjectPermissionsOrThrow(options); + await this.validateSettingsPermissionsOnObjectOrThrow(options); } else { if (!isPermissionsV2Enabled) await this.validateObjectRecordPermissionsOrThrow({ @@ -186,19 +186,19 @@ export abstract class GraphqlQueryBaseResolverService< } } - private async validateSystemObjectPermissionsOrThrow( + private async validateSettingsPermissionsOnObjectOrThrow( options: WorkspaceQueryRunnerOptions, ) { const { authContext, objectMetadataItemWithFieldMaps } = options; if ( - Object.keys(SYSTEM_OBJECTS_PERMISSIONS_REQUIREMENTS).includes( + Object.keys(OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS).includes( objectMetadataItemWithFieldMaps.nameSingular, ) ) { const permissionRequired: SettingPermissionType = // @ts-expect-error legacy noImplicitAny - SYSTEM_OBJECTS_PERMISSIONS_REQUIREMENTS[ + OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS[ objectMetadataItemWithFieldMaps.nameSingular ]; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.explorer.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.explorer.ts index f16d0c0eb..e56a17403 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.explorer.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.explorer.ts @@ -162,6 +162,10 @@ export class WorkspaceQueryHookExplorer implements OnModuleInit { { req: { workspaceId: executeParams?.[0].workspace.id, + userWorkspaceId: executeParams?.[0].userWorkspaceId, + apiKey: executeParams?.[0].apiKey, + workspaceMemberId: executeParams?.[0].workspaceMemberId, + user: executeParams?.[0].user, }, }, contextId, diff --git a/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts b/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts index 01664cdbe..b0f664cce 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts @@ -14,7 +14,8 @@ import { isDefined } from 'twenty-shared/utils'; import { WorkflowTriggerRestApiExceptionFilter } from 'src/engine/core-modules/workflow/filters/workflow-trigger-rest-api-exception.filter'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkflowVersionStatus, WorkflowVersionWorkspaceEntity, @@ -28,10 +29,13 @@ import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service'; @Controller('webhooks') -@UseFilters(WorkflowTriggerRestApiExceptionFilter) +@UseFilters( + WorkflowTriggerRestApiExceptionFilter, + PermissionsGraphqlApiExceptionFilter, +) export class WorkflowTriggerController { constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly workflowTriggerWorkspaceService: WorkflowTriggerWorkspaceService, ) {} @@ -68,8 +72,10 @@ export class WorkflowTriggerController { workspaceId: string; }) { const workflowRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflow', + { shouldBypassPermissionChecks: true }, ); const workflow = await workflowRepository.findOne({ @@ -94,8 +100,10 @@ export class WorkflowTriggerController { } const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowVersion', + { shouldBypassPermissionChecks: true }, ); const workflowVersion = await workflowVersionRepository.findOne({ where: { id: workflow.lastPublishedVersionId }, diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-builder.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-builder.resolver.ts index 958e70895..ac9766493 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-builder.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-builder.resolver.ts @@ -7,14 +7,24 @@ import { ComputeStepOutputSchemaInput } from 'src/engine/core-modules/workflow/d import { WorkflowTriggerGraphqlApiExceptionFilter } from 'src/engine/core-modules/workflow/filters/workflow-trigger-graphql-api-exception.filter'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; +import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type'; import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; @Resolver() -@UseGuards(WorkspaceAuthGuard, UserAuthGuard) -@UseFilters(WorkflowTriggerGraphqlApiExceptionFilter) +@UseGuards( + WorkspaceAuthGuard, + UserAuthGuard, + SettingsPermissionsGuard(SettingPermissionType.WORKFLOWS), +) +@UseFilters( + WorkflowTriggerGraphqlApiExceptionFilter, + PermissionsGraphqlApiExceptionFilter, +) export class WorkflowBuilderResolver { constructor( private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService, diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts index 33b1f1f37..d64747046 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-step.resolver.ts @@ -1,4 +1,4 @@ -import { UseGuards } from '@nestjs/common'; +import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto'; @@ -9,13 +9,21 @@ import { UpdateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; +import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service'; import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; @Resolver() -@UseGuards(WorkspaceAuthGuard, UserAuthGuard) +@UseGuards( + WorkspaceAuthGuard, + UserAuthGuard, + SettingsPermissionsGuard(SettingPermissionType.WORKFLOWS), +) +@UseFilters(PermissionsGraphqlApiExceptionFilter) export class WorkflowStepResolver { constructor( private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService, @@ -78,10 +86,12 @@ export class WorkflowStepResolver { @Mutation(() => WorkflowActionDTO) async updateWorkflowRunStep( + @AuthWorkspace() { id: workspaceId }: Workspace, @Args('input') { workflowRunId, step }: UpdateWorkflowRunStepInput, ): Promise { await this.workflowRunWorkspaceService.updateWorkflowRunStep({ + workspaceId, workflowRunId, step, }); diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts index 50bf9010d..f988a08ba 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts @@ -1,20 +1,30 @@ import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { buildCreatedByFromFullNameMetadata } from 'src/engine/core-modules/actor/utils/build-created-by-from-full-name-metadata.util'; import { User } from 'src/engine/core-modules/user/user.entity'; import { RunWorkflowVersionInput } from 'src/engine/core-modules/workflow/dtos/run-workflow-version-input.dto'; import { WorkflowRunDTO } from 'src/engine/core-modules/workflow/dtos/workflow-run.dto'; import { WorkflowTriggerGraphqlApiExceptionFilter } from 'src/engine/core-modules/workflow/filters/workflow-trigger-graphql-api-exception.filter'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator'; +import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; +import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service'; -import { buildCreatedByFromFullNameMetadata } from 'src/engine/core-modules/actor/utils/build-created-by-from-full-name-metadata.util'; @Resolver() -@UseGuards(WorkspaceAuthGuard, UserAuthGuard) -@UseFilters(WorkflowTriggerGraphqlApiExceptionFilter) +@UseGuards( + WorkspaceAuthGuard, + UserAuthGuard, + SettingsPermissionsGuard(SettingPermissionType.WORKFLOWS), +) +@UseFilters( + WorkflowTriggerGraphqlApiExceptionFilter, + PermissionsGraphqlApiExceptionFilter, +) export class WorkflowTriggerResolver { constructor( private readonly workflowTriggerWorkspaceService: WorkflowTriggerWorkspaceService, diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts index c19093777..c35d3fff9 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts @@ -1,16 +1,24 @@ -import { UseGuards } from '@nestjs/common'; +import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { CreateDraftFromWorkflowVersionInput } from 'src/engine/core-modules/workflow/dtos/create-draft-from-workflow-version-input'; import { WorkflowVersionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version.dto'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; +import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service'; @Resolver() -@UseGuards(WorkspaceAuthGuard, UserAuthGuard) +@UseGuards( + WorkspaceAuthGuard, + UserAuthGuard, + SettingsPermissionsGuard(SettingPermissionType.WORKFLOWS), +) +@UseFilters(PermissionsGraphqlApiExceptionFilter) export class WorkflowVersionResolver { constructor( private readonly workflowVersionWorkspaceService: WorkflowVersionWorkspaceService, diff --git a/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts b/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts index 401dada11..db43eeea5 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/workflow-api.module.ts @@ -5,6 +5,7 @@ import { WorkflowBuilderResolver } from 'src/engine/core-modules/workflow/resolv import { WorkflowStepResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-step.resolver'; import { WorkflowTriggerResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver'; import { WorkflowVersionResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-version.resolver'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowBuilderModule } from 'src/modules/workflow/workflow-builder/workflow-builder.module'; import { WorkflowVersionModule } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.module'; @@ -18,6 +19,7 @@ import { WorkflowTriggerModule } from 'src/modules/workflow/workflow-trigger/wor WorkflowCommonModule, WorkflowVersionModule, WorkflowRunModule, + PermissionsModule, ], controllers: [WorkflowTriggerController], providers: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/setting-permission-type.constants.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/setting-permission-type.constants.ts index 415e34dac..2368d8675 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/setting-permission-type.constants.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/setting-permission-type.constants.ts @@ -6,4 +6,5 @@ export enum SettingPermissionType { DATA_MODEL = 'DATA_MODEL', ADMIN_PANEL = 'ADMIN_PANEL', SECURITY = 'SECURITY', + WORKFLOWS = 'WORKFLOWS', } diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts index b43ed48e8..a63b9e143 100644 --- a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts @@ -84,7 +84,7 @@ export class RemoteServerService { const createdRemoteServer = entityManager.create( RemoteServerEntity, remoteServerToCreate, - ); + ) as RemoteServerEntity; const foreignDataWrapperQuery = this.foreignDataWrapperServerQueryFactory.createForeignDataWrapperServer( 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 e4a10cf44..b777daa38 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 @@ -11,6 +11,7 @@ import { In, Repository } from 'typeorm'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/workspace-permissions-cache/types/user-workspace-role-map.type'; @@ -18,6 +19,7 @@ import { WorkspacePermissionsCacheStorageService } from 'src/engine/metadata-mod import { TwentyORMExceptionCode } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; import { getFromCacheWithRecompute } from 'src/engine/utils/get-data-from-cache-with-recompute.util'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; type CacheResult = { version: T; @@ -244,7 +246,7 @@ export class WorkspacePermissionsCacheService { workspaceId, ...(roleIds ? { id: In(roleIds) } : {}), }, - relations: ['objectPermissions'], + relations: ['objectPermissions', 'settingPermissions'], }); const workspaceObjectMetadataCollection = @@ -256,7 +258,7 @@ export class WorkspacePermissionsCacheService { const objectRecordsPermissions: ObjectRecordsPermissions = {}; for (const objectMetadata of workspaceObjectMetadataCollection) { - const { id: objectMetadataId, isSystem } = objectMetadata; + const { id: objectMetadataId, isSystem, standardId } = objectMetadata; let canRead = role.canReadAllObjectRecords; let canUpdate = role.canUpdateAllObjectRecords; @@ -264,32 +266,48 @@ export class WorkspacePermissionsCacheService { let canDestroy = role.canDestroyAllObjectRecords; if (isPermissionsV2Enabled) { - const objectRecordPermissionsOverride = role.objectPermissions.find( - (objectPermission) => - objectPermission.objectMetadataId === objectMetadataId, - ); + if ( + standardId && + [ + STANDARD_OBJECT_IDS.workflow, + STANDARD_OBJECT_IDS.workflowRun, + STANDARD_OBJECT_IDS.workflowVersion, + ].includes(standardId) + ) { + const hasWorkflowsPermissions = this.hasWorkflowsPermissions(role); - const getPermissionValue = ( - overrideValue: boolean | undefined, - defaultValue: boolean, - ) => (isSystem ? true : (overrideValue ?? defaultValue)); + canRead = hasWorkflowsPermissions; + canUpdate = hasWorkflowsPermissions; + canSoftDelete = hasWorkflowsPermissions; + canDestroy = hasWorkflowsPermissions; + } else { + const objectRecordPermissionsOverride = role.objectPermissions.find( + (objectPermission) => + objectPermission.objectMetadataId === objectMetadataId, + ); - canRead = getPermissionValue( - objectRecordPermissionsOverride?.canReadObjectRecords, - canRead, - ); - canUpdate = getPermissionValue( - objectRecordPermissionsOverride?.canUpdateObjectRecords, - canUpdate, - ); - canSoftDelete = getPermissionValue( - objectRecordPermissionsOverride?.canSoftDeleteObjectRecords, - canSoftDelete, - ); - canDestroy = getPermissionValue( - objectRecordPermissionsOverride?.canDestroyObjectRecords, - canDestroy, - ); + const getPermissionValue = ( + overrideValue: boolean | undefined, + defaultValue: boolean, + ) => (isSystem ? true : (overrideValue ?? defaultValue)); + + canRead = getPermissionValue( + objectRecordPermissionsOverride?.canReadObjectRecords, + canRead, + ); + canUpdate = getPermissionValue( + objectRecordPermissionsOverride?.canUpdateObjectRecords, + canUpdate, + ); + canSoftDelete = getPermissionValue( + objectRecordPermissionsOverride?.canSoftDeleteObjectRecords, + canSoftDelete, + ); + canDestroy = getPermissionValue( + objectRecordPermissionsOverride?.canDestroyObjectRecords, + canDestroy, + ); + } } objectRecordsPermissions[objectMetadataId] = { @@ -298,9 +316,9 @@ export class WorkspacePermissionsCacheService { canSoftDelete, canDestroy, }; - } - permissionsByRoleId[role.id] = objectRecordsPermissions; + permissionsByRoleId[role.id] = objectRecordsPermissions; + } } return permissionsByRoleId; @@ -313,7 +331,7 @@ export class WorkspacePermissionsCacheService { where: { workspaceId, }, - select: ['id', 'isSystem'], + select: ['id', 'isSystem', 'standardId'], }); return workspaceObjectMetadata; @@ -336,4 +354,19 @@ export class WorkspacePermissionsCacheService { return acc; }, {} as UserWorkspaceRoleMap); } + + private hasWorkflowsPermissions(role: RoleEntity): boolean { + const hasWorkflowsPermissionFromRole = role.canUpdateAllSettings; + const hasWorkflowsPermissionsFromSettingPermissions = isDefined( + role.settingPermissions.find( + (settingPermission) => + settingPermission.setting === SettingPermissionType.WORKFLOWS, + ), + ); + + return ( + hasWorkflowsPermissionFromRole || + hasWorkflowsPermissionsFromSettingPermissions + ); + } } diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-delete-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-delete-one.pre-query.hook.ts index db24ddcf8..af67f09df 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-delete-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-delete-one.pre-query.hook.ts @@ -14,11 +14,14 @@ export class WorkflowVersionDeleteOnePreQueryHook ) {} async execute( - _authContext: AuthContext, + authContext: AuthContext, _objectName: string, payload: DeleteOneResolverArgs, ): Promise { + const { workspace } = authContext; + await this.workflowVersionValidationWorkspaceService.validateWorkflowVersionForDeleteOne( + workspace.id, payload, ); diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-update-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-update-one.pre-query.hook.ts index acd50865a..0eb63902f 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-update-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-version-update-one.pre-query.hook.ts @@ -15,12 +15,17 @@ export class WorkflowVersionUpdateOnePreQueryHook ) {} async execute( - _authContext: AuthContext, + authContext: AuthContext, _objectName: string, payload: UpdateOneResolverArgs, ): Promise> { + const { workspace } = authContext; + await this.workflowVersionValidationWorkspaceService.validateWorkflowVersionForUpdateOne( - payload, + { + workspaceId: workspace.id, + payload, + }, ); return payload; diff --git a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts index 8d90c86b9..7b7fa534a 100644 --- a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; +import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { WorkflowCommonException, @@ -18,7 +19,6 @@ import { WorkflowTriggerException, WorkflowTriggerExceptionCode, } from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception'; -import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; export type ObjectMetadataInfo = { objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps; @@ -28,14 +28,18 @@ export type ObjectMetadataInfo = { @Injectable() export class WorkflowCommonWorkspaceService { constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly serverlessFunctionService: ServerlessFunctionService, private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, ) {} - async getWorkflowVersionOrFail( - workflowVersionId: string, - ): Promise { + async getWorkflowVersionOrFail({ + workspaceId, + workflowVersionId, + }: { + workspaceId: string; + workflowVersionId: string; + }): Promise { if (!workflowVersionId) { throw new WorkflowTriggerException( 'Workflow version ID is required', @@ -44,8 +48,10 @@ export class WorkflowCommonWorkspaceService { } const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowVersion', + { shouldBypassPermissionChecks: true }, // settings permissions are checked at resolver-level ); const workflowVersion = await workflowVersionRepository.findOne({ @@ -124,18 +130,24 @@ export class WorkflowCommonWorkspaceService { operation: 'restore' | 'delete' | 'destroy'; }): Promise { const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowVersion', + { shouldBypassPermissionChecks: true }, // settings permissions are checked at resolver-level ); const workflowRunRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowRun', + { shouldBypassPermissionChecks: true }, // settings permissions are checked at resolver-level ); const workflowAutomatedTriggerRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowAutomatedTrigger', + { shouldBypassPermissionChecks: true }, // settings permissions are checked at resolver-level ); for (const workflowId of workflowIds) { diff --git a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-version-validation.workspace-service.ts b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-version-validation.workspace-service.ts index 94b4291db..9d005dd2f 100644 --- a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-version-validation.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-version-validation.workspace-service.ts @@ -8,7 +8,7 @@ import { UpdateOneResolverArgs, } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkflowQueryValidationException, WorkflowQueryValidationExceptionCode, @@ -24,10 +24,11 @@ import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/work export class WorkflowVersionValidationWorkspaceService { constructor( private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, ) {} async validateWorkflowVersionForCreateOne( + workspaceId: string, payload: CreateOneResolverArgs, ) { if ( @@ -41,8 +42,10 @@ export class WorkflowVersionValidationWorkspaceService { } const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowVersion', + { shouldBypassPermissionChecks: true }, // settings permissions are checked at resolver-level ); const workflowAlreadyHasDraftVersion = @@ -63,13 +66,18 @@ export class WorkflowVersionValidationWorkspaceService { } } - async validateWorkflowVersionForUpdateOne( - payload: UpdateOneResolverArgs, - ) { + async validateWorkflowVersionForUpdateOne({ + workspaceId, + payload, + }: { + workspaceId: string; + payload: UpdateOneResolverArgs; + }) { const workflowVersion = - await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail( - payload.id, - ); + await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({ + workspaceId, + workflowVersionId: payload.id, + }); // If the only field updated is the name, we can update the workflow version // Otherwise, we need to assert that the workflow version is a draft @@ -93,17 +101,23 @@ export class WorkflowVersionValidationWorkspaceService { } } - async validateWorkflowVersionForDeleteOne(payload: DeleteOneResolverArgs) { + async validateWorkflowVersionForDeleteOne( + workspaceId: string, + payload: DeleteOneResolverArgs, + ) { const workflowVersion = - await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail( - payload.id, - ); + await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({ + workspaceId, + workflowVersionId: payload.id, + }); assertWorkflowVersionIsDraft(workflowVersion); const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowVersion', + { shouldBypassPermissionChecks: true }, // settings permissions are checked at resolver-level ); const otherWorkflowVersionsExist = await workflowVersionRepository.exists({ diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts index 01c3b1a47..6a9d70229 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts @@ -10,7 +10,7 @@ import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkflowVersionStepException, WorkflowVersionStepExceptionCode, @@ -47,7 +47,7 @@ const BASE_STEP_DEFINITION: BaseWorkflowActionSettings = { @Injectable() export class WorkflowVersionStepWorkspaceService { constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService, private readonly serverlessFunctionService: ServerlessFunctionService, @InjectRepository(ObjectMetadataEntity, 'core') @@ -74,7 +74,8 @@ export class WorkflowVersionStepWorkspaceService { workspaceId, }); const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowVersion', ); @@ -118,8 +119,10 @@ export class WorkflowVersionStepWorkspaceService { step: WorkflowAction; }): Promise { const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowVersion', + { shouldBypassPermissionChecks: true }, ); const workflowVersion = await workflowVersionRepository.findOne({ @@ -174,8 +177,10 @@ export class WorkflowVersionStepWorkspaceService { stepIdToDelete: string; }): Promise { const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowVersion', + { shouldBypassPermissionChecks: true }, ); const workflowVersion = await workflowVersionRepository.findOne({ @@ -279,9 +284,10 @@ export class WorkflowVersionStepWorkspaceService { response: object; }) { const workflowRun = - await this.workflowRunWorkspaceService.getWorkflowRunOrFail( + await this.workflowRunWorkspaceService.getWorkflowRunOrFail({ workflowRunId, - ); + workspaceId, + }); const step = workflowRun.output?.flow?.steps?.find( (step) => step.id === stepId, @@ -302,6 +308,7 @@ export class WorkflowVersionStepWorkspaceService { } const enrichedResponse = await this.enrichFormStepResponse({ + workspaceId, step, response, }); @@ -319,6 +326,7 @@ export class WorkflowVersionStepWorkspaceService { }; await this.workflowRunWorkspaceService.saveWorkflowRunState({ + workspaceId, workflowRunId, stepOutput: newStepOutput, context: updatedContext, @@ -554,9 +562,11 @@ export class WorkflowVersionStepWorkspaceService { } private async enrichFormStepResponse({ + workspaceId, step, response, }: { + workspaceId: string; step: WorkflowFormAction; response: object; }) { @@ -580,9 +590,11 @@ export class WorkflowVersionStepWorkspaceService { // @ts-expect-error legacy noImplicitAny isValidUuid(response[key].id) ) { - const repository = await this.twentyORMManager.getRepository( - field.settings.objectName, - ); + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + field.settings.objectName, + ); const record = await repository.findOne({ // @ts-expect-error legacy noImplicitAny diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts index b6e09645d..0c23af39c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts @@ -7,7 +7,7 @@ import { Repository } from 'typeorm'; import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkflowVersionStepException, @@ -26,7 +26,7 @@ import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow- @Injectable() export class WorkflowVersionWorkspaceService { constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService, @InjectRepository(ObjectMetadataEntity, 'core') private readonly objectMetadataRepository: Repository, @@ -44,8 +44,10 @@ export class WorkflowVersionWorkspaceService { workflowVersionIdToCopy: string; }) { const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowVersion', + { shouldBypassPermissionChecks: true }, ); const workflowVersionToCopy = await workflowVersionRepository.findOne({ diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts index d23b53d18..137011409 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts @@ -11,7 +11,7 @@ import { RecordInputTransformerService } from 'src/engine/core-modules/record-tr import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { @@ -31,7 +31,7 @@ import { WorkflowCreateRecordActionInput } from 'src/modules/workflow/workflow-e @Injectable() export class CreateRecordWorkflowAction implements WorkflowExecutor { constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, @InjectRepository(ObjectMetadataEntity, 'core') private readonly objectMetadataRepository: Repository, private readonly workspaceEventEmitter: WorkspaceEventEmitter, @@ -76,9 +76,12 @@ export class CreateRecordWorkflowAction implements WorkflowExecutor { context, ) as WorkflowCreateRecordActionInput; - const repository = await this.twentyORMManager.getRepository( - workflowActionInput.objectName, - ); + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + workflowActionInput.objectName, + { shouldBypassPermissionChecks: true }, + ); const objectMetadata = await this.objectMetadataRepository.findOne({ where: { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts index 5c14e5409..904d651e0 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action.ts @@ -10,7 +10,7 @@ import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfa import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkflowStepExecutorException, @@ -29,7 +29,7 @@ import { WorkflowDeleteRecordActionInput } from 'src/modules/workflow/workflow-e @Injectable() export class DeleteRecordWorkflowAction implements WorkflowExecutor { constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, @InjectRepository(ObjectMetadataEntity, 'core') private readonly objectMetadataRepository: Repository, private readonly workspaceEventEmitter: WorkspaceEventEmitter, @@ -72,10 +72,6 @@ export class DeleteRecordWorkflowAction implements WorkflowExecutor { ); } - const repository = await this.twentyORMManager.getRepository( - workflowActionInput.objectName, - ); - const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId; if (!workspaceId) { @@ -85,6 +81,13 @@ export class DeleteRecordWorkflowAction implements WorkflowExecutor { ); } + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + workflowActionInput.objectName, + { shouldBypassPermissionChecks: true }, + ); + const objectMetadata = await this.objectMetadataRepository.findOne({ where: { nameSingular: workflowActionInput.objectName, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts index 71e7bf003..1b9cec496 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts @@ -16,7 +16,7 @@ import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/typ import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { @@ -36,7 +36,7 @@ import { WorkflowFindRecordsActionInput } from 'src/modules/workflow/workflow-ex @Injectable() export class FindRecordsWorkflowAction implements WorkflowExecutor { constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, ) {} @@ -67,10 +67,6 @@ export class FindRecordsWorkflowAction implements WorkflowExecutor { context, ) as WorkflowFindRecordsActionInput; - const repository = await this.twentyORMManager.getRepository( - workflowActionInput.objectName, - ); - const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId; if (!workspaceId) { @@ -80,6 +76,13 @@ export class FindRecordsWorkflowAction implements WorkflowExecutor { ); } + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + workflowActionInput.objectName, + { shouldBypassPermissionChecks: true }, + ); + const { objectMetadataItemWithFieldsMaps, objectMetadataMaps } = await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( workflowActionInput.objectName, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts index 116cfe382..eb5a9fee6 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts @@ -12,7 +12,7 @@ import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; @@ -33,7 +33,7 @@ import { WorkflowUpdateRecordActionInput } from 'src/modules/workflow/workflow-e @Injectable() export class UpdateRecordWorkflowAction implements WorkflowExecutor { constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, @InjectRepository(ObjectMetadataEntity, 'core') private readonly objectMetadataRepository: Repository, @@ -79,10 +79,6 @@ export class UpdateRecordWorkflowAction implements WorkflowExecutor { ); } - const repository = await this.twentyORMManager.getRepository( - workflowActionInput.objectName, - ); - const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId; if (!workspaceId) { @@ -92,6 +88,13 @@ export class UpdateRecordWorkflowAction implements WorkflowExecutor { ); } + const repository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + workflowActionInput.objectName, + { shouldBypassPermissionChecks: true }, + ); + const objectMetadata = await this.objectMetadataRepository.findOne({ where: { nameSingular: workflowActionInput.objectName, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts index 5ddad1d8a..86f356051 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/__tests__/workflow-executor.workspace-service.spec.ts @@ -18,7 +18,6 @@ describe('WorkflowExecutorWorkspaceService', () => { let service: WorkflowExecutorWorkspaceService; let workflowExecutorFactory: WorkflowExecutorFactory; let workspaceEventEmitter: WorkspaceEventEmitter; - let scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory; let workflowRunWorkspaceService: WorkflowRunWorkspaceService; const mockWorkflowExecutor = { @@ -86,9 +85,6 @@ describe('WorkflowExecutorWorkspaceService', () => { workspaceEventEmitter = module.get( WorkspaceEventEmitter, ); - scopedWorkspaceContextFactory = module.get( - ScopedWorkspaceContextFactory, - ); workflowRunWorkspaceService = module.get( WorkflowRunWorkspaceService, ); @@ -185,6 +181,7 @@ describe('WorkflowExecutorWorkspaceService', () => { data: 'some-data', 'step-1': { stepOutput: 'success' }, }, + workspaceId: 'workspace-id', }); expect(result).toEqual({ result: { success: true } }); @@ -221,6 +218,7 @@ describe('WorkflowExecutorWorkspaceService', () => { }, }, context: mockContext, + workspaceId: 'workspace-id', }); }); @@ -248,6 +246,7 @@ describe('WorkflowExecutorWorkspaceService', () => { output: mockPendingEvent, }, context: mockContext, + workspaceId: 'workspace-id', }); // No recursive call to execute should happen @@ -304,6 +303,7 @@ describe('WorkflowExecutorWorkspaceService', () => { }, }, context: mockContext, + workspaceId: 'workspace-id', }); expect(result).toEqual({ result: { success: true } }); @@ -387,6 +387,7 @@ describe('WorkflowExecutorWorkspaceService', () => { output: errorOutput, }, context: mockContext, + workspaceId: 'workspace-id', }); expect(result).toEqual(errorOutput); }); @@ -414,6 +415,7 @@ describe('WorkflowExecutorWorkspaceService', () => { }, }, context: mockContext, + workspaceId: 'workspace-id', }); expect(result).toEqual({ error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE, @@ -423,9 +425,8 @@ describe('WorkflowExecutorWorkspaceService', () => { describe('sendWorkflowNodeRunEvent', () => { it('should emit a billing event', () => { - service['sendWorkflowNodeRunEvent'](); + service['sendWorkflowNodeRunEvent']('workspace-id'); - expect(scopedWorkspaceContextFactory.create).toHaveBeenCalled(); expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith( BILLING_FEATURE_USED, [ @@ -437,24 +438,5 @@ describe('WorkflowExecutorWorkspaceService', () => { 'workspace-id', ); }); - - it('should handle missing workspace ID', () => { - mockScopedWorkspaceContextFactory.create.mockReturnValueOnce({ - workspaceId: null, - }); - - service['sendWorkflowNodeRunEvent'](); - - expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith( - BILLING_FEATURE_USED, - [ - { - eventName: BillingMeterEventName.WORKFLOW_NODE_RUN, - value: 1, - }, - ], - '', - ); - }); }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts index e3d2edbac..ef5e781dd 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service.ts @@ -21,6 +21,10 @@ import { WorkflowExecutorFactory } from 'src/modules/workflow/workflow-executor/ import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input'; import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type'; import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service'; +import { + WorkflowTriggerException, + WorkflowTriggerExceptionCode, +} from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception'; const MAX_RETRIES_ON_FAILURE = 3; @@ -58,15 +62,25 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor { let actionOutput: WorkflowExecutorOutput; + const { workspaceId } = this.scopedWorkspaceContextFactory.create(); + + if (!workspaceId) { + throw new WorkflowTriggerException( + 'No workspace id found', + WorkflowTriggerExceptionCode.INTERNAL_ERROR, + ); + } + if ( this.billingService.isBillingEnabled() && - !(await this.canBillWorkflowNodeExecution()) + !(await this.canBillWorkflowNodeExecution(workspaceId)) ) { const billingOutput = { error: BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE, }; await this.workflowRunWorkspaceService.saveWorkflowRunState({ + workspaceId, workflowRunId, stepOutput: { id: step.id, @@ -93,7 +107,7 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor { } if (!actionOutput.error) { - this.sendWorkflowNodeRunEvent(); + this.sendWorkflowNodeRunEvent(workspaceId); } const stepOutput: StepOutput = { @@ -106,6 +120,7 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor { workflowRunId, stepOutput, context, + workspaceId, }); return actionOutput; @@ -127,6 +142,7 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor { workflowRunId, stepOutput, context: updatedContext, + workspaceId, }); if (!isDefined(step.nextStepIds?.[0])) { @@ -159,15 +175,13 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor { workflowRunId, stepOutput, context, + workspaceId, }); return actionOutput; } - private sendWorkflowNodeRunEvent() { - const workspaceId = - this.scopedWorkspaceContextFactory.create().workspaceId ?? ''; - + private sendWorkflowNodeRunEvent(workspaceId: string) { this.workspaceEventEmitter.emitCustomBatchEvent( BILLING_FEATURE_USED, [ @@ -180,10 +194,7 @@ export class WorkflowExecutorWorkspaceService implements WorkflowExecutor { ); } - private async canBillWorkflowNodeExecution() { - const workspaceId = - this.scopedWorkspaceContextFactory.create().workspaceId ?? ''; - + private async canBillWorkflowNodeExecution(workspaceId: string) { return this.billingService.canBillMeteredProduct( workspaceId, BillingProductKey.WORKFLOW_NODE_EXECUTION, diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts index 3bbe7fb88..fd60c96e8 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts @@ -37,21 +37,25 @@ export class RunWorkflowJob { workflowRunId, payload, lastExecutedStepId, + workspaceId, }: RunWorkflowJobData): Promise { try { if (lastExecutedStepId) { await this.resumeWorkflowExecution({ + workspaceId, workflowRunId, lastExecutedStepId, }); } else { await this.startWorkflowExecution({ workflowRunId, + workspaceId, payload: payload ?? {}, }); } } catch (error) { await this.workflowRunWorkspaceService.endWorkflowRun({ + workspaceId, workflowRunId, status: WorkflowRunStatus.FAILED, error: error.message, @@ -61,9 +65,11 @@ export class RunWorkflowJob { private async startWorkflowExecution({ workflowRunId, + workspaceId, payload, }: { workflowRunId: string; + workspaceId: string; payload: object; }): Promise { const context = { @@ -71,14 +77,16 @@ export class RunWorkflowJob { }; const workflowRun = - await this.workflowRunWorkspaceService.getWorkflowRunOrFail( + await this.workflowRunWorkspaceService.getWorkflowRunOrFail({ workflowRunId, - ); + workspaceId, + }); const workflowVersion = - await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail( - workflowRun.workflowVersionId, - ); + await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({ + workspaceId, + workflowVersionId: workflowRun.workflowVersionId, + }); if (!workflowVersion.trigger || !workflowVersion.steps) { throw new WorkflowRunException( @@ -89,6 +97,7 @@ export class RunWorkflowJob { await this.workflowRunWorkspaceService.startWorkflowRun({ workflowRunId, + workspaceId, context, output: { flow: { @@ -110,20 +119,24 @@ export class RunWorkflowJob { currentStepId: workflowVersion.steps[0].id, steps: workflowVersion.steps, context, + workspaceId, }); } private async resumeWorkflowExecution({ workflowRunId, lastExecutedStepId, + workspaceId, }: { workflowRunId: string; lastExecutedStepId: string; + workspaceId: string; }): Promise { const workflowRun = - await this.workflowRunWorkspaceService.getWorkflowRunOrFail( + await this.workflowRunWorkspaceService.getWorkflowRunOrFail({ workflowRunId, - ); + workspaceId, + }); if (workflowRun.status !== WorkflowRunStatus.RUNNING) { throw new WorkflowRunException( @@ -148,6 +161,7 @@ export class RunWorkflowJob { if (!nextStepId) { await this.workflowRunWorkspaceService.endWorkflowRun({ workflowRunId, + workspaceId, status: WorkflowRunStatus.COMPLETED, }); @@ -159,6 +173,7 @@ export class RunWorkflowJob { currentStepId: nextStepId, steps: workflowRun.output?.flow?.steps ?? [], context: workflowRun.context ?? {}, + workspaceId, }); } @@ -167,12 +182,14 @@ export class RunWorkflowJob { currentStepId, steps, context, + workspaceId, }: { workflowRunId: string; currentStepId: string; steps: WorkflowAction[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any context: Record; + workspaceId: string; }) { const { error, pendingEvent } = await this.workflowExecutorWorkspaceService.execute({ @@ -188,6 +205,7 @@ export class RunWorkflowJob { await this.workflowRunWorkspaceService.endWorkflowRun({ workflowRunId, + workspaceId, status: error ? WorkflowRunStatus.FAILED : WorkflowRunStatus.COMPLETED, error, }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service.ts index e54a3f4f1..dab4205e7 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service.ts @@ -9,7 +9,7 @@ import { RecordPositionService } from 'src/engine/core-modules/record-position/s import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { StepOutput, @@ -17,7 +17,6 @@ import { WorkflowRunStatus, WorkflowRunWorkspaceEntity, } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity'; -import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { @@ -28,7 +27,7 @@ import { @Injectable() export class WorkflowRunWorkspaceService { constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, private readonly workspaceEventEmitter: WorkspaceEventEmitter, @@ -44,19 +43,34 @@ export class WorkflowRunWorkspaceService { workflowVersionId: string; createdBy: ActorMetadata; }) { + const workspaceId = + this.scopedWorkspaceContextFactory.create()?.workspaceId; + + if (!workspaceId) { + throw new WorkflowRunException( + 'Workspace id is invalid', + WorkflowRunExceptionCode.WORKFLOW_RUN_INVALID, + ); + } + const workflowRunRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowRun', + { shouldBypassPermissionChecks: true }, ); const workflowVersion = - await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail( + await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({ + workspaceId, workflowVersionId, - ); + }); const workflowRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflow', + { shouldBypassPermissionChecks: true }, ); const workflow = await workflowRepository.findOne({ @@ -78,16 +92,6 @@ export class WorkflowRunWorkspaceService { }, }); - const workspaceId = - this.scopedWorkspaceContextFactory.create()?.workspaceId; - - if (!workspaceId) { - throw new WorkflowRunException( - 'Workspace id is invalid', - WorkflowRunExceptionCode.WORKFLOW_RUN_INVALID, - ); - } - const position = await this.recordPositionService.buildRecordPosition({ value: 'first', objectMetadata: { @@ -111,17 +115,21 @@ export class WorkflowRunWorkspaceService { async startWorkflowRun({ workflowRunId, + workspaceId, context, output, }: { workflowRunId: string; + workspaceId: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any context: Record; output: WorkflowRunOutput; }) { const workflowRunRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowRun', + { shouldBypassPermissionChecks: true }, ); const workflowRunToUpdate = await workflowRunRepository.findOneBy({ @@ -159,16 +167,20 @@ export class WorkflowRunWorkspaceService { async endWorkflowRun({ workflowRunId, + workspaceId, status, error, }: { workflowRunId: string; + workspaceId: string; status: WorkflowRunStatus; error?: string; }) { const workflowRunRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowRun', + { shouldBypassPermissionChecks: true }, ); const workflowRunToUpdate = await workflowRunRepository.findOneBy({ @@ -202,16 +214,20 @@ export class WorkflowRunWorkspaceService { async saveWorkflowRunState({ workflowRunId, stepOutput, + workspaceId, context, }: { workflowRunId: string; stepOutput: StepOutput; + workspaceId: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any context: Record; }) { const workflowRunRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowRun', + { shouldBypassPermissionChecks: true }, ); const workflowRunToUpdate = await workflowRunRepository.findOneBy({ @@ -250,13 +266,17 @@ export class WorkflowRunWorkspaceService { async updateWorkflowRunStep({ workflowRunId, step, + workspaceId, }: { workflowRunId: string; step: WorkflowAction; + workspaceId: string; }) { const workflowRunRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowRun', + { shouldBypassPermissionChecks: true }, ); const workflowRunToUpdate = await workflowRunRepository.findOneBy({ @@ -302,12 +322,18 @@ export class WorkflowRunWorkspaceService { }); } - async getWorkflowRunOrFail( - workflowRunId: string, - ): Promise { + async getWorkflowRunOrFail({ + workflowRunId, + workspaceId, + }: { + workflowRunId: string; + workspaceId: string; + }): Promise { const workflowRunRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowRun', + { shouldBypassPermissionChecks: true }, ); const workflowRun = await workflowRunRepository.findOne({ @@ -353,8 +379,10 @@ export class WorkflowRunWorkspaceService { } const workflowRunRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowRun', + { shouldBypassPermissionChecks: true }, ); const workflowRunAfter = await workflowRunRepository.findOneBy({ diff --git a/packages/twenty-server/src/modules/workflow/workflow-status/jobs/__tests__/workflow-statuses-update.job.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-status/jobs/__tests__/workflow-statuses-update.job.spec.ts index 28f44ab48..1b5ae934e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-status/jobs/__tests__/workflow-statuses-update.job.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-status/jobs/__tests__/workflow-statuses-update.job.spec.ts @@ -4,7 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; @@ -28,17 +28,25 @@ describe('WorkflowStatusesUpdate', () => { update: jest.fn(), }; - const mockTwentyORMManager = { - getRepository: jest.fn().mockImplementation((entity) => { - if (entity === 'workflow') { - return Promise.resolve(mockWorkflowRepository); - } - if (entity === 'workflowVersion') { - return Promise.resolve(mockWorkflowVersionRepository); - } + const mockTwentyORMGlobalManager = { + getRepositoryForWorkspace: jest + .fn() + .mockImplementation((_workspaceId, entity, options) => { + if (!options?.shouldBypassPermissionChecks) { + throw new Error( + 'Permission check will fail because job runners dont have permissions', + ); + } - return Promise.resolve(null); - }), + if (entity === 'workflow') { + return Promise.resolve(mockWorkflowRepository); + } + if (entity === 'workflowVersion') { + return Promise.resolve(mockWorkflowVersionRepository); + } + + return Promise.resolve(null); + }), }; const mockServerlessFunctionService = { @@ -55,8 +63,8 @@ describe('WorkflowStatusesUpdate', () => { providers: [ WorkflowStatusesUpdateJob, { - provide: TwentyORMManager, - useValue: mockTwentyORMManager, + provide: TwentyORMGlobalManager, + useValue: mockTwentyORMGlobalManager, }, { provide: ServerlessFunctionService, diff --git a/packages/twenty-server/src/modules/workflow/workflow-status/jobs/workflow-statuses-update.job.ts b/packages/twenty-server/src/modules/workflow/workflow-status/jobs/workflow-statuses-update.job.ts index 58e4e6871..44a1b55a7 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-status/jobs/workflow-statuses-update.job.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-status/jobs/workflow-statuses-update.job.ts @@ -14,7 +14,7 @@ import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless import { ServerlessFunctionExceptionCode } from 'src/engine/metadata-modules/serverless-function/serverless-function.exception'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkflowVersionStatus, @@ -71,7 +71,7 @@ export class WorkflowStatusesUpdateJob { protected readonly logger = new Logger(WorkflowStatusesUpdateJob.name); constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly serverlessFunctionService: ServerlessFunctionService, private readonly workspaceEventEmitter: WorkspaceEventEmitter, @InjectRepository(ObjectMetadataEntity, 'core') @@ -128,13 +128,17 @@ export class WorkflowStatusesUpdateJob { workspaceId: string; }): Promise { const workflowRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflow', + { shouldBypassPermissionChecks: true }, ); const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowVersion', + { shouldBypassPermissionChecks: true }, ); const newWorkflowStatuses = await this.getWorkflowStatuses({ @@ -248,13 +252,17 @@ export class WorkflowStatusesUpdateJob { workspaceId: string; }): Promise { const workflowRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflow', + { shouldBypassPermissionChecks: true }, ); const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflowVersion', + { shouldBypassPermissionChecks: true }, ); const workflow = await workflowRepository.findOneOrFail({ diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/database-event-trigger.listener.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/database-event-trigger.listener.ts index 5b09608ed..be4055aeb 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/database-event-trigger.listener.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/database-event-trigger.listener.ts @@ -206,6 +206,7 @@ export class DatabaseEventTriggerListener { await this.twentyORMGlobalManager.getRepositoryForWorkspace( workspaceId, relatedObjectMetadataNameSingular, + { shouldBypassPermissionChecks: true }, ); record[joinField.name] = await relatedObjectRepository.findOne({ @@ -254,6 +255,7 @@ export class DatabaseEventTriggerListener { await this.twentyORMGlobalManager.getRepositoryForWorkspace( workspaceId, automatedTriggerTableName, + { shouldBypassPermissionChecks: true }, ); const eventListeners = await workflowAutomatedTriggerRepository.find({ diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/jobs/workflow-trigger.job.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/jobs/workflow-trigger.job.ts index 816246931..53d7ec1d8 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/jobs/workflow-trigger.job.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/jobs/workflow-trigger.job.ts @@ -10,7 +10,7 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { handleWorkflowTriggerException } from 'src/engine/core-modules/workflow/filters/workflow-trigger-graphql-api-exception.filter'; import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkflowVersionStatus, WorkflowVersionWorkspaceEntity, @@ -33,7 +33,7 @@ const DEFAULT_WORKFLOW_NAME = 'Workflow'; @Processor({ queueName: MessageQueue.workflowQueue, scope: Scope.REQUEST }) export class WorkflowTriggerJob { constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly workflowRunnerWorkspaceService: WorkflowRunnerWorkspaceService, @InjectMessageQueue(MessageQueue.workflowQueue) private readonly messageQueueService: MessageQueueService, @@ -43,8 +43,10 @@ export class WorkflowTriggerJob { async handle(data: WorkflowTriggerJobData): Promise { try { const workflowRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + data.workspaceId, 'workflow', + { shouldBypassPermissionChecks: true }, ); const workflow = await workflowRepository.findOneBy({ @@ -66,8 +68,10 @@ export class WorkflowTriggerJob { } const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + data.workspaceId, 'workflowVersion', + { shouldBypassPermissionChecks: true }, ); const workflowVersion = await workflowVersionRepository.findOneBy({ diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts index 680f219b1..bc66d5a05 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts @@ -9,7 +9,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { AutomatedTriggerType } from 'src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity'; import { @@ -36,7 +36,7 @@ import { assertNever } from 'src/utils/assert'; @Injectable() export class WorkflowTriggerWorkspaceService { constructor( - private readonly twentyORMManager: TwentyORMManager, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, private readonly workflowRunnerWorkspaceService: WorkflowRunnerWorkspaceService, @@ -68,9 +68,10 @@ export class WorkflowTriggerWorkspaceService { payload: object; createdBy: ActorMetadata; }) { - await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail( + await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({ workflowVersionId, - ); + workspaceId: this.getWorkspaceId(), + }); return this.workflowRunnerWorkspaceService.run({ workspaceId: this.getWorkspaceId(), @@ -81,9 +82,12 @@ export class WorkflowTriggerWorkspaceService { } async activateWorkflowVersion(workflowVersionId: string) { + const workspaceId = this.getWorkspaceId(); const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + this.getWorkspaceId(), 'workflowVersion', + { shouldBypassPermissionChecks: true }, // settings permissions are checked at resolver-level ); const workflowVersionNullable = await workflowVersionRepository.findOne({ @@ -96,8 +100,10 @@ export class WorkflowTriggerWorkspaceService { ); const workflowRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, 'workflow', + { shouldBypassPermissionChecks: true }, // settings permissions are checked at resolver-level ); const workflow = await workflowRepository.findOne({ @@ -113,7 +119,10 @@ export class WorkflowTriggerWorkspaceService { assertVersionCanBeActivated(workflowVersion, workflow); - const workspaceDataSource = await this.twentyORMManager.getDatasource(); + const workspaceDataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace({ + workspaceId: this.getWorkspaceId(), + }); const queryRunner = workspaceDataSource.createQueryRunner(); await queryRunner.connect(); @@ -142,7 +151,10 @@ export class WorkflowTriggerWorkspaceService { } async deactivateWorkflowVersion(workflowVersionId: string) { - const workspaceDataSource = await this.twentyORMManager.getDatasource(); + const workspaceDataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace({ + workspaceId: this.getWorkspaceId(), + }); const queryRunner = workspaceDataSource.createQueryRunner(); await queryRunner.connect(); @@ -150,8 +162,10 @@ export class WorkflowTriggerWorkspaceService { try { const workflowVersionRepository = - await this.twentyORMManager.getRepository( + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + this.getWorkspaceId(), 'workflowVersion', + { shouldBypassPermissionChecks: true }, ); await this.performDeactivationSteps( diff --git a/packages/twenty-server/test/integration/constants/workflow-gql-fields.constants.ts b/packages/twenty-server/test/integration/constants/workflow-gql-fields.constants.ts new file mode 100644 index 000000000..93c955ab6 --- /dev/null +++ b/packages/twenty-server/test/integration/constants/workflow-gql-fields.constants.ts @@ -0,0 +1,15 @@ +export const WORKFLOW_GQL_FIELDS = ` + id + name + lastPublishedVersionId + statuses + position + createdBy { + source + workspaceMemberId + name + } + createdAt + updatedAt + deletedAt +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/granular-settings-permissions.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/granular-settings-permissions.integration-spec.ts similarity index 91% rename from packages/twenty-server/test/integration/graphql/suites/settings-permissions/granular-settings-permissions.ts rename to packages/twenty-server/test/integration/graphql/suites/settings-permissions/granular-settings-permissions.integration-spec.ts index f4d0a1150..0af829aef 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/granular-settings-permissions.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/granular-settings-permissions.integration-spec.ts @@ -1,6 +1,7 @@ import { print } from 'graphql'; import request from 'supertest'; import { deleteOneRoleOperationFactory } from 'test/integration/graphql/utils/delete-one-role-operation-factory.util'; +import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.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 { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util'; @@ -84,7 +85,7 @@ describe('Granular settings permissions', () => { mutation UpsertSettingPermissions { upsertSettingPermissions(upsertSettingPermissionsInput: { roleId: "${customRoleId}" - settingPermissionKeys: [${SettingPermissionType.DATA_MODEL}, ${SettingPermissionType.WORKSPACE}] + settingPermissionKeys: [${SettingPermissionType.DATA_MODEL}, ${SettingPermissionType.WORKSPACE}, ${SettingPermissionType.WORKFLOWS}] }) { id setting @@ -243,6 +244,48 @@ describe('Granular settings permissions', () => { }); }); + describe('Workflows Permissions', () => { + it('should allow access to workflows operations when user has WORKFLOWS setting permission', async () => { + // Test creating a workflow (requires WORKFLOWS permission) + const createWorkflowQuery = { + query: ` + mutation CreateWorkflow { + createWorkflow(data: { + name: "Test Workflow" + }) { + id + name + } + } + `, + }; + + const response = await client + .post('/graphql') + .set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`) + .send(createWorkflowQuery); + + expect(response.status).toBe(200); + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.createWorkflow).toBeDefined(); + expect(response.body.data.createWorkflow.name).toBe('Test Workflow'); + + // Clean up - delete the created workflow + const graphqlOperation = destroyOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: ` + id + `, + recordId: response.body.data.createWorkflow.id, + }); + + await client + .post('/graphql') + .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) + .send(graphqlOperation); + }); + }); + describe('Denied Permissions', () => { it('should deny access to roles operations when user does not have ROLES setting permission', async () => { // Test creating a role (requires ROLES permission, which our custom role doesn't have) @@ -354,7 +397,7 @@ describe('Granular settings permissions', () => { expect(customRole).toBeDefined(); expect(customRole.canUpdateAllSettings).toBe(false); - expect(customRole.settingPermissions).toHaveLength(2); + expect(customRole.settingPermissions).toHaveLength(3); expect( customRole.settingPermissions.map((p: any) => p.setting), ).toContain(SettingPermissionType.DATA_MODEL); diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workflows.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workflows.integration-spec.ts new file mode 100644 index 000000000..e07afe837 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workflows.integration-spec.ts @@ -0,0 +1,370 @@ +import { randomUUID } from 'node:crypto'; + +import { WORKFLOW_GQL_FIELDS } from 'test/integration/constants/workflow-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 { makeGraphqlAPIRequestWithApiKey } from 'test/integration/graphql/utils/make-graphql-api-request-with-api-key.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 { 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'; + +describe('workflowsPermissions', () => { + describe('createOne workflow', () => { + describe('permissions V2 disabled', () => { + it('should throw a permission error when user does not have permission (guest role)', async () => { + const workflowId = randomUUID(); + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + data: { + id: workflowId, + name: 'Test Workflow', + }, + }); + + const response = + await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ createWorkflow: 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 a workflow when user has permission (admin role)', async () => { + const workflowId = randomUUID(); + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + data: { + id: workflowId, + name: 'Test Workflow Admin', + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.createWorkflow).toBeDefined(); + expect(response.body.data.createWorkflow.id).toBe(workflowId); + expect(response.body.data.createWorkflow.name).toBe( + 'Test Workflow Admin', + ); + + // Clean up - delete the created workflow + const destroyWorkflowOperation = destroyOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: ` + id + `, + recordId: response.body.data.createWorkflow.id, + }); + + await makeGraphqlAPIRequest(destroyWorkflowOperation); + }); + }); + + describe('permissions V2 enabled', () => { + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IS_PERMISSIONS_V2_ENABLED', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IS_PERMISSIONS_V2_ENABLED', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const workflowId = randomUUID(); + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + data: { + id: workflowId, + name: 'Test Workflow V2', + }, + }); + + const response = + await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ createWorkflow: 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 a workflow when user has permission (admin role)', async () => { + const workflowId = randomUUID(); + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + data: { + id: workflowId, + name: 'Test Workflow Admin', + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.createWorkflow).toBeDefined(); + expect(response.body.data.createWorkflow.id).toBe(workflowId); + expect(response.body.data.createWorkflow.name).toBe( + 'Test Workflow Admin', + ); + + // Clean up - delete the created workflow + const destroyWorkflowOperation = destroyOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: ` + id + `, + recordId: response.body.data.createWorkflow.id, + }); + + await makeGraphqlAPIRequest(destroyWorkflowOperation); + }); + + it('should create a workflow when executed by api key', async () => { + const workflowId = randomUUID(); + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + data: { + id: workflowId, + name: 'Test Workflow API Key', + }, + }); + + const response = + await makeGraphqlAPIRequestWithApiKey(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.createWorkflow).toBeDefined(); + expect(response.body.data.createWorkflow.id).toBe(workflowId); + expect(response.body.data.createWorkflow.name).toBe( + 'Test Workflow API Key', + ); + + // Clean up - delete the created workflow + const destroyWorkflowOperation = destroyOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: ` + id + `, + recordId: response.body.data.createWorkflow.id, + }); + + await makeGraphqlAPIRequest(destroyWorkflowOperation); + }); + }); + }); + + describe('updateOne workflow', () => { + describe('permissions V2 disabled', () => { + const workflowId = randomUUID(); + + beforeAll(async () => { + const createWorkflowOperation = createOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + data: { + id: workflowId, + name: 'Original Workflow Name', + }, + }); + + await makeGraphqlAPIRequest(createWorkflowOperation); + }); + + afterAll(async () => { + const destroyWorkflowOperation = destroyOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: ` + id + `, + recordId: workflowId, + }); + + await makeGraphqlAPIRequest(destroyWorkflowOperation); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + recordId: workflowId, + data: { + name: 'Updated Workflow Name Guest', + }, + }); + + const response = + await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ updateWorkflow: 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 a workflow when user has permission (admin role)', async () => { + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + recordId: workflowId, + data: { + name: 'Updated Workflow Name Admin', + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.updateWorkflow).toBeDefined(); + expect(response.body.data.updateWorkflow.id).toBe(workflowId); + expect(response.body.data.updateWorkflow.name).toBe( + 'Updated Workflow Name Admin', + ); + }); + }); + + describe('permissions V2 enabled', () => { + const workflowId = randomUUID(); + + beforeAll(async () => { + const createWorkflowOperation = createOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + data: { + id: workflowId, + name: 'Original Workflow V2', + }, + }); + + await makeGraphqlAPIRequest(createWorkflowOperation); + + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IS_PERMISSIONS_V2_ENABLED', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + }); + + afterAll(async () => { + const destroyWorkflowOperation = destroyOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: ` + id + `, + recordId: workflowId, + }); + + await makeGraphqlAPIRequest(destroyWorkflowOperation); + + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IS_PERMISSIONS_V2_ENABLED', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission (guest role)', async () => { + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + recordId: workflowId, + data: { + name: 'Updated Workflow V2 Guest', + }, + }); + + const response = + await makeGraphqlAPIRequestWithGuestRole(graphqlOperation); + + expect(response.body.data).toStrictEqual({ updateWorkflow: 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 a workflow when user has permission (admin role)', async () => { + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + recordId: workflowId, + data: { + name: 'Updated Workflow V2 Admin', + }, + }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.updateWorkflow).toBeDefined(); + expect(response.body.data.updateWorkflow.id).toBe(workflowId); + expect(response.body.data.updateWorkflow.name).toBe( + 'Updated Workflow V2 Admin', + ); + }); + + it('should update a workflow when executed by api key', async () => { + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'workflow', + gqlFields: WORKFLOW_GQL_FIELDS, + recordId: workflowId, + data: { + name: 'Updated Workflow API Key', + }, + }); + + const response = + await makeGraphqlAPIRequestWithApiKey(graphqlOperation); + + expect(response.body.data).toBeDefined(); + expect(response.body.data.updateWorkflow).toBeDefined(); + expect(response.body.data.updateWorkflow.id).toBe(workflowId); + expect(response.body.data.updateWorkflow.name).toBe( + 'Updated Workflow API Key', + ); + }); + }); + }); +});