Improve updating existing workflows

- allow updating entire workflow when no scheduled tasks exist
- allow updating conditions, concurrency, and steps config when scheduled tasks exists

Closes #42618

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen
2025-11-14 09:19:02 -03:00
committed by Pedro Igor
parent 167249dd6c
commit 464d1a6741
21 changed files with 625 additions and 305 deletions

View File

@@ -72,6 +72,11 @@ final class DefaultWorkflowExecutionContext implements WorkflowExecutionContext
return resourceId;
}
@Override
public WorkflowEvent getEvent() {
return event;
}
String getExecutionId() {
return this.executionId;
}
@@ -80,10 +85,6 @@ final class DefaultWorkflowExecutionContext implements WorkflowExecutionContext
return workflow;
}
WorkflowEvent getEvent() {
return event;
}
WorkflowStep getCurrentStep() {
return currentStep;
}

View File

@@ -24,15 +24,11 @@ import org.keycloak.models.workflow.WorkflowStateProvider.ScheduledStep;
import org.keycloak.representations.workflows.WorkflowConstants;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.utils.StringUtil;
import org.jboss.logging.Logger;
import static java.util.Optional.ofNullable;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_ENABLED;
import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_NAME;
public class DefaultWorkflowProvider implements WorkflowProvider {
private static final Logger log = Logger.getLogger(DefaultWorkflowProvider.class);
@@ -62,24 +58,41 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
@Override
public void updateWorkflow(Workflow workflow, WorkflowRepresentation representation) {
// first step - ensure the updated workflow is valid
WorkflowValidator.validateWorkflow(session, representation);
WorkflowRepresentation currentRep = toRepresentation(workflow);
// check if there are scheduled steps for this workflow - if there aren't, we can update freely
if (!stateProvider.hasScheduledSteps(workflow.getId())) {
// simply delete and re-create the workflow, ensuring the id remains the same
removeWorkflow(workflow);
representation.setId(workflow.getId());
toModel(representation);
} else {
// if there are scheduled steps, we don't allow to update the workflow's 'on' config
WorkflowRepresentation currentRepresentation = toRepresentation(workflow);
if (!Objects.equals(currentRepresentation.getOn(), representation.getOn())) {
throw new ModelValidationException("Cannot update 'on' configuration when there are scheduled resources for the workflow.");
}
// we compare the representation, removing first the entries we allow updating. If anything else changes, we throw a validation exception
String currentName = currentRep.getName(); currentRep.getConfig().remove(CONFIG_NAME);
String newName = representation.getName(); representation.getConfig().remove(CONFIG_NAME);
Boolean currentEnabled = currentRep.getEnabled(); currentRep.getConfig().remove(CONFIG_ENABLED);
Boolean newEnabled = representation.getEnabled(); representation.getConfig().remove(CONFIG_ENABLED);
// we also need to guarantee the steps remain the same - that is, in the same order with the same 'uses' property.
// each step can have its config updated, but the steps themselves cannot be changed.
List<WorkflowStepRepresentation> currentSteps = currentRepresentation.getSteps();
List<WorkflowStepRepresentation> newSteps = ofNullable(representation.getSteps()).orElse(List.of());
if (currentSteps.size() != newSteps.size()) {
throw new ModelValidationException("Cannot change the number or order of steps when there are scheduled resources for the workflow.");
}
for (int i = 0; i < currentSteps.size(); i++) {
WorkflowStepRepresentation currentStep = currentSteps.get(i);
WorkflowStepRepresentation newStep = newSteps.get(i);
if (!Objects.equals(currentStep.getUses(), newStep.getUses())) {
throw new ModelValidationException("Cannot change the number or order of steps when there are scheduled resources for the workflow.");
}
// set the id of the step to match the existing one, so we can update the config
newStep.setId(currentStep.getId());
}
if (!currentRep.equals(representation)) {
throw new ModelValidationException("Workflow update can only change 'name' and 'enabled' config entries.");
}
if (!Objects.equals(currentName, newName) || !Objects.equals(currentEnabled, newEnabled)) {
// only update component if something changed
representation.setName(newName);
representation.setEnabled(newEnabled);
this.updateWorkflowConfig(workflow, representation.getConfig());
// finally, update the workflow's config along with the steps' configs
workflow.updateConfig(representation.getConfig(), newSteps);
}
}
@@ -116,17 +129,23 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
return;
}
for (ScheduledStep scheduled : stateProvider.getDueScheduledSteps(workflow)) {
// check if the resource is still passes the workflow's resource conditions
DefaultWorkflowExecutionContext context = new DefaultWorkflowExecutionContext(session, workflow, scheduled);
WorkflowStep step = context.getCurrentStep();
if (step == null) {
log.warnf("Could not find step %s in workflow %s for resource %s. Removing the workflow state.",
scheduled.stepId(), scheduled.workflowId(), scheduled.resourceId());
EventBasedWorkflow provider = new EventBasedWorkflow(session, getWorkflowComponent(workflow.getId()));
if (!provider.validateResourceConditions(context)) {
log.debugf("Resource %s is no longer eligible for workflow %s. Cancelling execution of the workflow.",
scheduled.resourceId(), scheduled.workflowId());
stateProvider.remove(scheduled.executionId());
continue;
} else {
WorkflowStep step = context.getCurrentStep();
if (step == null) {
log.warnf("Could not find step %s in workflow %s for resource %s. Cancelling execution of the workflow.",
scheduled.stepId(), scheduled.workflowId(), scheduled.resourceId());
stateProvider.remove(scheduled.executionId());
} else {
runWorkflow(context);
}
}
runWorkflow(context);
}
});
}
@@ -159,17 +178,15 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
@Override
public Workflow toModel(WorkflowRepresentation rep) {
validateWorkflow(rep);
WorkflowValidator.validateWorkflow(session, rep);
MultivaluedHashMap<String, String> config = ofNullable(rep.getConfig()).orElse(new MultivaluedHashMap<>());
if (rep.isCancelIfRunning()) {
config.putSingle(WorkflowConstants.CONFIG_CANCEL_IF_RUNNING, "true");
}
Workflow workflow = addWorkflow(new Workflow(session, config));
Workflow workflow = addWorkflow(new Workflow(session, rep.getId(), config));
workflow.addSteps(rep.getSteps());
return workflow;
}
@@ -181,12 +198,6 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
return getStepProviderFactory(step).create(session, realm.getComponent(step.getId()));
}
private void updateWorkflowConfig(Workflow workflow, MultivaluedHashMap<String, String> config) {
ComponentModel component = getWorkflowComponent(workflow.getId());
component.setConfig(config);
realm.updateComponent(component);
}
private ComponentModel getWorkflowComponent(String id) {
ComponentModel component = realm.getComponent(id);
@@ -230,18 +241,18 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
try {
ScheduledStep scheduledStep = scheduledSteps.get(workflow.getId());
DefaultWorkflowExecutionContext context = new DefaultWorkflowExecutionContext(session, workflow, event);
// if workflow is not active for the resource, check if the provider allows activating based on the event
if (scheduledStep == null) {
if (provider.activateOnEvent(event)) {
if (provider.activate(context)) {
if (isAlreadyScheduledInSession(event, workflow)) {
return;
}
// If the workflow has a positive notBefore set, schedule the first step with it
if (DurationConverter.isPositiveDuration(workflow.getNotBefore())) {
scheduleWorkflow(event, workflow);
scheduleWorkflow(context);
} else {
DefaultWorkflowExecutionContext context = new DefaultWorkflowExecutionContext(session, workflow, event);
// process the workflow steps, scheduling or running them as needed
runWorkflow(context);
}
@@ -250,9 +261,9 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
// workflow is active for the resource, check if the provider wants to reset or deactivate it based on the event
String executionId = scheduledStep.executionId();
String resourceId = scheduledStep.resourceId();
if (provider.resetOnEvent(event)) {
if (provider.reset(context)) {
new DefaultWorkflowExecutionContext(session, workflow, event, scheduledStep).restart();
} else if (provider.deactivateOnEvent(event)) {
} else if (provider.deactivate(context)) {
log.debugf("Workflow '%s' cancelled for resource %s (execution id: %s)", workflow.getName(), resourceId, executionId);
stateProvider.remove(executionId);
}
@@ -260,7 +271,7 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
} catch (WorkflowInvalidStateException e) {
workflow.setEnabled(false);
workflow.setError(e.getMessage());
updateWorkflowConfig(workflow, workflow.getConfig());
workflow.updateConfig(workflow.getConfig(), null);
log.warnf("Workflow %s was disabled due to: %s", workflow.getId(), e.getMessage());
}
});
@@ -286,8 +297,8 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
return isAlreadyScheduled;
}
private void scheduleWorkflow(WorkflowEvent event, Workflow workflow) {
executor.runTask(session, new ScheduleWorkflowTask(new DefaultWorkflowExecutionContext(session, workflow, event)));
private void scheduleWorkflow(WorkflowExecutionContext context) {
executor.runTask(session, new ScheduleWorkflowTask((DefaultWorkflowExecutionContext) context));
}
private void runWorkflow(DefaultWorkflowExecutionContext context) {
@@ -298,49 +309,10 @@ public class DefaultWorkflowProvider implements WorkflowProvider {
return new WorkflowStepRepresentation(step.getId(), step.getProviderId(), step.getConfig());
}
private void validateWorkflow(WorkflowRepresentation rep) {
validateField(rep, "name", rep.getName());
//TODO: validate event and resource conditions (`on` and `if` properties) using the providers with a custom evaluator that calls validate on
// each condition provider used in the expression.
// if a workflow has a restart step, at least one of the previous steps must be scheduled to prevent an infinite loop of immediate executions
List<WorkflowStepRepresentation> steps = ofNullable(rep.getSteps()).orElse(List.of());
if (steps.isEmpty()) {
return;
}
steps.forEach(step -> validateField(step, "uses", step.getUses()));
List<WorkflowStepRepresentation> restartSteps = steps.stream()
.filter(step -> Objects.equals("restart", step.getUses()))
.toList();
if (!restartSteps.isEmpty()) {
if (restartSteps.size() > 1) {
throw new WorkflowInvalidStateException("Workflow can have only one restart step.");
}
WorkflowStepRepresentation restartStep = restartSteps.get(0);
if (steps.indexOf(restartStep) != steps.size() - 1) {
throw new WorkflowInvalidStateException("Workflow restart step must be the last step.");
}
boolean hasScheduledStep = steps.stream()
.anyMatch(step -> DurationConverter.isPositiveDuration(step.getAfter()));
if (!hasScheduledStep) {
throw new WorkflowInvalidStateException("A workflow with a restart step must have at least one step with a time delay.");
}
}
}
private void validateField(Object obj, String fieldName, String value) {
if (StringUtil.isBlank(value)) {
throw new ModelValidationException("%s field '%s' cannot be null or empty.".formatted(obj.getClass().getCanonicalName(), fieldName));
}
}
private Workflow addWorkflow(Workflow workflow) {
ComponentModel model = new ComponentModel();
model.setId(workflow.getId());
model.setParentId(realm.getId());
model.setProviderId(DefaultWorkflowProviderFactory.ID);
model.setProviderType(WorkflowProvider.class.getName());

View File

@@ -1,7 +1,5 @@
package org.keycloak.models.workflow;
import java.util.List;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.workflow.conditions.ExpressionWorkflowConditionProvider;
@@ -28,45 +26,50 @@ final class EventBasedWorkflow {
return ResourceType.USERS.equals(type);
}
boolean activateOnEvent(WorkflowEvent event) {
if (!supports(event.getResourceType())) {
/**
* Evaluates the specified context to determine whether the workflow should be activated or not. Activation will happen
* if the context's event matches the configured activation events and the resource conditions evaluate to true.
*
* @param executionContext
* @return
* @throws WorkflowInvalidStateException
*/
boolean activate(WorkflowExecutionContext executionContext) throws WorkflowInvalidStateException {
WorkflowEvent event = executionContext.getEvent();
if (event == null) {
return false;
}
return isActivationEvent(event) && evaluateConditions(event);
return supports(event.getResourceType()) && activateOnEvent(event) && validateResourceConditions(executionContext);
}
boolean deactivateOnEvent(WorkflowEvent event) {
boolean deactivate(WorkflowExecutionContext executionContext) throws WorkflowInvalidStateException {
// TODO: rework this once we support concurrency/restart-if-running and concurrency/cancel-if-running to use expressions just like activation conditions
if (!supports(event.getResourceType())) {
return false;
}
List<String> events = model.getConfig().getOrDefault(CONFIG_ON_EVENT, List.of());
for (String activationEvent : events) {
ResourceOperationType a = ResourceOperationType.valueOf(activationEvent.toUpperCase());
if (a.isDeactivationEvent(event.getEvent().getClass())) {
return !evaluateConditions(event);
}
}
return false;
}
boolean resetOnEvent(WorkflowEvent event) {
return isCancelIfRunning() && evaluateConditions(event);
boolean reset(WorkflowExecutionContext executionContext) throws WorkflowInvalidStateException {
WorkflowEvent event = executionContext.getEvent();
if (event == null) {
return false;
}
return supports(event.getResourceType()) && isCancelIfRunning() && validateResourceConditions(executionContext);
}
private boolean evaluateConditions(WorkflowEvent event) {
public boolean validateResourceConditions(WorkflowExecutionContext context) {
String conditions = getModel().getConfig().getFirst(CONFIG_CONDITIONS);
if (StringUtil.isBlank(conditions)) {
return true;
}
return new ExpressionWorkflowConditionProvider(getSession(), conditions).evaluate(event);
return new ExpressionWorkflowConditionProvider(getSession(), conditions).evaluate(context);
}
private boolean isActivationEvent(WorkflowEvent event) {
/**
* Determins whether the workflow should be activated based on the given event or not.
*
* @param event a reference to the workflow event.
* @return {@code true} if the workflow should be activated, {@code false} otherwise.
*/
private boolean activateOnEvent(WorkflowEvent event) {
// AD_HOC is a special case that always triggers the workflow regardless of the configured activation events
if (ResourceOperationType.AD_HOC.equals(event.getOperation())) {
return true;

View File

@@ -22,6 +22,7 @@ import java.time.Instant;
import java.util.List;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaDelete;
import jakarta.persistence.criteria.CriteriaQuery;
@@ -199,6 +200,22 @@ public class JpaWorkflowStateProvider implements WorkflowStateProvider {
}
}
@Override
public boolean hasScheduledSteps(String workflowId) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Long> criteriaQuery = cb.createQuery(Long.class);
Root<WorkflowStateEntity> stateRoot = criteriaQuery.from(WorkflowStateEntity.class);
criteriaQuery.select(cb.count(stateRoot));
criteriaQuery.where(cb.equal(stateRoot.get("workflowId"), workflowId));
TypedQuery<Long> query = em.createQuery(criteriaQuery);
query.setMaxResults(1);
Long count = query.getSingleResult();
return count > 0;
}
@Override
public void close() {
}

View File

@@ -0,0 +1,110 @@
package org.keycloak.models.workflow;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import org.keycloak.common.util.DurationConverter;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.workflow.conditions.expression.BooleanConditionParser;
import org.keycloak.models.workflow.conditions.expression.ConditionNameCollector;
import org.keycloak.models.workflow.conditions.expression.EvaluatorUtils;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.utils.StringUtil;
import static java.util.Optional.ofNullable;
public class WorkflowValidator {
public static void validateWorkflow(KeycloakSession session, WorkflowRepresentation rep) throws WorkflowInvalidStateException {
validateField(rep, "name", rep.getName());
//TODO: validate event and resource conditions (`on` and `if` properties) using the providers with a custom evaluator that calls validate on
// each condition provider used in the expression once we have the event condition providers implemented
if (StringUtil.isNotBlank(rep.getOn())) {
validateConditionExpression(session, rep.getOn(), "on");
}
if (StringUtil.isNotBlank(rep.getConditions())) {
validateConditionExpression(session, rep.getConditions(), "if");
}
// if a workflow has a restart step, at least one of the previous steps must be scheduled to prevent an infinite loop of immediate executions
List<WorkflowStepRepresentation> steps = ofNullable(rep.getSteps()).orElse(List.of());
if (steps.isEmpty()) {
return;
}
steps.forEach(step -> validateStep(session, step));
List<WorkflowStepRepresentation> restartSteps = steps.stream()
.filter(step -> Objects.equals("restart", step.getUses()))
.toList();
if (!restartSteps.isEmpty()) {
if (restartSteps.size() > 1) {
throw new WorkflowInvalidStateException("Workflow can have only one restart step.");
}
WorkflowStepRepresentation restartStep = restartSteps.get(0);
if (steps.indexOf(restartStep) != steps.size() - 1) {
throw new WorkflowInvalidStateException("Workflow restart step must be the last step.");
}
boolean hasScheduledStep = steps.stream()
.anyMatch(step -> DurationConverter.isPositiveDuration(step.getAfter()));
if (!hasScheduledStep) {
throw new WorkflowInvalidStateException("A workflow with a restart step must have at least one step with a time delay.");
}
}
}
private static void validateStep(KeycloakSession session, WorkflowStepRepresentation step) throws WorkflowInvalidStateException {
// validate the step rep has 'uses' defined
if (StringUtil.isBlank(step.getUses())) {
throw new WorkflowInvalidStateException("Step 'uses' cannot be null or empty.");
}
// validate the after time, if present
try {
Duration duration = DurationConverter.parseDuration(step.getAfter());
if (duration != null && duration.isNegative()) { // duration can only be null if the config is not set
throw new WorkflowInvalidStateException("Step 'after' configuration cannot be negative.");
}
} catch (IllegalArgumentException e) {
throw new WorkflowInvalidStateException("Step 'after' configuration is not valid: " + step.getAfter());
}
// verify the step does have valid provider
WorkflowStepProviderFactory<WorkflowStepProvider> factory = (WorkflowStepProviderFactory<WorkflowStepProvider>) session
.getKeycloakSessionFactory().getProviderFactory(WorkflowStepProvider.class, step.getUses());
if (factory == null) {
throw new WorkflowInvalidStateException("Could not find step provider: " + step.getUses());
}
}
private static void validateConditionExpression(KeycloakSession session, String expression, String fieldName) throws WorkflowInvalidStateException {
BooleanConditionParser.EvaluatorContext context = EvaluatorUtils.createEvaluatorContext(expression);
ConditionNameCollector collector = new ConditionNameCollector();
collector.visit(context);
// check if there are providers for the conditions used in the expression
if ("on".equals(fieldName)) {
// check if we can get a ResourceOperationType for the events in the expression
for (String name : collector.getConditionNames()) {
try {
ResourceOperationType.valueOf(name.replace("-", "_").toUpperCase());
} catch (IllegalArgumentException iae) {
throw new WorkflowInvalidStateException("Could not find event: " + name);
}
}
} else if ("if".equals(fieldName)) {
// try to get an instance of the provider -> method throws a WorkflowInvalidStateException if provider is not found
collector.getConditionNames().forEach(name -> Workflows.getConditionProvider(session, name, expression));
}
}
private static void validateField(Object obj, String fieldName, String value) throws WorkflowInvalidStateException {
if (StringUtil.isBlank(value)) {
throw new WorkflowInvalidStateException("%s field '%s' cannot be null or empty.".formatted(obj.getClass().getCanonicalName(), fieldName));
}
}
}

View File

@@ -7,7 +7,7 @@ import jakarta.persistence.criteria.Root;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.workflow.WorkflowConditionProvider;
import org.keycloak.models.workflow.WorkflowEvent;
import org.keycloak.models.workflow.WorkflowExecutionContext;
import org.keycloak.models.workflow.conditions.expression.BooleanConditionParser.EvaluatorContext;
import org.keycloak.models.workflow.conditions.expression.ConditionEvaluator;
import org.keycloak.models.workflow.conditions.expression.EvaluatorUtils;
@@ -25,9 +25,9 @@ public class ExpressionWorkflowConditionProvider implements WorkflowConditionPro
}
@Override
public boolean evaluate(WorkflowEvent event) {
public boolean evaluate(WorkflowExecutionContext context) {
validate();
ConditionEvaluator evaluator = new ConditionEvaluator(session, event);
ConditionEvaluator evaluator = new ConditionEvaluator(session, context);
return evaluator.visit(this.evaluatorContext);
}

View File

@@ -5,9 +5,8 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.workflow.ResourceType;
import org.keycloak.models.workflow.WorkflowConditionProvider;
import org.keycloak.models.workflow.WorkflowEvent;
import org.keycloak.models.workflow.WorkflowExecutionContext;
import org.keycloak.models.workflow.WorkflowInvalidStateException;
import org.keycloak.utils.StringUtil;
@@ -22,16 +21,11 @@ public class GroupMembershipWorkflowConditionProvider implements WorkflowConditi
}
@Override
public boolean evaluate(WorkflowEvent event) {
if (!ResourceType.USERS.equals(event.getResourceType())) {
return false;
}
public boolean evaluate(WorkflowExecutionContext context) {
validate();
String userId = event.getResourceId();
RealmModel realm = session.getContext().getRealm();
UserModel user = session.users().getUserById(realm, userId);
UserModel user = session.users().getUserById(realm, context.getResourceId());
if (user == null) {
return false;
}

View File

@@ -13,9 +13,8 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.jpa.entities.FederatedIdentityEntity;
import org.keycloak.models.workflow.ResourceType;
import org.keycloak.models.workflow.WorkflowConditionProvider;
import org.keycloak.models.workflow.WorkflowEvent;
import org.keycloak.models.workflow.WorkflowExecutionContext;
import org.keycloak.models.workflow.WorkflowInvalidStateException;
import org.keycloak.utils.StringUtil;
@@ -30,16 +29,11 @@ public class IdentityProviderWorkflowConditionProvider implements WorkflowCondit
}
@Override
public boolean evaluate(WorkflowEvent event) {
if (!ResourceType.USERS.equals(event.getResourceType())) {
return false;
}
public boolean evaluate(WorkflowExecutionContext context) {
validate();
String userId = event.getResourceId();
RealmModel realm = session.getContext().getRealm();
UserModel user = session.users().getUserById(realm, userId);
UserModel user = session.users().getUserById(realm, context.getResourceId());
if (user == null) {
return false;
}

View File

@@ -9,9 +9,8 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.RoleUtils;
import org.keycloak.models.workflow.ResourceType;
import org.keycloak.models.workflow.WorkflowConditionProvider;
import org.keycloak.models.workflow.WorkflowEvent;
import org.keycloak.models.workflow.WorkflowExecutionContext;
import org.keycloak.models.workflow.WorkflowInvalidStateException;
import org.keycloak.utils.StringUtil;
@@ -26,16 +25,11 @@ public class RoleWorkflowConditionProvider implements WorkflowConditionProvider
}
@Override
public boolean evaluate(WorkflowEvent event) {
if (!ResourceType.USERS.equals(event.getResourceType())) {
return false;
}
public boolean evaluate(WorkflowExecutionContext context) {
validate();
String userId = event.getResourceId();
RealmModel realm = session.getContext().getRealm();
UserModel user = session.users().getUserById(realm, userId);
UserModel user = session.users().getUserById(realm, context.getResourceId());
if (user == null) {
return false;

View File

@@ -7,9 +7,8 @@ import java.util.Properties;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.workflow.ResourceType;
import org.keycloak.models.workflow.WorkflowConditionProvider;
import org.keycloak.models.workflow.WorkflowEvent;
import org.keycloak.models.workflow.WorkflowExecutionContext;
import org.keycloak.models.workflow.WorkflowInvalidStateException;
import static org.keycloak.common.util.CollectionUtil.collectionEquals;
@@ -25,16 +24,11 @@ public class UserAttributeWorkflowConditionProvider implements WorkflowCondition
}
@Override
public boolean evaluate(WorkflowEvent event) {
if (!ResourceType.USERS.equals(event.getResourceType())) {
return false;
}
public boolean evaluate(WorkflowExecutionContext context) {
validate();
String userId = event.getResourceId();
RealmModel realm = session.getContext().getRealm();
UserModel user = session.users().getUserById(realm, userId);
UserModel user = session.users().getUserById(realm, context.getResourceId());
if (user == null) {
return false;

View File

@@ -0,0 +1,68 @@
package org.keycloak.models.workflow.conditions.expression;
public abstract class AbstractBooleanEvaluator extends BooleanConditionParserBaseVisitor<Boolean> {
@Override
public Boolean visitEvaluator(BooleanConditionParser.EvaluatorContext ctx) {
return visit(ctx.expression());
}
@Override
public Boolean visitExpression(BooleanConditionParser.ExpressionContext ctx) {
if (ctx.expression() != null && ctx.OR() != null) {
return visit(ctx.expression()) || visit(ctx.andExpression());
}
return visit(ctx.andExpression());
}
@Override
public Boolean visitAndExpression(BooleanConditionParser.AndExpressionContext ctx) {
if (ctx.andExpression() != null && ctx.AND() != null) {
return visit(ctx.andExpression()) && visit(ctx.notExpression());
}
return visit(ctx.notExpression());
}
@Override
public Boolean visitNotExpression(BooleanConditionParser.NotExpressionContext ctx) {
if (ctx.NOT() != null) {
return !visit(ctx.notExpression());
}
return visit(ctx.atom());
}
@Override
public Boolean visitAtom(BooleanConditionParser.AtomContext ctx) {
if (ctx.conditionCall() != null) {
return visit(ctx.conditionCall());
}
return visit(ctx.expression());
}
@Override
public abstract Boolean visitConditionCall(BooleanConditionParser.ConditionCallContext ctx);
protected String extractParameter(BooleanConditionParser.ParameterContext paramCtx) {
// Case 1: No parentheses were used (e.g., "user-logged-in")
// Case 2: Empty parentheses were used (e.g., "user-logged-in()")
if (paramCtx == null || paramCtx.ParameterText() == null) {
return null;
}
// Case 3: A parameter was provided (e.g., "has-role(param)")
String rawText = paramCtx.ParameterText().getText();
return unEscapeParameter(rawText);
}
/**
* The grammar defines escapes as '\)' and '\\'.
* @param rawText The raw text from the ParameterText token.
* @return A clean, un-escaped string.
*/
private String unEscapeParameter(String rawText) {
// This handles both \) -> ) and \\ -> \
// Note: replaceAll uses regex, so we must double-escape the backslashes
return rawText.replace("\\)", ")")
.replace("\\\\", "\\");
}
}

View File

@@ -2,85 +2,25 @@ package org.keycloak.models.workflow.conditions.expression;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.workflow.WorkflowConditionProvider;
import org.keycloak.models.workflow.WorkflowEvent;
import org.keycloak.models.workflow.WorkflowExecutionContext;
import static org.keycloak.models.workflow.Workflows.getConditionProvider;
public class ConditionEvaluator extends BooleanConditionParserBaseVisitor<Boolean> {
public class ConditionEvaluator extends AbstractBooleanEvaluator {
protected final KeycloakSession session;
protected final WorkflowEvent event;
protected final WorkflowExecutionContext context;
public ConditionEvaluator(KeycloakSession session, WorkflowEvent event) {
public ConditionEvaluator(KeycloakSession session, WorkflowExecutionContext context) {
this.session = session;
this.event = event;
}
@Override
public Boolean visitEvaluator(BooleanConditionParser.EvaluatorContext ctx) {
return visit(ctx.expression());
}
@Override
public Boolean visitExpression(BooleanConditionParser.ExpressionContext ctx) {
if (ctx.expression() != null && ctx.OR() != null) {
return visit(ctx.expression()) || visit(ctx.andExpression());
}
return visit(ctx.andExpression());
}
@Override
public Boolean visitAndExpression(BooleanConditionParser.AndExpressionContext ctx) {
if (ctx.andExpression() != null && ctx.AND() != null) {
return visit(ctx.andExpression()) && visit(ctx.notExpression());
}
return visit(ctx.notExpression());
}
@Override
public Boolean visitNotExpression(BooleanConditionParser.NotExpressionContext ctx) {
if (ctx.NOT() != null) {
return !visit(ctx.notExpression());
}
return visit(ctx.atom());
}
@Override
public Boolean visitAtom(BooleanConditionParser.AtomContext ctx) {
if (ctx.conditionCall() != null) {
return visit(ctx.conditionCall());
}
return visit(ctx.expression());
this.context = context;
}
@Override
public Boolean visitConditionCall(BooleanConditionParser.ConditionCallContext ctx) {
String conditionName = ctx.Identifier().getText();
WorkflowConditionProvider conditionProvider = getConditionProvider(session, conditionName, extractParameter(ctx.parameter()));
return conditionProvider.evaluate(event);
WorkflowConditionProvider conditionProvider = getConditionProvider(session, conditionName, super.extractParameter(ctx.parameter()));
return conditionProvider.evaluate(context);
}
protected String extractParameter(BooleanConditionParser.ParameterContext paramCtx) {
// Case 1: No parentheses were used (e.g., "user-logged-in")
// Case 2: Empty parentheses were used (e.g., "user-logged-in()")
if (paramCtx == null || paramCtx.ParameterText() == null) {
return null;
}
// Case 3: A parameter was provided (e.g., "has-role(param)")
String rawText = paramCtx.ParameterText().getText();
return unEscapeParameter(rawText);
}
/**
* The grammar defines escapes as '\)' and '\\'.
* @param rawText The raw text from the ParameterText token.
* @return A clean, un-escaped string.
*/
private String unEscapeParameter(String rawText) {
// This handles both \) -> ) and \\ -> \
// Note: replaceAll uses regex, so we must double-escape the backslashes
return rawText.replace("\\)", ")")
.replace("\\\\", "\\");
}
}

View File

@@ -0,0 +1,76 @@
package org.keycloak.models.workflow.conditions.expression;
import java.util.ArrayList;
import java.util.List;
/**
* This visitor traverses the entire parse tree and collects the names of all conditionCalls.
*/
public class ConditionNameCollector extends BooleanConditionParserBaseVisitor<Void> {
// 1. A list to store the names we find.
private final List<String> conditionNames = new ArrayList<>();
/**
* Returns the list of all collected condition call names.
*/
public List<String> getConditionNames() {
return conditionNames;
}
// --- Traversal Methods ---
// These methods are necessary to ensure we visit every node in the tree.
@Override
public Void visitEvaluator(BooleanConditionParser.EvaluatorContext ctx) {
return visit(ctx.expression());
}
@Override
public Void visitExpression(BooleanConditionParser.ExpressionContext ctx) {
// Visit both sides of the 'OR'
if (ctx.expression() != null) {
visit(ctx.expression());
}
return visit(ctx.andExpression());
}
@Override
public Void visitAndExpression(BooleanConditionParser.AndExpressionContext ctx) {
// Visit both sides of the 'AND'
if (ctx.andExpression() != null) {
visit(ctx.andExpression());
}
return visit(ctx.notExpression());
}
@Override
public Void visitNotExpression(BooleanConditionParser.NotExpressionContext ctx) {
// Visit the inner expression of the 'NOT'
if (ctx.notExpression() != null) {
return visit(ctx.notExpression());
}
return visit(ctx.atom());
}
@Override
public Void visitAtom(BooleanConditionParser.AtomContext ctx) {
// This is the key: decide whether to visit a conditionCall
// or a nested expression.
if (ctx.conditionCall() != null) {
return visit(ctx.conditionCall());
}
return visit(ctx.expression());
}
// --- The Collector Method ---
@Override
public Void visitConditionCall(BooleanConditionParser.ConditionCallContext ctx) {
String conditionName = ctx.Identifier().getText();
conditionNames.add(conditionName);
// We don't need to visit children (like 'parameter')
return null;
}
}

View File

@@ -4,10 +4,12 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.workflow.ResourceOperationType;
import org.keycloak.models.workflow.WorkflowEvent;
public class EventEvaluator extends ConditionEvaluator {
public class EventEvaluator extends AbstractBooleanEvaluator {
private final WorkflowEvent event;
public EventEvaluator(KeycloakSession session, WorkflowEvent event) {
super(session, event);
this.event = event;
}
@Override
@@ -15,6 +17,6 @@ public class EventEvaluator extends ConditionEvaluator {
String name = ctx.Identifier().getText();
ResourceOperationType operation = ResourceOperationType.valueOf(name.replace("-", "_").toUpperCase());
String param = super.extractParameter(ctx.parameter());
return operation.test(super.event, param);
return operation.test(event, param);
}
}

View File

@@ -59,7 +59,7 @@ public enum ResourceType {
&& this.supportedAdminOperationTypes.contains(event.getOperationType())) {
ResourceOperationType resourceOperationType = toOperationType(event.getOperationType());
if (resourceOperationType != null) {
if (resourceOperationType != null && event.getResourceId() != null) {
return new WorkflowEvent(this, resourceOperationType, event.getResourceId(), event);
}
}

View File

@@ -17,19 +17,19 @@
package org.keycloak.models.workflow;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
import org.keycloak.common.util.DurationConverter;
import jakarta.ws.rs.BadRequestException;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelValidationException;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.utils.StringUtil;
import static java.util.Optional.ofNullable;
@@ -39,10 +39,10 @@ import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_NA
public class Workflow {
private final String id;
private final RealmModel realm;
private final KeycloakSession session;
private MultivaluedHashMap<String, String> config;
private String id;
private String notBefore;
public Workflow(KeycloakSession session, ComponentModel c) {
@@ -52,9 +52,10 @@ public class Workflow {
this.config = c.getConfig();
}
public Workflow(KeycloakSession session, Map<String, List<String>> config) {
public Workflow(KeycloakSession session, String id, Map<String, List<String>> config) {
this.session = session;
this.realm = session.getContext().getRealm();
this.id = id;
MultivaluedHashMap<String, String> c = new MultivaluedHashMap<>();
config.forEach(c::addAll);
this.config = c;
@@ -98,6 +99,21 @@ public class Workflow {
config.putSingle(CONFIG_ERROR, message);
}
public void updateConfig(MultivaluedHashMap<String, String> config, List<WorkflowStepRepresentation> steps) {
ComponentModel component = getWorkflowComponent(this.id, WorkflowProvider.class.getName());
component.setConfig(config);
realm.updateComponent(component);
// check if there are steps to be updated as well
if (steps != null) {
steps.forEach(step -> {
ComponentModel stepComponent = getWorkflowComponent(step.getId(), WorkflowStepProvider.class.getName());
stepComponent.setConfig(step.getConfig());
realm.updateComponent(stepComponent);
});
}
}
public Stream<WorkflowStep> getSteps() {
return realm.getComponentsStream(getId(), WorkflowStepProvider.class.getName())
.map(WorkflowStep::new).sorted();
@@ -137,33 +153,15 @@ public class Workflow {
}
private WorkflowStep toModel(WorkflowStepRepresentation rep) {
validateStep(rep);
return new WorkflowStep(rep.getUses(), rep.getConfig());
}
private void validateStep(WorkflowStepRepresentation step) throws ModelValidationException {
private ComponentModel getWorkflowComponent(String id, String providerType) {
ComponentModel component = realm.getComponent(id);
// validate the step rep has 'uses' defined
if (StringUtil.isBlank(step.getUses())) {
throw new ModelValidationException("Step 'uses' cannot be null or empty.");
}
// validate the after time, if present
try {
Duration duration = DurationConverter.parseDuration(step.getAfter());
if (duration != null && duration.isNegative()) { // duration can only be null if the config is not set
throw new ModelValidationException("Step 'after' configuration cannot be negative.");
}
} catch (IllegalArgumentException e) {
throw new ModelValidationException("Step 'after' configuration is not valid: " + step.getAfter());
}
// verify the step does have valid provider
WorkflowStepProviderFactory<WorkflowStepProvider> factory = (WorkflowStepProviderFactory<WorkflowStepProvider>) session
.getKeycloakSessionFactory().getProviderFactory(WorkflowStepProvider.class, step.getUses());
if (factory == null) {
throw new WorkflowInvalidStateException("Step not found: " + step.getUses());
if (component == null || !Objects.equals(providerType, component.getProviderType())) {
throw new BadRequestException("Not a valid resource workflow: " + id);
}
return component;
}
}

View File

@@ -9,7 +9,7 @@ import org.keycloak.provider.Provider;
public interface WorkflowConditionProvider extends Provider {
boolean evaluate(WorkflowEvent event);
boolean evaluate(WorkflowExecutionContext context);
default Predicate toPredicate(CriteriaBuilder cb, CriteriaQuery<String> query, Root<?> resourceRoot) {
return null;

View File

@@ -11,4 +11,12 @@ public interface WorkflowExecutionContext {
* @return the id of the resource
*/
String getResourceId();
/**
* Returns the workflow event that activated the current workflow execution. Can be null if the execution is being
* resumed from a scheduled step.
*
* @return the event bound to the current execution.
*/
WorkflowEvent getEvent();
}

View File

@@ -57,6 +57,14 @@ public interface WorkflowStateProvider extends Provider {
*/
void removeAll();
/**
* Checks whether there are any scheduled steps for the given {@code workflowId}.
*
* @param workflowId the id of the workflow.
* @return {@code true} if there are scheduled steps, {@code false} otherwise.
*/
boolean hasScheduledSteps(String workflowId);
void scheduleStep(Workflow workflow, WorkflowStep step, String resourceId, String executionId);
ScheduledStep getScheduledStep(String workflowId, String resourceId);

View File

@@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -109,16 +110,10 @@ public class ExpressionConditionWorkflowTest extends AbstractWorkflowTest {
checkWorkflowRunsForUser("hdent", false);
managedRealm.admin().workflows().workflow(workflowId).delete().close();
// a malformed expression should cause the condition to evaluate to false and the step should not run for all users
// a malformed expression should cause the condition to evaluate to false and the workflow should not be created
expression = ")(has-role(tester) AND OR has-user-attribute(key, value1,value2)";
workflowId = createWorkflow(expression);
checkWorkflowRunsForUser("bwayne", false);
checkWorkflowRunsForUser("lfox", false);
checkWorkflowRunsForUser("jgordon", false);
checkWorkflowRunsForUser("hdent", false);
managedRealm.admin().workflows().workflow(workflowId).delete().close();
workflowId = createWorkflow(expression, false);
assertThat(workflowId, nullValue());
}
public void checkWorkflowRunsForUser(String username, boolean shouldHaveAttribute) {
@@ -174,6 +169,10 @@ public class ExpressionConditionWorkflowTest extends AbstractWorkflowTest {
}
private String createWorkflow(String expression) {
return this.createWorkflow(expression, true);
}
private String createWorkflow(String expression, boolean creationExpectedToSucceed) {
WorkflowRepresentation expectedWorkflow = WorkflowRepresentation.withName("myworkflow")
.onEvent("user-logged-in(test-app)")
.onCondition(expression)
@@ -188,7 +187,12 @@ public class ExpressionConditionWorkflowTest extends AbstractWorkflowTest {
WorkflowsResource workflows = managedRealm.admin().workflows();
try (Response response = workflows.create(expectedWorkflow)) {
assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode()));
if (creationExpectedToSucceed) {
assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode()));
} else {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
return null;
}
return ApiUtil.getCreatedId(response);
}
}

View File

@@ -42,6 +42,7 @@ import org.keycloak.models.workflow.DeleteUserStepProviderFactory;
import org.keycloak.models.workflow.DisableUserStepProviderFactory;
import org.keycloak.models.workflow.NotifyUserStepProviderFactory;
import org.keycloak.models.workflow.ResourceOperationType;
import org.keycloak.models.workflow.ResourceType;
import org.keycloak.models.workflow.RestartWorkflowStepProviderFactory;
import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory;
import org.keycloak.models.workflow.Workflow;
@@ -50,15 +51,20 @@ import org.keycloak.models.workflow.WorkflowStateProvider;
import org.keycloak.models.workflow.WorkflowStateProvider.ScheduledStep;
import org.keycloak.models.workflow.WorkflowStep;
import org.keycloak.models.workflow.conditions.IdentityProviderWorkflowConditionFactory;
import org.keycloak.models.workflow.conditions.RoleWorkflowConditionFactory;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.testframework.annotations.InjectAdminClient;
import org.keycloak.testframework.annotations.InjectKeycloakUrls;
import org.keycloak.testframework.annotations.InjectUser;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.mail.MailServer;
import org.keycloak.testframework.mail.annotations.InjectMailServer;
import org.keycloak.testframework.realm.ManagedUser;
import org.keycloak.testframework.realm.UserConfig;
import org.keycloak.testframework.realm.UserConfigBuilder;
import org.keycloak.testframework.remote.providers.runonserver.RunOnServer;
import org.keycloak.testframework.server.KeycloakUrls;
@@ -77,7 +83,9 @@ import org.junit.jupiter.api.Test;
import static org.keycloak.models.workflow.ResourceOperationType.USER_ADDED;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
@@ -90,6 +98,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest(config = WorkflowsBlockingServerConfig.class)
public class WorkflowManagementTest extends AbstractWorkflowTest {
@InjectUser(ref = "alice", config = DefaultUserConfig.class, lifecycle = LifeCycle.METHOD, realmRef = DEFAULT_REALM_NAME)
private ManagedUser userAlice;
@InjectMailServer
private MailServer mailServer;
@@ -119,9 +130,9 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
}
List<WorkflowRepresentation> actualWorkflows = workflows.list();
assertThat(actualWorkflows, Matchers.hasSize(1));
assertThat(actualWorkflows, hasSize(1));
assertThat(actualWorkflows.get(0).getSteps(), Matchers.hasSize(2));
assertThat(actualWorkflows.get(0).getSteps(), hasSize(2));
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()));
@@ -198,11 +209,11 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser").email("testuser@example.com").build()).close();
List<WorkflowRepresentation> actualWorkflows = workflows.list();
assertThat(actualWorkflows, Matchers.hasSize(2));
assertThat(actualWorkflows, hasSize(2));
workflows.workflow(workflowId).delete().close();
actualWorkflows = workflows.list();
assertThat(actualWorkflows, Matchers.hasSize(1));
assertThat(actualWorkflows, hasSize(1));
runOnServer.run((RunOnServer) session -> {
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
@@ -216,7 +227,7 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
}
@Test
public void testUpdate() {
public void testUpdateWorkflowWithNoScheduledSteps() {
WorkflowRepresentation workflowRep = WorkflowRepresentation.withName("test-workflow")
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
@@ -236,37 +247,151 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
}
List<WorkflowRepresentation> actualWorkflows = workflows.list();
assertThat(actualWorkflows, Matchers.hasSize(1));
assertThat(actualWorkflows, hasSize(1));
WorkflowRepresentation workflow = actualWorkflows.get(0);
assertThat(workflow.getName(), is("test-workflow"));
// while the workflow has no scheduled steps - i.e. no resource is currently going through the workflow - we can update any property
workflow.setName("changed");
managedRealm.admin().workflows().workflow(workflowId).update(workflow).close();
actualWorkflows = workflows.list();
workflow = actualWorkflows.get(0);
assertThat(workflow.getName(), is("changed"));
// now let's try to update another property that we can't update
String previousOn = workflow.getOn();
workflow.setOn(ResourceOperationType.USER_LOGGED_IN.toString());
try (Response response = workflows.workflow(workflowId).update(workflow)) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
}
// restore previous value, but change the conditions
workflow.setOn(previousOn);
workflow.setConditions(IdentityProviderWorkflowConditionFactory.ID + "(someidp)");
try (Response response = workflows.workflow(workflowId).update(workflow)) {
workflow.setOn("user-logged-in");
managedRealm.admin().workflows().workflow(workflow.getId()).update(workflow).close();
workflow = workflows.workflow(workflow.getId()).toRepresentation();
assertThat(workflow.getName(), is("changed"));
assertThat(workflow.getOn(), is("user-logged-in"));
assertThat(workflow.getConditions(), is(IdentityProviderWorkflowConditionFactory.ID + "(someidp)"));
// even adding or removing steps should be allowed
WorkflowStepRepresentation newStep = WorkflowStepRepresentation.create().of(DeleteUserStepProviderFactory.ID)
.after(Duration.ofDays(10))
.build();
workflow.getSteps().remove(1); // remove the disable step
workflow.getSteps().get(0).getConfig().putSingle("custom_message", "Your account will be disabled"); // change the notify step config
workflow.getSteps().add(newStep); // add a new delete step
managedRealm.admin().workflows().workflow(workflow.getId()).update(workflow).close();
workflow = workflows.workflow(workflow.getId()).toRepresentation();
assertThat(workflow.getSteps(), hasSize(2));
assertThat(workflow.getSteps().get(0).getUses(), is(NotifyUserStepProviderFactory.ID));
assertThat(workflow.getSteps().get(0).getConfig().getFirst("custom_message"), is("Your account will be disabled"));
assertThat(workflow.getSteps().get(1).getUses(), is(DeleteUserStepProviderFactory.ID));
}
@Test
public void testUpdateWorkflowWithScheduledSteps() {
WorkflowRepresentation expectedWorkflows = WorkflowRepresentation.withName("test-workflow")
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
.after(Duration.ofDays(5))
.build(),
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
.after(Duration.ofDays(10))
.build()
).build();
WorkflowsResource workflows = managedRealm.admin().workflows();
String workflowId;
try (Response response = workflows.create(expectedWorkflows)) {
assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode()));
workflowId = ApiUtil.getCreatedId(response);
}
// bind the workflow to a resource, so it schedules the first step
managedRealm.admin().workflows().workflow(workflowId).activate(ResourceType.USERS.name(), userAlice.getId());
// when a scheduled step exists, we cannot change the 'on' event, nor the number or order of steps. Individual step config can still be updated, except for the 'uses'.
WorkflowRepresentation workflow = managedRealm.admin().workflows().workflow(workflowId).toRepresentation();
workflow.setName("changed");
workflow.setConditions(IdentityProviderWorkflowConditionFactory.ID + "(someidp)");
workflow.getSteps().get(0).getConfig().putSingle("custom_message", "Your account will be disabled"); // modify one of the steps config
managedRealm.admin().workflows().workflow(workflow.getId()).update(workflow).close();
workflow = workflows.workflow(workflow.getId()).toRepresentation();
assertThat(workflow.getName(), is("changed"));
assertThat(workflow.getConditions(), is(IdentityProviderWorkflowConditionFactory.ID + "(someidp)"));
assertThat(workflow.getSteps().get(0).getConfig().getFirst("custom_message"), is("Your account will be disabled"));
// now let's try to update the 'on' event - should fail
workflow.setOn("user-logged-in");
try (Response response = workflows.workflow(workflow.getId()).update(workflow)) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
}
// revert conditions, but change one of the steps
workflow.setConditions(null);
workflow.getSteps().get(0).setAfter("8D");
try (Response response = workflows.workflow(workflowId).update(workflow)) {
// restore the 'on' value, but try removing a step
workflow.setOn(null);
WorkflowStepRepresentation removedStep = workflow.getSteps().remove(1); // remove disable step
try (Response response = workflows.workflow(workflow.getId()).update(workflow)) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
}
// restore the step, but invert the order of the steps
workflow.getSteps().add(0, removedStep);
try (Response response = workflows.workflow(workflow.getId()).update(workflow)) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
}
// restore the original order, but try changing the 'uses' of one step (i.e. replace it with something else)
workflow.getSteps().remove(0); // this will put notify back as the first step.
WorkflowStepRepresentation newStep = WorkflowStepRepresentation.create().of(DeleteUserStepProviderFactory.ID)
.after(Duration.ofDays(10))
.build();
workflow.getSteps().add(newStep); // we've added a delete step in the place of the disable step, with same config
try (Response response = workflows.workflow(workflow.getId()).update(workflow)) {
assertThat(response.getStatus(), is(Response.Status.BAD_REQUEST.getStatusCode()));
}
}
@Test
public void testUpdateWorkflowConditionsCancelsExecutionForAffectedResources() {
WorkflowRepresentation expectedWorkflows = WorkflowRepresentation.withName("test-workflow")
.withSteps(
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
.after(Duration.ofDays(5))
.build()
).build();
WorkflowsResource workflows = managedRealm.admin().workflows();
String workflowId;
try (Response response = workflows.create(expectedWorkflows)) {
assertThat(response.getStatus(), is(Response.Status.CREATED.getStatusCode()));
workflowId = ApiUtil.getCreatedId(response);
}
// bind the workflow to a resource, so it schedules the first step
managedRealm.admin().workflows().workflow(workflowId).activate(ResourceType.USERS.name(), userAlice.getId());
// check step has been scheduled for the user
runOnServer.run((RunOnServer) session -> {
RealmModel realm = session.getContext().getRealm();
UserModel user = session.users().getUserByUsername(realm, "alice");
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByResource(user.getId());
assertThat("A step should have been scheduled for the user " + user.getUsername(), scheduledSteps, hasSize(1));
});
// now update the workflow to add a condition that will make the user no longer eligible
WorkflowRepresentation workflow = managedRealm.admin().workflows().workflow(workflowId).toRepresentation();
workflow.setConditions(RoleWorkflowConditionFactory.ID + "(realm-management/realm-admin)");
managedRealm.admin().workflows().workflow(workflowId).update(workflow).close();
// simulate running the step - user should no longer be eligible, so the step should be cancelled
runScheduledSteps(Duration.ofDays(6));
// check the user is still enabled and no scheduled steps exist
runOnServer.run((RunOnServer) session -> {
RealmModel realm = session.getContext().getRealm();
UserModel user = session.users().getUserByUsername(realm, "alice");
assertThat(user.isEnabled(), is(true));
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByResource(user.getId());
assertThat(scheduledSteps, empty());
});
}
@Test
@@ -285,27 +410,27 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
// use the API to search for workflows by name, both partial and exact matches
WorkflowsResource workflows = managedRealm.admin().workflows();
List<WorkflowRepresentation> representations = workflows.list("alpha", false, null, null);
assertThat(representations, Matchers.hasSize(1));
List<WorkflowRepresentation> representations = workflows.list("alpha", false, null, null);
assertThat(representations, hasSize(1));
assertThat(representations.get(0).getName(), is("alpha-workflow"));
representations = workflows.list("workflow", false, null, null);
assertThat(representations, Matchers.hasSize(4));
representations = workflows.list("beta-workflow", true, null, null);
assertThat(representations, Matchers.hasSize(1));
representations = workflows.list("workflow", false, null, null);
assertThat(representations, hasSize(4));
representations = workflows.list("beta-workflow", true, null, null);
assertThat(representations, hasSize(1));
assertThat(representations.get(0).getName(), is("beta-workflow"));
representations = workflows.list("nonexistent", false, null, null);
assertThat(representations, Matchers.hasSize(0));
representations = workflows.list("nonexistent", false, null, null);
assertThat(representations, hasSize(0));
// test pagination parameters
representations = workflows.list(null, null, 1, 2);
assertThat(representations, Matchers.hasSize(2));
representations = workflows.list(null, null, 1, 2);
assertThat(representations, hasSize(2));
// returned workflows should be ordered by name
assertThat(representations.get(0).getName(), is("beta-workflow"));
assertThat(representations.get(1).getName(), is("delta-workflow"));
representations = workflows.list("gamma", false, 0, 10);
assertThat(representations, Matchers.hasSize(1));
representations = workflows.list("gamma", false, 0, 10);
assertThat(representations, hasSize(1));
assertThat(representations.get(0).getName(), is("gamma-workflow"));
}
@@ -329,7 +454,7 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
runOnServer.run((RunOnServer) session -> {
RealmModel realm = session.getContext().getRealm();
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
UserModel user = session.users().getUserByUsername(realm,"testuser");
UserModel user = session.users().getUserByUsername(realm, "testuser");
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertEquals(1, registeredWorkflows.size());
@@ -351,7 +476,7 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
runOnServer.run((RunOnServer) session -> {
RealmModel realm = session.getContext().getRealm();
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
UserModel user = session.users().getUserByUsername(realm,"testuser");
UserModel user = session.users().getUserByUsername(realm, "testuser");
try {
user = session.users().getUserById(realm, user.getId());
@@ -373,7 +498,7 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
// Verify that the first step (notify) was executed by checking email was sent
MimeMessage testUserMessage = findEmailByRecipient(mailServer, "testuser@example.com");
assertNotNull(testUserMessage, "The first step (notify) should have sent an email.");
mailServer.runCleanup();
}
@@ -529,7 +654,7 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
WorkflowsResource workflows = managedRealm.admin().workflows();
List<WorkflowRepresentation> actualWorkflows = workflows.list();
assertThat(actualWorkflows, Matchers.hasSize(1));
assertThat(actualWorkflows, hasSize(1));
WorkflowRepresentation workflow = actualWorkflows.get(0);
assertThat(workflow.getName(), is("test-workflow"));
@@ -947,7 +1072,7 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
}
public static void verifyEmailContent(MimeMessage message, String expectedRecipient, String subjectContains,
String... contentContains) {
String... contentContains) {
try {
assertEquals(expectedRecipient, MailUtils.getRecipient(message));
assertThat(message.getSubject(), Matchers.containsString(subjectContains));
@@ -968,4 +1093,16 @@ public class WorkflowManagementTest extends AbstractWorkflowTest {
Assertions.fail("Failed to read email message: " + e.getMessage());
}
}
private static class DefaultUserConfig implements UserConfig {
@Override
public UserConfigBuilder configure(UserConfigBuilder user) {
user.username("alice");
user.password("alice");
user.name("alice", "alice");
user.email("master-admin@email.org");
return user;
}
}
}