Validation for immediate workflows

Closes #42382

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
Vlasta Ramik 2025-09-18 14:51:04 +02:00 committed by GitHub
parent c1fdbb0be4
commit 44b4235b50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 264 additions and 52 deletions

View File

@ -25,6 +25,9 @@ import org.keycloak.component.ComponentModel;
public class Workflow {
public static final String SCHEDULED_KEY = "scheduled";
public static final String RECURRING_KEY = "recurring";
private MultivaluedHashMap<String, String> config;
private String providerId;
private String id;
@ -70,18 +73,18 @@ public class Workflow {
}
public boolean isRecurring() {
return config != null && Boolean.parseBoolean(config.getFirst("recurring"));
return config != null && Boolean.parseBoolean(config.getFirst(RECURRING_KEY));
}
public boolean isScheduled() {
return config != null && Boolean.parseBoolean(config.getFirstOrDefault("scheduled", "true"));
return config != null && Boolean.parseBoolean(config.getFirstOrDefault(SCHEDULED_KEY, "true"));
}
public Long getNotBefore() {
return notBefore;
}
public void setNotBefore(Long milliseconds) {
this.notBefore = milliseconds;
public void setNotBefore(Long notBefore) {
this.notBefore = notBefore;
}
}

View File

@ -17,16 +17,6 @@
package org.keycloak.models.workflow;
import static java.util.Optional.ofNullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import jakarta.ws.rs.BadRequestException;
import org.jboss.logging.Logger;
import org.keycloak.common.Profile;
@ -37,11 +27,24 @@ import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelValidationException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.workflow.WorkflowStateProvider.ScheduledStep;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.models.workflow.WorkflowStateProvider.ScheduledStep;
import org.keycloak.representations.workflows.WorkflowConditionRepresentation;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import static java.util.Optional.ofNullable;
import static org.keycloak.models.workflow.Workflow.RECURRING_KEY;
import static org.keycloak.models.workflow.Workflow.SCHEDULED_KEY;
import static org.keycloak.models.workflow.WorkflowStep.AFTER_KEY;
public class WorkflowsManager {
@ -85,6 +88,8 @@ public class WorkflowsManager {
for (int i = 0; i < steps.size(); i++) {
WorkflowStep step = steps.get(i);
validateStep(workflow, step);
// assign priority based on index.
step.setPriority(i + 1);
@ -122,8 +127,6 @@ public class WorkflowsManager {
persisted.setSteps(step.getSteps());
validateStep(persisted);
return persisted;
}
@ -334,6 +337,8 @@ public class WorkflowsManager {
MultivaluedHashMap<String, String> config = ofNullable(rep.getConfig()).orElse(new MultivaluedHashMap<>());
List<WorkflowConditionRepresentation> conditions = ofNullable(rep.getConditions()).orElse(List.of());
validateWorkflow(rep, config);
for (WorkflowConditionRepresentation condition : conditions) {
String conditionProviderId = condition.getProviderId();
config.computeIfAbsent("conditions", key -> new ArrayList<>()).add(conditionProviderId);
@ -355,6 +360,27 @@ public class WorkflowsManager {
return workflow;
}
private void validateWorkflow(WorkflowRepresentation rep, MultivaluedHashMap<String, String> config) {
// Validations:
// workflow cannot be both immediate and recurring
// immediate workflow cannot have time conditions
// all steps of scheduled workflow must have time condition
boolean isImmediate = config.containsKey(SCHEDULED_KEY) && !Boolean.parseBoolean(config.getFirst(SCHEDULED_KEY));
boolean isRecurring = config.containsKey(RECURRING_KEY) && Boolean.parseBoolean(config.getFirst(RECURRING_KEY));
boolean hasTimeCondition = rep.getSteps().stream().allMatch(step -> step.getConfig() != null
&& step.getConfig().containsKey(AFTER_KEY));
if (isImmediate && isRecurring) {
throw new WorkflowInvalidStateException("Workflow cannot be both immediate and recurring.");
}
if (isImmediate && hasTimeCondition) {
throw new WorkflowInvalidStateException("Immediate workflow cannot have steps with time conditions.");
}
if (!isImmediate && !hasTimeCondition) {
throw new WorkflowInvalidStateException("Scheduled workflow cannot have steps without time conditions.");
}
}
private WorkflowStep toModel(WorkflowStepRepresentation rep) {
List<WorkflowStep> subSteps = new ArrayList<>();
@ -375,17 +401,35 @@ public class WorkflowsManager {
return type.resolveResource(session, resourceId);
}
private void validateStep(WorkflowStep persisted) throws ModelValidationException {
List<WorkflowStep> aggregated = persisted.getSteps();
private void validateStep(Workflow workflow, WorkflowStep step) throws ModelValidationException {
boolean isAggregatedStep = !step.getSteps().isEmpty();
boolean isScheduledWorkflow = workflow.isScheduled();
if (!aggregated.isEmpty()) {
WorkflowStepProvider provider = getStepProvider(persisted);
if (!(provider instanceof AggregatedStepProvider)) {
// for now, only AggregatedActionProvider supports having sub-actions but we might want to support
// in the future more actions from having sub-actions by querying the capability from the provider or via
if (isAggregatedStep) {
if (!step.getProviderId().equals(AggregatedStepProviderFactory.ID)) {
// for now, only AggregatedStepProvider supports having sub-steps, but we might want to support
// in the future more steps from having sub-steps by querying the capability from the provider or via
// a marker interface
throw new ModelValidationException("Action provider " + persisted.getProviderId() + " does not support aggregated actions");
throw new ModelValidationException("Step provider " + step.getProviderId() + " does not support aggregated steps");
}
List<WorkflowStep> subSteps = step.getSteps();
// for each sub-step (in case it's not aggregated step on its own) check all it's sub-steps do not have
// time conditions, all its sub-steps are meant to be run at once
if (subSteps.stream().anyMatch(subStep ->
subStep.getConfig().getFirst(AFTER_KEY) != null &&
!subStep.getProviderId().equals(AggregatedStepProviderFactory.ID))) {
throw new ModelValidationException("Sub-steps of aggregated step cannot have time conditions.");
}
} else {
if (isScheduledWorkflow) {
if (step.getConfig().getFirst(AFTER_KEY) == null || step.getAfter() < 0) {
throw new ModelValidationException("All steps of scheduled workflow must have a valid 'after' time condition.");
}
} else { // immediate workflow
if (step.getConfig().getFirst(AFTER_KEY) != null) {
throw new ModelValidationException("Immediate workflow step cannot have a time condition.");
}
}
}
}

View File

@ -54,6 +54,15 @@ public class WorkflowResource {
return manager.toRepresentation(workflow);
}
/**
* Bind the workflow to the resource.
*
* @param type the resource type
* @param resourceId the resource id
* @param notBefore optional notBefore time in milliseconds to schedule the first workflow step,
* it overrides the first workflow step time configuration (after).
* if set and the workflow is not scheduled (immediate) a Bad Request response will be returned
*/
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Path("bind/{type}/{resourceId}")
@ -65,6 +74,9 @@ public class WorkflowResource {
}
if (notBefore != null) {
if (!workflow.isScheduled()) {
throw ErrorResponse.error("Immediate workflows does not support binding with provided time.", Response.Status.BAD_REQUEST);
}
workflow.setNotBefore(notBefore);
}

View File

@ -1,7 +1,9 @@
package org.keycloak.tests.admin.model.workflow;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@ -9,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import java.time.Duration;
import java.util.List;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.core.Response;
import org.junit.jupiter.api.Test;
import org.keycloak.common.util.Time;
@ -49,6 +52,7 @@ public class AdhocWorkflowTest {
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.withConfig("message", "message")
.after(Duration.ofDays(1))
.build())
.build()).close();
@ -60,6 +64,31 @@ public class AdhocWorkflowTest {
assertThat(aggregatedStep.getProviderId(), is(SetUserAttributeStepProviderFactory.ID));
}
@Test
public void testBindAdHocScheduledWithImmediateWorkflow() {
managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(EventBasedWorkflowProviderFactory.ID)
.immediate()
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.withConfig("message", "message")
.build())
.build()).close();
List<WorkflowRepresentation> workflows = managedRealm.admin().workflows().list();
assertThat(workflows, hasSize(1));
WorkflowRepresentation workflow = workflows.get(0);
try (Response response = managedRealm.admin().users().create(getUserRepresentation("alice", "Alice", "Wonderland", "alice@wornderland.org"))) {
String id = ApiUtil.getCreatedId(response);
try {
managedRealm.admin().workflows().workflow(workflow.getId()).bind(ResourceType.USERS.name(), id, Duration.ofDays(5).toMillis());
} catch (Exception e) {
assertThat(e, instanceOf(BadRequestException.class));
}
}
}
@Test
public void testRunAdHocScheduledWorkflow() {
managedRealm.admin().workflows().create(WorkflowRepresentation.create()
@ -79,30 +108,13 @@ public class AdhocWorkflowTest {
String id = ApiUtil.getCreatedId(response);
managedRealm.admin().workflows().workflow(workflow.getId()).bind(ResourceType.USERS.name(), id);
}
runOnServer.run((session -> {
RealmModel realm = configureSessionContext(session);
WorkflowsManager manager = new WorkflowsManager(session);
UserModel user = session.users().getUserByUsername(realm, "alice");
manager.runScheduledSteps();
assertNull(user.getAttributes().get("message"));
try {
Time.setOffset(Math.toIntExact(Duration.ofDays(6).toSeconds()));
manager.runScheduledSteps();
user = session.users().getUserByUsername(realm, "alice");
assertNotNull(user.getAttributes().get("message"));
} finally {
Time.setOffset(0);
}
}));
}
@Test
public void testRunAdHocNonScheduledWorkflow() {
public void testRunAdHocImmediateWorkflow() {
managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(EventBasedWorkflowProviderFactory.ID)
.immediate()
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.withConfig("message", "message")
@ -134,6 +146,7 @@ public class AdhocWorkflowTest {
.of(EventBasedWorkflowProviderFactory.ID)
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.after(Duration.ofDays(5))
.withConfig("message", "message")
.build())
.build()).close();

View File

@ -87,7 +87,37 @@ public class AggregatedStepTest {
}
@Test
public void testFailCreateIfSettingActionsToRegularActions() {
public void testCreateAggregatedStepAsSubStep() {
managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.withSteps(
WorkflowStepRepresentation.create().of(AggregatedStepProviderFactory.ID)
.after(Duration.ofDays(5))
.withSteps(WorkflowStepRepresentation.create()
.of(AggregatedStepProviderFactory.ID)
.withConfig("message", "message")
.after(Duration.ofDays(5))
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.withConfig("message", "message")
.build(),
WorkflowStepRepresentation.create()
.of(DisableUserStepProviderFactory.ID)
.build()
)
.build(),
WorkflowStepRepresentation.create()
.of(DisableUserStepProviderFactory.ID)
.build()
).build())
.build()).close();
List<WorkflowRepresentation> workflows = managedRealm.admin().workflows().list();
assertThat(workflows, hasSize(1));
}
@Test
public void testFailCreateIfSettingStepsToRegularSteps() {
try (Response response = managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.withSteps(
@ -104,7 +134,30 @@ public class AggregatedStepTest {
).build())
.build())) {
assertThat(response.getStatus(), is(Status.BAD_REQUEST.getStatusCode()));
assertThat(response.readEntity(ErrorRepresentation.class).getErrorMessage(), equalTo("Action provider " + SetUserAttributeStepProviderFactory.ID + " does not support aggregated actions"));
assertThat(response.readEntity(ErrorRepresentation.class).getErrorMessage(), equalTo("Step provider " + SetUserAttributeStepProviderFactory.ID + " does not support aggregated steps"));
}
}
@Test
public void testFailCreateIfSubStepHasTimeCondition() {
try (Response response = managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.withSteps(
WorkflowStepRepresentation.create().of(SetUserAttributeStepProviderFactory.ID)
.after(Duration.ofDays(5))
.withConfig("key", "value")
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.withConfig("message", "message")
.after(Duration.ofDays(1))
.build(),
WorkflowStepRepresentation.create()
.of(DisableUserStepProviderFactory.ID)
.build()
).build())
.build())) {
assertThat(response.getStatus(), is(Status.BAD_REQUEST.getStatusCode()));
assertThat(response.readEntity(ErrorRepresentation.class).getErrorMessage(), equalTo("Step provider " + SetUserAttributeStepProviderFactory.ID + " does not support aggregated steps"));
}
}

View File

@ -18,6 +18,8 @@
package org.keycloak.tests.admin.model.workflow;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ -58,6 +60,7 @@ import org.keycloak.models.workflow.WorkflowStateProvider.ScheduledStep;
import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory;
import org.keycloak.models.workflow.UserCreationTimeWorkflowProviderFactory;
import org.keycloak.models.workflow.conditions.IdentityProviderWorkflowConditionFactory;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.representations.workflows.WorkflowConditionRepresentation;
@ -79,7 +82,7 @@ public class WorkflowManagementTest {
private static final String REALM_NAME = "default";
@InjectRunOnServer(permittedPackages = "org.keycloak.tests")
@InjectRunOnServer(permittedPackages = {"org.keycloak.tests", "org.hamcrest"})
RunOnServerClient runOnServer;
@InjectRealm(lifecycle = LifeCycle.METHOD)
@ -557,11 +560,9 @@ public class WorkflowManagementTest {
.immediate()
.withSteps(
WorkflowStepRepresentation.create().of(SetUserAttributeStepProviderFactory.ID)
.after(Duration.ofDays(1))
.withConfig("message", "message")
.build(),
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
.after(Duration.ofDays(2))
.build()
).build()).close();
@ -572,11 +573,97 @@ public class WorkflowManagementTest {
runOnServer.run(session -> {
configureSessionContext(session);
UserModel user = session.users().getUserByUsername(session.getContext().getRealm(), "testuser");
assertEquals("message", user.getAttributes().get("message").get(0));
assertThat(user, notNullValue());
assertThat(user.getAttributes(), notNullValue());
assertThat(user.getAttributes().get("message"), notNullValue());
assertThat(user.getAttributes().get("message").get(0), is("message"));
assertFalse(user.isEnabled());
});
}
@Test
public void testCreateImmediateWorkflowWithTimeConditions() {
List<WorkflowRepresentation> workflows = WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.immediate()
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
.after(Duration.ofDays(3))
.build(),
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
.after(Duration.ofDays(7))
.build()
).build();
try (Response response = managedRealm.admin().workflows().create(workflows)) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
String error = response.readEntity(String.class);
assertThat(error, containsString("Immediate workflow cannot have steps with time conditions"));
}
}
@Test
public void testCreateScheduledWorkflowWithoutTimeConditions() {
List<WorkflowRepresentation> workflows = WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
.build(),
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
.build()
).build();
try (Response response = managedRealm.admin().workflows().create(workflows)) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
String error = response.readEntity(String.class);
assertThat(error, containsString("Scheduled workflow cannot have steps without time conditions"));
}
}
@Test
public void testCreateWorkflowMarkedAsBothImmediateAndRecurring() {
List<WorkflowRepresentation> workflows = WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.immediate()
.recurring()
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
.after(Duration.ofDays(3))
.build(),
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
.after(Duration.ofDays(7))
.build()
).build();
try (Response response = managedRealm.admin().workflows().create(workflows)) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
String error = response.readEntity(String.class);
assertThat(error, containsString("Workflow cannot be both immediate and recurring."));
}
}
@Test
public void testFailCreateNegativeTime() {
try (Response response = managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.withSteps(
WorkflowStepRepresentation.create().of(SetUserAttributeStepProviderFactory.ID)
.after(Duration.ofDays(-5))
.withConfig("key", "value")
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.withConfig("message", "message")
.build(),
WorkflowStepRepresentation.create()
.of(DisableUserStepProviderFactory.ID)
.build()
).build())
.build())) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
assertThat(response.readEntity(ErrorRepresentation.class).getErrorMessage(), equalTo("Step provider " + SetUserAttributeStepProviderFactory.ID + " does not support aggregated steps"));
}
}
@Test
public void testNotifyUserStepSendsEmailWithDefaultDisableMessage() {
// Create workflow: disable at 10 days, notify 3 days before (at day 7)