diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowResource.java index beab98e3299..543d73579e1 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowResource.java @@ -37,4 +37,9 @@ public interface WorkflowResource { @Consumes(MediaType.APPLICATION_JSON) void bind(@PathParam("type") String type, @PathParam("resourceId") String resourceId, Long milliseconds); + @Path("deactivate/{type}/{resourceId}") + @POST + @Consumes(MediaType.APPLICATION_JSON) + void deactivate(@PathParam("type") String type, @PathParam("resourceId") String resourceId); + } 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 2db228d8ac6..a09d760cc9b 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 @@ -132,6 +132,11 @@ public class DefaultWorkflowProvider implements WorkflowProvider { processEvent(Stream.of(workflow), new AdhocWorkflowEvent(type, resourceId)); } + @Override + public void deactivate(Workflow workflow, String resourceId) { + stateProvider.removeByWorkflowAndResource(workflow.getId(), resourceId); + } + @Override public void bindToAllEligibleResources(Workflow workflow) { if (workflow.isEnabled()) { 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 c6e4e390a59..9b5f67bd217 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 @@ -134,6 +134,21 @@ public class JpaWorkflowStateProvider implements WorkflowStateProvider { } } + @Override + public void removeByWorkflowAndResource(String workflowId, String resourceId) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaDelete delete = cb.createCriteriaDelete(WorkflowStateEntity.class); + Root root = delete.from(WorkflowStateEntity.class); + delete.where(cb.and(cb.equal(root.get("workflowId"), workflowId), cb.equal(root.get("resourceId"), resourceId))); + int deletedCount = em.createQuery(delete).executeUpdate(); + + if (LOGGER.isTraceEnabled()) { + if (deletedCount > 0) { + LOGGER.tracev("Deleted {0} state records for resource {1} of workflow {2}", deletedCount, resourceId, workflowId); + } + } + } + @Override public void removeByWorkflow(String workflowId) { CriteriaBuilder cb = em.getCriteriaBuilder(); 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 7799adba545..0001fd1f1e2 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 @@ -46,6 +46,8 @@ public interface WorkflowProvider extends Provider { void bind(Workflow workflow, ResourceType type, String resourceId); + void deactivate(Workflow workflow, String resourceId); + void submit(WorkflowEvent event); void runScheduledSteps(); 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 120d43819f5..2396c8d8391 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 @@ -33,6 +33,14 @@ public interface WorkflowStateProvider extends Provider { */ void removeByResource(String resourceId); + /** + * Deletes the state records associated with the given {@code resourceId} of the given {@code workflowId}. + * + * @param workflowId the id of the workflow. + * @param resourceId the id of the resource. + */ + void removeByWorkflowAndResource(String workflowId, String resourceId); + /** * Removes any record identified by the specified {@code workflowId}. * @param workflowId the id of the 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 031900446f6..86a0c6063d8 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 @@ -81,4 +81,23 @@ public class WorkflowResource { provider.bind(workflow, type, resourceId); } + /** + * Deactivate the workflow for the resource. + * + * @param type the resource type + * @param resourceId the resource id + */ + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Path("deactivate/{type}/{resourceId}") + public void deactivate(@PathParam("type") ResourceType type, @PathParam("resourceId") String resourceId) { + Object resource = provider.getResourceTypeSelector(type).resolveResource(resourceId); + + if (resource == null) { + throw new BadRequestException("Resource with id " + resourceId + " not found"); + } + + provider.deactivate(workflow, resourceId); + } + } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AdhocWorkflowTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AdhocWorkflowTest.java index c990c747ce8..162af588baa 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AdhocWorkflowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AdhocWorkflowTest.java @@ -1,11 +1,13 @@ package org.keycloak.tests.admin.model.workflow; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItems; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.keycloak.models.workflow.ResourceOperationType.USER_ADDED; import java.time.Duration; import java.util.List; @@ -15,8 +17,10 @@ import jakarta.ws.rs.core.Response; import org.junit.jupiter.api.Test; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.models.workflow.NotifyUserStepProviderFactory; import org.keycloak.models.workflow.ResourceType; import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory; +import org.keycloak.models.workflow.WorkflowStateProvider; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.workflows.WorkflowRepresentation; @@ -181,6 +185,69 @@ public class AdhocWorkflowTest extends AbstractWorkflowTest { })); } + @Test + public void testDeactivateWorkflowForResource() { + managedRealm.admin().workflows().create(WorkflowRepresentation.withName("One") + .onEvent(USER_ADDED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(SetUserAttributeStepProviderFactory.ID) + .withConfig("workflowOne", "first") + .after(Duration.ofDays(5)) + .build(), + WorkflowStepRepresentation.create() + .of(NotifyUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build() + ) + .withName("Two") + .onEvent(USER_ADDED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(SetUserAttributeStepProviderFactory.ID) + .withConfig("workflowTwo", "second") + .after(Duration.ofDays(5)) + .build(), + WorkflowStepRepresentation.create() + .of(NotifyUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build() + ) + .build()).close(); + + List workflows = managedRealm.admin().workflows().list(); + assertThat(workflows, hasSize(2)); + String workflowOneId = workflows.stream().filter(w -> w.getName().equals("One")).findFirst().orElseThrow(IllegalStateException::new).getId(); + + // create a new user - should bind the user to the workflow and set up the first step in both workflows + String id = ApiUtil.handleCreatedResponse(managedRealm.admin().users().create(getUserRepresentation("alice", "Alice", "Wonderland", "alice@wornderland.org"))); + + runScheduledSteps(Duration.ofDays(6)); + + runOnServer.run(session -> { + RealmModel realm = session.getContext().getRealm(); + + UserModel user = session.users().getUserByUsername(realm, "alice"); + assertThat(user.getAttributes().keySet(), hasItems("workflowOne", "workflowTwo")); + + // Verify that the steps are scheduled for the user + WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class); + List scheduledSteps = stateProvider.getScheduledStepsByResource(user.getId()); + assertNotNull(scheduledSteps, "Two steps should have been scheduled for the user " + user.getUsername()); + assertThat(scheduledSteps, hasSize(2)); + }); + + //deactivate workflow One + managedRealm.admin().workflows().workflow(workflowOneId).deactivate(ResourceType.USERS.name(), id); + + runOnServer.run(session -> { + // Verify that there is single step scheduled for the user + WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class); + List scheduledSteps = stateProvider.getScheduledStepsByResource(id); + assertThat(scheduledSteps, hasSize(1)); + }); + } + private UserRepresentation getUserRepresentation(String username, String firstName, String lastName, String email) { UserRepresentation representation = new UserRepresentation(); representation.setUsername(username);