From fe8fce859df041c87bd9b1a18906c0da2a83d417 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Mon, 22 Sep 2025 11:26:25 -0300 Subject: [PATCH] Improve the Workflow JSON schema Closes #42697 Signed-off-by: Pedro Igor --- .../common/util/reflections/Reflections.java | 35 ++++ ...stractWorkflowComponentRepresentation.java | 138 +++++++++++++ .../MultivaluedHashMapValueDeserializer.java | 36 ++++ .../MultivaluedHashMapValueSerializer.java | 60 ++++++ .../WorkflowConditionRepresentation.java | 73 +++---- .../workflows/WorkflowConstants.java | 28 +++ .../workflows/WorkflowRepresentation.java | 184 ++++++++++++------ .../workflows/WorkflowSetRepresentation.java | 27 +++ .../WorkflowStateRepresentation.java | 22 +++ .../workflows/WorkflowStepRepresentation.java | 103 +++++----- .../workflows/WorkflowDefinitionTest.java | 172 ++++++++++++++++ .../client/resource/WorkflowsResource.java | 8 +- .../AbstractUserWorkflowProvider.java | 8 +- .../workflow/EventBasedWorkflowProvider.java | 68 +++---- .../EventBasedWorkflowProviderFactory.java | 4 +- ...oupMembershipWorkflowConditionFactory.java | 2 +- ...ntityProviderWorkflowConditionFactory.java | 2 +- .../RoleWorkflowConditionFactory.java | 2 +- ...UserAttributeWorkflowConditionFactory.java | 2 +- .../workflow/ResourceOperationType.java | 5 +- .../keycloak/models/workflow/Workflow.java | 28 ++- .../models/workflow/WorkflowStep.java | 14 +- .../AggregatedStepProviderFactory.java | 2 +- .../DeleteUserStepProviderFactory.java | 2 +- .../DisableUserStepProviderFactory.java | 2 +- .../workflow/NotifyUserStepProvider.java | 16 +- .../NotifyUserStepProviderFactory.java | 2 +- .../SetUserAttributeStepProvider.java | 8 +- .../SetUserAttributeStepProviderFactory.java | 2 +- .../models/workflow/WorkflowsManager.java | 177 ++++++++++++----- .../admin/resource/WorkflowStepsResource.java | 8 +- .../admin/resource/WorkflowsResource.java | 7 +- .../model/workflow/AdhocWorkflowTest.java | 3 +- .../model/workflow/AggregatedStepTest.java | 4 +- ...redUserSessionRefreshTimeWorkflowTest.java | 13 +- .../GroupMembershipJoinWorkflowTest.java | 14 +- .../workflow/RoleWorkflowConditionTest.java | 3 +- .../UserAttributeWorkflowConditionTest.java | 3 +- .../workflow/WorkflowManagementTest.java | 51 +++-- .../workflow/WorkflowStepManagementTest.java | 21 +- 40 files changed, 1017 insertions(+), 342 deletions(-) create mode 100644 core/src/main/java/org/keycloak/representations/workflows/AbstractWorkflowComponentRepresentation.java create mode 100644 core/src/main/java/org/keycloak/representations/workflows/MultivaluedHashMapValueDeserializer.java create mode 100644 core/src/main/java/org/keycloak/representations/workflows/MultivaluedHashMapValueSerializer.java create mode 100644 core/src/main/java/org/keycloak/representations/workflows/WorkflowConstants.java create mode 100644 core/src/main/java/org/keycloak/representations/workflows/WorkflowSetRepresentation.java create mode 100644 core/src/main/java/org/keycloak/representations/workflows/WorkflowStateRepresentation.java create mode 100644 core/src/test/java/org/keycloak/representations/workflows/WorkflowDefinitionTest.java diff --git a/common/src/main/java/org/keycloak/common/util/reflections/Reflections.java b/common/src/main/java/org/keycloak/common/util/reflections/Reflections.java index 2dbffd55c33..15d613760ad 100644 --- a/common/src/main/java/org/keycloak/common/util/reflections/Reflections.java +++ b/common/src/main/java/org/keycloak/common/util/reflections/Reflections.java @@ -18,6 +18,7 @@ package org.keycloak.common.util.reflections; import java.beans.Introspector; +import java.io.IOException; import java.io.Serializable; import java.lang.annotation.Annotation; import java.lang.reflect.AccessibleObject; @@ -1051,4 +1052,38 @@ public class Reflections { return Object.class; } + + public static T convertValueToType(Object value, Class type) { + + if (value == null) { + return null; + + } else if (value instanceof String) { + if (type == String.class) { + return type.cast(value); + } else if (type == Boolean.class) { + return type.cast(Boolean.parseBoolean(value.toString())); + } else if (type == Integer.class) { + return type.cast(Integer.parseInt(value.toString())); + } else if (type == Long.class) { + return type.cast(Long.parseLong(value.toString())); + } + } else if (value instanceof Number) { + if (type == Integer.class) { + return type.cast(((Number) value).intValue()); + } else if (type == Long.class) { + return type.cast(((Number) value).longValue()); + } else if (type == String.class) { + return type.cast(value.toString()); + } + } else if (value instanceof Boolean) { + if (type == Boolean.class) { + return type.cast(value); + } else if (type == String.class) { + return type.cast(value); + } + } + + throw new RuntimeException("Unable to handle type [" + type + "]"); + } } diff --git a/core/src/main/java/org/keycloak/representations/workflows/AbstractWorkflowComponentRepresentation.java b/core/src/main/java/org/keycloak/representations/workflows/AbstractWorkflowComponentRepresentation.java new file mode 100644 index 00000000000..44fb577dd66 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/workflows/AbstractWorkflowComponentRepresentation.java @@ -0,0 +1,138 @@ +package org.keycloak.representations.workflows; + +import static org.keycloak.common.util.reflections.Reflections.isArrayType; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.reflections.Reflections; + +public abstract class AbstractWorkflowComponentRepresentation { + + private String id; + private String uses; + + @JsonProperty("with") + private MultivaluedHashMap config; + + public AbstractWorkflowComponentRepresentation(String id, String uses, MultivaluedHashMap config) { + this.id = id; + this.uses = uses; + this.config = config; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUses() { + return this.uses; + } + + public void setUses(String uses) { + this.uses = uses; + } + + public MultivaluedHashMap getConfig() { + return config; + } + + public void setConfig(MultivaluedHashMap config) { + if (this.config == null) { + this.config = config; + } + this.config.putAll(config); + } + + public void setConfig(String key, String value) { + setConfig(key, Collections.singletonList(value)); + } + + @JsonAnySetter + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + public void setConfig(String key, List values) { + if (this.config == null) { + this.config = new MultivaluedHashMap<>(); + } + this.config.put(key, values); + } + + protected T getConfigValue(String key, Class type) { + if (config == null) { + return null; + } + + return Reflections.convertValueToType(config.getFirst(key), type); + } + + protected List getConfigValues(String key) { + if (config == null) { + return null; + } + + try { + return config.get(key); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + protected T getConfigValuesOrSingle(String key) { + if (config == null) { + return null; + } + + List values = config.get(key); + + if (values == null || values.isEmpty()) { + return null; + } + + if (values.size() == 1) { + return (T) values.get(0); + } + + return (T) values; + } + + protected void setConfigValue(String key, Object... values) { + if (values == null || values.length == 0) { + return; + } + + if (this.config == null) { + this.config = new MultivaluedHashMap<>(); + } + + if (isArrayType(values.getClass())) { + this.config.put(key, Arrays.stream(values).map(Object::toString).collect(Collectors.toList())); + } else { + this.config.putSingle(key, values[0].toString()); + } + } + + protected void setConfigValue(String key, List values) { + if (this.config == null) { + this.config = new MultivaluedHashMap<>(); + } + this.config.put(key, values); + } + + protected void addConfigValue(String key, String value) { + if (this.config == null) { + this.config = new MultivaluedHashMap<>(); + } + + this.config.add(key, value); + } +} diff --git a/core/src/main/java/org/keycloak/representations/workflows/MultivaluedHashMapValueDeserializer.java b/core/src/main/java/org/keycloak/representations/workflows/MultivaluedHashMapValueDeserializer.java new file mode 100644 index 00000000000..bccaac136ab --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/workflows/MultivaluedHashMapValueDeserializer.java @@ -0,0 +1,36 @@ +package org.keycloak.representations.workflows; + +import java.io.IOException; +import java.util.Map.Entry; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import org.keycloak.common.util.MultivaluedHashMap; + +public final class MultivaluedHashMapValueDeserializer extends JsonDeserializer { + + @Override + public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + MultivaluedHashMap map = new MultivaluedHashMap<>(); + JsonNode node = p.getCodec().readTree(p); + + if (node.isObject()) { + for (Entry property : node.properties()) { + String key = property.getKey(); + JsonNode values = property.getValue(); + + if (values.isArray()) { + for (JsonNode value : values) { + map.add(key, value.asText()); + } + } else { + map.add(key, values.asText()); + } + } + } + + return map; + } +} diff --git a/core/src/main/java/org/keycloak/representations/workflows/MultivaluedHashMapValueSerializer.java b/core/src/main/java/org/keycloak/representations/workflows/MultivaluedHashMapValueSerializer.java new file mode 100644 index 00000000000..9165639ce13 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/workflows/MultivaluedHashMapValueSerializer.java @@ -0,0 +1,60 @@ +package org.keycloak.representations.workflows; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Map.Entry; +import java.util.Set; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import org.keycloak.common.util.MultivaluedHashMap; + +public final class MultivaluedHashMapValueSerializer extends JsonSerializer> { + + @Override + public void serialize(MultivaluedHashMap map, JsonGenerator gen, SerializerProvider serializers) throws IOException { + Set ignoredProperties = getIgnoredProperties(gen); + + gen.writeStartObject(); + + for (Entry> entry : map.entrySet()) { + String key = entry.getKey(); + + if (ignoredProperties.contains(key)) { + continue; + } + + List values = entry.getValue(); + + if (values.size() == 1) { + String value = values.get(0); + + if (Boolean.TRUE.toString().equalsIgnoreCase(value) || Boolean.FALSE.toString().equalsIgnoreCase(value)) { + gen.writeBooleanField(key, Boolean.parseBoolean(value)); + } else { + gen.writeObjectField(key, value); + } + } else { + gen.writeArrayFieldStart(key); + for (String v : values) { + gen.writeString(v); + } + gen.writeEndArray(); + } + } + + gen.writeEndObject(); + } + + private static Set getIgnoredProperties(JsonGenerator gen) { + Class parentClazz = gen.currentValue().getClass(); + return Arrays.stream(parentClazz.getDeclaredMethods()) + .map(Method::getName) + .filter(name -> name.startsWith("get")) + .map(name -> name.substring(3).toLowerCase()).collect(Collectors.toSet()); + } +} diff --git a/core/src/main/java/org/keycloak/representations/workflows/WorkflowConditionRepresentation.java b/core/src/main/java/org/keycloak/representations/workflows/WorkflowConditionRepresentation.java index 405d124370f..99cb6d4941e 100644 --- a/core/src/main/java/org/keycloak/representations/workflows/WorkflowConditionRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/workflows/WorkflowConditionRepresentation.java @@ -1,66 +1,41 @@ package org.keycloak.representations.workflows; -import java.util.Collections; -import java.util.HashMap; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_USES; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_WITH; + +import java.util.Arrays; import java.util.List; import java.util.Map; -public class WorkflowConditionRepresentation { +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.keycloak.common.util.MultivaluedHashMap; + +@JsonPropertyOrder({CONFIG_USES, CONFIG_WITH}) +public final class WorkflowConditionRepresentation extends AbstractWorkflowComponentRepresentation { public static Builder create() { return new Builder(); } - private String id; - private String providerId; - private Map> config; - public WorkflowConditionRepresentation() { - // reflection + super(null, null, null); } - public WorkflowConditionRepresentation(String providerId) { - this(providerId, null); + public WorkflowConditionRepresentation(String condition) { + this(condition, null); } - public WorkflowConditionRepresentation(String providerId, Map> config) { - this(null, providerId, config); + public WorkflowConditionRepresentation(String condition, MultivaluedHashMap config) { + super(null, condition, config); } - public WorkflowConditionRepresentation(String id, String providerId, Map> config) { - this.id = id; - this.providerId = providerId; - this.config = config; - } - - public String getProviderId() { - return providerId; - } - - public void setProviderId(String providerId) { - this.providerId = providerId; - } - - public Map> getConfig() { - return config; - } - - public void setConfig(Map> config) { - this.config = config; - } - - public void setConfig(String key, String value) { - if (this.config == null) { - this.config = new HashMap<>(); - } - this.config.put(key, Collections.singletonList(value)); - } - - public void setConfig(String key, List values) { - if (this.config == null) { - this.config = new HashMap<>(); - } - this.config.put(key, values); + @Override + @JsonSerialize(using = MultivaluedHashMapValueSerializer.class) + @JsonDeserialize(using = MultivaluedHashMapValueDeserializer.class) + public MultivaluedHashMap getConfig() { + return super.getConfig(); } public static class Builder { @@ -77,13 +52,13 @@ public class WorkflowConditionRepresentation { return this; } - public Builder withConfig(String key, List value) { - action.setConfig(key, value); + public Builder withConfig(String key, String... values) { + action.setConfig(key, Arrays.asList(values)); return this; } public Builder withConfig(Map> config) { - action.setConfig(config); + action.setConfig(new MultivaluedHashMap<>(config)); return this; } diff --git a/core/src/main/java/org/keycloak/representations/workflows/WorkflowConstants.java b/core/src/main/java/org/keycloak/representations/workflows/WorkflowConstants.java new file mode 100644 index 00000000000..2410e32b53a --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/workflows/WorkflowConstants.java @@ -0,0 +1,28 @@ +package org.keycloak.representations.workflows; + +public final class WorkflowConstants { + + public static final String DEFAULT_WORKFLOW = "event-based-workflow"; + + public static final String CONFIG_USES = "uses"; + public static final String CONFIG_WITH = "with"; + + // Entry configuration keys for Workflow + public static final String CONFIG_ON_EVENT = "on"; + public static final String CONFIG_RESET_ON = "reset-on"; + public static final String CONFIG_NAME = "name"; + public static final String CONFIG_RECURRING = "recurring"; + public static final String CONFIG_SCHEDULED = "scheduled"; + public static final String CONFIG_ENABLED = "enabled"; + public static final String CONFIG_CONDITIONS = "conditions"; + public static final String CONFIG_STEPS = "steps"; + public static final String CONFIG_ERROR = "error"; + public static final String CONFIG_STATE = "state"; + + // Entry configuration keys for WorkflowCondition + public static final String CONFIG_IF = "if"; + + // Entry configuration keys for WorkflowStep + public static final String CONFIG_AFTER = "after"; + public static final String CONFIG_PRIORITY = "priority"; +} diff --git a/core/src/main/java/org/keycloak/representations/workflows/WorkflowRepresentation.java b/core/src/main/java/org/keycloak/representations/workflows/WorkflowRepresentation.java index b65b8b63297..faaddc95041 100644 --- a/core/src/main/java/org/keycloak/representations/workflows/WorkflowRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/workflows/WorkflowRepresentation.java @@ -1,5 +1,18 @@ package org.keycloak.representations.workflows; +import static java.util.Optional.ofNullable; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ENABLED; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_IF; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_NAME; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ON_EVENT; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RECURRING; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RESET_ON; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_SCHEDULED; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_STATE; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_STEPS; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_WITH; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_USES; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -7,65 +20,107 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; -import java.util.Optional; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; import org.keycloak.common.util.MultivaluedHashMap; -public class WorkflowRepresentation { +@JsonPropertyOrder({"id", CONFIG_NAME, CONFIG_USES, CONFIG_ENABLED, CONFIG_ON_EVENT, CONFIG_RESET_ON, CONFIG_SCHEDULED, CONFIG_RECURRING, CONFIG_IF, CONFIG_STEPS, CONFIG_STATE}) +@JsonIgnoreProperties(CONFIG_WITH) +public final class WorkflowRepresentation extends AbstractWorkflowComponentRepresentation { public static Builder create() { - return new Builder(); + return new Builder().of(WorkflowConstants.DEFAULT_WORKFLOW); } - private String id; - private String providerId; - private MultivaluedHashMap config; private List steps; + + @JsonProperty(CONFIG_IF) private List conditions; + private WorkflowStateRepresentation state; + public WorkflowRepresentation() { - // reflection + super(null, null, null); } - public WorkflowRepresentation(String providerId) { - this(providerId, null); + public WorkflowRepresentation(String id, String workflow, MultivaluedHashMap config, List conditions, List steps) { + super(id, workflow, config); + this.conditions = conditions; + this.steps = steps; } - public WorkflowRepresentation(String providerId, Map> config) { - this(null, providerId, config); + public T getOn() { + return getConfigValuesOrSingle(CONFIG_ON_EVENT); } - public WorkflowRepresentation(String id, String providerId, Map> config) { - this.id = id; - this.providerId = providerId; - this.config = new MultivaluedHashMap<>(config); + public void setOn(String... events) { + setConfigValue(CONFIG_ON_EVENT, Arrays.asList(events)); } - public String getId() { - return id; + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + public void setOn(List events) { + setConfigValue(CONFIG_ON_EVENT, events); } - public void setId(String id) { - this.id = id; + @JsonIgnore + public List getOnValues() { + return ofNullable(getConfigValues(CONFIG_ON_EVENT)).orElse(Collections.emptyList()); } - public String getProviderId() { - return this.providerId; + @JsonProperty(CONFIG_RESET_ON) + public T getOnEventReset() { + return getConfigValuesOrSingle(CONFIG_RESET_ON); } - public void setProviderId(String providerId) { - this.providerId = providerId; + @JsonIgnore + public List getOnEventsReset() { + return ofNullable(getConfigValues(CONFIG_RESET_ON)).orElse(Collections.emptyList()); + } + + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + public void setOnEventReset(List names) { + setConfigValue(CONFIG_RESET_ON, names); + } + + @JsonIgnore + public void setOnEventReset(String... names) { + setOnEventReset(Arrays.asList(names)); } public String getName() { - return Optional.ofNullable(config).orElse(new MultivaluedHashMap<>()).getFirst("name"); + return getConfigValue(CONFIG_NAME, String.class); } public void setName(String name) { - if (this.config == null) { - this.config = new MultivaluedHashMap<>(); - } - this.config.putSingle("name", name); + setConfigValue(CONFIG_NAME, name); + } + + public Boolean getRecurring() { + return getConfigValue(CONFIG_RECURRING, Boolean.class); + } + + public void setRecurring(Boolean recurring) { + setConfigValue(CONFIG_RECURRING, recurring); + } + + public Boolean getScheduled() { + return getConfigValue(CONFIG_SCHEDULED, Boolean.class); + } + + public void setScheduled(Boolean scheduled) { + setConfigValue(CONFIG_SCHEDULED, scheduled); + } + + public Boolean getEnabled() { + return getConfigValue(CONFIG_ENABLED, Boolean.class); + } + + public void setEnabled(Boolean enabled) { + setConfigValue(CONFIG_ENABLED, enabled); } public void setConditions(List conditions) { @@ -84,96 +139,101 @@ public class WorkflowRepresentation { return steps; } - public MultivaluedHashMap getConfig() { - return config; + public WorkflowStateRepresentation getState() { + if (state == null) { + state = new WorkflowStateRepresentation(this); + } + + if (state.getErrors().isEmpty()) { + return null; + } + + return state; } - public void addStep(WorkflowStepRepresentation step) { - if (steps == null) { - steps = new ArrayList<>(); - } - steps.add(step); + public void setState(WorkflowStateRepresentation state) { + this.state = state; } public static class Builder { - private String providerId; - private final Map> config = new HashMap<>(); - private List conditions = new ArrayList<>(); - private final Map> steps = new HashMap<>(); + + private final Map> steps = new HashMap<>(); private List builders = new ArrayList<>(); + private WorkflowRepresentation representation; private Builder() { } - private Builder(String providerId, List builders) { - this.providerId = providerId; + private Builder(WorkflowRepresentation representation, List builders) { + this.representation = representation; this.builders = builders; } public Builder of(String providerId) { - Builder builder = new Builder(providerId, builders); + WorkflowRepresentation representation = new WorkflowRepresentation(); + representation.setUses(providerId); + Builder builder = new Builder(representation, builders); builders.add(builder); return builder; } public Builder onEvent(String operation) { - List events = config.computeIfAbsent("events", k -> new ArrayList<>()); - - events.add(operation); - + representation.addConfigValue(CONFIG_ON_EVENT, operation); return this; } public Builder onConditions(WorkflowConditionRepresentation... condition) { - if (conditions == null) { - conditions = new ArrayList<>(); - } - conditions.addAll(Arrays.asList(condition)); + representation.setConditions(Arrays.asList(condition)); return this; } public Builder withSteps(WorkflowStepRepresentation... steps) { - this.steps.computeIfAbsent(providerId, (k) -> new ArrayList<>()).addAll(Arrays.asList(steps)); + this.steps.computeIfAbsent(representation, (k) -> new ArrayList<>()).addAll(Arrays.asList(steps)); return this; } public Builder withConfig(String key, String value) { - config.put(key, Collections.singletonList(value)); + representation.addConfigValue(key, value); return this; } - public Builder withConfig(String key, List value) { - config.put(key, value); + public Builder withConfig(String key, List values) { + representation.setConfigValue(key, values); return this; } public Builder name(String name) { - return withConfig("name", name); + representation.setName(name); + return this; } public Builder immediate() { - return withConfig("scheduled", "false"); + representation.setScheduled(false); + return this; } public Builder recurring() { - return withConfig("recurring", "true"); + representation.setRecurring(true); + return this; } - public List build() { + public WorkflowSetRepresentation build() { List workflows = new ArrayList<>(); for (Builder builder : builders) { - for (Entry> entry : builder.steps.entrySet()) { - WorkflowRepresentation workflow = new WorkflowRepresentation(entry.getKey(), builder.config); + if (builder.steps.isEmpty()) { + continue; + } + for (Entry> entry : builder.steps.entrySet()) { + WorkflowRepresentation workflow = entry.getKey(); workflow.setSteps(entry.getValue()); - workflow.setConditions(builder.conditions); workflows.add(workflow); } } - return workflows; + return new WorkflowSetRepresentation(workflows); } } } diff --git a/core/src/main/java/org/keycloak/representations/workflows/WorkflowSetRepresentation.java b/core/src/main/java/org/keycloak/representations/workflows/WorkflowSetRepresentation.java new file mode 100644 index 00000000000..509d0ec82ce --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/workflows/WorkflowSetRepresentation.java @@ -0,0 +1,27 @@ +package org.keycloak.representations.workflows; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; + +public final class WorkflowSetRepresentation { + + @JsonUnwrapped + private List workflows; + + public WorkflowSetRepresentation() { + + } + + public WorkflowSetRepresentation(List workflows) { + this.workflows = workflows; + } + + public void setWorkflows(List workflows) { + this.workflows = workflows; + } + + public List getWorkflows() { + return workflows; + } +} diff --git a/core/src/main/java/org/keycloak/representations/workflows/WorkflowStateRepresentation.java b/core/src/main/java/org/keycloak/representations/workflows/WorkflowStateRepresentation.java new file mode 100644 index 00000000000..1e382024002 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/workflows/WorkflowStateRepresentation.java @@ -0,0 +1,22 @@ +package org.keycloak.representations.workflows; + +import static java.util.Optional.ofNullable; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ERROR; + +import java.util.Collections; +import java.util.List; + +public class WorkflowStateRepresentation { + + private List errors = Collections.emptyList(); + + public WorkflowStateRepresentation() {} + + public WorkflowStateRepresentation(WorkflowRepresentation workflow) { + this.errors = ofNullable(workflow.getConfigValues(CONFIG_ERROR)).orElse(Collections.emptyList()); + } + + public List getErrors() { + return errors; + } +} diff --git a/core/src/main/java/org/keycloak/representations/workflows/WorkflowStepRepresentation.java b/core/src/main/java/org/keycloak/representations/workflows/WorkflowStepRepresentation.java index 183411b7e3d..a6af4730e23 100644 --- a/core/src/main/java/org/keycloak/representations/workflows/WorkflowStepRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/workflows/WorkflowStepRepresentation.java @@ -1,77 +1,69 @@ package org.keycloak.representations.workflows; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_PRIORITY; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_STEPS; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_USES; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_WITH; + import java.time.Duration; import java.util.Arrays; -import java.util.Collections; import java.util.List; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import org.keycloak.common.util.MultivaluedHashMap; -public class WorkflowStepRepresentation { - - private static final String AFTER_KEY = "after"; +@JsonPropertyOrder({"id", CONFIG_USES, CONFIG_AFTER, CONFIG_PRIORITY, CONFIG_WITH, CONFIG_STEPS}) +public final class WorkflowStepRepresentation extends AbstractWorkflowComponentRepresentation { public static Builder create() { return new Builder(); } - private String id; - private String providerId; - private MultivaluedHashMap config; private List steps; public WorkflowStepRepresentation() { - // reflection + super(null, null, null); } - public WorkflowStepRepresentation(String providerId) { - this(providerId, null); + public WorkflowStepRepresentation(String step) { + this(step, null); } - public WorkflowStepRepresentation(String providerId, MultivaluedHashMap config) { - this(null, providerId, config, null); + public WorkflowStepRepresentation(String step, MultivaluedHashMap config) { + this(null, step, config, null); } - public WorkflowStepRepresentation(String id, String providerId, MultivaluedHashMap config, List steps) { - this.id = id; - this.providerId = providerId; - this.config = config; - this.steps = steps; - } + public WorkflowStepRepresentation(String id, String step, MultivaluedHashMap config, List steps) { + super(id, step, config); - public String getId() { - return id; - } - - public String getProviderId() { - return providerId; - } - - public void setProviderId(String providerId) { - this.providerId = providerId; - } - - public MultivaluedHashMap getConfig() { - return config; - } - - public void setConfig(MultivaluedHashMap config) { - this.config = config; - } - - public void setConfig(String key, String value) { - setConfig(key, Collections.singletonList(value)); - } - - public void setConfig(String key, List values) { - if (this.config == null) { - this.config = new MultivaluedHashMap<>(); + if (steps != null && !steps.isEmpty()) { + this.steps = steps; } - this.config.put(key, values); } - private void setAfter(long ms) { - setConfig(AFTER_KEY, String.valueOf(ms)); + @JsonSerialize(using = MultivaluedHashMapValueSerializer.class) + @JsonDeserialize(using = MultivaluedHashMapValueDeserializer.class) + public MultivaluedHashMap getConfig() { + return super.getConfig(); + } + + public String getAfter() { + return getConfigValue(CONFIG_AFTER, String.class); + } + + public void setAfter(long ms) { + setConfig(CONFIG_AFTER, String.valueOf(ms)); + } + + public String getPriority() { + return getConfigValue(CONFIG_PRIORITY, String.class); + } + + public void setPriority(long ms) { + setConfig(CONFIG_PRIORITY, String.valueOf(ms)); } public List getSteps() { @@ -96,9 +88,14 @@ public class WorkflowStepRepresentation { return this; } + public Builder id(String id) { + step.setId(id); + return this; + } + public Builder before(WorkflowStepRepresentation targetStep, Duration timeBeforeTarget) { // Calculate absolute time: targetStep.after - timeBeforeTarget - String targetAfter = targetStep.getConfig().get(AFTER_KEY).get(0); + String targetAfter = targetStep.getConfig().get(CONFIG_AFTER).get(0); long targetTime = Long.parseLong(targetAfter); long thisTime = targetTime - timeBeforeTarget.toMillis(); step.setAfter(thisTime); @@ -110,13 +107,13 @@ public class WorkflowStepRepresentation { return this; } - public Builder withSteps(WorkflowStepRepresentation... steps) { - step.setSteps(Arrays.asList(steps)); + public Builder withConfig(String key, String... value) { + step.setConfig(key, Arrays.asList(value)); return this; } - public Builder withConfig(String key, List values) { - step.setConfig(key, values); + public Builder withSteps(WorkflowStepRepresentation... steps) { + step.setSteps(Arrays.asList(steps)); return this; } diff --git a/core/src/test/java/org/keycloak/representations/workflows/WorkflowDefinitionTest.java b/core/src/test/java/org/keycloak/representations/workflows/WorkflowDefinitionTest.java new file mode 100644 index 00000000000..0b398758eb6 --- /dev/null +++ b/core/src/test/java/org/keycloak/representations/workflows/WorkflowDefinitionTest.java @@ -0,0 +1,172 @@ +package org.keycloak.representations.workflows; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.Test; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.util.JsonSerialization; + +public class WorkflowDefinitionTest { + + @Test + public void testFullDefinition() throws IOException { + WorkflowRepresentation expected = new WorkflowRepresentation(); + + expected.setId("workflow-id"); + expected.setUses("my-provider"); + expected.setName("my-name"); + expected.setOn("event"); + expected.setOnEventReset("event-reset-1", "event-reset-2"); + expected.setSteps(null); + expected.setConditions(null); + expected.setRecurring(true); + expected.setScheduled(true); + expected.setEnabled(true); + + expected.setConditions(Arrays.asList( + WorkflowConditionRepresentation.create() + .of("condition-1") + .withConfig("key1", "v1") + .withConfig("key2", "v1", "v2") + .build(), + WorkflowConditionRepresentation.create() + .of("condition-2") + .withConfig("key1", "v1") + .withConfig("key2", "v1", "v2") + .build(), + WorkflowConditionRepresentation.create() + .of("condition-1") + .withConfig("key1", "v1") + .withConfig("key2", "v1", "v2", "v3") + .build())); + + expected.setSteps(Arrays.asList( + WorkflowStepRepresentation.create() + .of("step-1") + .id("1") + .withConfig("key1", "v1") + .after(Duration.ofSeconds(10)) + .build(), + WorkflowStepRepresentation.create() + .of("step-2") + .id("2") + .withConfig("key1", "v1", "v2") + .build(), + WorkflowStepRepresentation.create() + .of("step-1") + .id("3") + .withConfig("key1", "v1") + .build())); + + String json = JsonSerialization.writeValueAsPrettyString(expected); + + WorkflowRepresentation actual = JsonSerialization.readValue(json, WorkflowRepresentation.class); + + assertEquals(expected.getId(), actual.getId()); + assertEquals(expected.getUses(), actual.getUses()); + assertTrue(actual.getOn() instanceof String); + assertEquals(expected.getOn(), (String) actual.getOn()); + assertArrayEquals(((List) expected.getOnEventReset()).toArray(), ((List) actual.getOnEventReset()).toArray()); + assertEquals(expected.getName(), actual.getName()); + assertEquals(expected.getRecurring(), actual.getRecurring()); + assertEquals(expected.getScheduled(), actual.getScheduled()); + assertEquals(expected.getEnabled(), actual.getEnabled()); + + List actualConditions = actual.getConditions(); + assertNotNull(actualConditions); + actualConditions = actualConditions.stream().sorted(Comparator.comparing(WorkflowConditionRepresentation::getUses)).collect(Collectors.toList()); + List expectedConditions = expected.getConditions().stream().sorted(Comparator.comparing(WorkflowConditionRepresentation::getUses)).collect(Collectors.toList()); + + assertEquals(expectedConditions.size(), actualConditions.size()); + assertEquals(expectedConditions.get(0).getUses(), actualConditions.get(0).getUses()); + assertEquals(expectedConditions.get(0).getConfig().get("key1"), actualConditions.get(0).getConfig().get("key1")); + assertEquals(expectedConditions.get(0).getConfig().get("key2"), actualConditions.get(0).getConfig().get("key2")); + assertEquals(expectedConditions.get(1).getConfig().get("key1"), actualConditions.get(1).getConfig().get("key1")); + assertEquals(expectedConditions.get(1).getConfig().get("key2"), actualConditions.get(1).getConfig().get("key2")); + assertEquals(expectedConditions.get(2).getConfig().get("key1"), actualConditions.get(2).getConfig().get("key1")); + assertEquals(expectedConditions.get(2).getConfig().get("key2"), actualConditions.get(2).getConfig().get("key2")); + + + List actualSteps = actual.getSteps(); + assertNotNull(actualSteps); + actualSteps = actualSteps.stream().sorted(Comparator.comparing(WorkflowStepRepresentation::getUses)).collect(Collectors.toList()); + List expectedSteps = expected.getSteps().stream().sorted(Comparator.comparing(WorkflowStepRepresentation::getUses)).collect(Collectors.toList()); + + assertEquals(expectedSteps.size(), actualSteps.size()); + assertEquals(expectedSteps.get(0).getUses(), actualSteps.get(0).getUses()); + assertEquals(expectedSteps.get(0).getConfig().get("key1"), actualSteps.get(0).getConfig().get("key1")); + assertEquals(expectedSteps.get(1).getConfig().get("key1"), actualSteps.get(1).getConfig().get("key1")); + assertEquals(expectedSteps.get(2).getConfig().get("key1"), actualSteps.get(2).getConfig().get("key1")); + + System.out.println(json); + } + + @Test + public void testOnEventAsArray() throws IOException { + WorkflowRepresentation expected = new WorkflowRepresentation(); + + expected.setOn("event", "event2"); + + String json = JsonSerialization.writeValueAsPrettyString(expected); + WorkflowRepresentation actual = JsonSerialization.readValue(json, WorkflowRepresentation.class); + assertTrue(actual.getOn() instanceof List); + assertEquals(Arrays.asList("event", "event2"), actual.getOn()); + + System.out.println(json); + } + + @Test + public void testOnEventAsString() throws IOException { + WorkflowRepresentation expected = new WorkflowRepresentation(); + + expected.setOn("event"); + + String json = JsonSerialization.writeValueAsPrettyString(expected); + WorkflowRepresentation actual = JsonSerialization.readValue(json, WorkflowRepresentation.class); + assertTrue(actual.getOn() instanceof String); + assertEquals("event", actual.getOn()); + + System.out.println(json); + } + + @Test + public void testAggregatedAction() throws IOException { + WorkflowRepresentation expected = new WorkflowRepresentation(); + MultivaluedHashMap config = new MultivaluedHashMap<>(); + config.put("k1", Collections.singletonList("v1")); + WorkflowStepRepresentation aggregated = new WorkflowStepRepresentation("step-1", config); + + config = new MultivaluedHashMap<>(); + config.put("k1", Collections.singletonList("v1")); + aggregated.setSteps(Arrays.asList(new WorkflowStepRepresentation("sub-step-1", new MultivaluedHashMap<>(config)), new WorkflowStepRepresentation("sub-step-2", new MultivaluedHashMap<>(config)))); + + expected.setSteps(Collections.singletonList(aggregated)); + + String json = JsonSerialization.writeValueAsPrettyString(expected); + WorkflowRepresentation actual = JsonSerialization.readValue(json, WorkflowRepresentation.class); + + List actualSteps = actual.getSteps(); + assertNotNull(actualSteps); + actualSteps = actualSteps.stream().sorted(Comparator.comparing(WorkflowStepRepresentation::getUses)).collect(Collectors.toList()); + List expectedSteps = expected.getSteps().stream().sorted(Comparator.comparing(WorkflowStepRepresentation::getUses)).collect(Collectors.toList()); + + assertEquals(expectedSteps.size(), actualSteps.size()); + assertEquals(expectedSteps.get(0).getUses(), actualSteps.get(0).getUses()); + assertEquals(expectedSteps.get(0).getConfig().get("k1"), actualSteps.get(0).getConfig().get("k1")); + assertEquals(expectedSteps.get(0).getSteps().size(), actualSteps.get(0).getSteps().size()); + assertEquals(expectedSteps.get(0).getSteps().get(0).getConfig().get("k1"), actualSteps.get(0).getSteps().get(0).getConfig().get("k1")); + + System.out.println(json); + } +} diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowsResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowsResource.java index ea966aa039d..0fda6d22067 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowsResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowsResource.java @@ -1,5 +1,7 @@ package org.keycloak.admin.client.resource; +import java.util.List; + import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; @@ -9,8 +11,7 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.keycloak.representations.workflows.WorkflowRepresentation; - -import java.util.List; +import org.keycloak.representations.workflows.WorkflowSetRepresentation; public interface WorkflowsResource { @@ -18,9 +19,10 @@ public interface WorkflowsResource { @Consumes(MediaType.APPLICATION_JSON) Response create(WorkflowRepresentation representation); + @Path("set") @POST @Consumes(MediaType.APPLICATION_JSON) - Response create(List representation); + Response create(WorkflowSetRepresentation representation); @GET @Produces(MediaType.APPLICATION_JSON) diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/AbstractUserWorkflowProvider.java b/model/jpa/src/main/java/org/keycloak/models/workflow/AbstractUserWorkflowProvider.java index 5a765fe90ae..9ae69b86f5d 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/AbstractUserWorkflowProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/AbstractUserWorkflowProvider.java @@ -17,6 +17,8 @@ package org.keycloak.models.workflow; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CONDITIONS; + import java.util.ArrayList; import java.util.List; @@ -26,6 +28,7 @@ import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Subquery; +import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.component.ComponentModel; import org.keycloak.connections.jpa.JpaConnectionProvider; import org.keycloak.models.KeycloakSession; @@ -70,7 +73,8 @@ public abstract class AbstractUserWorkflowProvider extends EventBasedWorkflowPro } private List getConditionsPredicate(CriteriaBuilder cb, CriteriaQuery query, Root path) { - List conditions = getModel().getConfig().getOrDefault("conditions", List.of()); + MultivaluedHashMap config = getModel().getConfig(); + List conditions = config.getOrDefault(CONFIG_CONDITIONS, List.of()); if (conditions.isEmpty()) { return List.of(); @@ -79,7 +83,7 @@ public abstract class AbstractUserWorkflowProvider extends EventBasedWorkflowPro List predicates = new ArrayList<>(); for (String providerId : conditions) { - WorkflowConditionProvider condition = resolveCondition(providerId); + WorkflowConditionProvider condition = getManager().getConditionProvider(providerId, config); Predicate predicate = condition.toPredicate(cb, query, path); if (predicate != null) { diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflowProvider.java b/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflowProvider.java index 0fe14e54fd4..47351c0eb75 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflowProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflowProvider.java @@ -1,22 +1,24 @@ package org.keycloak.models.workflow; -import java.util.HashMap; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CONDITIONS; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ON_EVENT; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RESET_ON; + import java.util.List; -import java.util.Map; -import java.util.Map.Entry; import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; public class EventBasedWorkflowProvider implements WorkflowProvider { private final KeycloakSession session; private final ComponentModel model; + private final WorkflowsManager manager; public EventBasedWorkflowProvider(KeycloakSession session, ComponentModel model) { this.session = session; this.model = model; + this.manager = new WorkflowsManager(session); } @Override @@ -42,25 +44,13 @@ public class EventBasedWorkflowProvider implements WorkflowProvider { return evaluate(event); } - protected boolean isActivationEvent(WorkflowEvent event) { - ResourceOperationType operation = event.getOperation(); - - if (ResourceOperationType.AD_HOC.equals(operation)) { - return true; - } - - List events = model.getConfig().getOrDefault("events", List.of()); - - return events.contains(operation.name()); - } - @Override public boolean deactivateOnEvent(WorkflowEvent event) { if (!supports(event.getResourceType())) { return false; } - List events = model.getConfig().getOrDefault("events", List.of()); + List events = model.getConfig().getOrDefault(CONFIG_ON_EVENT, List.of()); for (String activationEvent : events) { ResourceOperationType a = ResourceOperationType.valueOf(activationEvent); @@ -78,21 +68,16 @@ public class EventBasedWorkflowProvider implements WorkflowProvider { return isResetEvent(event) && evaluate(event); } - protected boolean isResetEvent(WorkflowEvent event) { - boolean resetEventEnabled = Boolean.parseBoolean(getModel().getConfig().getFirstOrDefault("reset-event-enabled", Boolean.FALSE.toString())); - return resetEventEnabled && isActivationEvent(event); - } - @Override public void close() { } protected boolean evaluate(WorkflowEvent event) { - List conditions = getModel().getConfig().getOrDefault("conditions", List.of()); + List conditions = getModel().getConfig().getOrDefault(CONFIG_CONDITIONS, List.of()); for (String providerId : conditions) { - WorkflowConditionProvider condition = resolveCondition(providerId); + WorkflowConditionProvider condition = manager.getConditionProvider(providerId, model.getConfig()); if (!condition.evaluate(event)) { return false; @@ -102,29 +87,16 @@ public class EventBasedWorkflowProvider implements WorkflowProvider { return true; } - protected WorkflowConditionProvider resolveCondition(String providerId) { - KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); - WorkflowConditionProviderFactory providerFactory = (WorkflowConditionProviderFactory) sessionFactory.getProviderFactory(WorkflowConditionProvider.class, providerId); + protected boolean isActivationEvent(WorkflowEvent event) { + ResourceOperationType operation = event.getOperation(); - if (providerFactory == null) { - throw new IllegalStateException("Could not find condition provider: " + providerId); + if (ResourceOperationType.AD_HOC.equals(operation)) { + return true; } - Map> config = new HashMap<>(); + List events = model.getConfig().getOrDefault(CONFIG_ON_EVENT, List.of()); - for (Entry> configEntry : model.getConfig().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; + return events.contains(operation.name()); } protected ComponentModel getModel() { @@ -134,4 +106,14 @@ public class EventBasedWorkflowProvider implements WorkflowProvider { protected KeycloakSession getSession() { return session; } + + protected WorkflowsManager getManager() { + return manager; + } + + protected boolean isResetEvent(WorkflowEvent event) { + return model.getConfig() + .getOrDefault(CONFIG_RESET_ON, List.of()) + .contains(event.getOperation().name()); + } } diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflowProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflowProviderFactory.java index 5b28e688844..aad729783f1 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflowProviderFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/EventBasedWorkflowProviderFactory.java @@ -8,12 +8,12 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.provider.ProviderConfigProperty; -public class EventBasedWorkflowProviderFactory implements WorkflowProviderFactory { +public class EventBasedWorkflowProviderFactory implements WorkflowProviderFactory { public static final String ID = "event-based-workflow"; @Override - public WorkflowProvider create(KeycloakSession session, ComponentModel model) { + public EventBasedWorkflowProvider create(KeycloakSession session, ComponentModel model) { return new EventBasedWorkflowProvider(session, model); } diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/GroupMembershipWorkflowConditionFactory.java b/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/GroupMembershipWorkflowConditionFactory.java index 69e371210ea..c409d27189e 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/GroupMembershipWorkflowConditionFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/GroupMembershipWorkflowConditionFactory.java @@ -9,7 +9,7 @@ import org.keycloak.models.workflow.WorkflowConditionProviderFactory; public class GroupMembershipWorkflowConditionFactory implements WorkflowConditionProviderFactory { - public static final String ID = "group-membership-condition"; + public static final String ID = "is-member-of"; public static final String EXPECTED_GROUPS = "groups"; @Override diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/IdentityProviderWorkflowConditionFactory.java b/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/IdentityProviderWorkflowConditionFactory.java index 4e866464736..6431da2a8ce 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/IdentityProviderWorkflowConditionFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/IdentityProviderWorkflowConditionFactory.java @@ -9,7 +9,7 @@ import org.keycloak.models.workflow.WorkflowConditionProviderFactory; public class IdentityProviderWorkflowConditionFactory implements WorkflowConditionProviderFactory { - public static final String ID = "identity-provider-condition"; + public static final String ID = "has-identity-provider-link"; public static final String EXPECTED_ALIASES = "alias"; @Override diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/RoleWorkflowConditionFactory.java b/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/RoleWorkflowConditionFactory.java index f50bddaaa9e..4f0ccb014b5 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/RoleWorkflowConditionFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/RoleWorkflowConditionFactory.java @@ -9,7 +9,7 @@ import org.keycloak.models.workflow.WorkflowConditionProviderFactory; public class RoleWorkflowConditionFactory implements WorkflowConditionProviderFactory { - public static final String ID = "role-condition"; + public static final String ID = "has-role"; public static final String EXPECTED_ROLES = "roles"; @Override diff --git a/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/UserAttributeWorkflowConditionFactory.java b/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/UserAttributeWorkflowConditionFactory.java index 5cd63ebd230..ee264b1d3f9 100644 --- a/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/UserAttributeWorkflowConditionFactory.java +++ b/model/jpa/src/main/java/org/keycloak/models/workflow/conditions/UserAttributeWorkflowConditionFactory.java @@ -9,7 +9,7 @@ import org.keycloak.models.workflow.WorkflowConditionProviderFactory; public class UserAttributeWorkflowConditionFactory implements WorkflowConditionProviderFactory { - public static final String ID = "user-attribute-condition"; + public static final String ID = "has-user-attribute"; @Override public UserAttributeWorkflowConditionProvider create(KeycloakSession session, Map> config) { diff --git a/server-spi-private/src/main/java/org/keycloak/models/workflow/ResourceOperationType.java b/server-spi-private/src/main/java/org/keycloak/models/workflow/ResourceOperationType.java index 99526a5b31c..3c3a2b78a7b 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/workflow/ResourceOperationType.java +++ b/server-spi-private/src/main/java/org/keycloak/models/workflow/ResourceOperationType.java @@ -10,17 +10,16 @@ import org.keycloak.models.FederatedIdentityModel.FederatedIdentityRemovedEvent; import org.keycloak.models.GroupModel.GroupMemberJoinEvent; import org.keycloak.models.RoleModel; import org.keycloak.models.RoleModel.RoleGrantedEvent; -import org.keycloak.models.RoleModel.RoleRevokedEvent; import org.keycloak.provider.ProviderEvent; public enum ResourceOperationType { USER_ADD(OperationType.CREATE, EventType.REGISTER), USER_LOGIN(EventType.LOGIN), - USER_FEDERATED_IDENTITY_ADD(new Class[] {FederatedIdentityCreatedEvent.class}, new Class[] {FederatedIdentityRemovedEvent.class}), + USER_FEDERATED_IDENTITY_ADD(FederatedIdentityCreatedEvent.class), USER_FEDERATED_IDENTITY_REMOVE(FederatedIdentityRemovedEvent.class), USER_GROUP_MEMBERSHIP_ADD(GroupMemberJoinEvent.class), - USER_ROLE_ADD(new Class[] {RoleGrantedEvent.class}, new Class[] {RoleRevokedEvent.class}), + USER_ROLE_ADD(RoleGrantedEvent.class), AD_HOC(new Class[] {}); private final List types; diff --git a/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java b/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java index 351e37bc130..9c13915d036 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java +++ b/server-spi-private/src/main/java/org/keycloak/models/workflow/Workflow.java @@ -17,6 +17,11 @@ package org.keycloak.models.workflow; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ENABLED; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ERROR; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RECURRING; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_SCHEDULED; + import java.util.List; import java.util.Map; @@ -25,9 +30,6 @@ import org.keycloak.component.ComponentModel; public class Workflow { - public static final String SCHEDULED_KEY = "scheduled"; - public static final String RECURRING_KEY = "recurring"; - private MultivaluedHashMap config; private String providerId; private String id; @@ -69,15 +71,15 @@ public class Workflow { } public boolean isEnabled() { - return config != null && Boolean.parseBoolean(config.getFirstOrDefault("enabled", "true")); + return config != null && Boolean.parseBoolean(config.getFirstOrDefault(CONFIG_ENABLED, "true")); } public boolean isRecurring() { - return config != null && Boolean.parseBoolean(config.getFirst(RECURRING_KEY)); + return config != null && Boolean.parseBoolean(config.getFirst(CONFIG_RECURRING)); } public boolean isScheduled() { - return config != null && Boolean.parseBoolean(config.getFirstOrDefault(SCHEDULED_KEY, "true")); + return config != null && Boolean.parseBoolean(config.getFirstOrDefault(CONFIG_SCHEDULED, "true")); } public Long getNotBefore() { @@ -87,4 +89,18 @@ public class Workflow { public void setNotBefore(Long notBefore) { this.notBefore = notBefore; } + + public void setEnabled(boolean enabled) { + if (config == null) { + config = new MultivaluedHashMap<>(); + } + config.putSingle(CONFIG_ENABLED, String.valueOf(enabled)); + } + + public void setError(String message) { + if (config == null) { + config = new MultivaluedHashMap<>(); + } + config.putSingle(CONFIG_ERROR, message); + } } diff --git a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStep.java b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStep.java index e53a1664b72..65556525e75 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStep.java +++ b/server-spi-private/src/main/java/org/keycloak/models/workflow/WorkflowStep.java @@ -17,6 +17,9 @@ package org.keycloak.models.workflow; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_PRIORITY; + import java.util.List; import org.keycloak.common.util.MultivaluedHashMap; @@ -24,9 +27,6 @@ import org.keycloak.component.ComponentModel; public class WorkflowStep implements Comparable { - public static final String AFTER_KEY = "after"; - public static final String PRIORITY_KEY = "priority"; - private String id; private String providerId; private MultivaluedHashMap config; @@ -72,11 +72,11 @@ public class WorkflowStep implements Comparable { } public void setPriority(int priority) { - setConfig(PRIORITY_KEY, String.valueOf(priority)); + setConfig(CONFIG_PRIORITY, String.valueOf(priority)); } public int getPriority() { - String value = getConfig().getFirst(PRIORITY_KEY); + String value = getConfig().getFirst(CONFIG_PRIORITY); if (value == null) { return Integer.MAX_VALUE; } @@ -88,11 +88,11 @@ public class WorkflowStep implements Comparable { } public void setAfter(Long ms) { - setConfig(AFTER_KEY, String.valueOf(ms)); + setConfig(CONFIG_AFTER, String.valueOf(ms)); } public Long getAfter() { - return Long.valueOf(getConfig().getFirstOrDefault(AFTER_KEY, "0")); + return Long.valueOf(getConfig().getFirstOrDefault(CONFIG_AFTER, "0")); } public List getSteps() { diff --git a/services/src/main/java/org/keycloak/models/workflow/AggregatedStepProviderFactory.java b/services/src/main/java/org/keycloak/models/workflow/AggregatedStepProviderFactory.java index 1e11d1ed8d4..084b0560877 100644 --- a/services/src/main/java/org/keycloak/models/workflow/AggregatedStepProviderFactory.java +++ b/services/src/main/java/org/keycloak/models/workflow/AggregatedStepProviderFactory.java @@ -10,7 +10,7 @@ import org.keycloak.provider.ProviderConfigProperty; public class AggregatedStepProviderFactory implements WorkflowStepProviderFactory { - public static final String ID = "aggregated-step-provider"; + public static final String ID = "aggregated"; @Override public AggregatedStepProvider create(KeycloakSession session, ComponentModel model) { diff --git a/services/src/main/java/org/keycloak/models/workflow/DeleteUserStepProviderFactory.java b/services/src/main/java/org/keycloak/models/workflow/DeleteUserStepProviderFactory.java index e511b6a3121..f2be7fb2f94 100644 --- a/services/src/main/java/org/keycloak/models/workflow/DeleteUserStepProviderFactory.java +++ b/services/src/main/java/org/keycloak/models/workflow/DeleteUserStepProviderFactory.java @@ -27,7 +27,7 @@ import org.keycloak.provider.ProviderConfigProperty; public class DeleteUserStepProviderFactory implements WorkflowStepProviderFactory { - public static final String ID = "delete-user-step-provider"; + public static final String ID = "delete-user"; @Override public DeleteUserStepProvider create(KeycloakSession session, ComponentModel model) { diff --git a/services/src/main/java/org/keycloak/models/workflow/DisableUserStepProviderFactory.java b/services/src/main/java/org/keycloak/models/workflow/DisableUserStepProviderFactory.java index c9699957e69..d708153d952 100644 --- a/services/src/main/java/org/keycloak/models/workflow/DisableUserStepProviderFactory.java +++ b/services/src/main/java/org/keycloak/models/workflow/DisableUserStepProviderFactory.java @@ -27,7 +27,7 @@ import org.keycloak.provider.ProviderConfigProperty; public class DisableUserStepProviderFactory implements WorkflowStepProviderFactory { - public static final String ID = "disable-user-step-provider"; + public static final String ID = "disable-user"; @Override public DisableUserStepProvider create(KeycloakSession session, ComponentModel model) { diff --git a/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProvider.java b/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProvider.java index 33c1bb9c194..358e21b6557 100644 --- a/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProvider.java +++ b/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProvider.java @@ -17,6 +17,8 @@ package org.keycloak.models.workflow; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER; + import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; import org.keycloak.email.EmailException; @@ -30,8 +32,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import static org.keycloak.models.workflow.WorkflowStep.AFTER_KEY; - public class NotifyUserStepProvider implements WorkflowStepProvider { private static final String ACCOUNT_DISABLE_NOTIFICATION_SUBJECT = "accountDisableNotificationSubject"; @@ -153,8 +153,8 @@ public class NotifyUserStepProvider implements WorkflowStepProvider { boolean foundCurrent = false; for (ComponentModel step : steps) { if (foundCurrent) { - timeToNextNonNotificationStep += step.get(AFTER_KEY, 0L); - if (!step.getProviderId().equals("notify-user-step-provider")) { + timeToNextNonNotificationStep += step.get(CONFIG_AFTER, 0L); + if (!step.getProviderId().equals("notify-user")) { // we found the next non-notification action, accumulate its time and break return Map.of(step, timeToNextNonNotificationStep); } @@ -169,16 +169,16 @@ public class NotifyUserStepProvider implements WorkflowStepProvider { private String getDefaultSubjectKey(String stepType) { return switch (stepType) { - case "disable-user-step-provider" -> ACCOUNT_DISABLE_NOTIFICATION_SUBJECT; - case "delete-user-step-provider" -> ACCOUNT_DELETE_NOTIFICATION_SUBJECT; + case DisableUserStepProviderFactory.ID -> ACCOUNT_DISABLE_NOTIFICATION_SUBJECT; + case DeleteUserStepProviderFactory.ID -> ACCOUNT_DELETE_NOTIFICATION_SUBJECT; default -> "accountNotificationSubject"; }; } private String getDefaultMessageKey(String stepType) { return switch (stepType) { - case "disable-user-step-provider" -> ACCOUNT_DISABLE_NOTIFICATION_BODY; - case "delete-user-step-provider" -> ACCOUNT_DELETE_NOTIFICATION_BODY; + case DisableUserStepProviderFactory.ID -> ACCOUNT_DISABLE_NOTIFICATION_BODY; + case DeleteUserStepProviderFactory.ID -> ACCOUNT_DELETE_NOTIFICATION_BODY; default -> "accountNotificationBody"; }; } diff --git a/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProviderFactory.java b/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProviderFactory.java index 6c57a6f6aba..a4bfffeca68 100644 --- a/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProviderFactory.java +++ b/services/src/main/java/org/keycloak/models/workflow/NotifyUserStepProviderFactory.java @@ -28,7 +28,7 @@ import org.keycloak.provider.ProviderConfigProperty; public class NotifyUserStepProviderFactory implements WorkflowStepProviderFactory { - public static final String ID = "notify-user-step-provider"; + public static final String ID = "notify-user"; @Override public NotifyUserStepProvider create(KeycloakSession session, ComponentModel model) { diff --git a/services/src/main/java/org/keycloak/models/workflow/SetUserAttributeStepProvider.java b/services/src/main/java/org/keycloak/models/workflow/SetUserAttributeStepProvider.java index 00e5ae8e83e..097f22ece08 100644 --- a/services/src/main/java/org/keycloak/models/workflow/SetUserAttributeStepProvider.java +++ b/services/src/main/java/org/keycloak/models/workflow/SetUserAttributeStepProvider.java @@ -17,6 +17,9 @@ package org.keycloak.models.workflow; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_PRIORITY; + import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; @@ -26,9 +29,6 @@ import org.keycloak.models.UserModel; import java.util.List; import java.util.Map.Entry; -import static org.keycloak.models.workflow.WorkflowStep.AFTER_KEY; -import static org.keycloak.models.workflow.WorkflowStep.PRIORITY_KEY; - public class SetUserAttributeStepProvider implements WorkflowStepProvider { private final KeycloakSession session; @@ -55,7 +55,7 @@ public class SetUserAttributeStepProvider implements WorkflowStepProvider { for (Entry> entry : stepModel.getConfig().entrySet()) { String key = entry.getKey(); - if (!key.startsWith(AFTER_KEY) && !key.startsWith(PRIORITY_KEY)) { + if (!key.startsWith(CONFIG_AFTER) && !key.startsWith(CONFIG_PRIORITY)) { log.debugv("Setting attribute {0} to user {1})", key, user.getId()); user.setAttribute(key, entry.getValue()); } diff --git a/services/src/main/java/org/keycloak/models/workflow/SetUserAttributeStepProviderFactory.java b/services/src/main/java/org/keycloak/models/workflow/SetUserAttributeStepProviderFactory.java index 8fa04077653..a417a6dcc47 100644 --- a/services/src/main/java/org/keycloak/models/workflow/SetUserAttributeStepProviderFactory.java +++ b/services/src/main/java/org/keycloak/models/workflow/SetUserAttributeStepProviderFactory.java @@ -27,7 +27,7 @@ import org.keycloak.provider.ProviderConfigProperty; public class SetUserAttributeStepProviderFactory implements WorkflowStepProviderFactory { - public static final String ID = "set-user-attr-step-provider"; + public static final String ID = "set-user-attribute"; @Override public SetUserAttributeStepProvider create(KeycloakSession session, ComponentModel model) { diff --git a/services/src/main/java/org/keycloak/models/workflow/WorkflowsManager.java b/services/src/main/java/org/keycloak/models/workflow/WorkflowsManager.java index a251d1b91d4..0c48b78ba5c 100644 --- a/services/src/main/java/org/keycloak/models/workflow/WorkflowsManager.java +++ b/services/src/main/java/org/keycloak/models/workflow/WorkflowsManager.java @@ -25,26 +25,29 @@ import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.component.ComponentFactory; import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.ModelValidationException; import org.keycloak.models.RealmModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.workflow.WorkflowStateProvider.ScheduledStep; import org.keycloak.representations.workflows.WorkflowConditionRepresentation; +import org.keycloak.representations.workflows.WorkflowConstants; import org.keycloak.representations.workflows.WorkflowRepresentation; import org.keycloak.representations.workflows.WorkflowStepRepresentation; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; -import java.util.Optional; import java.util.stream.Stream; import static java.util.Optional.ofNullable; -import static org.keycloak.models.workflow.Workflow.RECURRING_KEY; -import static org.keycloak.models.workflow.Workflow.SCHEDULED_KEY; -import static org.keycloak.models.workflow.WorkflowStep.AFTER_KEY; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_AFTER; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CONDITIONS; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_RECURRING; +import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_SCHEDULED; public class WorkflowsManager { @@ -66,12 +69,12 @@ public class WorkflowsManager { return addWorkflow(new Workflow(providerId, config)); } - public Workflow addWorkflow(Workflow workflow) { + private Workflow addWorkflow(Workflow workflow) { RealmModel realm = getRealm(); ComponentModel model = new ComponentModel(); model.setParentId(realm.getId()); - model.setProviderId(workflow.getProviderId()); + model.setProviderId(ofNullable(workflow.getProviderId()).orElse(WorkflowConstants.DEFAULT_WORKFLOW)); model.setProviderType(WorkflowProvider.class.getName()); MultivaluedHashMap config = workflow.getConfig(); @@ -84,26 +87,22 @@ public class WorkflowsManager { } // This method takes an ordered list of steps. First step in the list has the highest priority, last step has the lowest priority - public void createSteps(Workflow workflow, List steps) { + private void addSteps(Workflow workflow, String parentId, List steps) { for (int i = 0; i < steps.size(); i++) { WorkflowStep step = steps.get(i); - validateStep(workflow, step); + if (workflow.getId().equals(parentId)) { + // only validate top-level steps, sub-steps are validated as part of the parent step validation + validateStep(workflow, step); + } // assign priority based on index. step.setPriority(i + 1); - List subSteps = Optional.ofNullable(step.getSteps()).orElse(List.of()); - // persist the new step component. - step = addStep(workflow.getId(), step); + step = addStep(parentId, step); - for (int j = 0; j < subSteps.size(); j++) { - WorkflowStep subStep = subSteps.get(j); - // assign priority based on index. - subStep.setPriority(j + 1); - addStep(step.getId(), subStep); - } + addSteps(workflow, step.getId(), step.getSteps()); } } @@ -183,9 +182,18 @@ public class WorkflowsManager { } public WorkflowStepProvider getStepProvider(WorkflowStep step) { + return (WorkflowStepProvider) getStepProviderFactory(step).create(session, getRealm().getComponent(step.getId())); + } + + private ComponentFactory getStepProviderFactory(WorkflowStep step) { ComponentFactory stepFactory = (ComponentFactory) session.getKeycloakSessionFactory() .getProviderFactory(WorkflowStepProvider.class, step.getProviderId()); - return (WorkflowStepProvider) stepFactory.create(session, getRealm().getComponent(step.getId())); + + if (stepFactory == null) { + throw new WorkflowInvalidStateException("Step not found: " + step.getProviderId()); + } + + return stepFactory; } private RealmModel getRealm() { @@ -247,8 +255,8 @@ public class WorkflowsManager { } } } catch (WorkflowInvalidStateException e) { - workflow.getConfig().putSingle("enabled", "false"); - workflow.getConfig().putSingle("validation_error", e.getMessage()); + workflow.setEnabled(false); + workflow.setError(e.getMessage()); updateWorkflow(workflow, workflow.getConfig()); log.debugf("Workflow %s was disabled due to: %s", workflow.getId(), e.getMessage()); } @@ -319,16 +327,53 @@ public class WorkflowsManager { } public WorkflowRepresentation toRepresentation(Workflow workflow) { - WorkflowRepresentation rep = new WorkflowRepresentation(workflow.getId(), workflow.getProviderId(), workflow.getConfig()); + List conditions = toConditionRepresentation(workflow); + List steps = toRepresentation(getSteps(workflow.getId())); - for (WorkflowStep step : getSteps(workflow.getId())) { - rep.addStep(toRepresentation(step)); - } - - return rep; + return new WorkflowRepresentation(workflow.getId(), workflow.getProviderId(), workflow.getConfig(), conditions, steps); } - private WorkflowStepRepresentation toRepresentation(WorkflowStep step) { + private List toConditionRepresentation(Workflow workflow) { + MultivaluedHashMap workflowConfig = ofNullable(workflow.getConfig()).orElse(new MultivaluedHashMap<>()); + List ids = workflowConfig.getOrDefault(CONFIG_CONDITIONS, List.of()); + + if (ids.isEmpty()) { + return null; + } + + List conditions = new ArrayList<>(); + + for (String id : ids) { + MultivaluedHashMap config = new MultivaluedHashMap<>(); + + for (Entry> 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 toRepresentation(List existingSteps) { + if (existingSteps == null || existingSteps.isEmpty()) { + return null; + } + + List steps = new ArrayList<>(); + + for (WorkflowStep step : existingSteps) { + steps.add(toRepresentation(step)); + } + + return steps; + } + + public WorkflowStepRepresentation toRepresentation(WorkflowStep step) { List steps = step.getSteps().stream().map(this::toRepresentation).toList(); return new WorkflowStepRepresentation(step.getId(), step.getProviderId(), step.getConfig(), steps); } @@ -340,22 +385,23 @@ public class WorkflowsManager { validateWorkflow(rep, config); for (WorkflowConditionRepresentation condition : conditions) { - String conditionProviderId = condition.getProviderId(); - config.computeIfAbsent("conditions", key -> new ArrayList<>()).add(conditionProviderId); + String conditionProviderId = condition.getUses(); + getConditionProviderFactory(conditionProviderId); + config.computeIfAbsent(CONFIG_CONDITIONS, key -> new ArrayList<>()).add(conditionProviderId); for (Entry> configEntry : condition.getConfig().entrySet()) { config.put(conditionProviderId + "." + configEntry.getKey(), configEntry.getValue()); } } - Workflow workflow = addWorkflow(rep.getProviderId(), config); + Workflow workflow = addWorkflow(rep.getUses(), config); List steps = new ArrayList<>(); for (WorkflowStepRepresentation stepRep : rep.getSteps()) { steps.add(toModel(stepRep)); } - createSteps(workflow, steps); + addSteps(workflow, workflow.getId(), steps); return workflow; } @@ -366,10 +412,10 @@ public class WorkflowsManager { // immediate workflow cannot have time conditions // all steps of scheduled workflow must have time condition - boolean isImmediate = config.containsKey(SCHEDULED_KEY) && !Boolean.parseBoolean(config.getFirst(SCHEDULED_KEY)); - boolean isRecurring = config.containsKey(RECURRING_KEY) && Boolean.parseBoolean(config.getFirst(RECURRING_KEY)); + boolean isImmediate = config.containsKey(CONFIG_SCHEDULED) && !Boolean.parseBoolean(config.getFirst(CONFIG_SCHEDULED)); + boolean isRecurring = config.containsKey(CONFIG_RECURRING) && Boolean.parseBoolean(config.getFirst(CONFIG_RECURRING)); boolean hasTimeCondition = rep.getSteps().stream().allMatch(step -> step.getConfig() != null - && step.getConfig().containsKey(AFTER_KEY)); + && step.getConfig().containsKey(CONFIG_AFTER)); if (isImmediate && isRecurring) { throw new WorkflowInvalidStateException("Workflow cannot be both immediate and recurring."); } @@ -379,6 +425,20 @@ public class WorkflowsManager { if (!isImmediate && !hasTimeCondition) { throw new WorkflowInvalidStateException("Scheduled workflow cannot have steps without time conditions."); } + + validateEvents(rep.getOnValues()); + validateEvents(rep.getOnEventsReset()); + } + + + private static void validateEvents(List 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 WorkflowStep toModel(WorkflowStepRepresentation rep) { @@ -388,7 +448,7 @@ public class WorkflowsManager { subSteps.add(toModel(subStep)); } - return new WorkflowStep(rep.getProviderId(), rep.getConfig(), subSteps); + return new WorkflowStep(rep.getUses(), rep.getConfig(), subSteps); } public void bind(Workflow workflow, ResourceType type, String resourceId) { @@ -405,6 +465,8 @@ public class WorkflowsManager { boolean isAggregatedStep = !step.getSteps().isEmpty(); boolean isScheduledWorkflow = workflow.isScheduled(); + getStepProviderFactory(step); + if (isAggregatedStep) { if (!step.getProviderId().equals(AggregatedStepProviderFactory.ID)) { // for now, only AggregatedStepProvider supports having sub-steps, but we might want to support @@ -417,17 +479,17 @@ public class WorkflowsManager { // for each sub-step (in case it's not aggregated step on its own) check all it's sub-steps do not have // time conditions, all its sub-steps are meant to be run at once if (subSteps.stream().anyMatch(subStep -> - subStep.getConfig().getFirst(AFTER_KEY) != null && + subStep.getConfig().getFirst(CONFIG_AFTER) != null && !subStep.getProviderId().equals(AggregatedStepProviderFactory.ID))) { throw new ModelValidationException("Sub-steps of aggregated step cannot have time conditions."); } } else { if (isScheduledWorkflow) { - if (step.getConfig().getFirst(AFTER_KEY) == null || step.getAfter() < 0) { + if (step.getConfig().getFirst(CONFIG_AFTER) == null || step.getAfter() < 0) { throw new ModelValidationException("All steps of scheduled workflow must have a valid 'after' time condition."); } } else { // immediate workflow - if (step.getConfig().getFirst(AFTER_KEY) != null) { + if (step.getConfig().getFirst(CONFIG_AFTER) != null) { throw new ModelValidationException("Immediate workflow step cannot have a time condition."); } } @@ -524,13 +586,6 @@ public class WorkflowsManager { } } - public WorkflowStepRepresentation toStepRepresentation(WorkflowStep step) { - List steps = step.getSteps().stream() - .map(this::toStepRepresentation) - .toList(); - return new WorkflowStepRepresentation(step.getId(), step.getProviderId(), step.getConfig(), steps); - } - public WorkflowStep toStepModel(WorkflowStepRepresentation rep) { List subSteps = new ArrayList<>(); @@ -538,6 +593,36 @@ public class WorkflowsManager { subSteps.add(toStepModel(subStep)); } - return new WorkflowStep(rep.getProviderId(), rep.getConfig(), subSteps); + return new WorkflowStep(rep.getUses(), rep.getConfig(), subSteps); + } + + public WorkflowConditionProvider getConditionProvider(String providerId, MultivaluedHashMap modelConfig) { + WorkflowConditionProviderFactory providerFactory = getConditionProviderFactory(providerId); + Map> config = new HashMap<>(); + + for (Entry> 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; + } + + private WorkflowConditionProviderFactory getConditionProviderFactory(String providerId) { + KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); + WorkflowConditionProviderFactory providerFactory = (WorkflowConditionProviderFactory) sessionFactory.getProviderFactory(WorkflowConditionProvider.class, providerId); + + if (providerFactory == null) { + throw new WorkflowInvalidStateException("Could not find condition provider: " + providerId); + } + + return providerFactory; } } diff --git a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowStepsResource.java b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowStepsResource.java index 611318a62cc..b3dcf93bea5 100644 --- a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowStepsResource.java +++ b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowStepsResource.java @@ -77,7 +77,7 @@ public class WorkflowStepsResource { }) public List getSteps() { return workflowsManager.getSteps(workflow.getId()).stream() - .map(workflowsManager::toStepRepresentation) + .map(workflowsManager::toRepresentation) .toList(); } @@ -111,8 +111,7 @@ public class WorkflowStepsResource { WorkflowStep step = workflowsManager.toStepModel(stepRep); WorkflowStep addedStep = workflowsManager.addStepToWorkflow(workflow.getId(), step, position); - WorkflowStepRepresentation result = workflowsManager.toStepRepresentation(addedStep); - return Response.ok(result).build(); + return Response.ok(workflowsManager.toRepresentation(addedStep)).build(); } /** @@ -165,10 +164,11 @@ public class WorkflowStepsResource { } WorkflowStep step = workflowsManager.getStepById(stepId); + if (step == null) { throw new BadRequestException("Step not found: " + stepId); } - return workflowsManager.toStepRepresentation(step); + return workflowsManager.toRepresentation(step); } } diff --git a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowsResource.java b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowsResource.java index 5a28f3eff7d..ed0c1926ab5 100644 --- a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowsResource.java +++ b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowsResource.java @@ -16,9 +16,11 @@ import org.keycloak.models.ModelException; import org.keycloak.models.workflow.Workflow; import org.keycloak.models.workflow.WorkflowsManager; import org.keycloak.representations.workflows.WorkflowRepresentation; +import org.keycloak.representations.workflows.WorkflowSetRepresentation; import org.keycloak.services.ErrorResponse; import java.util.List; +import java.util.Optional; public class WorkflowsResource { @@ -44,10 +46,11 @@ public class WorkflowsResource { } } + @Path("set") @POST @Consumes(MediaType.APPLICATION_JSON) - public Response createAll(List reps) { - for (WorkflowRepresentation workflow : reps) { + public Response createAll(WorkflowSetRepresentation workflows) { + for (WorkflowRepresentation workflow : Optional.ofNullable(workflows.getWorkflows()).orElse(List.of())) { create(workflow).close(); } return Response.created(session.getContext().getUri().getRequestUri()).build(); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AdhocWorkflowTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AdhocWorkflowTest.java index d7e31ef8650..c130acb7a42 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AdhocWorkflowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AdhocWorkflowTest.java @@ -1,7 +1,6 @@ package org.keycloak.tests.admin.model.workflow; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; @@ -61,7 +60,7 @@ public class AdhocWorkflowTest { WorkflowRepresentation workflow = workflows.get(0); assertThat(workflow.getSteps(), hasSize(1)); WorkflowStepRepresentation aggregatedStep = workflow.getSteps().get(0); - assertThat(aggregatedStep.getProviderId(), is(SetUserAttributeStepProviderFactory.ID)); + assertThat(aggregatedStep.getUses(), is(SetUserAttributeStepProviderFactory.ID)); } @Test diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AggregatedStepTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AggregatedStepTest.java index 73e23299aa5..c4d70ba5ae2 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AggregatedStepTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AggregatedStepTest.java @@ -70,7 +70,7 @@ public class AggregatedStepTest { WorkflowRepresentation workflow = workflows.get(0); assertThat(workflow.getSteps(), hasSize(1)); WorkflowStepRepresentation aggregatedStep = workflow.getSteps().get(0); - assertThat(aggregatedStep.getProviderId(), is(AggregatedStepProviderFactory.ID)); + assertThat(aggregatedStep.getUses(), is(AggregatedStepProviderFactory.ID)); List steps = aggregatedStep.getSteps(); assertThat(steps, hasSize(2)); assertStep(steps, SetUserAttributeStepProviderFactory.ID, a -> { @@ -219,7 +219,7 @@ public class AggregatedStepTest { private void assertStep(List steps, String expectedProviderId, Consumer assertions) { assertTrue(steps.stream() .anyMatch(a -> { - if (a.getProviderId().equals(expectedProviderId)) { + if (a.getUses().equals(expectedProviderId)) { assertions.accept(a); return true; } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/BrokeredUserSessionRefreshTimeWorkflowTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/BrokeredUserSessionRefreshTimeWorkflowTest.java index 98e15170b4b..444ddcd2ec3 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/BrokeredUserSessionRefreshTimeWorkflowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/BrokeredUserSessionRefreshTimeWorkflowTest.java @@ -34,6 +34,7 @@ import java.util.List; import java.util.Map; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.keycloak.admin.client.resource.UsersResource; import org.keycloak.common.util.Time; @@ -50,6 +51,7 @@ import org.keycloak.models.workflow.conditions.IdentityProviderWorkflowCondition import org.keycloak.representations.idm.FederatedIdentityRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.workflows.WorkflowStateRepresentation; import org.keycloak.representations.workflows.WorkflowStepRepresentation; import org.keycloak.representations.workflows.WorkflowConditionRepresentation; import org.keycloak.representations.workflows.WorkflowRepresentation; @@ -152,11 +154,11 @@ public class BrokeredUserSessionRefreshTimeWorkflowTest { // check the workflow is disabled workflowRep = consumerRealm.admin().workflows().workflow(workflows.get(0).getId()).toRepresentation(); - assertThat(workflowRep.getConfig().getFirst("enabled"), allOf(notNullValue(), is("false"))); - List validationErrors = workflowRep.getConfig().get("validation_error"); - assertThat(validationErrors, notNullValue()); - assertThat(validationErrors, hasSize(1)); - assertThat(validationErrors.get(0), containsString("Identity provider %s does not exist.".formatted(IDP_OIDC_ALIAS))); + assertThat(workflowRep.getEnabled(), allOf(notNullValue(), is(false))); + WorkflowStateRepresentation status = workflowRep.getState(); + assertThat(status, notNullValue()); + assertThat(status.getErrors(), hasSize(1)); + assertThat(status.getErrors().get(0), containsString("Identity provider %s does not exist.".formatted(IDP_OIDC_ALIAS))); } @Test @@ -230,6 +232,7 @@ public class BrokeredUserSessionRefreshTimeWorkflowTest { }); } + @Disabled("For now deactivation events is not enabled to any event") @Test public void testAddRemoveFedIdentityAffectsWorkflowAssociation() { consumerRealm.admin().workflows().create(WorkflowRepresentation.create() diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/GroupMembershipJoinWorkflowTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/GroupMembershipJoinWorkflowTest.java index 1df1f205572..b51c91c46db 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/GroupMembershipJoinWorkflowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/GroupMembershipJoinWorkflowTest.java @@ -28,6 +28,8 @@ import org.keycloak.models.workflow.conditions.GroupMembershipWorkflowConditionF import org.keycloak.models.workflow.ResourceOperationType; import org.keycloak.models.workflow.WorkflowsManager; import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.workflows.WorkflowSetRepresentation; +import org.keycloak.representations.workflows.WorkflowStateRepresentation; import org.keycloak.representations.workflows.WorkflowStepRepresentation; import org.keycloak.representations.workflows.WorkflowConditionRepresentation; import org.keycloak.representations.workflows.WorkflowRepresentation; @@ -66,7 +68,7 @@ public class GroupMembershipJoinWorkflowTest { groupId = ApiUtil.getCreatedId(response); } - List expectedWorkflows = WorkflowRepresentation.create() + WorkflowSetRepresentation expectedWorkflows = WorkflowRepresentation.create() .of(EventBasedWorkflowProviderFactory.ID) .onEvent(ResourceOperationType.USER_GROUP_MEMBERSHIP_ADD.name()) .onConditions(WorkflowConditionRepresentation.create() @@ -151,11 +153,11 @@ public class GroupMembershipJoinWorkflowTest { // check the workflow is disabled workflowRep = managedRealm.admin().workflows().workflow(workflows.get(0).getId()).toRepresentation(); - assertThat(workflowRep.getConfig().getFirst("enabled"), allOf(notNullValue(), is("false"))); - List validationErrors = workflowRep.getConfig().get("validation_error"); - assertThat(validationErrors, notNullValue()); - assertThat(validationErrors, hasSize(1)); - assertThat(validationErrors.get(0), containsString("Group with id %s does not exist.".formatted(groupId))); + assertThat(workflowRep.getEnabled(), allOf(notNullValue(), is(false))); + WorkflowStateRepresentation status = workflowRep.getState(); + assertThat(status, notNullValue()); + assertThat(status.getErrors(), hasSize(1)); + assertThat(status.getErrors().get(0), containsString("Group with id %s does not exist.".formatted(groupId))); } private static RealmModel configureSessionContext(KeycloakSession session) { diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/RoleWorkflowConditionTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/RoleWorkflowConditionTest.java index 587c65519eb..d9390e8b1fb 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/RoleWorkflowConditionTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/RoleWorkflowConditionTest.java @@ -28,6 +28,7 @@ import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory; import org.keycloak.models.workflow.conditions.RoleWorkflowConditionFactory; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.workflows.WorkflowSetRepresentation; import org.keycloak.representations.workflows.WorkflowStepRepresentation; import org.keycloak.representations.workflows.WorkflowConditionRepresentation; import org.keycloak.representations.workflows.WorkflowRepresentation; @@ -136,7 +137,7 @@ public class RoleWorkflowConditionTest { createRoleIfNotExists(roleName); } - List expectedWorkflows = WorkflowRepresentation.create() + WorkflowSetRepresentation expectedWorkflows = WorkflowRepresentation.create() .of(EventBasedWorkflowProviderFactory.ID) .onEvent(ResourceOperationType.USER_ROLE_ADD.name()) .recurring() diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserAttributeWorkflowConditionTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserAttributeWorkflowConditionTest.java index 877b38d47bb..9a2d62b2303 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserAttributeWorkflowConditionTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserAttributeWorkflowConditionTest.java @@ -25,6 +25,7 @@ import org.keycloak.models.workflow.ResourceOperationType; import org.keycloak.models.workflow.WorkflowsManager; import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory; import org.keycloak.models.workflow.conditions.UserAttributeWorkflowConditionFactory; +import org.keycloak.representations.workflows.WorkflowSetRepresentation; import org.keycloak.representations.workflows.WorkflowStepRepresentation; import org.keycloak.representations.workflows.WorkflowConditionRepresentation; import org.keycloak.representations.workflows.WorkflowRepresentation; @@ -131,7 +132,7 @@ public class UserAttributeWorkflowConditionTest { } private void createWorkflow(Map> attributes) { - List expectedWorkflows = WorkflowRepresentation.create() + WorkflowSetRepresentation expectedWorkflows = WorkflowRepresentation.create() .of(EventBasedWorkflowProviderFactory.ID) .onEvent(ResourceOperationType.USER_ADD.name()) .recurring() diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java index 70b1a956cce..08b6d8f2074 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java @@ -22,6 +22,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -62,6 +63,8 @@ import org.keycloak.models.workflow.UserCreationTimeWorkflowProviderFactory; import org.keycloak.models.workflow.conditions.IdentityProviderWorkflowConditionFactory; import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.workflows.WorkflowConstants; +import org.keycloak.representations.workflows.WorkflowSetRepresentation; import org.keycloak.representations.workflows.WorkflowStepRepresentation; import org.keycloak.representations.workflows.WorkflowConditionRepresentation; import org.keycloak.representations.workflows.WorkflowRepresentation; @@ -93,7 +96,7 @@ public class WorkflowManagementTest { @Test public void testCreate() { - List expectedWorkflows = WorkflowRepresentation.create() + WorkflowSetRepresentation expectedWorkflows = WorkflowRepresentation.create() .of(UserCreationTimeWorkflowProviderFactory.ID) .withSteps( WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID) @@ -113,15 +116,16 @@ public class WorkflowManagementTest { List actualWorkflows = workflows.list(); assertThat(actualWorkflows, Matchers.hasSize(1)); - assertThat(actualWorkflows.get(0).getProviderId(), is(UserCreationTimeWorkflowProviderFactory.ID)); + assertThat(actualWorkflows.get(0).getUses(), is(UserCreationTimeWorkflowProviderFactory.ID)); assertThat(actualWorkflows.get(0).getSteps(), Matchers.hasSize(2)); - assertThat(actualWorkflows.get(0).getSteps().get(0).getProviderId(), is(NotifyUserStepProviderFactory.ID)); - assertThat(actualWorkflows.get(0).getSteps().get(1).getProviderId(), is(DisableUserStepProviderFactory.ID)); + assertThat(actualWorkflows.get(0).getSteps().get(0).getUses(), is(NotifyUserStepProviderFactory.ID)); + assertThat(actualWorkflows.get(0).getSteps().get(1).getUses(), is(DisableUserStepProviderFactory.ID)); + assertThat(actualWorkflows.get(0).getState(), is(nullValue())); } @Test public void testCreateWithNoConditions() { - List expectedWorkflows = WorkflowRepresentation.create() + WorkflowSetRepresentation expectedWorkflows = WorkflowRepresentation.create() .of(EventBasedWorkflowProviderFactory.ID) .withSteps( WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID) @@ -132,13 +136,36 @@ public class WorkflowManagementTest { .build() ).build(); - expectedWorkflows.get(0).setConditions(null); + expectedWorkflows.getWorkflows().get(0).setConditions(null); try (Response response = managedRealm.admin().workflows().create(expectedWorkflows)) { assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode())); } } + @Test + public void testCreateWithNoWorkflowSetDefaultWorkflow() { + WorkflowSetRepresentation expectedWorkflows = WorkflowRepresentation.create() + .of(null) + .withSteps( + WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build(), + WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID) + .after(Duration.ofDays(5)) + .build() + ).build(); + + expectedWorkflows.getWorkflows().get(0).setConditions(null); + + try (Response response = managedRealm.admin().workflows().create(expectedWorkflows)) { + assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode())); + } + + assertEquals(1, managedRealm.admin().workflows().list().size()); + assertEquals(WorkflowConstants.DEFAULT_WORKFLOW, managedRealm.admin().workflows().list().get(0).getUses()); + } + @Test public void testDelete() { WorkflowsResource workflows = managedRealm.admin().workflows(); @@ -166,7 +193,7 @@ public class WorkflowManagementTest { List actualWorkflows = workflows.list(); assertThat(actualWorkflows, Matchers.hasSize(2)); - WorkflowRepresentation workflow = actualWorkflows.stream().filter(p -> UserCreationTimeWorkflowProviderFactory.ID.equals(p.getProviderId())).findAny().orElse(null); + WorkflowRepresentation workflow = actualWorkflows.stream().filter(p -> UserCreationTimeWorkflowProviderFactory.ID.equals(p.getUses())).findAny().orElse(null); assertThat(workflow, notNullValue()); String id = workflow.getId(); workflows.workflow(id).delete().close(); @@ -187,7 +214,7 @@ public class WorkflowManagementTest { @Test public void testUpdate() { - List expectedWorkflows = WorkflowRepresentation.create() + WorkflowSetRepresentation expectedWorkflows = WorkflowRepresentation.create() .of(UserCreationTimeWorkflowProviderFactory.ID) .name("test-workflow") .withSteps( @@ -438,7 +465,7 @@ public class WorkflowManagementTest { mailServer.runCleanup(); // disable the workflow - scheduled steps should be paused and workflow should not activate for new users - workflow.getConfig().putSingle("enabled", "false"); + workflow.setEnabled(false); managedRealm.admin().workflows().workflow(workflow.getId()).update(workflow).close(); // create another user - should NOT bind the user to the workflow as it is disabled @@ -583,7 +610,7 @@ public class WorkflowManagementTest { @Test public void testCreateImmediateWorkflowWithTimeConditions() { - List workflows = WorkflowRepresentation.create() + WorkflowSetRepresentation workflows = WorkflowRepresentation.create() .of(UserCreationTimeWorkflowProviderFactory.ID) .immediate() .withSteps( @@ -604,7 +631,7 @@ public class WorkflowManagementTest { @Test public void testCreateScheduledWorkflowWithoutTimeConditions() { - List workflows = WorkflowRepresentation.create() + WorkflowSetRepresentation workflows = WorkflowRepresentation.create() .of(UserCreationTimeWorkflowProviderFactory.ID) .withSteps( WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID) @@ -622,7 +649,7 @@ public class WorkflowManagementTest { @Test public void testCreateWorkflowMarkedAsBothImmediateAndRecurring() { - List workflows = WorkflowRepresentation.create() + WorkflowSetRepresentation workflows = WorkflowRepresentation.create() .of(UserCreationTimeWorkflowProviderFactory.ID) .immediate() .recurring() diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowStepManagementTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowStepManagementTest.java index 4c06c2b9b1d..60f956863c3 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowStepManagementTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowStepManagementTest.java @@ -34,6 +34,7 @@ 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; @@ -68,7 +69,7 @@ public class WorkflowStepManagementTest { workflowsResource = managedRealm.admin().workflows(); // Create a workflow for testing (need at least one step for persistence) - List workflows = WorkflowRepresentation.create() + WorkflowSetRepresentation workflows = WorkflowRepresentation.create() .of(UserCreationTimeWorkflowProviderFactory.ID) .onEvent(ResourceOperationType.USER_ADD.toString()) .name("Test Workflow") @@ -101,7 +102,7 @@ public class WorkflowStepManagementTest { WorkflowStepsResource steps = workflow.steps(); WorkflowStepRepresentation stepRep = new WorkflowStepRepresentation(); - stepRep.setProviderId(DisableUserStepProviderFactory.ID); + stepRep.setUses(DisableUserStepProviderFactory.ID); stepRep.setConfig("name", "Test Step"); stepRep.setConfig("after", String.valueOf(Duration.ofDays(30).toMillis())); @@ -111,7 +112,7 @@ public class WorkflowStepManagementTest { assertNotNull(addedStep); assertNotNull(addedStep.getId()); - assertEquals(DisableUserStepProviderFactory.ID, addedStep.getProviderId()); + assertEquals(DisableUserStepProviderFactory.ID, addedStep.getUses()); } // Verify step is in workflow (should be 2 total: setup step + our added step) @@ -120,7 +121,7 @@ public class WorkflowStepManagementTest { // Verify our added step is present boolean foundOurStep = allSteps.stream() - .anyMatch(step -> DisableUserStepProviderFactory.ID.equals(step.getProviderId()) && + .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"); } @@ -132,7 +133,7 @@ public class WorkflowStepManagementTest { // Add one more step WorkflowStepRepresentation step1 = new WorkflowStepRepresentation(); - step1.setProviderId(DisableUserStepProviderFactory.ID); + step1.setUses(DisableUserStepProviderFactory.ID); step1.setConfig("after", String.valueOf(Duration.ofDays(30).toMillis())); String step1Id; @@ -162,7 +163,7 @@ public class WorkflowStepManagementTest { // Add first step at position 0 WorkflowStepRepresentation step1 = new WorkflowStepRepresentation(); - step1.setProviderId(NotifyUserStepProviderFactory.ID); + step1.setUses(NotifyUserStepProviderFactory.ID); step1.setConfig("name", "Step 1"); step1.setConfig("after", String.valueOf(Duration.ofDays(30).toMillis())); @@ -178,7 +179,7 @@ public class WorkflowStepManagementTest { // Add second step at position 1 WorkflowStepRepresentation step2 = new WorkflowStepRepresentation(); - step2.setProviderId(DisableUserStepProviderFactory.ID); + step2.setUses(DisableUserStepProviderFactory.ID); step2.setConfig("name", "Step 2"); step2.setConfig("after", String.valueOf(Duration.ofDays(60).toMillis())); @@ -194,7 +195,7 @@ public class WorkflowStepManagementTest { // Add third step at position 1 (middle) WorkflowStepRepresentation step3 = new WorkflowStepRepresentation(); - step3.setProviderId(NotifyUserStepProviderFactory.ID); + step3.setUses(NotifyUserStepProviderFactory.ID); step3.setConfig("name", "Step 3"); step3.setConfig("after", String.valueOf(Duration.ofDays(45).toMillis())); // Between 30 and 60 days @@ -217,7 +218,7 @@ public class WorkflowStepManagementTest { WorkflowStepsResource steps = workflow.steps(); WorkflowStepRepresentation stepRep = new WorkflowStepRepresentation(); - stepRep.setProviderId(NotifyUserStepProviderFactory.ID); + stepRep.setUses(NotifyUserStepProviderFactory.ID); stepRep.setConfig("name", "Test Step"); stepRep.setConfig("after", String.valueOf(Duration.ofDays(15).toMillis())); @@ -231,7 +232,7 @@ public class WorkflowStepManagementTest { WorkflowStepRepresentation retrievedStep = steps.get(stepId); assertNotNull(retrievedStep); assertEquals(stepId, retrievedStep.getId()); - assertEquals(NotifyUserStepProviderFactory.ID, retrievedStep.getProviderId()); + assertEquals(NotifyUserStepProviderFactory.ID, retrievedStep.getUses()); assertEquals("Test Step", retrievedStep.getConfig().getFirst("name")); }