From d6ba6a66a4a231e91e5c6beb41f7297145e22f87 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Thu, 4 Sep 2025 10:39:51 +0200 Subject: [PATCH] Restrict workflow object permissions (#14290) - workflows should not edit system objects or workflow related objects - system fields should be usable within variables for reading --- .../WorkflowEditActionCreateRecord.tsx | 18 ++++++++---- .../WorkflowEditActionDeleteRecord.tsx | 18 ++++++++---- .../WorkflowEditActionUpdateRecord.tsx | 18 ++++++++---- .../should-generate-field-fake-value.spec.ts | 4 +-- .../utils/should-generate-field-fake-value.ts | 2 +- .../create-record.workflow-action.ts | 28 +++++++++---------- .../delete-record.workflow-action.ts | 21 ++++++++++++++ .../update-record.workflow-action.ts | 13 +++++++++ packages/twenty-shared/src/workflow/index.ts | 1 + .../utils/canObjectBeManagedByWorkflow.ts | 18 ++++++++++++ 10 files changed, 109 insertions(+), 32 deletions(-) create mode 100644 packages/twenty-shared/src/workflow/utils/canObjectBeManagedByWorkflow.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx index 41b50cb8b5..92db0be137 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx @@ -15,6 +15,7 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components import { useTheme } from '@emotion/react'; import { useEffect, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; +import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; import { HorizontalSeparator, useIcons } from 'twenty-ui/display'; import { type SelectOption } from 'twenty-ui/input'; import { type JsonValue } from 'type-fest'; @@ -73,11 +74,18 @@ export const WorkflowEditActionCreateRecord = ({ useFilteredObjectMetadataItems(); const availableMetadata: Array> = - activeNonSystemObjectMetadataItems.map((item) => ({ - Icon: getIcon(item.icon), - label: item.labelPlural, - value: item.nameSingular, - })); + activeNonSystemObjectMetadataItems + .filter((objectMetadataItem) => + canObjectBeManagedByWorkflow({ + nameSingular: objectMetadataItem.nameSingular, + isSystem: objectMetadataItem.isSystem, + }), + ) + .map((item) => ({ + Icon: getIcon(item.icon), + label: item.labelPlural, + value: item.nameSingular, + })); const [formData, setFormData] = useState({ objectName: action.settings.input.objectName, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx index ea35204b88..a7904ca8c0 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord.tsx @@ -11,6 +11,7 @@ import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-acti import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { useTheme } from '@emotion/react'; import { isDefined } from 'twenty-shared/utils'; +import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; import { HorizontalSeparator, useIcons } from 'twenty-ui/display'; import { type SelectOption } from 'twenty-ui/input'; import { type JsonValue } from 'type-fest'; @@ -45,11 +46,18 @@ export const WorkflowEditActionDeleteRecord = ({ useFilteredObjectMetadataItems(); const availableMetadata: Array> = - activeNonSystemObjectMetadataItems.map((item) => ({ - Icon: getIcon(item.icon), - label: item.labelPlural, - value: item.nameSingular, - })); + activeNonSystemObjectMetadataItems + .filter((objectMetadataItem) => + canObjectBeManagedByWorkflow({ + nameSingular: objectMetadataItem.nameSingular, + isSystem: objectMetadataItem.isSystem, + }), + ) + .map((item) => ({ + Icon: getIcon(item.icon), + label: item.labelPlural, + value: item.nameSingular, + })); const [formData, setFormData] = useState({ objectNameSingular: action.settings.input.objectName, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx index 9b6e9159f6..2911197007 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx @@ -16,6 +16,7 @@ import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actio import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { useTheme } from '@emotion/react'; import { isDefined } from 'twenty-shared/utils'; +import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; import { HorizontalSeparator, useIcons } from 'twenty-ui/display'; import { type SelectOption } from 'twenty-ui/input'; import { type JsonValue } from 'type-fest'; @@ -53,11 +54,18 @@ export const WorkflowEditActionUpdateRecord = ({ useFilteredObjectMetadataItems(); const availableMetadata: Array> = - activeNonSystemObjectMetadataItems.map((item) => ({ - Icon: getIcon(item.icon), - label: item.labelPlural, - value: item.nameSingular, - })); + activeNonSystemObjectMetadataItems + .filter((objectMetadataItem) => + canObjectBeManagedByWorkflow({ + nameSingular: objectMetadataItem.nameSingular, + isSystem: objectMetadataItem.isSystem, + }), + ) + .map((item) => ({ + Icon: getIcon(item.icon), + label: item.labelPlural, + value: item.nameSingular, + })); const [formData, setFormData] = useState({ objectNameSingular: action.settings.input.objectName, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/__tests__/should-generate-field-fake-value.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/__tests__/should-generate-field-fake-value.spec.ts index 949ec36dc1..8d7bf2de09 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/__tests__/should-generate-field-fake-value.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/__tests__/should-generate-field-fake-value.spec.ts @@ -61,7 +61,7 @@ describe('shouldGenerateFieldFakeValue', () => { expect(shouldGenerateFieldFakeValue(field)).toBe(false); }); - it('should return false for system fields (except id)', () => { + it('should return true for system fields', () => { const field = getMockFieldMetadataEntity({ workspaceId: '20202020-0000-0000-0000-000000000000', objectMetadataId: '20202020-0000-0000-0000-000000000001', @@ -77,7 +77,7 @@ describe('shouldGenerateFieldFakeValue', () => { updatedAt: new Date(), }); - expect(shouldGenerateFieldFakeValue(field)).toBe(false); + expect(shouldGenerateFieldFakeValue(field)).toBe(true); }); it('should return false for relation fields', () => { diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/should-generate-field-fake-value.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/should-generate-field-fake-value.ts index e4bdfa7dc5..dc311eee54 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/should-generate-field-fake-value.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/should-generate-field-fake-value.ts @@ -12,7 +12,7 @@ export const shouldGenerateFieldFakeValue = ( ) => { return ( field.isActive && - (!field.isSystem || field.name === 'id' || field.name === 'userEmail') && + field.name !== 'searchVector' && (field.type !== FieldMetadataType.RELATION || isManyToOneRelationField(field as unknown as FieldMetadataEntity)) ); 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 bdffdc4d5a..23a32edbc2 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 @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'class-validator'; import { resolveInput } from 'twenty-shared/utils'; +import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; import { Repository } from 'typeorm'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; @@ -66,31 +67,30 @@ export class CreateRecordWorkflowAction implements WorkflowAction { { shouldBypassPermissionChecks: true }, ); - const objectMetadata = await this.objectMetadataRepository.findOne({ - where: { - nameSingular: workflowActionInput.objectName, - }, - }); + const { objectMetadataItemWithFieldsMaps } = + await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( + workflowActionInput.objectName, + workspaceId, + ); - if (!objectMetadata) { + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: objectMetadataItemWithFieldsMaps.nameSingular, + isSystem: objectMetadataItemWithFieldsMaps.isSystem, + }) + ) { throw new RecordCRUDActionException( - 'Failed to create: Object metadata not found', + 'Failed to create: Object cannot be created by workflow', RecordCRUDActionExceptionCode.INVALID_REQUEST, ); } const position = await this.recordPositionService.buildRecordPosition({ value: 'first', - objectMetadata, + objectMetadata: objectMetadataItemWithFieldsMaps, workspaceId, }); - const { objectMetadataItemWithFieldsMaps } = - await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( - workflowActionInput.objectName, - workspaceId, - ); - const validObjectRecord = Object.fromEntries( Object.entries(workflowActionInput.objectRecord).filter(([key]) => isDefined(objectMetadataItemWithFieldsMaps.fieldIdByName[key]), 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 a427dbb1a8..9fd6355b6e 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 @@ -2,11 +2,13 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'class-validator'; import { isValidUuid, resolveInput } from 'twenty-shared/utils'; +import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowStepExecutorException, WorkflowStepExecutorExceptionCode, @@ -25,6 +27,7 @@ import { type WorkflowDeleteRecordActionInput } from 'src/modules/workflow/workf export class DeleteRecordWorkflowAction implements WorkflowAction { constructor( private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, ) {} @@ -77,6 +80,24 @@ export class DeleteRecordWorkflowAction implements WorkflowAction { { shouldBypassPermissionChecks: true }, ); + const { objectMetadataItemWithFieldsMaps } = + await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps( + workflowActionInput.objectName, + workspaceId, + ); + + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: objectMetadataItemWithFieldsMaps.nameSingular, + isSystem: objectMetadataItemWithFieldsMaps.isSystem, + }) + ) { + throw new RecordCRUDActionException( + 'Failed to delete: Object cannot be deleted by workflow', + RecordCRUDActionExceptionCode.INVALID_REQUEST, + ); + } + const objectRecord = await repository.findOne({ where: { id: workflowActionInput.objectRecordId, 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 60071719c7..fdd2b0d7b8 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 @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import deepEqual from 'deep-equal'; import { isDefined, isValidUuid, resolveInput } from 'twenty-shared/utils'; +import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; @@ -106,6 +107,18 @@ export class UpdateRecordWorkflowAction implements WorkflowAction { workspaceId, ); + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: objectMetadataItemWithFieldsMaps.nameSingular, + isSystem: objectMetadataItemWithFieldsMaps.isSystem, + }) + ) { + throw new RecordCRUDActionException( + 'Failed to update: Object cannot be updated by workflow', + RecordCRUDActionExceptionCode.INVALID_REQUEST, + ); + } + const objectRecordWithFilteredFields = Object.keys( workflowActionInput.objectRecord, ).reduce((acc, key) => { diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 7befcc6f52..432b5d7bb5 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -53,4 +53,5 @@ export type { WorkflowRunStepInfos, } from './types/WorkflowRunStateStepInfos'; export { StepStatus } from './types/WorkflowRunStateStepInfos'; +export { canObjectBeManagedByWorkflow } from './utils/canObjectBeManagedByWorkflow'; export { getWorkflowRunContext } from './utils/getWorkflowRunContext'; diff --git a/packages/twenty-shared/src/workflow/utils/canObjectBeManagedByWorkflow.ts b/packages/twenty-shared/src/workflow/utils/canObjectBeManagedByWorkflow.ts new file mode 100644 index 0000000000..89c48fa663 --- /dev/null +++ b/packages/twenty-shared/src/workflow/utils/canObjectBeManagedByWorkflow.ts @@ -0,0 +1,18 @@ +export const canObjectBeManagedByWorkflow = ({ + nameSingular, + isSystem, +}: { + nameSingular: string; + isSystem: boolean; +}) => { + const excludedNonSystemObjectMetadataItemNames = [ + 'workflow', + 'workflowVersion', + 'workflowRun', + 'dashboard', + ]; + + return !excludedNonSystemObjectMetadataItemNames.includes( + nameSingular, + ) && !isSystem; +};