From b14d00e08f13eff72b3be65885bfa139cc6ef239 Mon Sep 17 00:00:00 2001 From: Stefan Guilhen Date: Thu, 4 Dec 2025 17:56:51 -0300 Subject: [PATCH] Improve workflow concurrency settings - allow restarting based on events - allow cancelling based on events Closes #44645 Signed-off-by: Stefan Guilhen --- .../WorkflowConcurrencyRepresentation.java | 40 ++- .../workflows/WorkflowConstants.java | 3 +- .../workflows/WorkflowRepresentation.java | 39 ++- .../workflows/WorkflowDefinitionTest.java | 2 +- .../workflow/DefaultWorkflowProvider.java | 7 +- .../models/workflow/EventBasedWorkflow.java | 69 +++- .../models/workflow/WorkflowValidator.java | 12 +- .../UserSessionRefreshTimeWorkflowTest.java | 89 +----- .../workflow/WorkflowConcurrencyTest.java | 299 ++++++++++++++++++ 9 files changed, 434 insertions(+), 126 deletions(-) create mode 100644 tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowConcurrencyTest.java diff --git a/core/src/main/java/org/keycloak/representations/workflows/WorkflowConcurrencyRepresentation.java b/core/src/main/java/org/keycloak/representations/workflows/WorkflowConcurrencyRepresentation.java index 8de619bc6b9..746f1ce6bfd 100644 --- a/core/src/main/java/org/keycloak/representations/workflows/WorkflowConcurrencyRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/workflows/WorkflowConcurrencyRepresentation.java @@ -2,33 +2,50 @@ package org.keycloak.representations.workflows; import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CANCEL_IF_RUNNING; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CANCEL_IN_PROGRESS; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RESTART_IN_PROGRESS; +@JsonPropertyOrder({CONFIG_CANCEL_IN_PROGRESS, CONFIG_RESTART_IN_PROGRESS}) +@JsonInclude(JsonInclude.Include.NON_NULL) public class WorkflowConcurrencyRepresentation { - @JsonProperty(CONFIG_CANCEL_IF_RUNNING) - private Boolean cancelIfRunning; + @JsonProperty(CONFIG_CANCEL_IN_PROGRESS) + private String cancelInProgress; + + @JsonProperty(CONFIG_RESTART_IN_PROGRESS) + private String restartInProgress; // A no-argument constructor is needed for Jackson deserialization public WorkflowConcurrencyRepresentation() {} - public WorkflowConcurrencyRepresentation(Boolean cancelIfRunning) { - this.cancelIfRunning = cancelIfRunning; + public WorkflowConcurrencyRepresentation(String restartInProgress, String cancelInProgress) { + this.restartInProgress = restartInProgress; + this.cancelInProgress = cancelInProgress; } - public Boolean isCancelIfRunning() { - return cancelIfRunning; + public String getCancelInProgress() { + return cancelInProgress; } - public void setCancelIfRunning(Boolean cancelIfRunning) { - this.cancelIfRunning = cancelIfRunning; + public void setCancelInProgress(String cancelInProgress) { + this.cancelInProgress = cancelInProgress; + } + + public String getRestartInProgress() { + return restartInProgress; + } + + public void setRestartInProgress(String restartInProgress) { + this.restartInProgress = restartInProgress; } @Override public int hashCode() { - return cancelIfRunning != null ? cancelIfRunning.hashCode() : 0; + return Objects.hash(cancelInProgress, restartInProgress); } @Override @@ -36,7 +53,8 @@ public class WorkflowConcurrencyRepresentation { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; WorkflowConcurrencyRepresentation that = (WorkflowConcurrencyRepresentation) obj; - return Objects.equals(cancelIfRunning, that.cancelIfRunning); + return Objects.equals(cancelInProgress, that.cancelInProgress) && + Objects.equals(restartInProgress, that.restartInProgress); } } diff --git a/core/src/main/java/org/keycloak/representations/workflows/WorkflowConstants.java b/core/src/main/java/org/keycloak/representations/workflows/WorkflowConstants.java index 9f46a28f90e..b5c8518a2a6 100644 --- a/core/src/main/java/org/keycloak/representations/workflows/WorkflowConstants.java +++ b/core/src/main/java/org/keycloak/representations/workflows/WorkflowConstants.java @@ -10,7 +10,8 @@ public final class WorkflowConstants { // Entry configuration keys for Workflow public static final String CONFIG_ON_EVENT = "on"; public static final String CONFIG_CONCURRENCY = "concurrency"; - public static final String CONFIG_CANCEL_IF_RUNNING = "cancel-if-running"; + public static final String CONFIG_RESTART_IN_PROGRESS = "restart-in-progress"; + public static final String CONFIG_CANCEL_IN_PROGRESS = "cancel-in-progress"; public static final String CONFIG_NAME = "name"; public static final String CONFIG_ENABLED = "enabled"; public static final String CONFIG_CONDITIONS = "conditions"; diff --git a/core/src/main/java/org/keycloak/representations/workflows/WorkflowRepresentation.java b/core/src/main/java/org/keycloak/representations/workflows/WorkflowRepresentation.java index 120b8d2dfe9..152f8eccf56 100644 --- a/core/src/main/java/org/keycloak/representations/workflows/WorkflowRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/workflows/WorkflowRepresentation.java @@ -12,13 +12,14 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CANCEL_IF_RUNNING; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CANCEL_IN_PROGRESS; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CONCURRENCY; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CONDITIONS; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ENABLED; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_IF; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_NAME; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ON_EVENT; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RESTART_IN_PROGRESS; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_STATE; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_STEPS; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_USES; @@ -108,11 +109,13 @@ public final class WorkflowRepresentation extends AbstractWorkflowComponentRepre } public WorkflowConcurrencyRepresentation getConcurrency() { + String cancelInProgress = getConfigValue(CONFIG_CANCEL_IN_PROGRESS, String.class); + String restartInProgress = getConfigValue(CONFIG_RESTART_IN_PROGRESS, String.class); if (this.concurrency == null) { - Boolean cancelIfRunning = getConfigValue(CONFIG_CANCEL_IF_RUNNING, Boolean.class); - if (cancelIfRunning != null) { + if (cancelInProgress != null || restartInProgress != null) { this.concurrency = new WorkflowConcurrencyRepresentation(); - this.concurrency.setCancelIfRunning(cancelIfRunning); + this.concurrency.setCancelInProgress(cancelInProgress); + this.concurrency.setRestartInProgress(restartInProgress); } } return this.concurrency; @@ -121,13 +124,19 @@ public final class WorkflowRepresentation extends AbstractWorkflowComponentRepre public void setConcurrency(WorkflowConcurrencyRepresentation concurrency) { this.concurrency = concurrency; if (concurrency != null) { - setConfigValue(CONFIG_CANCEL_IF_RUNNING, concurrency.isCancelIfRunning()); + setConfigValue(CONFIG_CANCEL_IN_PROGRESS, concurrency.getCancelInProgress()); + setConfigValue(CONFIG_RESTART_IN_PROGRESS, concurrency.getRestartInProgress()); } } @JsonIgnore - public boolean isCancelIfRunning() { - return concurrency != null && Boolean.TRUE.equals(concurrency.isCancelIfRunning()); + public String getCancelInProgress() { + return concurrency != null ? concurrency.getCancelInProgress() : null; + } + + @JsonIgnore + public String getRestartInProgress() { + return concurrency != null ? concurrency.getRestartInProgress() : null; } @Override @@ -165,16 +174,26 @@ public final class WorkflowRepresentation extends AbstractWorkflowComponentRepre } public Builder concurrency() { - representation.setConcurrency(new WorkflowConcurrencyRepresentation()); + if (representation.getConcurrency() == null) { + representation.setConcurrency(new WorkflowConcurrencyRepresentation()); + } return this; } // move this to its own builder if we expand the capabilities of the concurrency settings. - public Builder cancelIfRunning() { + public Builder cancelInProgress(String cancelInProgress) { if (representation.getConcurrency() == null) { representation.setConcurrency(new WorkflowConcurrencyRepresentation()); } - representation.getConcurrency().setCancelIfRunning(true); + representation.getConcurrency().setCancelInProgress(cancelInProgress); + return this; + } + + public Builder restartInProgress(String restartInProgress) { + if (representation.getConcurrency() == null) { + representation.setConcurrency(new WorkflowConcurrencyRepresentation()); + } + representation.getConcurrency().setRestartInProgress(restartInProgress); return this; } diff --git a/core/src/test/java/org/keycloak/representations/workflows/WorkflowDefinitionTest.java b/core/src/test/java/org/keycloak/representations/workflows/WorkflowDefinitionTest.java index 2c9d90e3606..de200f5c14c 100644 --- a/core/src/test/java/org/keycloak/representations/workflows/WorkflowDefinitionTest.java +++ b/core/src/test/java/org/keycloak/representations/workflows/WorkflowDefinitionTest.java @@ -27,7 +27,7 @@ public class WorkflowDefinitionTest { expected.setSteps(null); expected.setEnabled(true); - expected.setConcurrency(new WorkflowConcurrencyRepresentation(true)); + expected.setConcurrency(new WorkflowConcurrencyRepresentation("true", "user-role-removed(admin)")); expected.setSteps(Arrays.asList( WorkflowStepRepresentation.create() 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 2021b06cb82..a05623ec7d1 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,8 +200,11 @@ public class DefaultWorkflowProvider implements WorkflowProvider { WorkflowValidator.validateWorkflow(session, rep); MultivaluedHashMap config = ofNullable(rep.getConfig()).orElse(new MultivaluedHashMap<>()); - if (rep.isCancelIfRunning()) { - config.putSingle(WorkflowConstants.CONFIG_CANCEL_IF_RUNNING, "true"); + if (rep.getCancelInProgress() != null) { + config.putSingle(WorkflowConstants.CONFIG_CANCEL_IN_PROGRESS, rep.getCancelInProgress()); + } + if (rep.getRestartInProgress() != null) { + config.putSingle(WorkflowConstants.CONFIG_RESTART_IN_PROGRESS, rep.getRestartInProgress()); } Workflow workflow = addWorkflow(new Workflow(session, rep.getId(), config)); diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflow.java b/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflow.java index c22d4f1bc69..caaa77a7ad5 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflow.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflow.java @@ -8,9 +8,10 @@ import org.keycloak.models.workflow.conditions.expression.EvaluatorUtils; import org.keycloak.models.workflow.conditions.expression.EventEvaluator; import org.keycloak.utils.StringUtil; -import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CANCEL_IF_RUNNING; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CANCEL_IN_PROGRESS; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CONDITIONS; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ON_EVENT; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RESTART_IN_PROGRESS; final class EventBasedWorkflow { @@ -41,19 +42,37 @@ final class EventBasedWorkflow { return supports(event.getResourceType()) && activateOnEvent(event) && validateResourceConditions(executionContext); } + /** + * Evaluates the specified context to determine whether the workflow should be deactivated or not. Deactivation will happen + * if the context's event matches the configured cancel-in-progress setting. + * + * @param executionContext a reference to the workflow execution context. + * @return {@code true} if the workflow should be deactivated, {@code false} otherwise. + */ boolean deactivate(WorkflowExecutionContext executionContext) { - // TODO: rework this once we support concurrency/restart-if-running and concurrency/cancel-if-running to use expressions just like activation conditions - return false; + String cancelInProgress = model.getConfig().getFirst(CONFIG_CANCEL_IN_PROGRESS); + return matchesConcurrencySetting(executionContext, cancelInProgress); } + /** + * Evaluates the specified context to determine whether the workflow should be restarted or not. Restart will happen + * if the context's event matches the configured restart-in-progress setting. + * + * @param executionContext a reference to the workflow execution context. + * @return {@code true} if the workflow should be restarted, {@code false} otherwise. + */ boolean restart(WorkflowExecutionContext executionContext) { - WorkflowEvent event = executionContext.getEvent(); - if (event == null) { - return false; - } - return isCancelIfRunning() && activate(executionContext); + String restartInProgress = model.getConfig().getFirst(CONFIG_RESTART_IN_PROGRESS); + return matchesConcurrencySetting(executionContext, restartInProgress); } + /** + * Validates the resource conditions defined in the workflow configuration against the given execution context. + * If no conditions are defined, the method returns {@code true}. + * + * @param context a reference to the workflow execution context. + * @return {@code true} if the resource conditions are met or not defined, {@code false} otherwise. + */ public boolean validateResourceConditions(WorkflowExecutionContext context) { String conditions = getModel().getConfig().getFirst(CONFIG_CONDITIONS); if (StringUtil.isNotBlank(conditions)) { @@ -87,6 +106,36 @@ final class EventBasedWorkflow { } } + /** + * Determines whether the event in the given execution context matches the concurrency setting, which can be one of + * {@code restart-in-progress} or {@code cancel-in-progress}. If the setting is set to "true", the decision is based + * on the activation settings. If the setting contains an event expression, it is parsed and evaluated. + * + * @param executionContext a reference to the workflow execution context. + * @param concurrencySetting the concurrency setting to evaluate. + * @return {@code true} if the event matches the concurrency setting, {@code false} otherwise. + */ + private boolean matchesConcurrencySetting(WorkflowExecutionContext executionContext, String concurrencySetting) { + WorkflowEvent event = executionContext.getEvent(); + if (event == null) { + return false; + } + + if (StringUtil.isNotBlank(concurrencySetting)) { + // if the setting is "true", we decide based on the activation conditions but only if the workflow has activation events configured + if (Boolean.parseBoolean(concurrencySetting)) { + return StringUtil.isNotBlank(model.getConfig().getFirst(CONFIG_ON_EVENT)) && activate(executionContext); + } + else { + // the flag has an event expression - parse and evaluate it + BooleanConditionParser.EvaluatorContext context = EvaluatorUtils.createEvaluatorContext(model, concurrencySetting); + EventEvaluator eventEvaluator = new EventEvaluator(getSession(), executionContext.getEvent()); + return eventEvaluator.visit(context); + } + } + return false; + } + private ComponentModel getModel() { return model; } @@ -94,8 +143,4 @@ final class EventBasedWorkflow { private KeycloakSession getSession() { return session; } - - private boolean isCancelIfRunning() { - return Boolean.parseBoolean(model.getConfig().getFirstOrDefault(CONFIG_CANCEL_IF_RUNNING, "false")); - } } diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/WorkflowValidator.java b/model/jpa/src/main/java/org/keycloak/models/workflow/WorkflowValidator.java index a00c9304195..939bf6ac124 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/WorkflowValidator.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/WorkflowValidator.java @@ -27,6 +27,12 @@ public class WorkflowValidator { if (StringUtil.isNotBlank(rep.getConditions())) { validateConditionExpression(session, rep.getConditions(), "if"); } + if (StringUtil.isNotBlank(rep.getCancelInProgress())) { + validateConditionExpression(session, rep.getCancelInProgress(), "cancel-in-progress"); + } + if (StringUtil.isNotBlank(rep.getRestartInProgress())) { + validateConditionExpression(session, rep.getRestartInProgress(), "restart-in-progress"); + } // if a workflow has a restart step, at least one of the previous steps must be scheduled to prevent an infinite loop of immediate executions List steps = ofNullable(rep.getSteps()).orElse(List.of()); @@ -82,12 +88,16 @@ public class WorkflowValidator { } private static void validateConditionExpression(KeycloakSession session, String expression, String fieldName) throws WorkflowInvalidStateException { + if (Boolean.parseBoolean(expression)) { + // some fields allow the value "true" to be used - in this case there's nothing to validate + return; + } BooleanConditionParser.EvaluatorContext context = EvaluatorUtils.createEvaluatorContext(expression); ConditionNameCollector collector = new ConditionNameCollector(); collector.visit(context); // check if there are providers for the conditions used in the expression - if ("on".equals(fieldName)) { + if ("on".equals(fieldName) || "restart-in-progress".equals(fieldName) || "cancel-in-progress".equals(fieldName)) { // check if we can get a ResourceOperationType for the events in the expression for (String name : collector.getConditionNames()) { try { diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java index c9412afccf6..78df66b923c 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java @@ -17,19 +17,15 @@ package org.keycloak.tests.admin.model.workflow; -import java.io.IOException; import java.time.Duration; import java.util.List; import jakarta.mail.internet.MimeMessage; -import jakarta.ws.rs.core.Response; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.workflow.DisableUserStepProviderFactory; import org.keycloak.models.workflow.NotifyUserStepProviderFactory; -import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory; -import org.keycloak.models.workflow.WorkflowStateProvider; import org.keycloak.representations.workflows.WorkflowRepresentation; import org.keycloak.representations.workflows.WorkflowStepRepresentation; import org.keycloak.testframework.annotations.InjectUser; @@ -37,11 +33,9 @@ import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.injection.LifeCycle; import org.keycloak.testframework.mail.MailServer; import org.keycloak.testframework.mail.annotations.InjectMailServer; -import org.keycloak.testframework.realm.GroupConfigBuilder; import org.keycloak.testframework.realm.ManagedUser; import org.keycloak.testframework.realm.UserConfig; import org.keycloak.testframework.realm.UserConfigBuilder; -import org.keycloak.testframework.util.ApiUtil; import org.junit.jupiter.api.Test; @@ -51,10 +45,6 @@ import static org.keycloak.tests.admin.model.workflow.WorkflowManagementTest.fin import static org.keycloak.tests.admin.model.workflow.WorkflowManagementTest.findEmailsByRecipient; import static org.keycloak.tests.admin.model.workflow.WorkflowManagementTest.verifyEmailContent; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -69,89 +59,12 @@ public class UserSessionRefreshTimeWorkflowTest extends AbstractWorkflowTest { @InjectMailServer private MailServer mailServer; - @Test - public void testWorkflowIsRestartedOnSameEvent() throws IOException { - // create a workflow that can restarted on the same event - i.e. has concurrency setting to cancel if running - managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow") - .onEvent(USER_LOGGED_IN.toString()) - .concurrency().cancelIfRunning() // this setting enables restarting the workflow - .withSteps( - WorkflowStepRepresentation.create() - .of(SetUserAttributeStepProviderFactory.ID) - .withConfig("attribute", "attr1") - .after(Duration.ofDays(1)) - .build(), - WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID) - .after(Duration.ofDays(5)) - .build() - ).build()).close(); - - // login with alice - this will attach the workflow to the user and schedule the first step - oauth.openLoginForm(); - String userId = userAlice.getId(); - String username = userAlice.getUsername(); - loginPage.fillLogin(username, userAlice.getPassword()); - loginPage.submit(); - assertTrue(driver.page().getPageSource() != null && driver.page().getPageSource().contains("Happy days")); - - // store the first step id for later comparison - String firstStepId = runOnServer.fetch(session-> { - WorkflowStateProvider provider = session.getProvider(WorkflowStateProvider.class); - List< WorkflowStateProvider.ScheduledStep> steps = provider.getScheduledStepsByResource(userId).toList(); - assertThat(steps, hasSize(1)); - return steps.get(0).stepId(); - }, String.class); - - // run the first schedule task - workflow should now be waiting to run the second step - runScheduledSteps(Duration.ofDays(2)); - String secondStepId = runOnServer.fetch(session -> { - RealmModel realm = session.getContext().getRealm(); - UserModel user = session.users().getUserByUsername(realm, username); - // first step should have run and the attribute should be set - assertThat(user.getFirstAttribute("attribute"), is("attr1")); - assertTrue(user.isEnabled()); - - WorkflowStateProvider provider = session.getProvider(WorkflowStateProvider.class); - List< WorkflowStateProvider.ScheduledStep> steps = provider.getScheduledStepsByResource(userId).toList(); - assertThat(steps, hasSize(1)); - return steps.get(0).stepId(); - }, String.class); - assertThat(secondStepId, is(not(firstStepId))); - - String groupId; - // trigger an unrelated event - like user joining a group. The workflow must not be restarted - try (Response response = managedRealm.admin().groups().add(GroupConfigBuilder.create() - .name("generic-group").build())) { - groupId = ApiUtil.getCreatedId(response); - } - managedRealm.admin().users().get(userAlice.getId()).joinGroup(groupId); - - runOnServer.run(session -> { - WorkflowStateProvider provider = session.getProvider(WorkflowStateProvider.class); - List< WorkflowStateProvider.ScheduledStep> steps = provider.getScheduledStepsByResource(userId).toList(); - // step id must remain the same as before - assertThat(steps, hasSize(1)); - assertThat(steps.get(0).stepId(), is(secondStepId)); - }); - - // now trigger the same event again that can restart the workflow - oauth.openLoginForm(); - - // workflow should be restarted and the first step should be scheduled again - runOnServer.run(session -> { - WorkflowStateProvider provider = session.getProvider(WorkflowStateProvider.class); - List< WorkflowStateProvider.ScheduledStep> steps = provider.getScheduledStepsByResource(userId).toList(); - // step id must be the first one now as the workflow was restarted - assertThat(steps, hasSize(1)); - assertThat(steps.get(0).stepId(), is(firstStepId)); - }); - } @Test public void testDisabledUserAfterInactivityPeriod() { managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow") .onEvent(USER_ADDED.toString(), USER_LOGGED_IN.toString()) - .concurrency().cancelIfRunning() // this setting enables restarting the workflow + .concurrency().restartInProgress("true") // this setting enables restarting the workflow .withSteps( WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID) .after(Duration.ofDays(5)) diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowConcurrencyTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowConcurrencyTest.java new file mode 100644 index 00000000000..4e2b0ae9551 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowConcurrencyTest.java @@ -0,0 +1,299 @@ +package org.keycloak.tests.admin.model.workflow; + +import java.time.Duration; +import java.util.List; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.workflow.DisableUserStepProviderFactory; +import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory; +import org.keycloak.models.workflow.WorkflowStateProvider; +import org.keycloak.representations.workflows.WorkflowRepresentation; +import org.keycloak.representations.workflows.WorkflowStepRepresentation; +import org.keycloak.testframework.annotations.InjectUser; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.injection.LifeCycle; +import org.keycloak.testframework.realm.GroupConfigBuilder; +import org.keycloak.testframework.realm.ManagedUser; +import org.keycloak.testframework.realm.UserConfig; +import org.keycloak.testframework.realm.UserConfigBuilder; +import org.keycloak.testframework.util.ApiUtil; + +import org.junit.jupiter.api.Test; + +import static org.keycloak.models.workflow.ResourceOperationType.USER_LOGGED_IN; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for workflows with concurrency settings that allow restarting and cancelling the workflow using the same activation event + * or different events. + */ +@KeycloakIntegrationTest(config = WorkflowsBlockingServerConfig.class) +public class WorkflowConcurrencyTest extends AbstractWorkflowTest { + + @InjectUser(ref = "alice", config = WorkflowConcurrencyTest.DefaultUserConfig.class, lifecycle = LifeCycle.METHOD, realmRef = DEFAULT_REALM_NAME) + private ManagedUser userAlice; + + @Test + public void testWorkflowIsRestartedOnSameEvent() { + // create a workflow that can be restarted on the same event - i.e. has concurrency setting with restart-in-progress=true + managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow") + .onEvent(USER_LOGGED_IN.toString()) + .concurrency().restartInProgress("true") + .withSteps( + WorkflowStepRepresentation.create() + .of(SetUserAttributeStepProviderFactory.ID) + .withConfig("attribute", "attr1") + .after(Duration.ofDays(1)) + .build(), + WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build() + ).build()).close(); + + // create a test group so we can use it to trigger a non-restarting event + String testGroupId; + try (Response response = managedRealm.admin().groups().add(GroupConfigBuilder.create() + .name("testgroup").build())) { + testGroupId = ApiUtil.getCreatedId(response); + } + this.assertWorkflowAffectedOnCorrectEvent( + () -> managedRealm.admin().users().get(userAlice.getId()).joinGroup(testGroupId), // unrelated event + () -> oauth.openLoginForm(), // correct event + false); + } + + @Test + public void testWorkflowIsRestartedOnDifferentEvent() { + // create a couple of test groups to trigger different group membership events + String testGroupId; + try (Response response = managedRealm.admin().groups().add(GroupConfigBuilder.create() + .name("testgroup").build())) { + testGroupId = ApiUtil.getCreatedId(response); + } + String anotherGroupId; + try (Response response = managedRealm.admin().groups().add(GroupConfigBuilder.create() + .name("anothergroup").build())) { + anotherGroupId = ApiUtil.getCreatedId(response); + } + + // create a workflow that can be restarted on a different event - i.e. restart-in-progress is set to an event expression + // in this case we will use user-group-membership-added event to restart the workflow when user joins the group "testgroup" + managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow") + .onEvent(USER_LOGGED_IN.toString()) + .concurrency().restartInProgress("user-group-membership-added(testgroup)") + .withSteps( + WorkflowStepRepresentation.create() + .of(SetUserAttributeStepProviderFactory.ID) + .withConfig("attribute", "attr1") + .after(Duration.ofDays(1)) + .build(), + WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build() + ).build()).close(); + + this.assertWorkflowAffectedOnCorrectEvent( + () -> managedRealm.admin().users().get(userAlice.getId()).joinGroup(anotherGroupId), // unrelated event + () -> managedRealm.admin().users().get(userAlice.getId()).joinGroup(testGroupId), // correct event + false); + } + + @Test + public void testWorkflowIsCancelledOnSameEvent() { + // create a workflow that can be cancelled on the same event - i.e. has concurrency setting with cancel-in-progress=true + managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow") + .onEvent(USER_LOGGED_IN.toString()) + .concurrency().cancelInProgress("true") + .withSteps( + WorkflowStepRepresentation.create() + .of(SetUserAttributeStepProviderFactory.ID) + .withConfig("attribute", "attr1") + .after(Duration.ofDays(1)) + .build(), + WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build() + ).build()).close(); + + // create a test group so we can use it to trigger a non-restarting event + String testGroupId; + try (Response response = managedRealm.admin().groups().add(GroupConfigBuilder.create() + .name("testgroup").build())) { + testGroupId = ApiUtil.getCreatedId(response); + } + this.assertWorkflowAffectedOnCorrectEvent( + () -> managedRealm.admin().users().get(userAlice.getId()).joinGroup(testGroupId), // unrelated event + () -> oauth.openLoginForm(), // correct event + true); + } + + @Test + public void testWorkflowIsCancelledOnDifferentEvent() { + // create a couple of test groups to trigger different group membership events + String testGroupId; + try (Response response = managedRealm.admin().groups().add(GroupConfigBuilder.create() + .name("testgroup").build())) { + testGroupId = ApiUtil.getCreatedId(response); + } + String anotherGroupId; + try (Response response = managedRealm.admin().groups().add(GroupConfigBuilder.create() + .name("anothergroup").build())) { + anotherGroupId = ApiUtil.getCreatedId(response); + } + + // create a workflow that can be cancelled on a different event - i.e. cancel-in-progress is set to an event expression + // in this case we will use user-group-membership-added event to cancel the workflow when user joins the group "testgroup" + managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow") + .onEvent(USER_LOGGED_IN.toString()) + .concurrency().cancelInProgress("user-group-membership-added(testgroup)") + .withSteps( + WorkflowStepRepresentation.create() + .of(SetUserAttributeStepProviderFactory.ID) + .withConfig("attribute", "attr1") + .after(Duration.ofDays(1)) + .build(), + WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build() + ).build()).close(); + + this.assertWorkflowAffectedOnCorrectEvent( + () -> managedRealm.admin().users().get(userAlice.getId()).joinGroup(anotherGroupId), // unrelated event + () -> managedRealm.admin().users().get(userAlice.getId()).joinGroup(testGroupId), // correct event + true); + } + + @Test + public void testWorkflowIsRestartedOnSameEventAndCancelledOnDifferentEvent() { + // create a couple of test groups to trigger different group membership events + String testGroupId; + try (Response response = managedRealm.admin().groups().add(GroupConfigBuilder.create() + .name("testgroup").build())) { + testGroupId = ApiUtil.getCreatedId(response); + } + String anotherGroupId; + try (Response response = managedRealm.admin().groups().add(GroupConfigBuilder.create() + .name("anothergroup").build())) { + anotherGroupId = ApiUtil.getCreatedId(response); + } + + // create workflow with both settings - restart-in-progress on same event, cancel-in-progress on different event + managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow") + .onEvent(USER_LOGGED_IN.toString()) + .concurrency().restartInProgress("true") + .cancelInProgress("user-group-membership-added(testgroup)") + .withSteps( + WorkflowStepRepresentation.create() + .of(SetUserAttributeStepProviderFactory.ID) + .withConfig("attribute", "attr1") + .after(Duration.ofDays(1)) + .build(), + WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build() + ).build()).close(); + + // joining "anothergroup" should have no effect, so test that re-authenticating restarts the workflow + this.assertWorkflowAffectedOnCorrectEvent( + () -> managedRealm.admin().users().get(userAlice.getId()).joinGroup(anotherGroupId), // unrelated event + () -> oauth.openLoginForm(), // correct event to restart + false); // should not cancel the workflow + + // now joining "testgroup" should cancel the workflow + this.assertWorkflowAffectedOnCorrectEvent( + () -> {}, // do nothing as the unrelated event for this test + () -> managedRealm.admin().users().get(userAlice.getId()).joinGroup(testGroupId), // correct event to cancel + false, // do not attempt to login again + true); // should cancel the workflow + } + + private void assertWorkflowAffectedOnCorrectEvent(Runnable unrelatedEventTrigger, Runnable relatedEventTrigger, boolean cancelled) { + this.assertWorkflowAffectedOnCorrectEvent(unrelatedEventTrigger, relatedEventTrigger, true, cancelled); + } + + private void assertWorkflowAffectedOnCorrectEvent(Runnable unrelatedEventTrigger, Runnable relatedEventTrigger, boolean login, boolean cancelled) { + + String userId = userAlice.getId(); + String username = userAlice.getUsername(); + if (login) { + // login with alice - this will attach the workflow to the user and schedule the first step + oauth.openLoginForm(); + loginPage.fillLogin(username, userAlice.getPassword()); + loginPage.submit(); + assertTrue(driver.page().getPageSource() != null && driver.page().getPageSource().contains("Happy days")); + } + + // store the first step id for later comparison + String firstStepId = runOnServer.fetch(session-> { + WorkflowStateProvider provider = session.getProvider(WorkflowStateProvider.class); + List< WorkflowStateProvider.ScheduledStep> steps = provider.getScheduledStepsByResource(userId).toList(); + assertThat(steps, hasSize(1)); + return steps.get(0).stepId(); + }, String.class); + + // run the first schedule task - workflow should now be waiting to run the second step + runScheduledSteps(Duration.ofDays(2)); + String secondStepId = runOnServer.fetch(session -> { + RealmModel realm = session.getContext().getRealm(); + UserModel user = session.users().getUserByUsername(realm, username); + // first step should have run and the attribute should be set + assertThat(user.getFirstAttribute("attribute"), is("attr1")); + assertTrue(user.isEnabled()); + + WorkflowStateProvider provider = session.getProvider(WorkflowStateProvider.class); + List< WorkflowStateProvider.ScheduledStep> steps = provider.getScheduledStepsByResource(userId).toList(); + assertThat(steps, hasSize(1)); + return steps.get(0).stepId(); + }, String.class); + assertThat(secondStepId, is(not(firstStepId))); + + // run the non-restarting event trigger - the workflow must not be restarted + unrelatedEventTrigger.run(); + + runOnServer.run(session -> { + WorkflowStateProvider provider = session.getProvider(WorkflowStateProvider.class); + List< WorkflowStateProvider.ScheduledStep> steps = provider.getScheduledStepsByResource(userId).toList(); + // step id must remain the same as before + assertThat(steps, hasSize(1)); + assertThat(steps.get(0).stepId(), is(secondStepId)); + }); + + // run the restarting event trigger - this must restart the workflow + relatedEventTrigger.run(); + + // workflow should have been restarted or cancelled + runOnServer.run(session -> { + WorkflowStateProvider provider = session.getProvider(WorkflowStateProvider.class); + List< WorkflowStateProvider.ScheduledStep> steps = provider.getScheduledStepsByResource(userId).toList(); + // step id must be the first one now as the workflow was restarted + if (cancelled) { + assertThat(steps, hasSize(0)); + } else { + // restarted - first step must be scheduled again + assertThat(steps, hasSize(1)); + assertThat(steps.get(0).stepId(), is(firstStepId)); + } + }); + } + + private static class DefaultUserConfig implements UserConfig { + + @Override + public UserConfigBuilder configure(UserConfigBuilder user) { + user.username("alice"); + user.password("alice"); + user.name("alice", "alice"); + user.email("master-admin@email.org"); + return user; + } + } + +}