Improve workflow concurrency settings

- allow restarting based on events
 - allow cancelling based on events

Closes #44645

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen
2025-12-04 17:56:51 -03:00
committed by Pedro Igor
parent b5178a2bec
commit b14d00e08f
9 changed files with 434 additions and 126 deletions

View File

@@ -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);
}
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -200,8 +200,11 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
WorkflowValidator.validateWorkflow(session, rep);
MultivaluedHashMap<String, String> 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));

View File

@@ -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"));
}
}

View File

@@ -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<WorkflowStepRepresentation> 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 {

View File

@@ -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))

View File

@@ -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;
}
}
}