diff --git a/core/src/main/java/org/keycloak/representations/workflows/WorkflowStepRepresentation.java b/core/src/main/java/org/keycloak/representations/workflows/WorkflowStepRepresentation.java index b56bb0bf169..3fa4b4daa77 100644 --- a/core/src/main/java/org/keycloak/representations/workflows/WorkflowStepRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/workflows/WorkflowStepRepresentation.java @@ -50,11 +50,6 @@ public class WorkflowStepRepresentation extends AbstractWorkflowComponentReprese this.scheduledAt = scheduledAt; } - @JsonIgnore - public String getId() { - return super.getId(); - } - public String getUses() { return this.uses; } diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowsResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowsResource.java index 43146b7837e..c8668010068 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowsResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowsResource.java @@ -48,4 +48,8 @@ public interface WorkflowsResource { @Path("{id}") WorkflowResource workflow(@PathParam("id") String id); + + @POST + @Path("migrate") + Response migrate(@QueryParam("from") String stepFrom, @QueryParam("to") String stepTo); } diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowExecutionContext.java b/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowExecutionContext.java index 72dc53d1894..da8d7111351 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowExecutionContext.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowExecutionContext.java @@ -29,6 +29,17 @@ final class DefaultWorkflowExecutionContext implements WorkflowExecutionContext this(session, workflow, event, null, UUID.randomUUID().toString(), event.getResourceId()); } + /** + * A new execution context for a workflow event. The execution ID is provided as a parameter + * + * @param workflow the workflow + * @param event the event + * @param executionId the execution ID + */ + DefaultWorkflowExecutionContext(KeycloakSession session, Workflow workflow, WorkflowEvent event, String executionId) { + this(session, workflow, event, null, executionId, event.getResourceId()); + } + /** * A new execution context for a workflow event, resuming a previously scheduled step. The execution ID is taken from the scheduled step * with no current step, indicating that the workflow is being restarted due to an event. diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowProvider.java b/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowProvider.java index cff33a301f2..b9872bc081f 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/DefaultWorkflowProvider.java @@ -200,6 +200,68 @@ public class DefaultWorkflowProvider implements WorkflowProvider { }); } + @Override + public void migrateScheduledResources(String stepIdFrom, String stepIdTo) { + if (stepIdFrom.equals(stepIdTo)) { + return; // nothing to do as both steps are the same + } + + // first, we use the steps to find the workflows involved + ComponentModel stepFromModel = getWorkflowComponent(stepIdFrom, WorkflowStepProvider.class.getName()); + Workflow workflowFrom = getWorkflow(stepFromModel.getParentId()); + ComponentModel stepToModel = getWorkflowComponent(stepIdTo, WorkflowStepProvider.class.getName()); + Workflow workflowTo = getWorkflow(stepToModel.getParentId()); + + // get the scheduled steps from the source step + List scheduledStepsFrom = stateProvider.getScheduledStepsByStep(workflowFrom.getId(), stepIdFrom).toList(); + + // when migrating between different workflows, we need to perform additional validations + if (!workflowFrom.getId().equals(workflowTo.getId())) { + + // ensure both workflows support the same resource type + if (workflowFrom.getSupportedType() != workflowTo.getSupportedType()) { + throw new ModelValidationException("Cannot migrate scheduled resources between workflows that support different resource types."); + } + + // ensure all resources scheduled in the source step satisfy the activation conditions of the destination workflow + EventBasedWorkflow eventBasedWorkflow = new EventBasedWorkflow(session, workflowTo.getSupportedType(), getWorkflowComponent(workflowTo.getId())); + for (ScheduledStep scheduledStep : scheduledStepsFrom) { + DefaultWorkflowExecutionContext context = new DefaultWorkflowExecutionContext(session, workflowTo, scheduledStep); + if (!eventBasedWorkflow.validateResourceConditions(context)) { + throw new ModelValidationException("Cannot migrate resource %s to workflow %s as it does not satisfy the workflow's activation conditions." + .formatted(scheduledStep.resourceId(), workflowTo.getName())); + } + } + } + + // perform the migration - for each scheduled step in the source, we remove it and activate the destination workflow from the specified step + int stepPosition = workflowTo.getStepById(stepIdTo).getPriority() - 1; + for (ScheduledStep scheduledStep : scheduledStepsFrom) { + // remove the scheduled step from the source workflow + stateProvider.remove(scheduledStep.executionId()); + + // activate the destination workflow for the resource, starting from the specified step + DefaultWorkflowExecutionContext context; + if (workflowFrom.getId().equals(workflowTo.getId())) { + // we reuse the executionId when migrating within the same workflow + context = new DefaultWorkflowExecutionContext(session, workflowTo, new AdhocWorkflowEvent(workflowTo.getSupportedType(), scheduledStep.resourceId()), + scheduledStep.executionId()); + } else { + context = new DefaultWorkflowExecutionContext(session, workflowTo, new AdhocWorkflowEvent(workflowTo.getSupportedType(), + scheduledStep.resourceId())); + } + context.restart(stepPosition); + + if (log.isDebugEnabled()) { + WorkflowStep stepFrom = workflowFrom.getStepById(stepIdFrom); + WorkflowStep stepTo = workflowTo.getStepById(stepIdTo); + log.debugf("Migrated resource %s from workflow %s (step %s) to workflow %s (step %s). New execution id: %s", + scheduledStep.resourceId(), workflowFrom.getName(), stepFrom.getProviderId(), workflowTo.getName(), + stepTo.getProviderId(), context.getExecutionId()); + } + } + } + @Override public void activate(Workflow workflow, ResourceType type, String resourceId) { if (type != workflow.getSupportedType()) { @@ -257,15 +319,19 @@ public class DefaultWorkflowProvider implements WorkflowProvider { } private ComponentModel getWorkflowComponent(String id) { + return this.getWorkflowComponent(id, WorkflowProvider.class.getName()); + } + + private ComponentModel getWorkflowComponent(String id, String providerType) { ComponentModel component = realm.getComponent(id); - if (component == null || !WorkflowProvider.class.getName().equals(component.getProviderType())) { - throw new BadRequestException("Not a valid resource workflow: " + id); + if (component == null || !Objects.equals(providerType, component.getProviderType())) { + throw new BadRequestException("Not a valid workflow resource: " + id); } - return component; } + /* ================================= Workflows component providers and factories ================================= */ private WorkflowProvider getWorkflowProvider(Workflow workflow) { diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/JpaWorkflowStateProvider.java b/model/jpa/src/main/java/org/keycloak/models/workflow/JpaWorkflowStateProvider.java index 8f9e82dc7a6..d661318050f 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/JpaWorkflowStateProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/JpaWorkflowStateProvider.java @@ -116,6 +116,24 @@ public class JpaWorkflowStateProvider implements WorkflowStateProvider { .map(this::toScheduledStep); } + @Override + public Stream getScheduledStepsByStep(String workflowId, String stepId) { + if (StringUtil.isBlank(workflowId) || StringUtil.isBlank(stepId)) { + return Stream.empty(); + } + + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery query = cb.createQuery(WorkflowStateEntity.class); + Root stateRoot = query.from(WorkflowStateEntity.class); + + Predicate byWorkflowAndStep = cb.and(cb.equal(stateRoot.get("workflowId"), workflowId), + cb.equal(stateRoot.get("scheduledStepId"), stepId)); + query.where(byWorkflowAndStep); + + return em.createQuery(query).getResultStream() + .map(this::toScheduledStep); + } + @Override public Stream getScheduledStepsByResource(String resourceId) { CriteriaBuilder cb = em.getCriteriaBuilder(); diff --git a/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java b/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java index 2b6bcef5f17..f7655b28641 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java +++ b/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java @@ -221,7 +221,7 @@ public class Workflow { ComponentModel component = realm.getComponent(id); if (component == null || !Objects.equals(providerType, component.getProviderType())) { - throw new BadRequestException("Not a valid resource workflow: " + id); + throw new BadRequestException("Not a valid workflow resource: " + id); } return component; } diff --git a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowProvider.java b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowProvider.java index a432a42400f..e8175ac74b6 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowProvider.java @@ -68,4 +68,22 @@ public interface WorkflowProvider extends Provider { void runScheduledSteps(); void activateForAllEligibleResources(Workflow workflow); + + /** + * Migrates scheduled resources from one workflow step to another. The destination step might be a step in the same + * workflow or a step in a different workflow. + *
+ * If the resources are being migrated to a different workflow, the following conditions must be met: + *
    + *
  • the source and destination workflows must support the same resource type;
  • + *
  • all resources must satisfy the activation conditions of the destination workflow.
  • + *
+ * The process behaves exactly as if the resources were being activated for the first time in the destination workflow, + * except that the first step to be processed is the specified destination step. So, if the step is a scheduled step, + * the resources will be scheduled accordingly. If the step is not a scheduled step, it will run immediately. + * + * @param stepIdFrom the id of the step to migrate from. + * @param stepIdTo the id of the step to migrate to. + */ + void migrateScheduledResources(String stepIdFrom, String stepIdTo); } diff --git a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStateProvider.java b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStateProvider.java index 6da78acb32c..624d88f4bd6 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStateProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStateProvider.java @@ -73,13 +73,7 @@ public interface WorkflowStateProvider extends Provider { Stream getScheduledStepsByWorkflow(String workflowId); - default Stream getScheduledStepsByWorkflow(Workflow workflow) { - if (workflow == null) { - return Stream.empty(); - } - - return getScheduledStepsByWorkflow(workflow.getId()); - } + Stream getScheduledStepsByStep(String workflowId, String stepId); Stream getDueScheduledSteps(Workflow workflow); diff --git a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowResource.java b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowResource.java index a173a51ce38..8e243a7d24e 100644 --- a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowResource.java +++ b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowResource.java @@ -96,13 +96,14 @@ public class WorkflowResource { }) public WorkflowRepresentation toRepresentation( @Parameter( - description = "Indicates whether the workflow id should be included in the representation or not - defaults to true" + description = "Indicates whether the workflow and step ids should be included in the representation or not - defaults to true" ) - @QueryParam("includeId") Boolean includeId + @QueryParam("includeId") Boolean includeIds ) { WorkflowRepresentation rep = provider.toRepresentation(workflow); - if (Boolean.FALSE.equals(includeId)) { + if (Boolean.FALSE.equals(includeIds)) { rep.setId(null); + rep.getSteps().forEach(step -> step.setId(null)); } return rep; } diff --git a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowsResource.java b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowsResource.java index 8506640b7ea..d462e860000 100644 --- a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowsResource.java +++ b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowsResource.java @@ -146,4 +146,34 @@ public class WorkflowsResource { auth.realm().requireManageRealm(); return provider.getScheduledWorkflowsByResource(resourceId).toList(); } + + @Path("migrate") + @POST + @Tag(name = KeycloakOpenAPI.Admin.Tags.WORKFLOWS) + @Operation( + summary = "Migrate scheduled resources from one step to another", + description = "Migrate scheduled resources from one step to another step in the same or in a different workflow." + ) + @APIResponses(value = { + @APIResponse(responseCode = "204", description = "No Content"), + @APIResponse(responseCode = "400", description = "Bad Request") + }) + public Response migrate( + @Parameter(description = "A String representing the id of the step to migrate from") + @QueryParam("from") String stepIdFrom, + @Parameter(description = "A String representing the id of the step to migrate to") + @QueryParam("to") String stepIdTo) { + auth.realm().requireManageRealm(); + + if (stepIdFrom == null || stepIdTo == null) { + throw ErrorResponse.error("Both 'from' and 'to' step ids must be provided for migration.", Response.Status.BAD_REQUEST); + } + + try { + provider.migrateScheduledResources(stepIdFrom, stepIdTo); + return Response.noContent().build(); + } catch (ModelException me) { + throw ErrorResponse.error(me.getMessage(), Response.Status.BAD_REQUEST); + } + } } diff --git a/tests/base/src/test/java/org/keycloak/tests/workflow/WorkflowMigrationTest.java b/tests/base/src/test/java/org/keycloak/tests/workflow/WorkflowMigrationTest.java new file mode 100644 index 00000000000..9e59ffdf34a --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/workflow/WorkflowMigrationTest.java @@ -0,0 +1,334 @@ +package org.keycloak.tests.workflow; + +import java.time.Duration; +import java.util.List; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.models.workflow.AddRequiredActionStepProvider; +import org.keycloak.models.workflow.AddRequiredActionStepProviderFactory; +import org.keycloak.models.workflow.DeleteUserStepProviderFactory; +import org.keycloak.models.workflow.DisableUserStepProviderFactory; +import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory; +import org.keycloak.models.workflow.WorkflowStateProvider; +import org.keycloak.models.workflow.client.DeleteClientStepProviderFactory; +import org.keycloak.models.workflow.conditions.UserAttributeWorkflowConditionFactory; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.workflows.StepExecutionStatus; +import org.keycloak.representations.workflows.WorkflowRepresentation; +import org.keycloak.representations.workflows.WorkflowStepRepresentation; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.realm.UserConfigBuilder; +import org.keycloak.testframework.remote.providers.runonserver.RunOnServer; +import org.keycloak.testframework.util.ApiUtil; +import org.keycloak.tests.workflow.config.WorkflowsBlockingServerConfig; + +import org.junit.jupiter.api.Test; + +import static org.keycloak.models.workflow.ResourceOperationType.CLIENT_ADDED; +import static org.keycloak.models.workflow.ResourceOperationType.USER_CREATED; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + +/** + * Tests migrating resources from one workflow to another. + */ +@KeycloakIntegrationTest(config = WorkflowsBlockingServerConfig.class) +public class WorkflowMigrationTest extends AbstractWorkflowTest { + + @Test + public void testMigrationFailsIfWorkflowsNotCompatible() { + // create two incompatible workflows - one that operates on users, another on clients + var response = managedRealm.admin().workflows().create(WorkflowRepresentation.withName("client-workflow") + .onEvent(CLIENT_ADDED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(DeleteClientStepProviderFactory.ID) + .after(Duration.ofDays(10)) + .build() + ).build()); + assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode())); + String clientWorkflowId = ApiUtil.getCreatedId(response); + response.close(); + + response = managedRealm.admin().workflows().create(WorkflowRepresentation.withName("user-workflow") + .onEvent(USER_CREATED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(AddRequiredActionStepProviderFactory.ID) + .withConfig(AddRequiredActionStepProvider.REQUIRED_ACTION_KEY, "UPDATE_PASSWORD") + .after(Duration.ofDays(5)) + .build() + ).build()); + assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode())); + String userWorkflowId = ApiUtil.getCreatedId(response); + response.close(); + + // create a few of users so that they are attached to the user workflow + for (int i = 1; i <= 3; i++) { + try (var createUserResponse = managedRealm.admin().users().create(UserConfigBuilder.create().username("user-" + i).build())) { + assertThat(createUserResponse.getStatus(), is(Response.Status.CREATED.getStatusCode())); + String userId = ApiUtil.getCreatedId(createUserResponse); + // check created user is attached to the first workflow + List activeWorkflows = managedRealm.admin().workflows().getScheduledWorkflows(userId); + assertThat(activeWorkflows, hasSize(1)); + assertThat(activeWorkflows.get(0).getName(), is("user-workflow")); + } + } + + // attempt to migrate the users from the user workflow to the client workflow - should fail + WorkflowRepresentation userWorkflow = managedRealm.admin().workflows().workflow(userWorkflowId).toRepresentation(); + String fromStepId = userWorkflow.getSteps().get(0).getId(); + WorkflowRepresentation clientWorkflow = managedRealm.admin().workflows().workflow(clientWorkflowId).toRepresentation(); + String toStepId = clientWorkflow.getSteps().get(0).getId(); + + try (var migrateResponse = managedRealm.admin().workflows().migrate(fromStepId, toStepId)) { + assertThat(migrateResponse.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode())); + } + } + + @Test + public void testMigrationFailsIfResourcesDontMeetWorkflowConditions() { + // create two user workflows, the second having a condition that the users don't meet + var response = managedRealm.admin().workflows().create(WorkflowRepresentation.withName("workflow-1") + .onEvent(USER_CREATED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build() + ).build()); + assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode())); + String firstWorkflowId = ApiUtil.getCreatedId(response); + response.close(); + + response = managedRealm.admin().workflows().create(WorkflowRepresentation.withName("workflow-2") + .onCondition(UserAttributeWorkflowConditionFactory.ID + "(some-missing-key:some-missing-value)") + .withSteps( + WorkflowStepRepresentation.create() + .of(SetUserAttributeStepProviderFactory.ID) + .withConfig("key", "value") + .after(Duration.ofDays(10)) + .build() + ).build()); + assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode())); + String secondWorkflowId = ApiUtil.getCreatedId(response); + response.close(); + + // create a few of users so that they are attached to the first workflow + for (int i = 1; i <= 3; i++) { + try (var createUserResponse = managedRealm.admin().users().create(UserConfigBuilder.create().username("user-" + i).build())) { + assertThat(createUserResponse.getStatus(), is(Response.Status.CREATED.getStatusCode())); + String userId = ApiUtil.getCreatedId(createUserResponse); + // check created user is attached to the first workflow + List activeWorkflows = managedRealm.admin().workflows().getScheduledWorkflows(userId); + assertThat(activeWorkflows, hasSize(1)); + assertThat(activeWorkflows.get(0).getName(), is("workflow-1")); + } + } + + // attempt to migrate the users from the first workflow to the second workflow - should fail + WorkflowRepresentation firstWorkflow = managedRealm.admin().workflows().workflow(firstWorkflowId).toRepresentation(); + String fromStepId = firstWorkflow.getSteps().get(0).getId(); + WorkflowRepresentation secondWorkflow = managedRealm.admin().workflows().workflow(secondWorkflowId).toRepresentation(); + String toStepId = secondWorkflow.getSteps().get(0).getId(); + + try (var migrateResponse = managedRealm.admin().workflows().migrate(fromStepId, toStepId)) { + assertThat(migrateResponse.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode())); + } + } + + @Test + public void testMigrateToScheduledStepInDifferentWorkflow() { + + var response = managedRealm.admin().workflows().create(WorkflowRepresentation.withName("workflow-1") + .onEvent(USER_CREATED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build() + ).build()); + assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode())); + String firstWorkflowId = ApiUtil.getCreatedId(response); + response.close(); + + response = managedRealm.admin().workflows().create(WorkflowRepresentation.withName("workflow-2") + .withSteps( + WorkflowStepRepresentation.create() + .of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build(), + WorkflowStepRepresentation.create() + .of(DeleteUserStepProviderFactory.ID) + .after(Duration.ofDays(10)) + .build() + ).build()); + assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode())); + String secondWorkflowId = ApiUtil.getCreatedId(response); + response.close(); + + // create a few of users so that they are attached to the first workflow + String[] userIds = new String[4]; + for (int i = 0; i <= 3; i++) { + try (var createUserResponse = managedRealm.admin().users().create(UserConfigBuilder.create().username("user-" + i).build())) { + assertThat(createUserResponse.getStatus(), is(Response.Status.CREATED.getStatusCode())); + userIds[i] = ApiUtil.getCreatedId(createUserResponse); + // check created user is attached to the first workflow + List activeWorkflows = managedRealm.admin().workflows().getScheduledWorkflows(userIds[i]); + assertThat(activeWorkflows, hasSize(1)); + assertThat(activeWorkflows.get(0).getName(), is("workflow-1")); + } + } + + // migrate users to the delete step in the second workflow + WorkflowRepresentation firstWorkflow = managedRealm.admin().workflows().workflow(firstWorkflowId).toRepresentation(); + String fromStepId = firstWorkflow.getSteps().get(0).getId(); + WorkflowRepresentation secondWorkflow = managedRealm.admin().workflows().workflow(secondWorkflowId).toRepresentation(); + assertThat(secondWorkflow.getSteps().get(1).getUses(), is(DeleteUserStepProviderFactory.ID)); + String toStepId = secondWorkflow.getSteps().get(1).getId(); + + try (var migrateResponse = managedRealm.admin().workflows().migrate(fromStepId, toStepId)) { + assertThat(migrateResponse.getStatus(), is(Response.Status.NO_CONTENT.getStatusCode())); + } + + // check users are now attached to the second workflow and scheduled to run the second step + runOnServer.run((RunOnServer) session -> { + // the workflow state table should have a scheduled step for the user in the first workflow + for (int i = 0; i <= 3; i++) { + WorkflowStateProvider stateProvider = session.getKeycloakSessionFactory().getProviderFactory(WorkflowStateProvider.class).create(session); + List steps = stateProvider.getScheduledStepsByResource(userIds[i]).toList(); + assertThat(steps, hasSize(1)); + assertThat(steps.get(0).workflowId(), is(secondWorkflowId)); + assertThat(steps.get(0).stepId(), is(toStepId)); + } + }); + } + + @Test + public void testMigrateToImmediateStepInDifferentWorkflow() { + var response = managedRealm.admin().workflows().create(WorkflowRepresentation.withName("workflow-1") + .onEvent(USER_CREATED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build() + ).build()); + assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode())); + String firstWorkflowId = ApiUtil.getCreatedId(response); + response.close(); + + response = managedRealm.admin().workflows().create(WorkflowRepresentation.withName("workflow-2") + .withSteps( + WorkflowStepRepresentation.create() + .of(DisableUserStepProviderFactory.ID) + .build(), + WorkflowStepRepresentation.create() + .of(DeleteUserStepProviderFactory.ID) + .after(Duration.ofDays(10)) + .build() + ).build()); + assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode())); + String secondWorkflowId = ApiUtil.getCreatedId(response); + response.close(); + + // create a few of users so that they are attached to the first workflow + String[] userIds = new String[4]; + for (int i = 0; i <= 3; i++) { + try (var createUserResponse = managedRealm.admin().users().create(UserConfigBuilder.create().username("user-" + i).build())) { + assertThat(createUserResponse.getStatus(), is(Response.Status.CREATED.getStatusCode())); + userIds[i] = ApiUtil.getCreatedId(createUserResponse); + // check created user is attached to the first workflow + List activeWorkflows = managedRealm.admin().workflows().getScheduledWorkflows(userIds[i]); + assertThat(activeWorkflows, hasSize(1)); + assertThat(activeWorkflows.get(0).getName(), is("workflow-1")); + } + } + + // migrate users to the first step in the second workflow - it is an immediate step, so it should run right away + WorkflowRepresentation firstWorkflow = managedRealm.admin().workflows().workflow(firstWorkflowId).toRepresentation(); + String fromStepId = firstWorkflow.getSteps().get(0).getId(); + WorkflowRepresentation secondWorkflow = managedRealm.admin().workflows().workflow(secondWorkflowId).toRepresentation(); + String toStepId = secondWorkflow.getSteps().get(0).getId(); + + try (var migrateResponse = managedRealm.admin().workflows().migrate(fromStepId, toStepId)) { + assertThat(migrateResponse.getStatus(), is(Response.Status.NO_CONTENT.getStatusCode())); + } + + // check the users are now disabled, and scheduled to run the second step + for (int i = 0; i <= 3; i++) { + UserRepresentation user = managedRealm.admin().users().get(userIds[i]).toRepresentation(); + assertThat(user.isEnabled(), is(false)); + List activeWorkflows = managedRealm.admin().workflows().getScheduledWorkflows(userIds[i]); + assertThat(activeWorkflows, hasSize(1)); + WorkflowRepresentation firstActiveWorkflow = activeWorkflows.get(0); + assertThat(firstActiveWorkflow.getName(), is("workflow-2")); + assertThat(firstActiveWorkflow.getSteps(), hasSize(2)); + assertThat(firstActiveWorkflow.getSteps().get(0).getExecutionStatus(), is(StepExecutionStatus.COMPLETED)); + assertThat(firstActiveWorkflow.getSteps().get(1).getExecutionStatus(), is(StepExecutionStatus.PENDING)); + } + } + + @Test + public void testMigrateToStepInSameWorkflow() { + var response = managedRealm.admin().workflows().create(WorkflowRepresentation.withName("workflow") + .onEvent(USER_CREATED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(AddRequiredActionStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .withConfig(AddRequiredActionStepProvider.REQUIRED_ACTION_KEY, "UPDATE_PASSWORD") + .build(), + WorkflowStepRepresentation.create() + .of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(10)) + .build(), + WorkflowStepRepresentation.create() + .of(DeleteUserStepProviderFactory.ID) + .after(Duration.ofDays(20)) + .build() + ).build()); + assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode())); + String workflowId = ApiUtil.getCreatedId(response); + response.close(); + + // create a few of users so that they are attached to the workflow + String[] userIds = new String[4]; + for (int i = 0; i <= 3; i++) { + try (var createUserResponse = managedRealm.admin().users().create(UserConfigBuilder.create().username("user-" + i).build())) { + assertThat(createUserResponse.getStatus(), is(Response.Status.CREATED.getStatusCode())); + userIds[i] = ApiUtil.getCreatedId(createUserResponse); + // check created user is attached to the first workflow + List activeWorkflows = managedRealm.admin().workflows().getScheduledWorkflows(userIds[i]); + assertThat(activeWorkflows, hasSize(1)); + assertThat(activeWorkflows.get(0).getName(), is("workflow")); + assertThat(activeWorkflows.get(0).getSteps(), hasSize(3)); + assertThat(activeWorkflows.get(0).getSteps().get(0).getExecutionStatus(), is(StepExecutionStatus.PENDING)); + } + } + + // migrate users to the delete step in the same workflow + WorkflowRepresentation workflow = managedRealm.admin().workflows().workflow(workflowId).toRepresentation(); + String fromStepId = workflow.getSteps().get(0).getId(); + String toStepId = workflow.getSteps().get(2).getId(); + + try (var migrateResponse = managedRealm.admin().workflows().migrate(fromStepId, toStepId)) { + assertThat(migrateResponse.getStatus(), is(Response.Status.NO_CONTENT.getStatusCode())); + } + + // check the users are now scheduled to run the delete step instead of the first step + for (int i = 0; i <= 3; i++) { + List activeWorkflows = managedRealm.admin().workflows().getScheduledWorkflows(userIds[i]); + assertThat(activeWorkflows, hasSize(1)); + WorkflowRepresentation firstActiveWorkflow = activeWorkflows.get(0); + assertThat(firstActiveWorkflow.getName(), is("workflow")); + assertThat(firstActiveWorkflow.getSteps(), hasSize(3)); + assertThat(firstActiveWorkflow.getSteps().get(0).getExecutionStatus(), is(StepExecutionStatus.COMPLETED)); + assertThat(firstActiveWorkflow.getSteps().get(1).getExecutionStatus(), is(StepExecutionStatus.COMPLETED)); + assertThat(firstActiveWorkflow.getSteps().get(2).getExecutionStatus(), is(StepExecutionStatus.PENDING)); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/workflow/condition/GroupWorkflowConditionTest.java b/tests/base/src/test/java/org/keycloak/tests/workflow/condition/GroupWorkflowConditionTest.java index a9a03077c32..645e0dc0167 100644 --- a/tests/base/src/test/java/org/keycloak/tests/workflow/condition/GroupWorkflowConditionTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/workflow/condition/GroupWorkflowConditionTest.java @@ -129,7 +129,7 @@ public class GroupWorkflowConditionTest extends AbstractWorkflowTest { // check workflow was correctly assigned to the users WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class); RealmModel realm = session.getContext().getRealm(); - List scheduledUsers = stateProvider.getScheduledStepsByWorkflow(workflow) + List scheduledUsers = stateProvider.getScheduledStepsByWorkflow(workflow.getId()) .map(step -> session.users().getUserById(realm, step.resourceId()).getUsername()).toList(); assertThat(scheduledUsers, hasSize(10)); diff --git a/tests/base/src/test/java/org/keycloak/tests/workflow/condition/IdpLinkConditionWorkflowTest.java b/tests/base/src/test/java/org/keycloak/tests/workflow/condition/IdpLinkConditionWorkflowTest.java index bc2e946a42c..767d5ba0aad 100644 --- a/tests/base/src/test/java/org/keycloak/tests/workflow/condition/IdpLinkConditionWorkflowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/workflow/condition/IdpLinkConditionWorkflowTest.java @@ -168,7 +168,7 @@ public class IdpLinkConditionWorkflowTest extends AbstractWorkflowTest { // check no workflows are yet attached to the previous users, only to the ones created after the workflow was in place WorkflowStateProvider stateProvider = session.getKeycloakSessionFactory().getProviderFactory(WorkflowStateProvider.class).create(session); - List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow).toList(); + List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow.getId()).toList(); assertEquals(3, scheduledSteps.size()); scheduledSteps.forEach(scheduledStep -> { assertEquals(notifyStep.getId(), scheduledStep.stepId()); @@ -191,7 +191,7 @@ public class IdpLinkConditionWorkflowTest extends AbstractWorkflowTest { WorkflowStep disableStep = workflow.getSteps().toList().get(1); WorkflowStateProvider stateProvider = session.getKeycloakSessionFactory().getProviderFactory(WorkflowStateProvider.class).create(session); - List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow).toList(); + List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow.getId()).toList(); assertEquals(3, scheduledSteps.size()); scheduledSteps.forEach(scheduledStep -> { assertEquals(disableStep.getId(), scheduledStep.stepId()); @@ -217,7 +217,7 @@ public class IdpLinkConditionWorkflowTest extends AbstractWorkflowTest { Workflow workflow = registeredWorkflows.get(0); // check workflow was correctly assigned to the old users, not affecting users already associated with the workflow. WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class); - List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow).toList(); + List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow.getId()).toList(); assertEquals(13, scheduledSteps.size()); List steps = workflow.getSteps().toList(); diff --git a/tests/base/src/test/java/org/keycloak/tests/workflow/condition/RoleWorkflowConditionTest.java b/tests/base/src/test/java/org/keycloak/tests/workflow/condition/RoleWorkflowConditionTest.java index 73d1f5d9f8a..2887a7381b5 100644 --- a/tests/base/src/test/java/org/keycloak/tests/workflow/condition/RoleWorkflowConditionTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/workflow/condition/RoleWorkflowConditionTest.java @@ -114,7 +114,7 @@ public class RoleWorkflowConditionTest extends AbstractWorkflowTest { Workflow workflow = registeredWorkflows.get(0); // check workflow was correctly assigned to the users WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class); - List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow).toList(); + List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow.getId()).toList(); assertThat(scheduledSteps, hasSize(10)); }); }); diff --git a/tests/base/src/test/java/org/keycloak/tests/workflow/condition/UserAttributeWorkflowConditionTest.java b/tests/base/src/test/java/org/keycloak/tests/workflow/condition/UserAttributeWorkflowConditionTest.java index 772882d62d1..2fa6d41b72e 100644 --- a/tests/base/src/test/java/org/keycloak/tests/workflow/condition/UserAttributeWorkflowConditionTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/workflow/condition/UserAttributeWorkflowConditionTest.java @@ -109,7 +109,7 @@ public class UserAttributeWorkflowConditionTest extends AbstractWorkflowTest { Workflow workflow = registeredWorkflows.get(0); // check workflow was correctly assigned to the users WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class); - List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow).toList(); + List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow.getId()).toList(); assertThat(scheduledSteps, hasSize(10)); }); }); diff --git a/tests/base/src/test/java/org/keycloak/tests/workflow/execution/DisableActiveWorkflowTest.java b/tests/base/src/test/java/org/keycloak/tests/workflow/execution/DisableActiveWorkflowTest.java index f3245fc7e25..62e27702eea 100644 --- a/tests/base/src/test/java/org/keycloak/tests/workflow/execution/DisableActiveWorkflowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/workflow/execution/DisableActiveWorkflowTest.java @@ -107,7 +107,7 @@ public class DisableActiveWorkflowTest extends AbstractWorkflowTest { List registeredWorkflow = provider.getWorkflows().toList(); assertEquals(1, registeredWorkflow.size()); WorkflowStateProvider stateProvider = session.getKeycloakSessionFactory().getProviderFactory(WorkflowStateProvider.class).create(session); - List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(registeredWorkflow.get(0)).toList(); + List scheduledSteps = stateProvider.getScheduledStepsByWorkflow(registeredWorkflow.get(0).getId()).toList(); // verify that there's only one scheduled step, for the first user assertEquals(1, scheduledSteps.size());