mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-21 06:20:05 -06:00
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:
committed by
Pedro Igor
parent
b5178a2bec
commit
b14d00e08f
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user