mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 23:12:06 -03:30
Workflows code cleanup
Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
parent
aedd7fe5db
commit
652270302d
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,8 +82,4 @@ public class WorkflowResource {
|
||||
manager.bind(workflow, type, resourceId);
|
||||
}
|
||||
|
||||
@Path("steps")
|
||||
public WorkflowStepsResource steps() {
|
||||
return new WorkflowStepsResource(manager, workflow);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user