Allow running scheduled workflows

Closes #44865

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2025-12-11 18:59:06 -03:00
parent 0e4b629d91
commit 0d5766f3a8
21 changed files with 472 additions and 115 deletions

View File

@ -9,6 +9,7 @@ public final class WorkflowConstants {
// Entry configuration keys for Workflow
public static final String CONFIG_ON_EVENT = "on";
public static final String CONFIG_SCHEDULE = "schedule";
public static final String CONFIG_CONCURRENCY = "concurrency";
public static final String CONFIG_RESTART_IN_PROGRESS = "restart-in-progress";
public static final String CONFIG_CANCEL_IN_PROGRESS = "cancel-in-progress";
@ -26,4 +27,9 @@ public final class WorkflowConstants {
public static final String CONFIG_AFTER = "after";
public static final String CONFIG_PRIORITY = "priority";
public static final String CONFIG_SCHEDULED_AT = "scheduled-at";
// Entry configuration keys for WorkflowSchedule
public static final String CONFIG_SCHEDULE_AFTER = "schedule." + CONFIG_AFTER;
public static final String CONFIG_BATCH_SIZE = "batch-size";
public static final String CONFIG_SCHEDULE_BATCH_SIZE = "schedule." + CONFIG_BATCH_SIZE;
}

View File

@ -20,12 +20,15 @@ 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_SCHEDULE;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_SCHEDULE_AFTER;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_SCHEDULE_BATCH_SIZE;
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;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_WITH;
@JsonPropertyOrder({"id", CONFIG_NAME, CONFIG_USES, CONFIG_ENABLED, CONFIG_ON_EVENT, CONFIG_CONCURRENCY, CONFIG_IF, CONFIG_STEPS, CONFIG_STATE})
@JsonPropertyOrder({"id", CONFIG_NAME, CONFIG_USES, CONFIG_ENABLED, CONFIG_ON_EVENT, CONFIG_SCHEDULE, CONFIG_CONCURRENCY, CONFIG_IF, CONFIG_STEPS, CONFIG_STATE})
@JsonIgnoreProperties(CONFIG_WITH)
@JsonInclude(JsonInclude.Include.NON_NULL)
public final class WorkflowRepresentation extends AbstractWorkflowComponentRepresentation {
@ -41,6 +44,9 @@ public final class WorkflowRepresentation extends AbstractWorkflowComponentRepre
@JsonProperty(CONFIG_CONCURRENCY)
private WorkflowConcurrencyRepresentation concurrency;
@JsonProperty(CONFIG_SCHEDULE)
private WorkflowScheduleRepresentation schedule;
public WorkflowRepresentation() {
super(null, null);
}
@ -59,6 +65,29 @@ public final class WorkflowRepresentation extends AbstractWorkflowComponentRepre
setConfigValue(CONFIG_ON_EVENT, eventConditions);
}
public WorkflowScheduleRepresentation getSchedule() {
if (schedule == null) {
String after = getConfigValue(CONFIG_SCHEDULE_AFTER, String.class);
Integer batchSize = getConfigValue(CONFIG_SCHEDULE_BATCH_SIZE, Integer.class);
if (after != null || batchSize != null) {
this.schedule = new WorkflowScheduleRepresentation();
this.schedule.setAfter(after);
this.schedule.setBatchSize(batchSize);
}
}
return this.schedule;
}
public void setSchedule(WorkflowScheduleRepresentation schedule) {
this.schedule = schedule;
if (schedule != null) {
setConfigValue(CONFIG_SCHEDULE_AFTER, schedule.getAfter());
setConfigValue(CONFIG_SCHEDULE_BATCH_SIZE, schedule.getBatchSize());
}
}
public String getName() {
return getConfigValue(CONFIG_NAME, String.class);
}
@ -217,6 +246,11 @@ public final class WorkflowRepresentation extends AbstractWorkflowComponentRepre
return this;
}
public Builder schedule(WorkflowScheduleRepresentation schedule) {
representation.setSchedule(schedule);
return this;
}
public WorkflowRepresentation build() {
return representation;
}

View File

@ -0,0 +1,57 @@
package org.keycloak.representations.workflows;
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_AFTER;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_BATCH_SIZE;
@JsonPropertyOrder({CONFIG_AFTER, CONFIG_BATCH_SIZE})
@JsonInclude(JsonInclude.Include.NON_NULL)
public class WorkflowScheduleRepresentation {
private String after;
@JsonProperty(CONFIG_BATCH_SIZE)
private Integer batchSize;
public static Builder create() {
return new Builder();
}
public String getAfter() {
return after;
}
public void setAfter(String after) {
this.after = after;
}
public Integer getBatchSize() {
return this.batchSize;
}
public void setBatchSize(Integer batchSize) {
this.batchSize = batchSize;
}
public static class Builder {
private final WorkflowScheduleRepresentation schedule = new WorkflowScheduleRepresentation();
public Builder after(String after) {
schedule.setAfter(after);
return this;
}
public Builder batchSize(int batchSize) {
schedule.setBatchSize(batchSize);
return this;
}
public WorkflowScheduleRepresentation build() {
return schedule;
}
}
}

View File

@ -8,6 +8,7 @@ include::workflows/understanding-workflow-definition.adoc[leveloffset=+2]
include::workflows/understanding-workflow-expression.adoc[leveloffset=+2]
include::workflows/managing-workflows.adoc[leveloffset=+2]
include::workflows/listening-workflow-events.adoc[leveloffset=+2]
include::workflows/scheduling-workflows.adoc[leveloffset=+2]
include::workflows/defining-conditions.adoc[leveloffset=+2]
include::workflows/defining-steps.adoc[leveloffset=+2]
include::workflows/understanding-workflows-engine.adoc[leveloffset=+2]

View File

@ -70,7 +70,7 @@ image:images/organizations-edit-identity-provider.png[alt="Editing linked identi
== Unlinking an identity provider from an organization
When an identity provider is unlinked from an organization, it remains available as a realm-level provider that is no longer ssociated with an organization. To delete the unlinked provider, use the *Identity Providers* section in the menu.
When an identity provider is unlinked from an organization, it remains available as a realm-level provider that is no longer associated with an organization. To delete the unlinked provider, use the *Identity Providers* section in the menu.
.Procedure

View File

@ -3,7 +3,8 @@
[[_workflow_conditions_]]
= Defining conditions
The optional `if` setting allows you to define the conditions the target resource must meet in order for the workflow to be triggered.
The optional `if` setting allows you to define the conditions, as expressions, that the target resource must meet in
order for the workflow to be triggered. See <<_workflow_expression_language_>> for more details.
Conditions provide fine-grained control over whether a workflow execution should be created.
They allow you to inspect the context of the event and the state of the resource.

View File

@ -0,0 +1,34 @@
[id="scheduling-workflows_{context}"]
[[_scheduling_workflows_]]
= Scheduling workflows
Workflows can be scheduled to run periodically according to a defined interval. This is done using the `schedule` setting
in the workflow definition.
A key difference between event-based triggering and scheduled workflows is that scheduled workflows follows a passive execution model
where the workflow engine periodically checks for realm resources that meet the defined condition.
```yaml
name: Track inactive users
schedule:
after: 5s
batch-size: 100
```
This method of scheduling is useful for automating tasks that need to be performed regularly, such as cleaning up inactive
users or enforcing specific policies on realm resources. It is an alternative to event-based triggering, but it can also
be used in combination with it. When used together, the workflow will be triggered either by the defined event or by the schedule.
When a workflow is scheduled, it will be triggered automatically at the defined interval. At each run, the workflow engine
will query for the realm resources that matches the workflow's condition and will create a workflow execution for each of them,
up to the defined batch size. If no condition is defined, the workflow will be executed for all realm resources of the type associated
with the workflow. In the example above, the workflow is scheduled to run every 5 seconds and will process up to 100 realm resources at each run.
The `schedule` setting supports the following parameters:
* `after`: Defines the interval between each run of the workflow.
* `batch-size`: Defines the maximum number of realm resources to process at each run.

View File

@ -42,7 +42,10 @@ When a user has been inactive for a certain period, a workflow can send reminder
```yaml
name: Track inactive users
on: user_authenticated
on: user-authenticated
schedule:
after: 5s
batch-size: 2
concurrency:
restart-in-progress: true
steps:

View File

@ -53,6 +53,12 @@ This setting is mandatory.
`on`::
Define a condition that determines the event that will trigger the workflow.
The condition is written using an expression language that supports a variety of checks on the event.
See <<_workflow_events_>> for more details.
This setting is optional.
`schedule`::
Define a schedule that will trigger the workflow at defined intervals.
See <<_scheduling_workflows_>> for more details.
This setting is optional.
`if`::
@ -61,10 +67,12 @@ The condition is written using an expression language that supports a variety of
associated with the event.
A workflow execution is only created if the expression evaluates to `true`.
If this setting is omitted, the event defined in the `on` setting will always create the workflow execution.
See <<_workflow_conditions_>> for more details.
This setting is optional.
`steps`::
Define the step chain consisting of one or more steps to be sequentially executed during the lifetime of a workflow.
See <<_workflow_steps_>> for more details.
This setting is mandatory.
`concurrency`::

View File

@ -28,14 +28,6 @@ public interface WorkflowResource {
@Produces(APPLICATION_JSON)
WorkflowRepresentation toRepresentation();
@Path("activate-all")
@POST
void activateAll();
@Path("activate-all")
@POST
void activateAll(@QueryParam("notBefore") String notBefore);
@Path("activate/{type}/{resourceId}")
@POST
void activate(@PathParam("type") String type, @PathParam("resourceId") String resourceId);

View File

@ -1,5 +1,6 @@
package org.keycloak.models.workflow;
import java.time.Duration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@ -24,6 +25,8 @@ import org.keycloak.models.workflow.WorkflowStateProvider.ScheduledStep;
import org.keycloak.representations.workflows.WorkflowConstants;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.services.scheduled.ClusterAwareScheduledTaskRunner;
import org.keycloak.timer.TimerProvider;
import org.jboss.logging.Logger;
@ -95,6 +98,9 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
// finally, update the workflow's config along with the steps' configs
workflow.updateConfig(representation.getConfig(), newSteps);
}
cancelScheduledWorkflow(workflow);
scheduleWorkflow(workflow);
}
@Override
@ -104,6 +110,7 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
realm.getComponentsStream(workflow.getId(), WorkflowStepProvider.class.getName()).forEach(realm::removeComponent);
realm.removeComponent(component);
stateProvider.removeByWorkflow(workflow.getId());
cancelScheduledWorkflow(workflow);
}
@Override
@ -341,6 +348,29 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
model.setConfig(config);
}
return new Workflow(session, realm.addComponentModel(model));
workflow = new Workflow(session, realm.addComponentModel(model));
scheduleWorkflow(workflow);
return workflow;
}
private void scheduleWorkflow(Workflow workflow) {
String scheduled = workflow.getConfig().getFirst(WorkflowConstants.CONFIG_SCHEDULE_AFTER);
if (scheduled != null) {
Duration duration = DurationConverter.parseDuration(scheduled);
TimerProvider timer = session.getProvider(TimerProvider.class);
timer.schedule(new ClusterAwareScheduledTaskRunner(sessionFactory, new ScheduledWorkflowRunner(workflow.getId(), realm.getId()), duration.toMillis()), duration.toMillis());
}
}
void cancelScheduledWorkflow(Workflow workflow) {
session.getProvider(TimerProvider.class).cancelTask(new ScheduledWorkflowRunner(workflow.getId(), realm.getId()).getTaskName());
}
void rescheduleWorkflow(Workflow workflow) {
cancelScheduledWorkflow(workflow);
scheduleWorkflow(workflow);
}
}

View File

@ -8,10 +8,15 @@ import org.keycloak.component.ComponentModel;
import org.keycloak.executors.ExecutorsProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel.RealmRemovedEvent;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.PostMigrationEvent;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.provider.ProviderEvent;
import org.keycloak.provider.ProviderEventListener;
public class DefaultWorkflowProviderFactory implements WorkflowProviderFactory<DefaultWorkflowProvider> {
public class DefaultWorkflowProviderFactory implements WorkflowProviderFactory<DefaultWorkflowProvider>, ProviderEventListener {
static final String ID = "default";
private static final long DEFAULT_EXECUTOR_TASK_TIMEOUT = 1000L;
@ -44,8 +49,37 @@ public class DefaultWorkflowProviderFactory implements WorkflowProviderFactory<D
@Override
public void postInit(KeycloakSessionFactory factory) {
this.executor = new WorkflowExecutor(getTaskExecutor(factory), blocking, taskTimeout);
factory.register(this);
}
@Override
public void onEvent(ProviderEvent event) {
if (event instanceof PostMigrationEvent ev) {
KeycloakModelUtils.runJobInTransaction(ev.getFactory(), session ->
session.realms().getRealmsStream().forEach(realm -> {
session.getContext().setRealm(realm);
DefaultWorkflowProvider provider = create(session);
try {
provider.getWorkflows().forEach(provider::rescheduleWorkflow);
} finally {
session.getContext().setRealm(null);
provider.close();
}
}));
} else if (event instanceof RealmRemovedEvent ev) {
KeycloakSession session = ev.getKeycloakSession();
DefaultWorkflowProvider provider = create(session);
try {
provider.getWorkflows().forEach(provider::cancelScheduledWorkflow);
} finally {
provider.close();
}
}
}
@Override
public void close() {

View File

@ -0,0 +1,54 @@
package org.keycloak.models.workflow;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.timer.ScheduledTask;
import org.jboss.logging.Logger;
public class ScheduledWorkflowRunner implements ScheduledTask {
private static final Logger log = Logger.getLogger(DefaultWorkflowProvider.class);
private final String workflowId;
private final String realmId;
public ScheduledWorkflowRunner(String workflowId, String realmId) {
this.workflowId = workflowId;
this.realmId = realmId;
}
@Override
public void run(KeycloakSession session) {
RealmModel realm = session.realms().getRealm(realmId);
if (realm == null) {
log.warnf("Realm %s for scheduled workflow %s not found, cancelling task", realmId, workflowId);
throw new IllegalStateException("Realm for scheduled workflow not found: " + realmId);
}
session.getContext().setRealm(realm);
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
Workflow workflow = provider.getWorkflow(workflowId);
if (workflow == null) {
log.warnf("Scheduled workflow %s in realm %s not found, cancelling task", workflowId, realmId);
throw new IllegalStateException("Scheduled workflow not found: " + workflowId);
}
log.debugf("Executing scheduled workflow '%s' in realm %s", workflow.getName(), realm.getName());
try {
provider.activateForAllEligibleResources(workflow);
} catch (Exception e) {
log.errorf(e, "Error while executing scheduled workflow %s in realm %s", workflow.getName(), realm.getName());
}
log.debugf("Finished executing scheduled workflow '%s' in realm %s", workflow.getName(), realm.getName());
}
@Override
public String getTaskName() {
return "workflow-" + workflowId;
}
}

View File

@ -36,6 +36,7 @@ import org.keycloak.models.jpa.entities.UserEntity;
import org.keycloak.models.workflow.conditions.expression.BooleanConditionParser;
import org.keycloak.models.workflow.conditions.expression.EvaluatorUtils;
import org.keycloak.models.workflow.conditions.expression.PredicateEvaluator;
import org.keycloak.representations.workflows.WorkflowConstants;
import org.keycloak.utils.StringUtil;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CONDITIONS;
@ -77,7 +78,9 @@ public class UserResourceTypeWorkflowProvider implements ResourceTypeSelector {
query.select(userRoot.get("id")).where(predicates);
return em.createQuery(query).getResultList();
int batchSize = Integer.parseInt(workflow.getConfig().getFirstOrDefault(WorkflowConstants.CONFIG_SCHEDULE_BATCH_SIZE, "100"));
return em.createQuery(query).setMaxResults(batchSize).getResultList();
}
@Override

View File

@ -76,6 +76,7 @@ public class WorkflowResource {
})
public void update(WorkflowRepresentation rep) {
try {
rep.setId(workflow.getId());
provider.updateWorkflow(workflow, rep);
} catch (ModelException me) {
throw ErrorResponse.error(me.getMessage(), Response.Status.BAD_REQUEST);
@ -152,23 +153,6 @@ public class WorkflowResource {
provider.activate(workflow, type, resourceId);
}
@POST
@Path("activate-all")
@Tag(name = KeycloakOpenAPI.Admin.Tags.WORKFLOWS)
@Operation(summary = "Activate workflow for all eligible resources", description = "Activate the workflow for all eligible resources; an optional notBefore may schedule the first step for all activations.")
@APIResponses(value = {
@APIResponse(responseCode = "204", description = "No Content"),
@APIResponse(responseCode = "400", description = "Bad Request")
})
public void activateAll(@QueryParam("notBefore") String notBefore) {
if (notBefore != null) {
workflow.setNotBefore(notBefore);
}
provider.activateForAllEligibleResources(workflow);
}
/**
* Deactivate the workflow for the resource.
*

View File

@ -29,6 +29,7 @@ import jakarta.ws.rs.core.Response.Status;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.BearerAuthFilter;
import org.keycloak.admin.client.resource.WorkflowResource;
import org.keycloak.admin.client.resource.WorkflowsResource;
import org.keycloak.models.workflow.DeleteUserStepProviderFactory;
import org.keycloak.models.workflow.DisableUserStepProviderFactory;
@ -477,6 +478,32 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
}
@Test
public void testUpdateWorkflowNoIdInRepresentation() {
WorkflowRepresentation expectedWorkflows = WorkflowRepresentation.withName("test-workflow")
.withSteps(
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
.after(Duration.ofDays(5))
.build()
).build();
WorkflowsResource workflows = managedRealm.admin().workflows();
String workflowId;
try (Response response = workflows.create(expectedWorkflows)) {
assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode()));
workflowId = ApiUtil.getCreatedId(response);
}
WorkflowResource workflow = managedRealm.admin().workflows().workflow(workflowId);
WorkflowRepresentation rep = workflow.toRepresentation();
rep.setId(null);
rep.setConditions(RoleWorkflowConditionFactory.ID + "(realm-management/realm-admin)");
try (Response response = workflow.update(rep)) {
assertThat(response.getStatus(), is(Status.NO_CONTENT.getStatusCode()));
}
}
@Test
public void testSearch() {
// create a few workflows with different names

View File

@ -0,0 +1,67 @@
package org.keycloak.tests.workflow;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowScheduleRepresentation;
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.ManagedUser;
import org.keycloak.testframework.realm.UserConfig;
import org.keycloak.testframework.realm.UserConfigBuilder;
import org.keycloak.tests.workflow.config.WorkflowsBlockingServerConfig;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.notNullValue;
@KeycloakIntegrationTest(config = WorkflowsBlockingServerConfig.class)
public class WorkflowScheduleTest extends AbstractWorkflowTest {
@InjectUser(ref = "alice", config = DefaultUserConfig.class, lifecycle = LifeCycle.METHOD, realmRef = DEFAULT_REALM_NAME)
private ManagedUser userAlice;
@Test
public void testSchedule() {
WorkflowRepresentation expectedWorkflow = WorkflowRepresentation.withName("myworkflow")
.schedule(WorkflowScheduleRepresentation.create().after("1s").batchSize(10).build())
.withSteps(
WorkflowStepRepresentation.create().of(SetUserAttributeStepProviderFactory.ID)
.withConfig("test", "test")
.build()
).build();
managedRealm.admin().workflows().create(expectedWorkflow).close();
Awaitility.await()
.timeout(Duration.ofSeconds(15))
.pollInterval(Duration.ofSeconds(1))
.untilAsserted(() -> {
UserResource user = managedRealm.admin().users().get(userAlice.getId());
Map<String, List<String>> attributes = user.getUnmanagedAttributes();
assertThat(attributes, notNullValue());
assertThat(attributes.get("test"), containsInAnyOrder("test"));
});
}
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;
}
}
}

View File

@ -19,6 +19,7 @@ import org.keycloak.models.workflow.conditions.GroupMembershipWorkflowConditionF
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowScheduleRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.GroupConfigBuilder;
@ -28,6 +29,7 @@ import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.tests.workflow.AbstractWorkflowTest;
import org.keycloak.tests.workflow.config.WorkflowsBlockingServerConfig;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
@ -101,6 +103,7 @@ public class GroupWorkflowConditionTest extends AbstractWorkflowTest {
managedRealm.admin().workflows().create(WorkflowRepresentation.withName("group-membership-workflow")
.onCondition(GROUP_CONDITION)
.schedule(WorkflowScheduleRepresentation.create().after("1s").build())
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
.after(Duration.ofDays(5))
@ -112,30 +115,32 @@ public class GroupWorkflowConditionTest extends AbstractWorkflowTest {
List<WorkflowRepresentation> workflows = managedRealm.admin().workflows().list();
assertThat(workflows, hasSize(1));
// activate the workflow for all eligible users
managedRealm.admin().workflows().workflow(workflows.get(0).getId()).activateAll();
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
Workflow workflow = registeredWorkflows.get(0);
Awaitility.await()
.timeout(Duration.ofSeconds(15))
.pollInterval(Duration.ofSeconds(1))
.untilAsserted(() -> {
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
Workflow workflow = registeredWorkflows.get(0);
// check workflow was correctly assigned to the users
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
RealmModel realm = session.getContext().getRealm();
List<String> scheduledUsers = stateProvider.getScheduledStepsByWorkflow(workflow)
.map(step -> session.users().getUserById(realm, step.resourceId()).getUsername()).toList();
assertThat(scheduledUsers, hasSize(10));
// check workflow was correctly assigned to the users
RealmModel realm = session.getContext().getRealm();
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<String> scheduledUsers = stateProvider.getScheduledStepsByWorkflow(workflow)
.map(step -> session.users().getUserById(realm, step.resourceId()).getUsername()).toList();
assertThat(scheduledUsers, hasSize(10));
List<String> expectedUsers = session.users().searchForUserStream(realm, Map.of())
.map(UserModel::getUsername)
.filter(username -> username.startsWith("group-member-"))
.filter(username -> Integer.parseInt(username.substring("group-member-".length())) % 2 == 0)
.toList();
assertThat(scheduledUsers, containsInAnyOrder(expectedUsers.toArray()));
});
List<String> expectedUsers = session.users().searchForUserStream(realm, Map.of())
.map(UserModel::getUsername)
.filter(username -> username.startsWith("group-member-"))
.filter(username -> Integer.parseInt(username.substring("group-member-".length())) % 2 == 0)
.toList();
assertThat(scheduledUsers, containsInAnyOrder(expectedUsers.toArray()));
});
});
}
}

View File

@ -40,6 +40,7 @@ import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowScheduleRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.UserConfigBuilder;
@ -49,6 +50,7 @@ import org.keycloak.tests.workflow.AbstractWorkflowTest;
import org.keycloak.tests.workflow.config.WorkflowsBlockingServerConfig;
import org.keycloak.testsuite.util.IdentityProviderBuilder;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Test;
import static org.keycloak.models.workflow.ResourceOperationType.USER_CREATED;
@ -133,6 +135,7 @@ public class IdpLinkConditionWorkflowTest extends AbstractWorkflowTest {
managedRealm.admin().workflows().create(WorkflowRepresentation.withName("myworkflow")
.onEvent(ResourceOperationType.USER_FEDERATED_IDENTITY_ADDED.name())
.onCondition(IdentityProviderWorkflowConditionFactory.ID + "(" + IDP_OIDC_ALIAS + ")")
.schedule(WorkflowScheduleRepresentation.create().after("1s").build())
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
.after(Duration.ofDays(5))
@ -200,43 +203,46 @@ public class IdpLinkConditionWorkflowTest extends AbstractWorkflowTest {
List<WorkflowRepresentation> workflows = managedRealm.admin().workflows().list();
assertThat(workflows, hasSize(1));
// activate the workflow for all eligible users - i.e. only users from the same idp who are not yet assigned to the workflow.
managedRealm.admin().workflows().workflow(workflows.get(0).getId()).activateAll();
runOnServer.run((RunOnServer) session -> {
RealmModel realm = session.getContext().getRealm();
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertEquals(1, registeredWorkflows.size());
Workflow workflow = registeredWorkflows.get(0);
Awaitility.await()
.timeout(Duration.ofSeconds(15))
.pollInterval(Duration.ofSeconds(1))
.untilAsserted(() -> {
runOnServer.run((RunOnServer) session -> {
RealmModel realm = session.getContext().getRealm();
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertEquals(1, registeredWorkflows.size());
Workflow workflow = registeredWorkflows.get(0);
// check workflow was correctly assigned to the old users, not affecting users already associated with the workflow.
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow).toList();
assertEquals(13, scheduledSteps.size());
// check workflow was correctly assigned to the old users, not affecting users already associated with the workflow.
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow).toList();
assertEquals(13, scheduledSteps.size()); // total users now associated with the workflow
List<WorkflowStep> steps = workflow.getSteps().toList();
assertEquals(2, steps.size());
WorkflowStep notifyStep = steps.get(0);
List<ScheduledStep> scheduledToNotify = scheduledSteps.stream()
.filter(step -> notifyStep.getId().equals(step.stepId())).toList();
assertEquals(10, scheduledToNotify.size());
scheduledToNotify.forEach(scheduledStep -> {
UserModel user = session.users().getUserById(realm, scheduledStep.resourceId());
assertNotNull(user);
assertTrue(user.getUsername().startsWith("idp-user-"));
});
List<WorkflowStep> steps = workflow.getSteps().toList();
assertEquals(2, steps.size());
WorkflowStep notifyStep = steps.get(0);
List<ScheduledStep> scheduledToNotify = scheduledSteps.stream()
.filter(step -> notifyStep.getId().equals(step.stepId())).toList();
assertEquals(10, scheduledToNotify.size()); // only the 10 old users should be assigned to the first step
scheduledToNotify.forEach(scheduledStep -> {
UserModel user = session.users().getUserById(realm, scheduledStep.resourceId());
assertNotNull(user);
assertTrue(user.getUsername().startsWith("idp-user-"));
});
WorkflowStep disableStep = workflow.getSteps().toList().get(1);
List<ScheduledStep> scheduledToDisable = scheduledSteps.stream()
.filter(step -> disableStep.getId().equals(step.stepId())).toList();
assertEquals(3, scheduledToDisable.size()); // the 3 "new" users should still be at the disable step
scheduledToDisable.forEach(scheduledStep -> {
UserModel user = session.users().getUserById(realm, scheduledStep.resourceId());
assertNotNull(user);
assertTrue(user.getUsername().startsWith("new-idp-user-"));
});
});
WorkflowStep disableStep = workflow.getSteps().toList().get(1);
List<ScheduledStep> scheduledToDisable = scheduledSteps.stream()
.filter(step -> disableStep.getId().equals(step.stepId())).toList();
assertEquals(3, scheduledToDisable.size());
scheduledToDisable.forEach(scheduledStep -> {
UserModel user = session.users().getUserById(realm, scheduledStep.resourceId());
assertNotNull(user);
assertTrue(user.getUsername().startsWith("new-idp-user-"));
});
});
});
}
private void setupIdentityProvider() {

View File

@ -25,6 +25,7 @@ import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowScheduleRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.RoleConfigBuilder;
@ -34,6 +35,7 @@ import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.tests.workflow.AbstractWorkflowTest;
import org.keycloak.tests.workflow.config.WorkflowsBlockingServerConfig;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -86,7 +88,7 @@ public class RoleWorkflowConditionTest extends AbstractWorkflowTest {
}
managedRealm.admin().workflows().create(WorkflowRepresentation.withName("test-role-workflow")
.onEvent(ResourceOperationType.USER_ROLE_GRANTED.name())
.schedule(WorkflowScheduleRepresentation.create().after("1s").build())
.onCondition(RoleWorkflowConditionFactory.ID + "(testRole)")
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
@ -99,20 +101,23 @@ public class RoleWorkflowConditionTest extends AbstractWorkflowTest {
List<WorkflowRepresentation> workflows = managedRealm.admin().workflows().list();
assertThat(workflows, hasSize(1));
// activate the workflow for all eligible users
managedRealm.admin().workflows().workflow(workflows.get(0).getId()).activateAll();
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
Workflow workflow = registeredWorkflows.get(0);
// check workflow was correctly assigned to the users
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow).toList();
assertThat(scheduledSteps, hasSize(10));
});
Awaitility.await()
.timeout(Duration.ofSeconds(15))
.pollInterval(Duration.ofSeconds(1))
.untilAsserted(() -> {
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
Workflow workflow = registeredWorkflows.get(0);
// check workflow was correctly assigned to the users
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow).toList();
assertThat(scheduledSteps, hasSize(10));
});
});
}
private void assertUserRoles(String username, boolean shouldExist, String... roles) {

View File

@ -22,6 +22,7 @@ import org.keycloak.models.workflow.conditions.UserAttributeWorkflowConditionFac
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowScheduleRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.UserConfigBuilder;
@ -29,6 +30,7 @@ import org.keycloak.testframework.remote.providers.runonserver.RunOnServer;
import org.keycloak.tests.workflow.AbstractWorkflowTest;
import org.keycloak.tests.workflow.config.WorkflowsBlockingServerConfig;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -94,20 +96,23 @@ public class UserAttributeWorkflowConditionTest extends AbstractWorkflowTest {
List<WorkflowRepresentation> workflows = managedRealm.admin().workflows().list();
assertThat(workflows, hasSize(1));
// activate the workflow for all eligible users
managedRealm.admin().workflows().workflow(workflows.get(0).getId()).activateAll();
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
Workflow workflow = registeredWorkflows.get(0);
// check workflow was correctly assigned to the users
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow).toList();
assertThat(scheduledSteps, hasSize(10));
});
Awaitility.await()
.timeout(Duration.ofSeconds(15))
.pollInterval(Duration.ofSeconds(1))
.untilAsserted(() -> {
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
Workflow workflow = registeredWorkflows.get(0);
// check workflow was correctly assigned to the users
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow).toList();
assertThat(scheduledSteps, hasSize(10));
});
});
}
private void assertUserAttribute(String username, boolean shouldExist, String... values) {
@ -157,6 +162,7 @@ public class UserAttributeWorkflowConditionTest extends AbstractWorkflowTest {
WorkflowRepresentation expectedWorkflow = WorkflowRepresentation.withName("myworkflow")
.onEvent(ResourceOperationType.USER_CREATED.name())
.schedule(WorkflowScheduleRepresentation.create().after("1s").build())
.onCondition(attributeCondition)
.withSteps(
WorkflowStepRepresentation.create()