Improve the Workflow JSON schema

Closes #42697

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2025-09-22 11:26:25 -03:00
parent 54d2451b35
commit fe8fce859d
40 changed files with 1017 additions and 342 deletions

View File

@ -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> T convertValueToType(Object value, Class<T> 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 + "]");
}
}

View File

@ -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<String, String> config;
public AbstractWorkflowComponentRepresentation(String id, String uses, MultivaluedHashMap<String, String> 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<String, String> getConfig() {
return config;
}
public void setConfig(MultivaluedHashMap<String, String> 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<String> values) {
if (this.config == null) {
this.config = new MultivaluedHashMap<>();
}
this.config.put(key, values);
}
protected <T> T getConfigValue(String key, Class<T> type) {
if (config == null) {
return null;
}
return Reflections.convertValueToType(config.getFirst(key), type);
}
protected List<String> getConfigValues(String key) {
if (config == null) {
return null;
}
try {
return config.get(key);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
protected <T> T getConfigValuesOrSingle(String key) {
if (config == null) {
return null;
}
List<String> 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<String> 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);
}
}

View File

@ -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<String, String> map = new MultivaluedHashMap<>();
JsonNode node = p.getCodec().readTree(p);
if (node.isObject()) {
for (Entry<String, JsonNode> 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;
}
}

View File

@ -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<MultivaluedHashMap<String, String>> {
@Override
public void serialize(MultivaluedHashMap<String, String> map, JsonGenerator gen, SerializerProvider serializers) throws IOException {
Set<String> ignoredProperties = getIgnoredProperties(gen);
gen.writeStartObject();
for (Entry<String, List<String>> entry : map.entrySet()) {
String key = entry.getKey();
if (ignoredProperties.contains(key)) {
continue;
}
List<String> 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<String> 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());
}
}

View File

@ -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<String, List<String>> 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<String, List<String>> config) {
this(null, providerId, config);
public WorkflowConditionRepresentation(String condition, MultivaluedHashMap<String, String> config) {
super(null, condition, config);
}
public WorkflowConditionRepresentation(String id, String providerId, Map<String, List<String>> 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<String, List<String>> getConfig() {
return config;
}
public void setConfig(Map<String, List<String>> 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<String> 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<String, String> getConfig() {
return super.getConfig();
}
public static class Builder {
@ -77,13 +52,13 @@ public class WorkflowConditionRepresentation {
return this;
}
public Builder withConfig(String key, List<String> value) {
action.setConfig(key, value);
public Builder withConfig(String key, String... values) {
action.setConfig(key, Arrays.asList(values));
return this;
}
public Builder withConfig(Map<String, List<String>> config) {
action.setConfig(config);
action.setConfig(new MultivaluedHashMap<>(config));
return this;
}

View File

@ -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";
}

View File

@ -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<String, String> config;
private List<WorkflowStepRepresentation> steps;
@JsonProperty(CONFIG_IF)
private List<WorkflowConditionRepresentation> 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<String, String> config, List<WorkflowConditionRepresentation> conditions, List<WorkflowStepRepresentation> steps) {
super(id, workflow, config);
this.conditions = conditions;
this.steps = steps;
}
public WorkflowRepresentation(String providerId, Map<String, List<String>> config) {
this(null, providerId, config);
public <T> T getOn() {
return getConfigValuesOrSingle(CONFIG_ON_EVENT);
}
public WorkflowRepresentation(String id, String providerId, Map<String, List<String>> 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<String> events) {
setConfigValue(CONFIG_ON_EVENT, events);
}
public void setId(String id) {
this.id = id;
@JsonIgnore
public List<String> getOnValues() {
return ofNullable(getConfigValues(CONFIG_ON_EVENT)).orElse(Collections.emptyList());
}
public String getProviderId() {
return this.providerId;
@JsonProperty(CONFIG_RESET_ON)
public <T> T getOnEventReset() {
return getConfigValuesOrSingle(CONFIG_RESET_ON);
}
public void setProviderId(String providerId) {
this.providerId = providerId;
@JsonIgnore
public List<String> getOnEventsReset() {
return ofNullable(getConfigValues(CONFIG_RESET_ON)).orElse(Collections.emptyList());
}
@JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
public void setOnEventReset(List<String> 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<>();
setConfigValue(CONFIG_NAME, name);
}
this.config.putSingle("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<WorkflowConditionRepresentation> conditions) {
@ -84,96 +139,101 @@ public class WorkflowRepresentation {
return steps;
}
public MultivaluedHashMap<String, String> getConfig() {
return config;
public WorkflowStateRepresentation getState() {
if (state == null) {
state = new WorkflowStateRepresentation(this);
}
public void addStep(WorkflowStepRepresentation step) {
if (steps == null) {
steps = new ArrayList<>();
if (state.getErrors().isEmpty()) {
return null;
}
steps.add(step);
return state;
}
public void setState(WorkflowStateRepresentation state) {
this.state = state;
}
public static class Builder {
private String providerId;
private final Map<String, List<String>> config = new HashMap<>();
private List<WorkflowConditionRepresentation> conditions = new ArrayList<>();
private final Map<String, List<WorkflowStepRepresentation>> steps = new HashMap<>();
private final Map<WorkflowRepresentation, List<WorkflowStepRepresentation>> steps = new HashMap<>();
private List<Builder> builders = new ArrayList<>();
private WorkflowRepresentation representation;
private Builder() {
}
private Builder(String providerId, List<Builder> builders) {
this.providerId = providerId;
private Builder(WorkflowRepresentation representation, List<Builder> 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<String> 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<String> value) {
config.put(key, value);
public Builder withConfig(String key, List<String> 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<WorkflowRepresentation> build() {
public WorkflowSetRepresentation build() {
List<WorkflowRepresentation> workflows = new ArrayList<>();
for (Builder builder : builders) {
for (Entry<String, List<WorkflowStepRepresentation>> entry : builder.steps.entrySet()) {
WorkflowRepresentation workflow = new WorkflowRepresentation(entry.getKey(), builder.config);
if (builder.steps.isEmpty()) {
continue;
}
for (Entry<WorkflowRepresentation, List<WorkflowStepRepresentation>> 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);
}
}
}

View File

@ -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<WorkflowRepresentation> workflows;
public WorkflowSetRepresentation() {
}
public WorkflowSetRepresentation(List<WorkflowRepresentation> workflows) {
this.workflows = workflows;
}
public void setWorkflows(List<WorkflowRepresentation> workflows) {
this.workflows = workflows;
}
public List<WorkflowRepresentation> getWorkflows() {
return workflows;
}
}

View File

@ -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<String> errors = Collections.emptyList();
public WorkflowStateRepresentation() {}
public WorkflowStateRepresentation(WorkflowRepresentation workflow) {
this.errors = ofNullable(workflow.getConfigValues(CONFIG_ERROR)).orElse(Collections.emptyList());
}
public List<String> getErrors() {
return errors;
}
}

View File

@ -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<String, String> config;
private List<WorkflowStepRepresentation> 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<String, String> config) {
this(null, providerId, config, null);
public WorkflowStepRepresentation(String step, MultivaluedHashMap<String, String> config) {
this(null, step, config, null);
}
public WorkflowStepRepresentation(String id, String providerId, MultivaluedHashMap<String, String> config, List<WorkflowStepRepresentation> steps) {
this.id = id;
this.providerId = providerId;
this.config = config;
public WorkflowStepRepresentation(String id, String step, MultivaluedHashMap<String, String> config, List<WorkflowStepRepresentation> steps) {
super(id, step, config);
if (steps != null && !steps.isEmpty()) {
this.steps = steps;
}
public String getId() {
return id;
}
public String getProviderId() {
return providerId;
}
public void setProviderId(String providerId) {
this.providerId = providerId;
}
@JsonSerialize(using = MultivaluedHashMapValueSerializer.class)
@JsonDeserialize(using = MultivaluedHashMapValueDeserializer.class)
public MultivaluedHashMap<String, String> getConfig() {
return config;
return super.getConfig();
}
public void setConfig(MultivaluedHashMap<String, String> config) {
this.config = config;
public String getAfter() {
return getConfigValue(CONFIG_AFTER, String.class);
}
public void setConfig(String key, String value) {
setConfig(key, Collections.singletonList(value));
public void setAfter(long ms) {
setConfig(CONFIG_AFTER, String.valueOf(ms));
}
public void setConfig(String key, List<String> values) {
if (this.config == null) {
this.config = new MultivaluedHashMap<>();
}
this.config.put(key, values);
public String getPriority() {
return getConfigValue(CONFIG_PRIORITY, String.class);
}
private void setAfter(long ms) {
setConfig(AFTER_KEY, String.valueOf(ms));
public void setPriority(long ms) {
setConfig(CONFIG_PRIORITY, String.valueOf(ms));
}
public List<WorkflowStepRepresentation> 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<String> values) {
step.setConfig(key, values);
public Builder withSteps(WorkflowStepRepresentation... steps) {
step.setSteps(Arrays.asList(steps));
return this;
}

View File

@ -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<WorkflowConditionRepresentation> actualConditions = actual.getConditions();
assertNotNull(actualConditions);
actualConditions = actualConditions.stream().sorted(Comparator.comparing(WorkflowConditionRepresentation::getUses)).collect(Collectors.toList());
List<WorkflowConditionRepresentation> 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<WorkflowStepRepresentation> actualSteps = actual.getSteps();
assertNotNull(actualSteps);
actualSteps = actualSteps.stream().sorted(Comparator.comparing(WorkflowStepRepresentation::getUses)).collect(Collectors.toList());
List<WorkflowStepRepresentation> expectedSteps = expected.getSteps().stream().sorted(Comparator.comparing(WorkflowStepRepresentation::getUses)).collect(Collectors.toList());
assertEquals(expectedSteps.size(), actualSteps.size());
assertEquals(expectedSteps.get(0).getUses(), actualSteps.get(0).getUses());
assertEquals(expectedSteps.get(0).getConfig().get("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<String, String> config = new MultivaluedHashMap<>();
config.put("k1", Collections.singletonList("v1"));
WorkflowStepRepresentation aggregated = new WorkflowStepRepresentation("step-1", config);
config = new MultivaluedHashMap<>();
config.put("k1", Collections.singletonList("v1"));
aggregated.setSteps(Arrays.asList(new WorkflowStepRepresentation("sub-step-1", new MultivaluedHashMap<>(config)), new WorkflowStepRepresentation("sub-step-2", new MultivaluedHashMap<>(config))));
expected.setSteps(Collections.singletonList(aggregated));
String json = JsonSerialization.writeValueAsPrettyString(expected);
WorkflowRepresentation actual = JsonSerialization.readValue(json, WorkflowRepresentation.class);
List<WorkflowStepRepresentation> actualSteps = actual.getSteps();
assertNotNull(actualSteps);
actualSteps = actualSteps.stream().sorted(Comparator.comparing(WorkflowStepRepresentation::getUses)).collect(Collectors.toList());
List<WorkflowStepRepresentation> expectedSteps = expected.getSteps().stream().sorted(Comparator.comparing(WorkflowStepRepresentation::getUses)).collect(Collectors.toList());
assertEquals(expectedSteps.size(), actualSteps.size());
assertEquals(expectedSteps.get(0).getUses(), actualSteps.get(0).getUses());
assertEquals(expectedSteps.get(0).getConfig().get("k1"), actualSteps.get(0).getConfig().get("k1"));
assertEquals(expectedSteps.get(0).getSteps().size(), actualSteps.get(0).getSteps().size());
assertEquals(expectedSteps.get(0).getSteps().get(0).getConfig().get("k1"), actualSteps.get(0).getSteps().get(0).getConfig().get("k1"));
System.out.println(json);
}
}

View File

@ -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<WorkflowRepresentation> representation);
Response create(WorkflowSetRepresentation representation);
@GET
@Produces(MediaType.APPLICATION_JSON)

View File

@ -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<Predicate> getConditionsPredicate(CriteriaBuilder cb, CriteriaQuery<String> query, Root<UserEntity> path) {
List<String> conditions = getModel().getConfig().getOrDefault("conditions", List.of());
MultivaluedHashMap<String, String> config = getModel().getConfig();
List<String> conditions = config.getOrDefault(CONFIG_CONDITIONS, List.of());
if (conditions.isEmpty()) {
return List.of();
@ -79,7 +83,7 @@ public abstract class AbstractUserWorkflowProvider extends EventBasedWorkflowPro
List<Predicate> 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) {

View File

@ -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<String> 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<String> events = model.getConfig().getOrDefault("events", List.of());
List<String> 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<String> conditions = getModel().getConfig().getOrDefault("conditions", List.of());
List<String> 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<WorkflowConditionProvider> providerFactory = (WorkflowConditionProviderFactory<WorkflowConditionProvider>) 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<String, List<String>> config = new HashMap<>();
List<String> events = model.getConfig().getOrDefault(CONFIG_ON_EVENT, List.of());
for (Entry<String, List<String>> 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());
}
}

View File

@ -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<EventBasedWorkflowProvider> {
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);
}

View File

@ -9,7 +9,7 @@ import org.keycloak.models.workflow.WorkflowConditionProviderFactory;
public class GroupMembershipWorkflowConditionFactory implements WorkflowConditionProviderFactory<GroupMembershipWorkflowConditionProvider> {
public static final String ID = "group-membership-condition";
public static final String ID = "is-member-of";
public static final String EXPECTED_GROUPS = "groups";
@Override

View File

@ -9,7 +9,7 @@ import org.keycloak.models.workflow.WorkflowConditionProviderFactory;
public class IdentityProviderWorkflowConditionFactory implements WorkflowConditionProviderFactory<IdentityProviderWorkflowConditionProvider> {
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

View File

@ -9,7 +9,7 @@ import org.keycloak.models.workflow.WorkflowConditionProviderFactory;
public class RoleWorkflowConditionFactory implements WorkflowConditionProviderFactory<RoleWorkflowConditionProvider> {
public static final String ID = "role-condition";
public static final String ID = "has-role";
public static final String EXPECTED_ROLES = "roles";
@Override

View File

@ -9,7 +9,7 @@ import org.keycloak.models.workflow.WorkflowConditionProviderFactory;
public class UserAttributeWorkflowConditionFactory implements WorkflowConditionProviderFactory<UserAttributeWorkflowConditionProvider> {
public static final String ID = "user-attribute-condition";
public static final String ID = "has-user-attribute";
@Override
public UserAttributeWorkflowConditionProvider create(KeycloakSession session, Map<String, List<String>> config) {

View File

@ -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<Object> types;

View File

@ -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<String, String> 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);
}
}

View File

@ -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<WorkflowStep> {
public static final String AFTER_KEY = "after";
public static final String PRIORITY_KEY = "priority";
private String id;
private String providerId;
private MultivaluedHashMap<String, String> config;
@ -72,11 +72,11 @@ public class WorkflowStep implements Comparable<WorkflowStep> {
}
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<WorkflowStep> {
}
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<WorkflowStep> getSteps() {

View File

@ -10,7 +10,7 @@ import org.keycloak.provider.ProviderConfigProperty;
public class AggregatedStepProviderFactory implements WorkflowStepProviderFactory<AggregatedStepProvider> {
public static final String ID = "aggregated-step-provider";
public static final String ID = "aggregated";
@Override
public AggregatedStepProvider create(KeycloakSession session, ComponentModel model) {

View File

@ -27,7 +27,7 @@ import org.keycloak.provider.ProviderConfigProperty;
public class DeleteUserStepProviderFactory implements WorkflowStepProviderFactory<DeleteUserStepProvider> {
public static final String ID = "delete-user-step-provider";
public static final String ID = "delete-user";
@Override
public DeleteUserStepProvider create(KeycloakSession session, ComponentModel model) {

View File

@ -27,7 +27,7 @@ import org.keycloak.provider.ProviderConfigProperty;
public class DisableUserStepProviderFactory implements WorkflowStepProviderFactory<DisableUserStepProvider> {
public static final String ID = "disable-user-step-provider";
public static final String ID = "disable-user";
@Override
public DisableUserStepProvider create(KeycloakSession session, ComponentModel model) {

View File

@ -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";
};
}

View File

@ -28,7 +28,7 @@ import org.keycloak.provider.ProviderConfigProperty;
public class NotifyUserStepProviderFactory implements WorkflowStepProviderFactory<NotifyUserStepProvider> {
public static final String ID = "notify-user-step-provider";
public static final String ID = "notify-user";
@Override
public NotifyUserStepProvider create(KeycloakSession session, ComponentModel model) {

View File

@ -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<String, List<String>> 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());
}

View File

@ -27,7 +27,7 @@ import org.keycloak.provider.ProviderConfigProperty;
public class SetUserAttributeStepProviderFactory implements WorkflowStepProviderFactory<SetUserAttributeStepProvider> {
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) {

View File

@ -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<String, String> 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<WorkflowStep> steps) {
private void addSteps(Workflow workflow, String parentId, List<WorkflowStep> steps) {
for (int i = 0; i < steps.size(); i++) {
WorkflowStep step = steps.get(i);
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<WorkflowStep> 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<WorkflowConditionRepresentation> conditions = toConditionRepresentation(workflow);
List<WorkflowStepRepresentation> steps = toRepresentation(getSteps(workflow.getId()));
for (WorkflowStep step : getSteps(workflow.getId())) {
rep.addStep(toRepresentation(step));
return new WorkflowRepresentation(workflow.getId(), workflow.getProviderId(), workflow.getConfig(), conditions, steps);
}
return rep;
private List<WorkflowConditionRepresentation> toConditionRepresentation(Workflow workflow) {
MultivaluedHashMap<String, String> workflowConfig = ofNullable(workflow.getConfig()).orElse(new MultivaluedHashMap<>());
List<String> ids = workflowConfig.getOrDefault(CONFIG_CONDITIONS, List.of());
if (ids.isEmpty()) {
return null;
}
private WorkflowStepRepresentation toRepresentation(WorkflowStep step) {
List<WorkflowConditionRepresentation> conditions = new ArrayList<>();
for (String id : ids) {
MultivaluedHashMap<String, String> config = new MultivaluedHashMap<>();
for (Entry<String, List<String>> configEntry : workflowConfig.entrySet()) {
String key = configEntry.getKey();
if (key.startsWith(id + ".")) {
config.put(key.substring(id.length() + 1), configEntry.getValue());
}
}
conditions.add(new WorkflowConditionRepresentation(id, config));
}
return conditions;
}
private List<WorkflowStepRepresentation> toRepresentation(List<WorkflowStep> existingSteps) {
if (existingSteps == null || existingSteps.isEmpty()) {
return null;
}
List<WorkflowStepRepresentation> steps = new ArrayList<>();
for (WorkflowStep step : existingSteps) {
steps.add(toRepresentation(step));
}
return steps;
}
public WorkflowStepRepresentation toRepresentation(WorkflowStep step) {
List<WorkflowStepRepresentation> 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<String, List<String>> configEntry : condition.getConfig().entrySet()) {
config.put(conditionProviderId + "." + configEntry.getKey(), configEntry.getValue());
}
}
Workflow workflow = addWorkflow(rep.getProviderId(), config);
Workflow workflow = addWorkflow(rep.getUses(), config);
List<WorkflowStep> 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<String> events) {
for (String event : ofNullable(events).orElse(List.of())) {
try {
ResourceOperationType.valueOf(event.toUpperCase());
} catch (IllegalArgumentException e) {
throw new WorkflowInvalidStateException("Invalid event type: " + event);
}
}
}
private 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<WorkflowStepRepresentation> steps = step.getSteps().stream()
.map(this::toStepRepresentation)
.toList();
return new WorkflowStepRepresentation(step.getId(), step.getProviderId(), step.getConfig(), steps);
}
public WorkflowStep toStepModel(WorkflowStepRepresentation rep) {
List<WorkflowStep> 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<String, String> modelConfig) {
WorkflowConditionProviderFactory<WorkflowConditionProvider> providerFactory = getConditionProviderFactory(providerId);
Map<String, List<String>> config = new HashMap<>();
for (Entry<String, List<String>> configEntry : modelConfig.entrySet()) {
if (configEntry.getKey().startsWith(providerId)) {
config.put(configEntry.getKey().substring(providerId.length() + 1), configEntry.getValue());
}
}
WorkflowConditionProvider condition = providerFactory.create(session, config);
if (condition == null) {
throw new IllegalStateException("Factory " + providerFactory.getClass() + " returned a null provider");
}
return condition;
}
private WorkflowConditionProviderFactory<WorkflowConditionProvider> getConditionProviderFactory(String providerId) {
KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
WorkflowConditionProviderFactory<WorkflowConditionProvider> providerFactory = (WorkflowConditionProviderFactory<WorkflowConditionProvider>) sessionFactory.getProviderFactory(WorkflowConditionProvider.class, providerId);
if (providerFactory == null) {
throw new WorkflowInvalidStateException("Could not find condition provider: " + providerId);
}
return providerFactory;
}
}

View File

@ -77,7 +77,7 @@ public class WorkflowStepsResource {
})
public List<WorkflowStepRepresentation> 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);
}
}

View File

@ -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<WorkflowRepresentation> 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();

View File

@ -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

View File

@ -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<WorkflowStepRepresentation> steps = aggregatedStep.getSteps();
assertThat(steps, hasSize(2));
assertStep(steps, SetUserAttributeStepProviderFactory.ID, a -> {
@ -219,7 +219,7 @@ public class AggregatedStepTest {
private void assertStep(List<WorkflowStepRepresentation> steps, String expectedProviderId, Consumer<WorkflowStepRepresentation> assertions) {
assertTrue(steps.stream()
.anyMatch(a -> {
if (a.getProviderId().equals(expectedProviderId)) {
if (a.getUses().equals(expectedProviderId)) {
assertions.accept(a);
return true;
}

View File

@ -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<String> 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()

View File

@ -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<WorkflowRepresentation> 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<String> 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) {

View File

@ -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<WorkflowRepresentation> expectedWorkflows = WorkflowRepresentation.create()
WorkflowSetRepresentation expectedWorkflows = WorkflowRepresentation.create()
.of(EventBasedWorkflowProviderFactory.ID)
.onEvent(ResourceOperationType.USER_ROLE_ADD.name())
.recurring()

View File

@ -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<String, List<String>> attributes) {
List<WorkflowRepresentation> expectedWorkflows = WorkflowRepresentation.create()
WorkflowSetRepresentation expectedWorkflows = WorkflowRepresentation.create()
.of(EventBasedWorkflowProviderFactory.ID)
.onEvent(ResourceOperationType.USER_ADD.name())
.recurring()

View File

@ -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<WorkflowRepresentation> expectedWorkflows = WorkflowRepresentation.create()
WorkflowSetRepresentation expectedWorkflows = WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
@ -113,15 +116,16 @@ public class WorkflowManagementTest {
List<WorkflowRepresentation> 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<WorkflowRepresentation> 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<WorkflowRepresentation> 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<WorkflowRepresentation> 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<WorkflowRepresentation> workflows = WorkflowRepresentation.create()
WorkflowSetRepresentation workflows = WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.immediate()
.withSteps(
@ -604,7 +631,7 @@ public class WorkflowManagementTest {
@Test
public void testCreateScheduledWorkflowWithoutTimeConditions() {
List<WorkflowRepresentation> 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<WorkflowRepresentation> workflows = WorkflowRepresentation.create()
WorkflowSetRepresentation workflows = WorkflowRepresentation.create()
.of(UserCreationTimeWorkflowProviderFactory.ID)
.immediate()
.recurring()

View File

@ -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<WorkflowRepresentation> 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"));
}