feat(workflow): dedicated updateWorkflowVersionTrigger mutation + close version CRUD holes (#23207)

Prerequisite for the workflow-core soft-ref migration: every
`workflowVersion` content write must go through a dedicated,
draft-guarded server mutation so it can later be wrapped in a
transactional core mirror. This closes the generic-CRUD holes that let
writes bypass that path.

## Part A - dedicated `updateWorkflowVersionTrigger` mutation
The builder saved a version's trigger through generic
`updateOneWorkflowVersion` - the only content write not going through a
dedicated mutation. Added:
- Server: `updateWorkflowVersionTrigger(input: { workflowVersionId,
trigger })` resolver +
`WorkflowVersionStepWorkspaceService.updateWorkflowVersionTrigger`,
draft-guarded via `getValidatedDraftWorkflowVersion` then
`updateWorkflowVersionStepsAndTrigger` (reuses existing write logic).
- Front: `useUpdateWorkflowVersionTrigger` now calls the dedicated
mutation instead of `useUpdateOneRecord`.

## Part B - restrict generic `updateOneWorkflowVersion`
`validateWorkflowVersionForUpdateOne` previously allowed writing
`trigger`, `position`, `workflowId` (re-parenting),
`coreWorkflowVersionId`, and let `steps: null` slip through on a draft.
It now rejects any update that sets `steps`, `trigger`, `status`,
`workflowId`, or `coreWorkflowVersionId`, or that clears the `name`,
while still allowing a plain rename. (A name-only allowlist was tried
first but blocked legitimate renames - at the pre-hook the generic
update payload is not single-key - so it was replaced by this denylist,
verified live.)

## Part C - close the destroy/restore hole
`workflowVersion` had no `destroyOne/destroyMany/restoreOne/restoreMany`
query hooks, so a caller with object permission could hard-destroy any
version (including active) or resurrect one with no validation. Added
pre-hooks that forbid all four via the API ("Method not allowed"),
matching the existing forbidden generic mutations (`createOne`,
`deleteMany`, ...). Rationale: there is no legitimate API use for
standalone version destroy/restore - retention purging happens through
the trash-cleanup cron (internal, not hook-gated) and restore happens
through the workflow-restore cascade or create-draft-from-version.

## Tests
Integration specs that set a trigger through the generic mutation were
migrated to the new `updateWorkflowVersionTrigger` mutation (new
`update-workflow-version-trigger.util.ts`). Unit test for the front hook
updated.

## Verification
- `twenty-server` + `twenty-front` typecheck: clean.
- oxlint + oxfmt on all changed files: clean.
- `graphql.ts` regenerated for the new mutation; its types match the
server DTOs exactly. Local `graphql:generate` introspects a running
server, so it only succeeds against a server built from this branch - CI
regenerates against the PR server and verifies.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23207?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
Thomas Trompette
2026-07-24 16:32:09 +02:00
committed by GitHub
parent 25b0b2601f
commit cbcfba0de2
22 changed files with 478 additions and 226 deletions
@@ -31,6 +31,10 @@ import { WorkflowVersionCreateManyPreQueryHook } from 'src/modules/workflow/comm
import { WorkflowVersionCreateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-create-one.pre-query.hook';
import { WorkflowVersionDeleteManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-delete-many.pre-query.hook';
import { WorkflowVersionDeleteOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-delete-one.pre-query.hook';
import { WorkflowVersionDestroyManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-destroy-many.pre-query.hook';
import { WorkflowVersionDestroyOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-destroy-one.pre-query.hook';
import { WorkflowVersionRestoreManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-restore-many.pre-query.hook';
import { WorkflowVersionRestoreOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-restore-one.pre-query.hook';
import { WorkflowVersionUpdateManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-update-many.pre-query.hook';
import { WorkflowVersionUpdateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-update-one.pre-query.hook';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
@@ -67,6 +71,10 @@ import { WorkflowVersionValidationWorkspaceService } from 'src/modules/workflow/
WorkflowVersionUpdateManyPreQueryHook,
WorkflowVersionDeleteOnePreQueryHook,
WorkflowVersionDeleteManyPreQueryHook,
WorkflowVersionDestroyOnePreQueryHook,
WorkflowVersionDestroyManyPreQueryHook,
WorkflowVersionRestoreOnePreQueryHook,
WorkflowVersionRestoreManyPreQueryHook,
WorkflowCreateOnePostQueryHook,
WorkflowCreateManyPostQueryHook,
WorkflowVersionValidationWorkspaceService,
@@ -0,0 +1,23 @@
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { type DestroyManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import {
WorkflowQueryValidationException,
WorkflowQueryValidationExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-query-validation.exception';
@WorkspaceQueryHook(`workflowVersion.destroyMany`)
export class WorkflowVersionDestroyManyPreQueryHook implements WorkspacePreQueryHookInstance {
async execute(
_authContext: WorkspaceAuthContext,
_objectName: string,
_payload: DestroyManyResolverArgs,
): Promise<DestroyManyResolverArgs> {
throw new WorkflowQueryValidationException(
'Method not allowed.',
WorkflowQueryValidationExceptionCode.FORBIDDEN,
);
}
}
@@ -0,0 +1,23 @@
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { type DestroyOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import {
WorkflowQueryValidationException,
WorkflowQueryValidationExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-query-validation.exception';
@WorkspaceQueryHook(`workflowVersion.destroyOne`)
export class WorkflowVersionDestroyOnePreQueryHook implements WorkspacePreQueryHookInstance {
async execute(
_authContext: WorkspaceAuthContext,
_objectName: string,
_payload: DestroyOneResolverArgs,
): Promise<DestroyOneResolverArgs> {
throw new WorkflowQueryValidationException(
'Method not allowed.',
WorkflowQueryValidationExceptionCode.FORBIDDEN,
);
}
}
@@ -0,0 +1,23 @@
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { type RestoreManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import {
WorkflowQueryValidationException,
WorkflowQueryValidationExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-query-validation.exception';
@WorkspaceQueryHook(`workflowVersion.restoreMany`)
export class WorkflowVersionRestoreManyPreQueryHook implements WorkspacePreQueryHookInstance {
async execute(
_authContext: WorkspaceAuthContext,
_objectName: string,
_payload: RestoreManyResolverArgs,
): Promise<RestoreManyResolverArgs> {
throw new WorkflowQueryValidationException(
'Method not allowed.',
WorkflowQueryValidationExceptionCode.FORBIDDEN,
);
}
}
@@ -0,0 +1,23 @@
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { type RestoreOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import {
WorkflowQueryValidationException,
WorkflowQueryValidationExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-query-validation.exception';
@WorkspaceQueryHook(`workflowVersion.restoreOne`)
export class WorkflowVersionRestoreOnePreQueryHook implements WorkspacePreQueryHookInstance {
async execute(
_authContext: WorkspaceAuthContext,
_objectName: string,
_payload: RestoreOneResolverArgs,
): Promise<RestoreOneResolverArgs> {
throw new WorkflowQueryValidationException(
'Method not allowed.',
WorkflowQueryValidationExceptionCode.FORBIDDEN,
);
}
}
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { IsNull, Not } from 'typeorm';
import {
@@ -84,33 +85,37 @@ export class WorkflowVersionValidationWorkspaceService {
workspaceId: string;
payload: UpdateOneResolverArgs<WorkflowVersionWorkspaceEntity>;
}) {
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workspaceId,
workflowVersionId: payload.id,
});
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workspaceId,
workflowVersionId: payload.id,
});
if (!(Object.keys(payload.data).length === 1 && payload.data.name)) {
assertWorkflowVersionIsDraft(workflowVersion);
}
const protectedFieldNames = [
'steps',
'trigger',
'status',
'position',
'workflowId',
'coreWorkflowVersionId',
];
const setsProtectedField = protectedFieldNames.some(
(fieldName) => fieldName in payload.data,
);
const clearsName =
'name' in payload.data && !isNonEmptyString(payload.data.name);
if (payload.data.status && payload.data.status !== workflowVersion.status) {
if (setsProtectedField || clearsName) {
throw new WorkflowQueryValidationException(
'Cannot update workflow version status manually',
'Updating a workflowVersion through the generic mutation is restricted. ' +
'steps, trigger, status, position, workflowId and coreWorkflowVersionId cannot be changed, and the name cannot be cleared. ' +
'Use the dedicated workflowVersion mutations (createWorkflowVersionStep, updateWorkflowVersionStep, ' +
'deleteWorkflowVersionStep, updateWorkflowVersionTrigger, activateWorkflowVersion, ...) instead.',
WorkflowQueryValidationExceptionCode.FORBIDDEN,
{
userFriendlyMessage: msg`Cannot update workflow version status manually`,
userFriendlyMessage: msg`This field cannot be updated directly on a workflow version`,
},
);
}
if (payload.data.steps) {
throw new WorkflowQueryValidationException(
'Updating workflowVersion steps directly is forbidden. ' +
'Use createWorkflowVersionStep, updateWorkflowVersionStep or deleteWorkflowVersionStep endpoint instead.',
WorkflowQueryValidationExceptionCode.FORBIDDEN,
);
}
}
async validateWorkflowVersionForDeleteOne(
@@ -3,10 +3,13 @@ import { Injectable } from '@nestjs/common';
import { type CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step.input';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-action.dto';
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
import { type WorkflowVersionTriggerDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-trigger.dto';
import { WorkflowVersionStepCreationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-creation.workspace-service';
import { WorkflowVersionStepDeletionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-deletion.workspace-service';
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
import { WorkflowVersionStepUpdateWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-update.workspace-service';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
@Injectable()
export class WorkflowVersionStepWorkspaceService {
@@ -14,6 +17,7 @@ export class WorkflowVersionStepWorkspaceService {
private readonly workflowVersionStepCreationWorkspaceService: WorkflowVersionStepCreationWorkspaceService,
private readonly workflowVersionStepUpdateWorkspaceService: WorkflowVersionStepUpdateWorkspaceService,
private readonly workflowVersionStepDeletionWorkspaceService: WorkflowVersionStepDeletionWorkspaceService,
private readonly workflowVersionStepHelpersWorkspaceService: WorkflowVersionStepHelpersWorkspaceService,
) {}
async createWorkflowVersionStep({
@@ -49,6 +53,33 @@ export class WorkflowVersionStepWorkspaceService {
);
}
async updateWorkflowVersionTrigger({
workspaceId,
workflowVersionId,
trigger,
}: {
workspaceId: string;
workflowVersionId: string;
trigger: WorkflowTrigger;
}): Promise<WorkflowVersionTriggerDTO> {
await this.workflowVersionStepHelpersWorkspaceService.getValidatedDraftWorkflowVersion(
{
workflowVersionId,
workspaceId,
},
);
await this.workflowVersionStepHelpersWorkspaceService.updateWorkflowVersionStepsAndTrigger(
{
workspaceId,
workflowVersionId,
trigger,
},
);
return { trigger };
}
async deleteWorkflowVersionStep({
workspaceId,
workflowVersionId,