Workflows code cleanup

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen 2025-10-09 19:10:15 -03:00 committed by Pedro Igor
parent aedd7fe5db
commit 652270302d
8 changed files with 235 additions and 881 deletions

View File

@ -37,6 +37,4 @@ public interface WorkflowResource {
@Consumes(MediaType.APPLICATION_JSON)
void bind(@PathParam("type") String type, @PathParam("resourceId") String resourceId, Long milliseconds);
@Path("steps")
WorkflowStepsResource steps();
}

View File

@ -1,41 +0,0 @@
package org.keycloak.admin.client.resource;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import java.util.List;
public interface WorkflowStepsResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
List<WorkflowStepRepresentation> list();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
Response create(WorkflowStepRepresentation stepRep, @QueryParam("position") Integer position);
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
Response create(WorkflowStepRepresentation stepRep);
@Path("{stepId}")
@GET
@Produces(MediaType.APPLICATION_JSON)
WorkflowStepRepresentation get(@PathParam("stepId") String stepId);
@Path("{stepId}")
@DELETE
Response delete(@PathParam("stepId") String stepId);
}

View File

@ -41,7 +41,6 @@ import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.stream.Stream;
import static java.util.Optional.ofNullable;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CONDITIONS;
@ -65,9 +64,7 @@ public class WorkflowsManager {
this.workflowStateProvider = session.getKeycloakSessionFactory().getProviderFactory(WorkflowStateProvider.class).create(session);
}
public Workflow addWorkflow(String providerId, Map<String, List<String>> config) {
return addWorkflow(new Workflow(providerId, config));
}
/* ========================================= Workflows CRUD operations =========================================== */
private Workflow addWorkflow(Workflow workflow) {
RealmModel realm = getRealm();
@ -99,7 +96,7 @@ public class WorkflowsManager {
}
}
private WorkflowStep addStep(Workflow workflow, WorkflowStep step) {
private void addStep(Workflow workflow, WorkflowStep step) {
RealmModel realm = getRealm();
ComponentModel workflowModel = realm.getComponent(workflow.getId());
@ -108,14 +105,65 @@ public class WorkflowsManager {
}
ComponentModel stepModel = new ComponentModel();
stepModel.setId(step.getId());//need to keep stable UUIDs not to break a link in state table
stepModel.setParentId(workflowModel.getId());
stepModel.setProviderId(step.getProviderId());
stepModel.setProviderType(WorkflowStepProvider.class.getName());
stepModel.setConfig(step.getConfig());
realm.addComponentModel(stepModel);
}
return new WorkflowStep(realm.addComponentModel(stepModel));
public void updateWorkflow(Workflow workflow, WorkflowRepresentation representation) {
WorkflowRepresentation currentRep = toRepresentation(workflow);
// we compare the representation, removing first the entries we allow updating. If anything else changes, we throw a validation exception
String currentName = currentRep.getName(); currentRep.getConfig().remove(CONFIG_NAME);
String newName = representation.getName(); representation.getConfig().remove(CONFIG_NAME);
Boolean currentEnabled = currentRep.getEnabled(); currentRep.getConfig().remove(CONFIG_ENABLED);
Boolean newEnabled = representation.getEnabled(); representation.getConfig().remove(CONFIG_ENABLED);
if (!currentRep.equals(representation)) {
throw new ModelValidationException("Workflow update can only change 'name' and 'enabled' config entries.");
}
if (!Objects.equals(currentName, newName) || !Objects.equals(currentEnabled, newEnabled)) {
// only update component if something changed
representation.setName(newName);
representation.setEnabled(newEnabled);
this.updateWorkflowConfig(workflow, representation.getConfig());
}
}
private void updateWorkflowConfig(Workflow workflow, MultivaluedHashMap<String, String> config) {
ComponentModel component = getWorkflowComponent(workflow.getId());
component.setConfig(config);
getRealm().updateComponent(component);
}
public void removeWorkflow(String id) {
RealmModel realm = getRealm();
realm.getComponentsStream(realm.getId(), WorkflowProvider.class.getName())
.filter(workflow -> workflow.getId().equals(id))
.forEach(workflow -> {
realm.getComponentsStream(workflow.getId(), WorkflowStepProvider.class.getName()).forEach(realm::removeComponent);
realm.removeComponent(workflow);
});
workflowStateProvider.removeByWorkflow(id);
}
public Workflow getWorkflow(String id) {
return new Workflow(getWorkflowComponent(id));
}
private ComponentModel getWorkflowComponent(String id) {
ComponentModel component = getRealm().getComponent(id);
if (component == null || !WorkflowProvider.class.getName().equals(component.getProviderType())) {
throw new BadRequestException("Not a valid resource workflow: " + id);
}
return component;
}
public List<Workflow> getWorkflows() {
@ -125,29 +173,12 @@ public class WorkflowsManager {
}
public List<WorkflowStep> getSteps(String workflowId) {
return getStepsStream(workflowId).toList();
RealmModel realm = getRealm();
return realm.getComponentsStream(workflowId, WorkflowStepProvider.class.getName())
.map(WorkflowStep::new).sorted().toList();
}
public Stream<WorkflowStep> getStepsStream(String parentId) {
RealmModel realm = session.getContext().getRealm();
return realm.getComponentsStream(parentId, WorkflowStepProvider.class.getName())
.map(this::toStep).sorted();
}
private WorkflowStep toStep(ComponentModel model) {
return new WorkflowStep(model);
}
public WorkflowStep getStepById(String id) {
RealmModel realm = session.getContext().getRealm();
ComponentModel component = realm.getComponent(id);
if (component == null) {
return null;
}
return toStep(component);
}
/* ================================= Workflows component providers and factories ================================= */
private WorkflowProvider getWorkflowProvider(Workflow workflow) {
ComponentFactory<?, ?> factory = (ComponentFactory<?, ?>) session.getKeycloakSessionFactory()
@ -156,40 +187,47 @@ public class WorkflowsManager {
}
public WorkflowStepProvider getStepProvider(WorkflowStep step) {
return (WorkflowStepProvider) getStepProviderFactory(step).create(session, getRealm().getComponent(step.getId()));
return getStepProviderFactory(step).create(session, getRealm().getComponent(step.getId()));
}
private ComponentFactory<?, ?> getStepProviderFactory(WorkflowStep step) {
ComponentFactory<?, ?> stepFactory = (ComponentFactory<?, ?>) session.getKeycloakSessionFactory()
.getProviderFactory(WorkflowStepProvider.class, step.getProviderId());
private WorkflowStepProviderFactory<WorkflowStepProvider> getStepProviderFactory(WorkflowStep step) {
WorkflowStepProviderFactory<WorkflowStepProvider> factory = (WorkflowStepProviderFactory<WorkflowStepProvider>) session
.getKeycloakSessionFactory().getProviderFactory(WorkflowStepProvider.class, step.getProviderId());
if (stepFactory == null) {
if (factory == null) {
throw new WorkflowInvalidStateException("Step not found: " + step.getProviderId());
}
return stepFactory;
return factory;
}
private RealmModel getRealm() {
return session.getContext().getRealm();
}
public WorkflowConditionProvider getConditionProvider(String providerId, MultivaluedHashMap<String, String> modelConfig) {
WorkflowConditionProviderFactory<WorkflowConditionProvider> providerFactory = getConditionProviderFactory(providerId);
Map<String, List<String>> config = new HashMap<>();
public void removeWorkflows() {
RealmModel realm = getRealm();
realm.getComponentsStream(realm.getId(), WorkflowProvider.class.getName()).forEach(workflow -> {
realm.getComponentsStream(workflow.getId(), WorkflowStepProvider.class.getName()).forEach(realm::removeComponent);
realm.removeComponent(workflow);
});
}
public void scheduleAllEligibleResources(Workflow workflow) {
if (workflow.isEnabled()) {
WorkflowProvider provider = getWorkflowProvider(workflow);
provider.getEligibleResourcesForInitialStep()
.forEach(resourceId -> processEvent(List.of(workflow), new AdhocWorkflowEvent(ResourceType.USERS, resourceId)));
for (Entry<String, List<String>> configEntry : modelConfig.entrySet()) {
if (configEntry.getKey().startsWith(providerId)) {
config.put(configEntry.getKey().substring(providerId.length() + 1), configEntry.getValue());
}
}
return providerFactory.create(session, config);
}
public WorkflowConditionProviderFactory<WorkflowConditionProvider> getConditionProviderFactory(String providerId) {
KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
WorkflowConditionProviderFactory<WorkflowConditionProvider> providerFactory = (WorkflowConditionProviderFactory<WorkflowConditionProvider>) sessionFactory.getProviderFactory(WorkflowConditionProvider.class, providerId);
if (providerFactory == null) {
throw new WorkflowInvalidStateException("Could not find condition provider: " + providerId);
}
return providerFactory;
}
/* =================== Workflows execution methods (bind, process events, run scheduled steps) =================== */
public void processEvent(WorkflowEvent event) {
processEvent(getWorkflows(), event);
}
@ -267,304 +305,16 @@ public class WorkflowsManager {
});
}
public void removeWorkflow(String id) {
RealmModel realm = getRealm();
realm.getComponentsStream(realm.getId(), WorkflowProvider.class.getName())
.filter(workflow -> workflow.getId().equals(id))
.forEach(workflow -> {
realm.getComponentsStream(workflow.getId(), WorkflowStepProvider.class.getName()).forEach(realm::removeComponent);
realm.removeComponent(workflow);
});
workflowStateProvider.removeByWorkflow(id);
}
public Workflow getWorkflow(String id) {
return new Workflow(getWorkflowComponent(id));
}
public void updateWorkflow(Workflow workflow, WorkflowRepresentation representation) {
WorkflowRepresentation currentRep = toRepresentation(workflow);
// we compare the representation, removing first the entries we allow updating. If anything else changes, we throw a validation exception
String currentName = currentRep.getName(); currentRep.getConfig().remove(CONFIG_NAME);
String newName = representation.getName(); representation.getConfig().remove(CONFIG_NAME);
Boolean currentEnabled = currentRep.getEnabled(); currentRep.getConfig().remove(CONFIG_ENABLED);
Boolean newEnabled = representation.getEnabled(); representation.getConfig().remove(CONFIG_ENABLED);
if (!currentRep.equals(representation)) {
throw new ModelValidationException("Workflow update can only change 'name' and 'enabled' config entries.");
}
if (!Objects.equals(currentName, newName) || !Objects.equals(currentEnabled, newEnabled)) {
// only update component if something changed
representation.setName(newName);
representation.setEnabled(newEnabled);
this.updateWorkflowConfig(workflow, representation.getConfig());
}
}
private void updateWorkflowConfig(Workflow workflow, MultivaluedHashMap<String, String> config) {
ComponentModel component = getWorkflowComponent(workflow.getId());
component.setConfig(config);
getRealm().updateComponent(component);
}
private ComponentModel getWorkflowComponent(String id) {
ComponentModel component = getRealm().getComponent(id);
if (component == null || !WorkflowProvider.class.getName().equals(component.getProviderType())) {
throw new BadRequestException("Not a valid resource workflow: " + id);
}
return component;
}
public WorkflowRepresentation toRepresentation(Workflow workflow) {
List<WorkflowConditionRepresentation> conditions = toConditionRepresentation(workflow);
List<WorkflowStepRepresentation> steps = toRepresentation(getSteps(workflow.getId()));
return new WorkflowRepresentation(workflow.getId(), workflow.getProviderId(), workflow.getConfig(), conditions, steps);
}
private List<WorkflowConditionRepresentation> toConditionRepresentation(Workflow workflow) {
MultivaluedHashMap<String, String> workflowConfig = ofNullable(workflow.getConfig()).orElse(new MultivaluedHashMap<>());
List<String> ids = workflowConfig.getOrDefault(CONFIG_CONDITIONS, List.of());
if (ids.isEmpty()) {
return null;
}
List<WorkflowConditionRepresentation> conditions = new ArrayList<>();
for (String id : ids) {
MultivaluedHashMap<String, String> config = new MultivaluedHashMap<>();
for (Entry<String, List<String>> configEntry : workflowConfig.entrySet()) {
String key = configEntry.getKey();
if (key.startsWith(id + ".")) {
config.put(key.substring(id.length() + 1), configEntry.getValue());
}
}
conditions.add(new WorkflowConditionRepresentation(id, config));
}
return conditions;
}
private List<WorkflowStepRepresentation> toRepresentation(List<WorkflowStep> existingSteps) {
if (existingSteps == null || existingSteps.isEmpty()) {
return null;
}
List<WorkflowStepRepresentation> steps = new ArrayList<>();
for (WorkflowStep step : existingSteps) {
steps.add(toRepresentation(step));
}
return steps;
}
public WorkflowStepRepresentation toRepresentation(WorkflowStep step) {
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());
for (WorkflowConditionRepresentation condition : conditions) {
String conditionProviderId = condition.getUses();
getConditionProviderFactory(conditionProviderId);
config.computeIfAbsent(CONFIG_CONDITIONS, key -> new ArrayList<>()).add(conditionProviderId);
for (Entry<String, List<String>> configEntry : condition.getConfig().entrySet()) {
config.put(conditionProviderId + "." + configEntry.getKey(), configEntry.getValue());
}
}
Workflow workflow = addWorkflow(rep.getUses(), config);
List<WorkflowStep> steps = rep.getSteps().stream().map(this::toModel).toList();
addSteps(workflow, steps);
return workflow;
}
private void validateWorkflow(WorkflowRepresentation rep) {
validateEvents(rep.getOnValues());
validateEvents(rep.getOnEventsReset());
// a recurring workflow must have at least one scheduled step to prevent an infinite loop of immediate executions
if (rep.getConfig() != null && Boolean.parseBoolean(rep.getConfig().getFirstOrDefault(CONFIG_RECURRING, "false"))) {
boolean hasScheduledStep = ofNullable(rep.getSteps()).orElse(List.of()).stream()
.anyMatch(step -> Integer.parseInt(ofNullable(step.getAfter()).orElse("0")) > 0);
if (!hasScheduledStep) {
throw new WorkflowInvalidStateException("A recurring workflow must have at least one step with a time delay.");
}
}
}
private static void validateEvents(List<String> events) {
for (String event : ofNullable(events).orElse(List.of())) {
try {
ResourceOperationType.valueOf(event.toUpperCase());
} catch (IllegalArgumentException e) {
throw new WorkflowInvalidStateException("Invalid event type: " + event);
}
}
}
public void bind(Workflow workflow, ResourceType type, String resourceId) {
processEvent(List.of(workflow), new AdhocWorkflowEvent(type, resourceId));
}
public Object resolveResource(ResourceType type, String resourceId) {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(type, "resourceId");
return type.resolveResource(session, resourceId);
}
private void validateStep(WorkflowStep step) throws ModelValidationException {
if (step.getAfter() < 0) {
throw new ModelValidationException("Step 'after' time condition cannot be negative.");
public void bindToAllEligibleResources(Workflow workflow) {
if (workflow.isEnabled()) {
WorkflowProvider provider = getWorkflowProvider(workflow);
provider.getEligibleResourcesForInitialStep()
.forEach(resourceId -> processEvent(List.of(workflow), new AdhocWorkflowEvent(ResourceType.USERS, resourceId)));
}
// verify the step does have valid provider
getStepProviderFactory(step);
}
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(workflow.getId());
int targetPosition = position != null ? position : existingSteps.size();
if (targetPosition < 0 || targetPosition > existingSteps.size()) {
throw new BadRequestException("Invalid position: " + targetPosition + ". Must be between 0 and " + existingSteps.size());
}
// First, shift existing steps at and after the target position to make room
shiftStepsForInsertion(targetPosition, existingSteps);
step.setPriority(targetPosition + 1);
WorkflowStep addedStep = addStep(workflow, step);
log.debugf("Added step %s to workflow %s at position %d", addedStep.getId(), workflow.getId(), targetPosition);
return addedStep;
}
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(workflow.getId())) {
throw new BadRequestException("Step not found or not part of workflow: " + stepId);
}
realm.removeComponent(stepComponent);
// Reorder remaining steps and update state
reorderAllSteps(workflow.getId());
updateScheduledStepsAfterStepChange(workflow, stepId);
log.debugf("Removed step %s from workflow %s", stepId, workflow.getId());
}
private void shiftStepsForInsertion(int insertPosition, List<WorkflowStep> existingSteps) {
RealmModel realm = getRealm();
// Shift all steps at and after the insertion position by +1 priority
for (int i = insertPosition; i < existingSteps.size(); i++) {
WorkflowStep step = existingSteps.get(i);
step.setPriority(step.getPriority() + 1);
updateStepComponent(realm, step);
}
}
private void reorderAllSteps(String workflowId) {
List<WorkflowStep> steps = getSteps(workflowId);
RealmModel realm = getRealm();
for (int i = 0; i < steps.size(); i++) {
WorkflowStep step = steps.get(i);
step.setPriority(i + 1);
updateStepComponent(realm, step);
}
}
private void updateStepComponent(RealmModel realm, WorkflowStep step) {
ComponentModel component = realm.getComponent(step.getId());
component.setConfig(step.getConfig());
realm.updateComponent(component);
}
private void updateScheduledStepsAfterStepChange(Workflow workflow, String stepId) {
for (ScheduledStep scheduled : workflowStateProvider.getScheduledStepsByStep(stepId)) {
WorkflowExecutionContext context = buildFromScheduledStep(scheduled);
context.restart();
workflowStateProvider.scheduleStep(workflow, context.getNextStep(), scheduled.resourceId(), context.getExecutionId());
}
}
public WorkflowStep toModel(WorkflowStepRepresentation rep) {
WorkflowStep step = new WorkflowStep(rep.getUses(), rep.getConfig());
validateStep(step);
return step;
}
public WorkflowConditionProvider getConditionProvider(String providerId, MultivaluedHashMap<String, String> modelConfig) {
WorkflowConditionProviderFactory<WorkflowConditionProvider> providerFactory = getConditionProviderFactory(providerId);
Map<String, List<String>> config = new HashMap<>();
for (Entry<String, List<String>> configEntry : modelConfig.entrySet()) {
if (configEntry.getKey().startsWith(providerId)) {
config.put(configEntry.getKey().substring(providerId.length() + 1), configEntry.getValue());
}
}
WorkflowConditionProvider condition = providerFactory.create(session, config);
if (condition == null) {
throw new IllegalStateException("Factory " + providerFactory.getClass() + " returned a null provider");
}
return condition;
}
public WorkflowConditionProviderFactory<WorkflowConditionProvider> getConditionProviderFactory(String providerId) {
KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
WorkflowConditionProviderFactory<WorkflowConditionProvider> providerFactory = (WorkflowConditionProviderFactory<WorkflowConditionProvider>) sessionFactory.getProviderFactory(WorkflowConditionProvider.class, providerId);
if (providerFactory == null) {
throw new WorkflowInvalidStateException("Could not find condition provider: " + providerId);
}
return providerFactory;
}
private WorkflowExecutionContext buildAndInitContext(Workflow workflow, String resourceId) {
WorkflowExecutionContext context = new WorkflowExecutionContext(workflow, getSteps(workflow.getId()), resourceId);
context.init();
return context;
}
private WorkflowExecutionContext buildFromScheduledStep(ScheduledStep scheduledStep) {
return new WorkflowExecutionContext(
getWorkflow(scheduledStep.workflowId()),
getSteps(scheduledStep.workflowId()),
scheduledStep.resourceId(),
scheduledStep.stepId(),
scheduledStep.executionId()
);
}
private void processWorkflow(Workflow workflow, WorkflowExecutionContext context, String resourceId) {
@ -578,7 +328,6 @@ public class WorkflowsManager {
return;
} else {
// Otherwise run the step right away
runWorkflowStep(context, step, resourceId);
}
}
@ -607,4 +356,144 @@ public class WorkflowsManager {
}
});
}
/* ======================= Workflows representation <-> model conversions and validations ======================== */
public WorkflowRepresentation toRepresentation(Workflow workflow) {
List<WorkflowConditionRepresentation> conditions = toConditionRepresentation(workflow);
List<WorkflowStepRepresentation> steps = toRepresentation(getSteps(workflow.getId()));
return new WorkflowRepresentation(workflow.getId(), workflow.getProviderId(), workflow.getConfig(), conditions, steps);
}
private List<WorkflowConditionRepresentation> toConditionRepresentation(Workflow workflow) {
MultivaluedHashMap<String, String> workflowConfig = ofNullable(workflow.getConfig()).orElse(new MultivaluedHashMap<>());
List<String> ids = workflowConfig.getOrDefault(CONFIG_CONDITIONS, List.of());
if (ids.isEmpty()) {
return null;
}
List<WorkflowConditionRepresentation> conditions = new ArrayList<>();
for (String id : ids) {
MultivaluedHashMap<String, String> config = new MultivaluedHashMap<>();
for (Entry<String, List<String>> configEntry : workflowConfig.entrySet()) {
String key = configEntry.getKey();
if (key.startsWith(id + ".")) {
config.put(key.substring(id.length() + 1), configEntry.getValue());
}
}
conditions.add(new WorkflowConditionRepresentation(id, config));
}
return conditions;
}
private List<WorkflowStepRepresentation> toRepresentation(List<WorkflowStep> existingSteps) {
if (existingSteps == null || existingSteps.isEmpty()) {
return null;
}
List<WorkflowStepRepresentation> steps = new ArrayList<>();
for (WorkflowStep step : existingSteps) {
steps.add(new WorkflowStepRepresentation(step.getId(), step.getProviderId(), step.getConfig()));
}
return steps;
}
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());
for (WorkflowConditionRepresentation condition : conditions) {
String conditionProviderId = condition.getUses();
getConditionProviderFactory(conditionProviderId);
config.computeIfAbsent(CONFIG_CONDITIONS, key -> new ArrayList<>()).add(conditionProviderId);
for (Entry<String, List<String>> configEntry : condition.getConfig().entrySet()) {
config.put(conditionProviderId + "." + configEntry.getKey(), configEntry.getValue());
}
}
Workflow workflow = addWorkflow(new Workflow(rep.getUses(), config));
List<WorkflowStep> steps = rep.getSteps().stream().map(this::toModel).toList();
addSteps(workflow, steps);
return workflow;
}
public WorkflowStep toModel(WorkflowStepRepresentation rep) {
WorkflowStep step = new WorkflowStep(rep.getUses(), rep.getConfig());
validateStep(step);
return step;
}
private void validateWorkflow(WorkflowRepresentation rep) {
validateEvents(rep.getOnValues());
validateEvents(rep.getOnEventsReset());
// a recurring workflow must have at least one scheduled step to prevent an infinite loop of immediate executions
if (rep.getConfig() != null && Boolean.parseBoolean(rep.getConfig().getFirstOrDefault(CONFIG_RECURRING, "false"))) {
boolean hasScheduledStep = ofNullable(rep.getSteps()).orElse(List.of()).stream()
.anyMatch(step -> Integer.parseInt(ofNullable(step.getAfter()).orElse("0")) > 0);
if (!hasScheduledStep) {
throw new WorkflowInvalidStateException("A recurring workflow must have at least one step with a time delay.");
}
}
}
private static void validateEvents(List<String> events) {
for (String event : ofNullable(events).orElse(List.of())) {
try {
ResourceOperationType.valueOf(event.toUpperCase());
} catch (IllegalArgumentException e) {
throw new WorkflowInvalidStateException("Invalid event type: " + event);
}
}
}
private void validateStep(WorkflowStep step) throws ModelValidationException {
if (step.getAfter() < 0) {
throw new ModelValidationException("Step 'after' time condition cannot be negative.");
}
// verify the step does have valid provider
getStepProviderFactory(step);
}
/* ================================== Workflow execution context helper methods ================================== */
private WorkflowExecutionContext buildAndInitContext(Workflow workflow, String resourceId) {
WorkflowExecutionContext context = new WorkflowExecutionContext(workflow, getSteps(workflow.getId()), resourceId);
context.init();
return context;
}
private WorkflowExecutionContext buildFromScheduledStep(ScheduledStep scheduledStep) {
return new WorkflowExecutionContext(
getWorkflow(scheduledStep.workflowId()),
getSteps(scheduledStep.workflowId()),
scheduledStep.resourceId(),
scheduledStep.stepId(),
scheduledStep.executionId()
);
}
/* ============================================ Other utility methods ============================================ */
public Object resolveResource(ResourceType type, String resourceId) {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(type, "resourceId");
return type.resolveResource(session, resourceId);
}
private RealmModel getRealm() {
return session.getContext().getRealm();
}
}

View File

@ -82,8 +82,4 @@ public class WorkflowResource {
manager.bind(workflow, type, resourceId);
}
@Path("steps")
public WorkflowStepsResource steps() {
return new WorkflowStepsResource(manager, workflow);
}
}

View File

@ -1,179 +0,0 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.workflow.admin.resource;
import java.util.List;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.keycloak.models.ModelException;
import org.keycloak.models.workflow.WorkflowStep;
import org.keycloak.models.workflow.Workflow;
import org.keycloak.models.workflow.WorkflowsManager;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.services.ErrorResponse;
/**
* Resource for managing steps within a workflow.
*
*/
@Tag(name = "Workflow Steps", description = "Manage steps within workflows")
public class WorkflowStepsResource {
private final WorkflowsManager workflowsManager;
private final Workflow workflow;
public WorkflowStepsResource(WorkflowsManager workflowsManager, Workflow workflow) {
this.workflowsManager = workflowsManager;
this.workflow = workflow;
}
/**
* Get all steps for this workflow.
*
* @return list of steps
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get all steps for this workflow")
@APIResponses({
@APIResponse(responseCode = "200", description = "Success",
content = @Content(mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(type = SchemaType.ARRAY,
implementation = WorkflowStepRepresentation.class)))
})
public List<WorkflowStepRepresentation> getSteps() {
return workflowsManager.getSteps(workflow.getId()).stream()
.map(workflowsManager::toRepresentation)
.toList();
}
/**
* Add a new step to this workflow.
*
* @param stepRep step representation
* @param position optional position to insert the step at (0-based index)
* @return the created step
*/
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Add a new step to this workflow")
@APIResponses({
@APIResponse(responseCode = "200", description = "Step created successfully",
content = @Content(mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = WorkflowStepRepresentation.class))),
@APIResponse(responseCode = "400", description = "Invalid step representation or position")
})
public Response addStep(
@RequestBody(description = "Step to add", required = true,
content = @Content(schema = @Schema(implementation = WorkflowStepRepresentation.class)))
WorkflowStepRepresentation stepRep,
@Parameter(description = "Position to insert the step at (0-based index). If not specified, step is added at the end.")
@QueryParam("position") Integer position) {
if (stepRep == null) {
throw ErrorResponse.error("Step representation cannot be null", Response.Status.BAD_REQUEST);
}
try {
WorkflowStep step = workflowsManager.toModel(stepRep);
WorkflowStep addedStep = workflowsManager.addStepToWorkflow(workflow, step, position);
return Response.ok(workflowsManager.toRepresentation(addedStep)).build();
} catch (ModelException e) {
throw ErrorResponse.error(e.getMessage(), Response.Status.BAD_REQUEST);
}
}
/**
* Remove a step from this workflow.
*
* @param stepId ID of the step to remove
* @return no content response on success
*/
@Path("{stepId}")
@DELETE
@Operation(summary = "Remove a step from this workflow")
@APIResponses({
@APIResponse(responseCode = "204", description = "Step removed successfully"),
@APIResponse(responseCode = "400", description = "Invalid step ID"),
@APIResponse(responseCode = "404", description = "Step not found")
})
public Response removeStep(
@Parameter(description = "ID of the step to remove", required = true)
@PathParam("stepId") String stepId) {
if (stepId == null || stepId.trim().isEmpty()) {
throw new BadRequestException("Step ID cannot be null or empty");
}
workflowsManager.removeStepFromWorkflow(workflow, stepId);
return Response.noContent().build();
}
/**
* Get a specific step by its ID.
*
* @param stepId ID of the step to retrieve
* @return the step representation
*/
@Path("{stepId}")
@GET
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get a specific step by its ID")
@APIResponses({
@APIResponse(responseCode = "200", description = "Step found",
content = @Content(mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = WorkflowStepRepresentation.class))),
@APIResponse(responseCode = "400", description = "Invalid step ID"),
@APIResponse(responseCode = "404", description = "Step not found")
})
public WorkflowStepRepresentation getStep(
@Parameter(description = "ID of the step to retrieve", required = true)
@PathParam("stepId") String stepId) {
if (stepId == null || stepId.trim().isEmpty()) {
throw new BadRequestException("Step ID cannot be null or empty");
}
WorkflowStep step = workflowsManager.getStepById(stepId);
if (step == null) {
throw new BadRequestException("Step not found: " + stepId);
}
return workflowsManager.toRepresentation(step);
}
}

View File

@ -29,7 +29,6 @@ import java.time.Duration;
import java.util.List;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
@ -73,7 +72,7 @@ public class UserSessionRefreshTimeWorkflowTest {
@InjectUser(ref = "alice", config = DefaultUserConfig.class, lifecycle = LifeCycle.METHOD)
private ManagedUser userAlice;
@InjectRealm
@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm managedRealm;
@InjectWebDriver
@ -88,16 +87,6 @@ public class UserSessionRefreshTimeWorkflowTest {
@InjectMailServer
private MailServer mailServer;
@BeforeEach
public void onBefore() {
oauth.realm("default");
runOnServer.run(session -> {
WorkflowsManager manager = new WorkflowsManager(session);
manager.removeWorkflows();
});
}
@Test
public void testDisabledUserAfterInactivityPeriod() {
managedRealm.admin().workflows().create(WorkflowRepresentation.create()

View File

@ -413,7 +413,7 @@ public class WorkflowManagementTest {
});
// assign the workflow to the eligible users - i.e. only users from the same idp who are not yet assigned to the workflow.
workflowsManager.scheduleAllEligibleResources(workflow);
workflowsManager.bindToAllEligibleResources(workflow);
// check workflow was correctly assigned to the old users, not affecting users already associated with the workflow.
scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow);

View File

@ -1,298 +0,0 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.tests.admin.model.workflow;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.keycloak.admin.client.resource.WorkflowResource;
import org.keycloak.admin.client.resource.WorkflowStepsResource;
import org.keycloak.admin.client.resource.WorkflowsResource;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.workflow.DisableUserStepProviderFactory;
import org.keycloak.models.workflow.NotifyUserStepProviderFactory;
import org.keycloak.models.workflow.UserCreationTimeWorkflowProviderFactory;
import org.keycloak.models.workflow.WorkflowStep;
import org.keycloak.models.workflow.Workflow;
import org.keycloak.models.workflow.WorkflowsManager;
import org.keycloak.models.workflow.WorkflowStateProvider;
import org.keycloak.models.workflow.ResourceType;
import org.keycloak.models.workflow.ResourceOperationType;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowSetRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
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;
import jakarta.ws.rs.core.Response;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest(config = WorkflowsServerConfig.class)
public class WorkflowStepManagementTest {
@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm managedRealm;
@InjectRunOnServer(permittedPackages = "org.keycloak.tests")
RunOnServerClient runOnServer;
private WorkflowsResource workflowsResource;
private String workflowId;
@BeforeEach
public void setup() {
workflowsResource = managedRealm.admin().workflows();
// Create a workflow for testing (need at least one step for persistence)
WorkflowSetRepresentation workflows = WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.onEvent(ResourceOperationType.USER_ADD.toString())
.name("Test Workflow")
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
.after(Duration.ofDays(1))
.build()
)
.build();
try (Response response = workflowsResource.create(workflows)) {
if (response.getStatus() != 201) {
String responseBody = response.readEntity(String.class);
System.err.println("Workflow creation failed with status: " + response.getStatus());
System.err.println("Response body: " + responseBody);
}
assertEquals(201, response.getStatus());
// Since we created a list of workflows, get the first one from the list
List<WorkflowRepresentation> createdWorkflows = workflowsResource.list();
assertNotNull(createdWorkflows);
assertEquals(1, createdWorkflows.size());
workflowId = createdWorkflows.get(0).getId();
}
}
@Test
public void testAddStepToWorkflow() {
WorkflowResource workflow = workflowsResource.workflow(workflowId);
WorkflowStepsResource steps = workflow.steps();
WorkflowStepRepresentation stepRep = new WorkflowStepRepresentation();
stepRep.setUses(DisableUserStepProviderFactory.ID);
stepRep.setConfig("name", "Test Step");
stepRep.setConfig("after", String.valueOf(Duration.ofDays(30).toMillis()));
try (Response response = steps.create(stepRep)) {
assertEquals(200, response.getStatus());
WorkflowStepRepresentation addedStep = response.readEntity(WorkflowStepRepresentation.class);
assertNotNull(addedStep);
assertNotNull(addedStep.getId());
assertEquals(DisableUserStepProviderFactory.ID, addedStep.getUses());
}
// Verify step is in workflow (should be 2 total: setup step + our added step)
List<WorkflowStepRepresentation> allSteps = steps.list();
assertEquals(2, allSteps.size());
// Verify our added step is present
boolean foundOurStep = allSteps.stream()
.anyMatch(step -> DisableUserStepProviderFactory.ID.equals(step.getUses()) &&
"Test Step".equals(step.getConfig().getFirst("name")));
assertTrue(foundOurStep, "Our added step should be present in the workflow");
}
@Test
public void testRemoveStepFromWorkflow() {
WorkflowResource workflow = workflowsResource.workflow(workflowId);
WorkflowStepsResource steps = workflow.steps();
// Add one more step
WorkflowStepRepresentation step1 = new WorkflowStepRepresentation();
step1.setUses(DisableUserStepProviderFactory.ID);
step1.setConfig("after", String.valueOf(Duration.ofDays(30).toMillis()));
String step1Id;
try (Response response = steps.create(step1)) {
assertEquals(200, response.getStatus());
step1Id = response.readEntity(WorkflowStepRepresentation.class).getId();
}
// Verify both steps exist
List<WorkflowStepRepresentation> allSteps = steps.list();
assertEquals(2, allSteps.size());
// Remove the step we added
try (Response response = steps.delete(step1Id)) {
assertEquals(204, response.getStatus());
}
// Verify only the original setup step remains
allSteps = steps.list();
assertEquals(1, allSteps.size());
}
@Test
public void testAddStepAtSpecificPosition() {
WorkflowResource workflow = workflowsResource.workflow(workflowId);
WorkflowStepsResource steps = workflow.steps();
// Add first step at position 0
WorkflowStepRepresentation step1 = new WorkflowStepRepresentation();
step1.setUses(NotifyUserStepProviderFactory.ID);
step1.setConfig("name", "Step 1");
step1.setConfig("after", String.valueOf(Duration.ofDays(30).toMillis()));
String step1Id;
try (Response response = steps.create(step1, 0)) {
assertEquals(200, response.getStatus());
step1Id = response.readEntity(WorkflowStepRepresentation.class).getId();
}
// Verify step1 is at position 0
List<WorkflowStepRepresentation> allSteps = steps.list();
assertEquals(step1Id, allSteps.get(0).getId());
// Add second step at position 1
WorkflowStepRepresentation step2 = new WorkflowStepRepresentation();
step2.setUses(DisableUserStepProviderFactory.ID);
step2.setConfig("name", "Step 2");
step2.setConfig("after", String.valueOf(Duration.ofDays(60).toMillis()));
String step2Id;
try (Response response = steps.create(step2, 1)) {
assertEquals(200, response.getStatus());
step2Id = response.readEntity(WorkflowStepRepresentation.class).getId();
}
// Verify step2 is at position 1
allSteps = steps.list();
assertEquals(step2Id, allSteps.get(1).getId());
// Add third step at position 1 (middle)
WorkflowStepRepresentation step3 = new WorkflowStepRepresentation();
step3.setUses(NotifyUserStepProviderFactory.ID);
step3.setConfig("name", "Step 3");
step3.setConfig("after", String.valueOf(Duration.ofDays(45).toMillis())); // Between 30 and 60 days
String step3Id;
try (Response response = steps.create(step3, 1)) {
assertEquals(200, response.getStatus());
step3Id = response.readEntity(WorkflowStepRepresentation.class).getId();
}
// Verify step3 is at position 1 (inserted between step1 and step2)
allSteps = steps.list();
assertEquals(step1Id, allSteps.get(0).getId());
assertEquals(step3Id, allSteps.get(1).getId());
assertEquals(step2Id, allSteps.get(2).getId());
}
@Test
public void testGetSpecificStep() {
WorkflowResource workflow = workflowsResource.workflow(workflowId);
WorkflowStepsResource steps = workflow.steps();
WorkflowStepRepresentation stepRep = new WorkflowStepRepresentation();
stepRep.setUses(NotifyUserStepProviderFactory.ID);
stepRep.setConfig("name", "Test Step");
stepRep.setConfig("after", String.valueOf(Duration.ofDays(15).toMillis()));
String stepId;
try (Response response = steps.create(stepRep)) {
assertEquals(200, response.getStatus());
stepId = response.readEntity(WorkflowStepRepresentation.class).getId();
}
// Get the specific step
WorkflowStepRepresentation retrievedStep = steps.get(stepId);
assertNotNull(retrievedStep);
assertEquals(stepId, retrievedStep.getId());
assertEquals(NotifyUserStepProviderFactory.ID, retrievedStep.getUses());
assertEquals("Test Step", retrievedStep.getConfig().getFirst("name"));
}
@Test
public void testScheduledStepTableUpdatesAfterStepManagement() {
runOnServer.run(session -> {
configureSessionContext(session);
WorkflowsManager manager = new WorkflowsManager(session);
Workflow workflow = manager.addWorkflow(UserCreationTimeWorkflowProviderFactory.ID, Map.of());
WorkflowStep step1 = new WorkflowStep(NotifyUserStepProviderFactory.ID, null);
step1.setAfter(Duration.ofDays(30).toMillis());
WorkflowStep step2 = new WorkflowStep(DisableUserStepProviderFactory.ID, null);
step2.setAfter(Duration.ofDays(60).toMillis());
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";
manager.bind(workflow, ResourceType.USERS, testResourceId);
// Get scheduled steps for the workflow
WorkflowStateProvider stateProvider = session.getKeycloakSessionFactory().getProviderFactory(WorkflowStateProvider.class).create(session);
var scheduledStepsBeforeRemoval = stateProvider.getScheduledStepsByWorkflow(workflow.getId());
assertNotNull(scheduledStepsBeforeRemoval);
// Remove the first step
manager.removeStepFromWorkflow(workflow, addedStep1.getId());
// Verify scheduled steps are updated
var scheduledStepsAfterRemoval = stateProvider.getScheduledStepsByWorkflow(workflow.getId());
assertNotNull(scheduledStepsAfterRemoval);
// Verify remaining steps are still properly ordered
List<WorkflowStep> remainingSteps = manager.getSteps(workflow.getId());
assertEquals(1, remainingSteps.size());
assertEquals(addedStep2.getId(), remainingSteps.get(0).getId());
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);
step3.setAfter(Duration.ofDays(15).toMillis());
manager.addStepToWorkflow(workflow, step3, 0); // Insert at beginning
// Verify final state
List<WorkflowStep> finalSteps = manager.getSteps(workflow.getId());
assertEquals(2, finalSteps.size());
assertEquals(step3.getProviderId(), finalSteps.get(0).getProviderId());
assertEquals(1, finalSteps.get(0).getPriority());
assertEquals(addedStep2.getId(), finalSteps.get(1).getId());
assertEquals(2, finalSteps.get(1).getPriority());
});
}
private static void configureSessionContext(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName("default");
session.getContext().setRealm(realm);
}
}