Allow defining steps in a workflow that can run immediate or scheduled

Closes #42888

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
vramik 2025-09-25 13:20:33 +02:00 committed by Pedro Igor
parent 27796c5dce
commit 80453bdbfb
16 changed files with 115 additions and 821 deletions

View File

@ -7,7 +7,6 @@ import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_NA
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ON_EVENT;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RECURRING;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RESET_ON;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_SCHEDULED;
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_WITH;
@ -28,7 +27,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import org.keycloak.common.util.MultivaluedHashMap;
@JsonPropertyOrder({"id", CONFIG_NAME, CONFIG_USES, CONFIG_ENABLED, CONFIG_ON_EVENT, CONFIG_RESET_ON, CONFIG_SCHEDULED, CONFIG_RECURRING, CONFIG_IF, CONFIG_STEPS, CONFIG_STATE})
@JsonPropertyOrder({"id", CONFIG_NAME, CONFIG_USES, CONFIG_ENABLED, CONFIG_ON_EVENT, CONFIG_RESET_ON, CONFIG_RECURRING, CONFIG_IF, CONFIG_STEPS, CONFIG_STATE})
@JsonIgnoreProperties(CONFIG_WITH)
public final class WorkflowRepresentation extends AbstractWorkflowComponentRepresentation {
@ -107,14 +106,6 @@ public final class WorkflowRepresentation extends AbstractWorkflowComponentRepre
setConfigValue(CONFIG_RECURRING, recurring);
}
public Boolean getScheduled() {
return getConfigValue(CONFIG_SCHEDULED, Boolean.class);
}
public void setScheduled(Boolean scheduled) {
setConfigValue(CONFIG_SCHEDULED, scheduled);
}
public Boolean getEnabled() {
return getConfigValue(CONFIG_ENABLED, Boolean.class);
}
@ -207,11 +198,6 @@ public final class WorkflowRepresentation extends AbstractWorkflowComponentRepre
return this;
}
public Builder immediate() {
representation.setScheduled(false);
return this;
}
public Builder recurring() {
representation.setRecurring(true);
return this;

View File

@ -8,7 +8,6 @@ import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_WI
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
@ -22,26 +21,16 @@ public final class WorkflowStepRepresentation extends AbstractWorkflowComponentR
return new Builder();
}
private List<WorkflowStepRepresentation> steps;
public WorkflowStepRepresentation() {
super(null, null, null);
this(null, null, null);
}
public WorkflowStepRepresentation(String step) {
this(step, null);
public WorkflowStepRepresentation(String uses) {
this(null, uses, null);
}
public WorkflowStepRepresentation(String step, MultivaluedHashMap<String, String> config) {
this(null, step, config, null);
}
public WorkflowStepRepresentation(String id, String step, MultivaluedHashMap<String, String> config, List<WorkflowStepRepresentation> steps) {
super(id, step, config);
if (steps != null && !steps.isEmpty()) {
this.steps = steps;
}
public WorkflowStepRepresentation(String id, String uses, MultivaluedHashMap<String, String> config) {
super(id, uses, config);
}
@JsonSerialize(using = MultivaluedHashMapValueSerializer.class)
@ -66,14 +55,6 @@ public final class WorkflowStepRepresentation extends AbstractWorkflowComponentR
setConfig(CONFIG_PRIORITY, String.valueOf(ms));
}
public List<WorkflowStepRepresentation> getSteps() {
return steps;
}
public void setSteps(List<WorkflowStepRepresentation> steps) {
this.steps = steps;
}
public static class Builder {
private WorkflowStepRepresentation step;
@ -112,11 +93,6 @@ public final class WorkflowStepRepresentation extends AbstractWorkflowComponentR
return this;
}
public Builder withSteps(WorkflowStepRepresentation... steps) {
step.setSteps(Arrays.asList(steps));
return this;
}
public WorkflowStepRepresentation build() {
return step;
}

View File

@ -8,13 +8,11 @@ import static org.junit.Assert.assertTrue;
import java.io.IOException;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.Test;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.util.JsonSerialization;
public class WorkflowDefinitionTest {
@ -31,7 +29,6 @@ public class WorkflowDefinitionTest {
expected.setSteps(null);
expected.setConditions(null);
expected.setRecurring(true);
expected.setScheduled(true);
expected.setEnabled(true);
expected.setConditions(Arrays.asList(
@ -77,10 +74,9 @@ public class WorkflowDefinitionTest {
assertEquals(expected.getUses(), actual.getUses());
assertTrue(actual.getOn() instanceof String);
assertEquals(expected.getOn(), (String) actual.getOn());
assertArrayEquals(((List) expected.getOnEventReset()).toArray(), ((List) actual.getOnEventReset()).toArray());
assertArrayEquals(((List<?>) expected.getOnEventReset()).toArray(), ((List<?>) actual.getOnEventReset()).toArray());
assertEquals(expected.getName(), actual.getName());
assertEquals(expected.getRecurring(), actual.getRecurring());
assertEquals(expected.getScheduled(), actual.getScheduled());
assertEquals(expected.getEnabled(), actual.getEnabled());
List<WorkflowConditionRepresentation> actualConditions = actual.getConditions();
@ -140,33 +136,4 @@ public class WorkflowDefinitionTest {
System.out.println(json);
}
@Test
public void testAggregatedAction() throws IOException {
WorkflowRepresentation expected = new WorkflowRepresentation();
MultivaluedHashMap<String, String> config = new MultivaluedHashMap<>();
config.put("k1", Collections.singletonList("v1"));
WorkflowStepRepresentation aggregated = new WorkflowStepRepresentation("step-1", config);
config = new MultivaluedHashMap<>();
config.put("k1", Collections.singletonList("v1"));
aggregated.setSteps(Arrays.asList(new WorkflowStepRepresentation("sub-step-1", new MultivaluedHashMap<>(config)), new WorkflowStepRepresentation("sub-step-2", new MultivaluedHashMap<>(config))));
expected.setSteps(Collections.singletonList(aggregated));
String json = JsonSerialization.writeValueAsPrettyString(expected);
WorkflowRepresentation actual = JsonSerialization.readValue(json, WorkflowRepresentation.class);
List<WorkflowStepRepresentation> actualSteps = actual.getSteps();
assertNotNull(actualSteps);
actualSteps = actualSteps.stream().sorted(Comparator.comparing(WorkflowStepRepresentation::getUses)).collect(Collectors.toList());
List<WorkflowStepRepresentation> expectedSteps = expected.getSteps().stream().sorted(Comparator.comparing(WorkflowStepRepresentation::getUses)).collect(Collectors.toList());
assertEquals(expectedSteps.size(), actualSteps.size());
assertEquals(expectedSteps.get(0).getUses(), actualSteps.get(0).getUses());
assertEquals(expectedSteps.get(0).getConfig().get("k1"), actualSteps.get(0).getConfig().get("k1"));
assertEquals(expectedSteps.get(0).getSteps().size(), actualSteps.get(0).getSteps().size());
assertEquals(expectedSteps.get(0).getSteps().get(0).getConfig().get("k1"), actualSteps.get(0).getSteps().get(0).getConfig().get("k1"));
System.out.println(json);
}
}

View File

@ -20,7 +20,6 @@ package org.keycloak.models.workflow;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ENABLED;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ERROR;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RECURRING;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_SCHEDULED;
import java.util.List;
import java.util.Map;
@ -31,20 +30,10 @@ import org.keycloak.component.ComponentModel;
public class Workflow {
private MultivaluedHashMap<String, String> config;
private String providerId;
private final String providerId;
private String id;
private Long notBefore;
public Workflow() {
// reflection
}
public Workflow(String providerId) {
this.providerId = providerId;
this.id = null;
this.config = null;
}
public Workflow(ComponentModel c) {
this.id = c.getId();
this.providerId = c.getProviderId();
@ -78,10 +67,6 @@ public class Workflow {
return config != null && Boolean.parseBoolean(config.getFirst(CONFIG_RECURRING));
}
public boolean isScheduled() {
return config != null && Boolean.parseBoolean(config.getFirstOrDefault(CONFIG_SCHEDULED, "true"));
}
public Long getNotBefore() {
return notBefore;
}

View File

@ -20,26 +20,18 @@ package org.keycloak.models.workflow;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_PRIORITY;
import java.util.List;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel;
public class WorkflowStep implements Comparable<WorkflowStep> {
private String id;
private String providerId;
private final String providerId;
private MultivaluedHashMap<String, String> config;
private List<WorkflowStep> steps = List.of();
public WorkflowStep() {
// reflection
}
public WorkflowStep(String providerId, MultivaluedHashMap<String, String> config, List<WorkflowStep> steps) {
public WorkflowStep(String providerId, MultivaluedHashMap<String, String> config) {
this.providerId = providerId;
this.config = config;
this.steps = steps;
}
public WorkflowStep(ComponentModel model) {
@ -95,17 +87,6 @@ public class WorkflowStep implements Comparable<WorkflowStep> {
return Long.valueOf(getConfig().getFirstOrDefault(CONFIG_AFTER, "0"));
}
public List<WorkflowStep> getSteps() {
if (steps == null) {
return List.of();
}
return steps;
}
public void setSteps(List<WorkflowStep> steps) {
this.steps = steps;
}
@Override
public int compareTo(WorkflowStep other) {
return Integer.compare(this.getPriority(), other.getPriority());

View File

@ -1,47 +0,0 @@
package org.keycloak.models.workflow;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
public class AggregatedStepProvider implements WorkflowStepProvider {
private final KeycloakSession session;
private final ComponentModel model;
private final Logger log = Logger.getLogger(AggregatedStepProvider.class);
public AggregatedStepProvider(KeycloakSession session, ComponentModel model) {
this.session = session;
this.model = model;
}
@Override
public void close() {
}
@Override
public void run(List<String> userIds) {
List<WorkflowStepProvider> steps = getSteps();
for (String userId : userIds) {
for (WorkflowStepProvider step : steps) {
try {
step.run(List.of(userId));
} catch (Exception e) {
log.errorf(e, "Failed to execute step %s for user %s", model.getProviderId(), userId);
}
}
}
}
private List<WorkflowStepProvider> getSteps() {
WorkflowsManager manager = new WorkflowsManager(session);
return manager.getStepById(model.getId())
.getSteps().stream()
.map(manager::getStepProvider)
.toList();
}
}

View File

@ -1,54 +0,0 @@
package org.keycloak.models.workflow;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
public class AggregatedStepProviderFactory implements WorkflowStepProviderFactory<AggregatedStepProvider> {
public static final String ID = "aggregated";
@Override
public AggregatedStepProvider create(KeycloakSession session, ComponentModel model) {
return new AggregatedStepProvider(session, model);
}
@Override
public void init(Config.Scope config) {
// no-op
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// no-op
}
@Override
public void close() {
// no-op
}
@Override
public String getId() {
return ID;
}
@Override
public ResourceType getType() {
return ResourceType.USERS;
}
@Override
public String getHelpText() {
return "";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
}

View File

@ -44,10 +44,7 @@ import java.util.Objects;
import java.util.stream.Stream;
import static java.util.Optional.ofNullable;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CONDITIONS;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RECURRING;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_SCHEDULED;
public class WorkflowsManager {
@ -87,7 +84,7 @@ public class WorkflowsManager {
}
// This method takes an ordered list of steps. First step in the list has the highest priority, last step has the lowest priority
private void addSteps(Workflow workflow, String parentId, List<WorkflowStep> steps) {
private void addSteps(Workflow workflow, List<WorkflowStep> steps) {
for (int i = 0; i < steps.size(); i++) {
WorkflowStep step = steps.get(i);
@ -95,33 +92,27 @@ public class WorkflowsManager {
step.setPriority(i + 1);
// persist the new step component.
step = addStep(parentId, step);
addSteps(workflow, step.getId(), step.getSteps());
addStep(workflow, step);
}
}
private WorkflowStep addStep(String parentId, WorkflowStep step) {
private WorkflowStep addStep(Workflow workflow, WorkflowStep step) {
RealmModel realm = getRealm();
ComponentModel parentModel = realm.getComponent(parentId);
ComponentModel workflowModel = realm.getComponent(workflow.getId());
if (parentModel == null) {
throw new ModelValidationException("Parent component not found: " + parentId);
if (workflowModel == null) {
throw new ModelValidationException("Workflow with id '%s' not found.".formatted(workflow.getId()));
}
ComponentModel stepModel = new ComponentModel();
stepModel.setId(step.getId());//need to keep stable UUIDs not to break a link in state table
stepModel.setParentId(parentModel.getId());
stepModel.setParentId(workflowModel.getId());
stepModel.setProviderId(step.getProviderId());
stepModel.setProviderType(WorkflowStepProvider.class.getName());
stepModel.setConfig(step.getConfig());
WorkflowStep persisted = new WorkflowStep(realm.addComponentModel(stepModel));
persisted.setSteps(step.getSteps());
return persisted;
return new WorkflowStep(realm.addComponentModel(stepModel));
}
public List<Workflow> getWorkflows() {
@ -141,11 +132,7 @@ public class WorkflowsManager {
}
private WorkflowStep toStep(ComponentModel model) {
WorkflowStep step = new WorkflowStep(model);
step.setSteps(getSteps(step.getId()));
return step;
return new WorkflowStep(model);
}
public WorkflowStep getStepById(String id) {
@ -160,14 +147,7 @@ public class WorkflowsManager {
}
private WorkflowStep getFirstStep(Workflow workflow) {
WorkflowStep step = getSteps(workflow.getId()).get(0);
Long notBefore = workflow.getNotBefore();
if (notBefore != null) {
step.setAfter(notBefore);
}
return step;
return getSteps(workflow.getId()).get(0);
}
private WorkflowProvider getWorkflowProvider(Workflow workflow) {
@ -228,18 +208,36 @@ public class WorkflowsManager {
if (!currentlyAssignedWorkflows.contains(workflow.getId())) {
// if workflow is not active for the resource, check if the provider allows activating based on the event
if (provider.activateOnEvent(event)) {
if (workflow.isScheduled()) {
// workflow is scheduled, so we schedule the first step
log.debugf("Scheduling first step of workflow %s for resource %s based on event %s",
workflow.getId(), event.getResourceId(), event.getOperation());
workflowStateProvider.scheduleStep(workflow, getFirstStep(workflow), event.getResourceId());
} else {
// workflow is not scheduled, so we run all steps immediately
log.debugf("Running all steps of workflow %s for resource %s based on event %s",
workflow.getId(), event.getResourceId(), event.getOperation());
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), session.getContext(), s ->
getSteps(workflow.getId()).forEach(step -> getStepProvider(step).run(List.of(event.getResourceId())))
);
WorkflowStep firstStep = getFirstStep(workflow);
for (WorkflowStep step : getSteps(workflow.getId())) {
// If the workflow has a notBefore set, schedule the first step with it
if (step.getId().equals(firstStep.getId()) && workflow.getNotBefore() != null && workflow.getNotBefore() > 0) {
log.debugf("Scheduling first step %s of workflow %s for resource %s based on on event %s with notBefore %d",
step.getId(), workflow.getId(), event.getResourceId(), event.getOperation(), workflow.getNotBefore());
Long originalAfter = step.getAfter();
try {
step.setAfter(workflow.getNotBefore());
workflowStateProvider.scheduleStep(workflow, step, event.getResourceId());
continue;
} finally {
// restore the original after value
step.setAfter(originalAfter);
}
}
if (step.getAfter() > 0) {
// If a step has a time defined, schedule it and stop processing the other steps of workflow
log.debugf("Scheduling step %s of workflow %s for resource %s based on event %s",
step.getId(), workflow.getId(), event.getResourceId(), event.getOperation());
workflowStateProvider.scheduleStep(workflow, step, event.getResourceId());
break;
} else {
// Otherwise run the step right away
log.debugf("Running step %s of workflow %s for resource %s based on event %s",
step.getId(), workflow.getId(), event.getResourceId(), event.getOperation());
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), session.getContext(), s ->
getStepProvider(step).run(List.of(event.getResourceId()))
);
}
}
}
} else {
@ -261,33 +259,42 @@ public class WorkflowsManager {
public void runScheduledSteps() {
this.getWorkflows().stream().filter(Workflow::isEnabled).forEach(workflow -> {
for (ScheduledStep scheduled : workflowStateProvider.getDueScheduledSteps(workflow)) {
List<WorkflowStep> steps = getSteps(workflow.getId());
for (ScheduledStep scheduled : workflowStateProvider.getDueScheduledSteps(workflow)) {
List<WorkflowStep> steps = getSteps(workflow.getId());
for (int i = 0; i < steps.size(); i++) {
WorkflowStep currentStep = steps.get(i);
for (int i = 0; i < steps.size(); i++) {
WorkflowStep currentStep = steps.get(i);
if (currentStep.getId().equals(scheduled.stepId())) {
getStepProvider(currentStep).run(List.of(scheduled.resourceId()));
if (currentStep.getId().equals(scheduled.stepId())) {
getStepProvider(currentStep).run(List.of(scheduled.resourceId()));
if (steps.size() > i + 1) {
// schedule the next step using the time offset difference between the steps.
WorkflowStep nextStep = steps.get(i + 1);
workflowStateProvider.scheduleStep(workflow, nextStep, scheduled.resourceId());
} else {
// this was the last step, check if the workflow is recurring - i.e. if we need to schedule the first step again
if (workflow.isRecurring()) {
WorkflowStep firstStep = getFirstStep(workflow);
workflowStateProvider.scheduleStep(workflow, firstStep, scheduled.resourceId());
} else {
// not recurring, remove the state record
workflowStateProvider.remove(workflow.getId(), scheduled.resourceId());
int nextIndex = i + 1;
// Process subsequent steps: run immediately if no time condition, schedule if time condition
while (nextIndex < steps.size()) {
WorkflowStep nextStep = steps.get(nextIndex);
if (nextStep.getAfter() > 0) {
workflowStateProvider.scheduleStep(workflow, nextStep, scheduled.resourceId());
break;
} else {
getStepProvider(nextStep).run(List.of(scheduled.resourceId()));
nextIndex++;
}
}
if (nextIndex == steps.size()) {
// this was the last step, check if the workflow is recurring - i.e. if we need to schedule the first step again
if (workflow.isRecurring()) {
WorkflowStep firstStep = getFirstStep(workflow);
workflowStateProvider.scheduleStep(workflow, firstStep, scheduled.resourceId());
} else {
// not recurring, remove the state record
workflowStateProvider.remove(workflow.getId(), scheduled.resourceId());
}
}
}
}
}
}
});
});
}
public void removeWorkflow(String id) {
@ -306,7 +313,7 @@ public class WorkflowsManager {
}
public void updateWorkflow(Workflow workflow, MultivaluedHashMap<String, String> config) {
validateWorkflow(toRepresentation(workflow), config);
validateWorkflow(toRepresentation(workflow));
ComponentModel component = getWorkflowComponent(workflow.getId());
component.setConfig(config);
getRealm().updateComponent(component);
@ -370,16 +377,15 @@ public class WorkflowsManager {
}
public WorkflowStepRepresentation toRepresentation(WorkflowStep step) {
List<WorkflowStepRepresentation> steps = step.getSteps().stream().map(this::toRepresentation).toList();
return new WorkflowStepRepresentation(step.getId(), step.getProviderId(), step.getConfig(), steps);
return new WorkflowStepRepresentation(step.getId(), step.getProviderId(), step.getConfig());
}
public Workflow toModel(WorkflowRepresentation rep) {
validateWorkflow(rep);
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.getUses();
getConditionProviderFactory(conditionProviderId);
@ -391,42 +397,19 @@ public class WorkflowsManager {
}
Workflow workflow = addWorkflow(rep.getUses(), config);
List<WorkflowStep> steps = new ArrayList<>();
for (WorkflowStepRepresentation stepRep : rep.getSteps()) {
steps.add(toModel(workflow, stepRep));
}
List<WorkflowStep> steps = rep.getSteps().stream().map(this::toModel).toList();
addSteps(workflow, workflow.getId(), steps);
addSteps(workflow, steps);
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(CONFIG_SCHEDULED) && !Boolean.parseBoolean(config.getFirst(CONFIG_SCHEDULED));
boolean isRecurring = config.containsKey(CONFIG_RECURRING) && Boolean.parseBoolean(config.getFirst(CONFIG_RECURRING));
boolean hasTimeCondition = rep.getSteps().stream().allMatch(step -> step.getConfig() != null
&& step.getConfig().containsKey(CONFIG_AFTER));
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 void validateWorkflow(WorkflowRepresentation rep) {
validateEvents(rep.getOnValues());
validateEvents(rep.getOnEventsReset());
}
private static void validateEvents(List<String> events) {
for (String event : ofNullable(events).orElse(List.of())) {
try {
@ -447,52 +430,19 @@ public class WorkflowsManager {
return type.resolveResource(session, resourceId);
}
private void validateStep(Workflow workflow, WorkflowStep step, boolean topLevel) throws ModelValidationException {
private void validateStep(WorkflowStep step) throws ModelValidationException {
if (step.getAfter() < 0) {
throw new ModelValidationException("Step 'after' time condition cannot be negative.");
}
boolean isAggregatedStep = !step.getSteps().isEmpty();
boolean isScheduledWorkflow = workflow.isScheduled();
// verify the step does have valid provider
getStepProviderFactory(step);
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("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(CONFIG_AFTER) != null &&
!subStep.getProviderId().equals(AggregatedStepProviderFactory.ID))) {
throw new ModelValidationException("Sub-steps of aggregated step cannot have time conditions.");
}
} else {
if (isScheduledWorkflow && topLevel) {
if (step.getConfig().getFirst(CONFIG_AFTER) == null) {
throw new ModelValidationException("All steps of scheduled workflow must have a valid 'after' time condition.");
}
} else { // immediate workflow | sub-step of aggregated step
if (step.getConfig().getFirst(CONFIG_AFTER) != null) {
throw new ModelValidationException(topLevel ?
"Immediate workflow step cannot have a time condition." :
"Sub-step of aggregated step cannot have a time conditions.");
}
}
}
}
public WorkflowStep addStepToWorkflow(String workflowId, WorkflowStep step, Integer position) {
Objects.requireNonNull(workflowId, "workflowId cannot be null");
public WorkflowStep addStepToWorkflow(Workflow workflow, WorkflowStep step, Integer position) {
Objects.requireNonNull(workflow, "workflow cannot be null");
Objects.requireNonNull(step, "step cannot be null");
List<WorkflowStep> existingSteps = getSteps(workflowId);
List<WorkflowStep> existingSteps = getSteps(workflow.getId());
int targetPosition = position != null ? position : existingSteps.size();
if (targetPosition < 0 || targetPosition > existingSteps.size()) {
@ -503,32 +453,32 @@ public class WorkflowsManager {
shiftStepsForInsertion(targetPosition, existingSteps);
step.setPriority(targetPosition + 1);
WorkflowStep addedStep = addStep(workflowId, step);
WorkflowStep addedStep = addStep(workflow, step);
updateScheduledStepsAfterStepChange(workflowId);
updateScheduledStepsAfterStepChange(workflow.getId());
log.debugf("Added step %s to workflow %s at position %d", addedStep.getId(), workflowId, targetPosition);
log.debugf("Added step %s to workflow %s at position %d", addedStep.getId(), workflow.getId(), targetPosition);
return addedStep;
}
public void removeStepFromWorkflow(String workflowId, String stepId) {
Objects.requireNonNull(workflowId, "workflowId cannot be null");
public void removeStepFromWorkflow(Workflow workflow, String stepId) {
Objects.requireNonNull(workflow, "workflow cannot be null");
Objects.requireNonNull(stepId, "stepId cannot be null");
RealmModel realm = getRealm();
ComponentModel stepComponent = realm.getComponent(stepId);
if (stepComponent == null || !stepComponent.getParentId().equals(workflowId)) {
if (stepComponent == null || !stepComponent.getParentId().equals(workflow.getId())) {
throw new BadRequestException("Step not found or not part of workflow: " + stepId);
}
realm.removeComponent(stepComponent);
// Reorder remaining steps and update state
reorderAllSteps(workflowId);
updateScheduledStepsAfterStepChange(workflowId);
reorderAllSteps(workflow.getId());
updateScheduledStepsAfterStepChange(workflow.getId());
log.debugf("Removed step %s from workflow %s", stepId, workflowId);
log.debugf("Removed step %s from workflow %s", stepId, workflow.getId());
}
private void shiftStepsForInsertion(int insertPosition, List<WorkflowStep> existingSteps) {
@ -578,20 +528,9 @@ public class WorkflowsManager {
}
}
public WorkflowStep toModel(Workflow workflow, WorkflowStepRepresentation rep) {
return toModel(workflow, rep, true);
}
private WorkflowStep toModel(Workflow workflow, WorkflowStepRepresentation rep, boolean topLevel) {
List<WorkflowStep> subSteps = new ArrayList<>();
for (WorkflowStepRepresentation subStep : ofNullable(rep.getSteps()).orElse(List.of())) {
subSteps.add(toModel(workflow, subStep, false));
}
WorkflowStep step = new WorkflowStep(rep.getUses(), rep.getConfig(), subSteps);
validateStep(workflow, step, topLevel);
public WorkflowStep toModel(WorkflowStepRepresentation rep) {
WorkflowStep step = new WorkflowStep(rep.getUses(), rep.getConfig());
validateStep(step);
return step;
}

View File

@ -64,7 +64,6 @@ public class WorkflowResource {
* @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)
@ -77,9 +76,6 @@ 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

@ -110,8 +110,8 @@ public class WorkflowStepsResource {
throw ErrorResponse.error("Step representation cannot be null", Response.Status.BAD_REQUEST);
}
try {
WorkflowStep step = workflowsManager.toModel(workflow, stepRep);
WorkflowStep addedStep = workflowsManager.addStepToWorkflow(workflow.getId(), step, position);
WorkflowStep step = workflowsManager.toModel(stepRep);
WorkflowStep addedStep = workflowsManager.addStepToWorkflow(workflow, step, position);
return Response.ok(workflowsManager.toRepresentation(addedStep)).build();
} catch (ModelException e) {
@ -140,7 +140,7 @@ public class WorkflowStepsResource {
throw new BadRequestException("Step ID cannot be null or empty");
}
workflowsManager.removeStepFromWorkflow(workflow.getId(), stepId);
workflowsManager.removeStepFromWorkflow(workflow, stepId);
return Response.noContent().build();
}

View File

@ -19,5 +19,4 @@ org.keycloak.models.workflow.DisableUserStepProviderFactory
org.keycloak.models.workflow.NotifyUserStepProviderFactory
org.keycloak.models.workflow.DeleteUserStepProviderFactory
org.keycloak.models.workflow.SetUserAttributeStepProviderFactory
org.keycloak.models.workflow.AggregatedStepProviderFactory
org.keycloak.models.workflow.AddRequiredActionStepProviderFactory

View File

@ -23,8 +23,6 @@ import static org.hamcrest.Matchers.is;
@KeycloakIntegrationTest(config = WorkflowsServerConfig.class)
public class AddRequiredActionTest {
private static final String REALM_NAME = "default";
@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm managedRealm;
@ -32,7 +30,6 @@ public class AddRequiredActionTest {
public void testStepRun() {
managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.immediate()
.withSteps(
WorkflowStepRepresentation.create()
.of(AddRequiredActionStepProviderFactory.ID)

View File

@ -67,7 +67,6 @@ public class AdhocWorkflowTest {
public void testBindAdHocScheduledWithImmediateWorkflow() {
managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(EventBasedWorkflowProviderFactory.ID)
.immediate()
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.withConfig("message", "message")
@ -113,7 +112,6 @@ public class AdhocWorkflowTest {
public void testRunAdHocImmediateWorkflow() {
managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(EventBasedWorkflowProviderFactory.ID)
.immediate()
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.withConfig("message", "message")
@ -140,12 +138,11 @@ public class AdhocWorkflowTest {
}
@Test
public void testRunAdHocTimedScheduledWorkflow() {
public void testRunAdHocTimedWorkflow() {
managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(EventBasedWorkflowProviderFactory.ID)
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.after(Duration.ofDays(5))
.withConfig("message", "message")
.build())
.build()).close();

View File

@ -1,231 +0,0 @@
package org.keycloak.tests.admin.model.workflow;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasEntry;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.List;
import java.util.function.Consumer;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.junit.jupiter.api.Test;
import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.workflow.AggregatedStepProviderFactory;
import org.keycloak.models.workflow.DisableUserStepProviderFactory;
import org.keycloak.models.workflow.WorkflowsManager;
import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory;
import org.keycloak.models.workflow.UserCreationTimeWorkflowProviderFactory;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
@KeycloakIntegrationTest(config = WorkflowsServerConfig.class)
public class AggregatedStepTest {
private static final String REALM_NAME = "default";
@InjectRunOnServer(permittedPackages = "org.keycloak.tests")
RunOnServerClient runOnServer;
@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm managedRealm;
@Test
public void testCreate() {
try (Response response = managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.withSteps(
WorkflowStepRepresentation.create().of(AggregatedStepProviderFactory.ID)
.after(Duration.ofDays(5))
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.withConfig("message", "message")
.build(),
WorkflowStepRepresentation.create()
.of(DisableUserStepProviderFactory.ID)
.build()
).build())
.build())) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
List<WorkflowRepresentation> workflows = managedRealm.admin().workflows().list();
assertThat(workflows, hasSize(1));
WorkflowRepresentation workflow = workflows.get(0);
assertThat(workflow.getSteps(), hasSize(1));
WorkflowStepRepresentation aggregatedStep = workflow.getSteps().get(0);
assertThat(aggregatedStep.getUses(), is(AggregatedStepProviderFactory.ID));
List<WorkflowStepRepresentation> steps = aggregatedStep.getSteps();
assertThat(steps, hasSize(2));
assertStep(steps, SetUserAttributeStepProviderFactory.ID, a -> {
assertNotNull(a.getConfig());
assertThat(a.getConfig().isEmpty(), is(false));
assertThat(a.getConfig(), hasEntry("priority", List.of("1")));
assertThat(a.getConfig(), hasEntry("message", List.of("message")));
});
assertStep(steps, DisableUserStepProviderFactory.ID, a -> {
assertNotNull(a.getConfig());
assertThat(a.getConfig().isEmpty(), is(false));
assertThat(a.getConfig(), hasEntry("priority", List.of("2")));
});
}
}
@Test
public void testCreateAggregatedStepAsSubStep() {
try (Response response = 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())) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
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(
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(Status.BAD_REQUEST.getStatusCode()));
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("Sub-step of aggregated step cannot have a time conditions."));
}
}
@Test
public void testStepRun() {
managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.withSteps(
WorkflowStepRepresentation.create().of(AggregatedStepProviderFactory.ID)
.after(Duration.ofDays(5))
.withSteps(WorkflowStepRepresentation.create()
.of(SetUserAttributeStepProviderFactory.ID)
.withConfig("message", "message")
.build(),
WorkflowStepRepresentation.create()
.of(DisableUserStepProviderFactory.ID)
.build()
).build())
.build()).close();
managedRealm.admin().users().create(getUserRepresentation("alice", "Alice", "Wonderland", "alice@wornderland.org")).close();
runOnServer.run((session -> {
RealmModel realm = configureSessionContext(session);
WorkflowsManager manager = new WorkflowsManager(session);
try {
Time.setOffset(Math.toIntExact(Duration.ofDays(6).toSeconds()));
manager.runScheduledSteps();
UserModel user = session.users().getUserByUsername(realm, "alice");
assertNotNull(user.getAttributes().get("message"));
assertFalse(user.isEnabled());
} finally {
Time.setOffset(0);
}
}));
}
private static RealmModel configureSessionContext(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(REALM_NAME);
session.getContext().setRealm(realm);
return realm;
}
private UserRepresentation getUserRepresentation(String username, String firstName, String lastName, String email) {
UserRepresentation representation = new UserRepresentation();
representation.setUsername(username);
representation.setFirstName(firstName);
representation.setLastName(lastName);
representation.setEmail(email);
representation.setEnabled(true);
CredentialRepresentation credential = new CredentialRepresentation();
credential.setType(CredentialRepresentation.PASSWORD);
credential.setValue(username);
representation.setCredentials(List.of(credential));
return representation;
}
private void assertStep(List<WorkflowStepRepresentation> steps, String expectedProviderId, Consumer<WorkflowStepRepresentation> assertions) {
assertTrue(steps.stream()
.anyMatch(a -> {
if (a.getUses().equals(expectedProviderId)) {
assertions.accept(a);
return true;
}
return false;
}));
}
}

View File

@ -18,7 +18,6 @@
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;
@ -28,7 +27,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_SCHEDULED;
import java.time.Duration;
import java.util.Arrays;
@ -585,7 +583,6 @@ public class WorkflowManagementTest {
// create a test workflow with no time conditions - should run immediately when scheduled
managedRealm.admin().workflows().create(WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.immediate()
.withSteps(
WorkflowStepRepresentation.create().of(SetUserAttributeStepProviderFactory.ID)
.withConfig("message", "message")
@ -609,126 +606,6 @@ public class WorkflowManagementTest {
});
}
@Test
public void testCreateImmediateWorkflowWithTimeConditions() {
WorkflowSetRepresentation 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 testUpdateWorkflowFromImmediateToScheduled() {
// Create an immediate workflow
WorkflowSetRepresentation workflows = WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.immediate()
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
.build(),
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
.build()
).build();
WorkflowsResource workflowsResource = managedRealm.admin().workflows();
try (Response response = workflowsResource.create(workflows)) {
assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode()));
}
WorkflowRepresentation workflow = workflowsResource.list().get(0);
// Attempt to update the workflow to scheduled
workflow.getConfig().putSingle(CONFIG_SCHEDULED, "true");
try (Response response = workflowsResource.workflow(workflow.getId()).update(workflow)) {
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 testUpdateWorkflowFromScheduledToImmediate() {
// Create a scheduled workflow
WorkflowSetRepresentation workflows = WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
.after(Duration.ofDays(1))
.build(),
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
.after(Duration.ofDays(1))
.build()
).build();
WorkflowsResource workflowsResource = managedRealm.admin().workflows();
try (Response response = workflowsResource.create(workflows)) {
assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode()));
}
WorkflowRepresentation workflow = workflowsResource.list().get(0);
// Attempt to update the workflow to immediate
workflow.setScheduled(false);
try (Response response = workflowsResource.workflow(workflow.getId()).update(workflow)) {
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() {
WorkflowSetRepresentation 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() {
WorkflowSetRepresentation 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 testFailCreateWorkflowWithNegativeTime() {
WorkflowSetRepresentation workflows = WorkflowRepresentation.create()
@ -737,14 +614,7 @@ public class WorkflowManagementTest {
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())
.build();
try (Response response = managedRealm.admin().workflows().create(workflows)) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));

View File

@ -44,23 +44,14 @@ import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import jakarta.ws.rs.core.Response;
import org.keycloak.testframework.util.ApiUtil;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_SCHEDULED;
@KeycloakIntegrationTest(config = WorkflowsServerConfig.class)
public class WorkflowStepManagementTest {
@ -136,64 +127,6 @@ public class WorkflowStepManagementTest {
assertTrue(foundOurStep, "Our added step should be present in the workflow");
}
@Test
public void testAddStepWithTimeToImmediateWorkflow() {
workflowsResource.create(WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.onEvent(ResourceOperationType.USER_ADD.toString())
.name("immediate-workflow")
.immediate()
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID).build()
).build()).close();
String immediateWorkflowId = workflowsResource.list().stream()
.filter(wf -> "immediate-workflow".equals(wf.getName()))
.findFirst()
.map(WorkflowRepresentation::getId)
.orElse(null);
assertThat(immediateWorkflowId, notNullValue());
WorkflowRepresentation representation = workflowsResource.workflow(immediateWorkflowId).toRepresentation();
assertThat(representation.getConfig().getFirst(CONFIG_SCHEDULED), equalTo("false"));
// Attempt to add a step with 'after' time (should be rejected)
WorkflowStepRepresentation stepRep = new WorkflowStepRepresentation();
stepRep.setUses(DisableUserStepProviderFactory.ID);
stepRep.setConfig("name", "Invalid Step");
stepRep.setConfig("after", String.valueOf(Duration.ofDays(30).toMillis()));
try (Response response = workflowsResource.workflow(immediateWorkflowId).steps().create(stepRep)) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
String errorMessage = response.readEntity(String.class);
assertThat(errorMessage, containsString("Immediate workflow step cannot have a time condition."));
}
// Verify step was not added (should still be only the original setup step)
List<WorkflowStepRepresentation> allSteps = workflowsResource.workflow(immediateWorkflowId).steps().list();
assertThat(allSteps, hasSize(1));
}
@Test
public void testAddStepWithoutTimeToScheduledWorkflow() {
WorkflowRepresentation representation = workflowsResource.workflow(workflowId).toRepresentation();
assertThat(representation.getConfig().getFirst(CONFIG_SCHEDULED), nullValue());//immediate
// Attempt to add a step without 'after' time (should be rejected)
WorkflowStepRepresentation stepRep = WorkflowStepRepresentation.create()
.of(NotifyUserStepProviderFactory.ID)
.build();
try (Response response = workflowsResource.workflow(workflowId).steps().create(stepRep)) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
String errorMessage = response.readEntity(String.class);
assertThat(errorMessage, containsString("All steps of scheduled workflow must have a valid 'after' time condition."));
}
// Verify step was not added (should still be only the original setup step)
List<WorkflowStepRepresentation> allSteps = workflowsResource.workflow(workflowId).steps().list();
assertThat(allSteps, hasSize(1));
}
@Test
public void testRemoveStepFromWorkflow() {
WorkflowResource workflow = workflowsResource.workflow(workflowId);
@ -312,13 +245,13 @@ public class WorkflowStepManagementTest {
Workflow workflow = manager.addWorkflow(UserCreationTimeWorkflowProviderFactory.ID, Map.of());
WorkflowStep step1 = new WorkflowStep(NotifyUserStepProviderFactory.ID, null, List.of());
WorkflowStep step1 = new WorkflowStep(NotifyUserStepProviderFactory.ID, null);
step1.setAfter(Duration.ofDays(30).toMillis());
WorkflowStep step2 = new WorkflowStep(DisableUserStepProviderFactory.ID, null, List.of());
WorkflowStep step2 = new WorkflowStep(DisableUserStepProviderFactory.ID, null);
step2.setAfter(Duration.ofDays(60).toMillis());
WorkflowStep addedStep1 = manager.addStepToWorkflow(workflow.getId(), step1, null);
WorkflowStep addedStep2 = manager.addStepToWorkflow(workflow.getId(), step2, null);
WorkflowStep addedStep1 = manager.addStepToWorkflow(workflow, step1, null);
WorkflowStep addedStep2 = manager.addStepToWorkflow(workflow, step2, null);
// Simulate scheduled steps by binding workflow to a test resource
String testResourceId = "test-user-123";
@ -331,7 +264,7 @@ public class WorkflowStepManagementTest {
assertNotNull(scheduledStepsBeforeRemoval);
// Remove the first step
manager.removeStepFromWorkflow(workflow.getId(), addedStep1.getId());
manager.removeStepFromWorkflow(workflow, addedStep1.getId());
// Verify scheduled steps are updated
var scheduledStepsAfterRemoval = stateProvider.getScheduledStepsByWorkflow(workflow.getId());
@ -344,9 +277,9 @@ public class WorkflowStepManagementTest {
assertEquals(1, remainingSteps.get(0).getPriority()); // Should be reordered to priority 1
// Add a new step and verify scheduled steps are updated
WorkflowStep step3 = new WorkflowStep(NotifyUserStepProviderFactory.ID, null, List.of());
WorkflowStep step3 = new WorkflowStep(NotifyUserStepProviderFactory.ID, null);
step3.setAfter(Duration.ofDays(15).toMillis());
manager.addStepToWorkflow(workflow.getId(), step3, 0); // Insert at beginning
manager.addStepToWorkflow(workflow, step3, 0); // Insert at beginning
// Verify final state
List<WorkflowStep> finalSteps = manager.getSteps(workflow.getId());