[RLM] Allow adding and removing actions to existing policies

Closes #42384

Signed-off-by: Martin Kanis <mkanis@redhat.com>
This commit is contained in:
Martin Kanis 2025-09-16 13:58:18 +02:00 committed by Pedro Igor
parent 44b4235b50
commit 7ae9ebb467
6 changed files with 626 additions and 0 deletions

View File

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

View File

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

View File

@ -433,4 +433,111 @@ public class WorkflowsManager {
}
}
}
public WorkflowStep addStepToWorkflow(String workflowId, WorkflowStep step, Integer position) {
Objects.requireNonNull(workflowId, "workflowId cannot be null");
Objects.requireNonNull(step, "step cannot be null");
List<WorkflowStep> existingSteps = getSteps(workflowId);
int targetPosition = position != null ? position : existingSteps.size();
if (targetPosition < 0 || targetPosition > existingSteps.size()) {
throw new BadRequestException("Invalid position: " + targetPosition + ". Must be between 0 and " + existingSteps.size());
}
// First, shift existing steps at and after the target position to make room
shiftStepsForInsertion(targetPosition, existingSteps);
step.setPriority(targetPosition + 1);
WorkflowStep addedStep = addStep(workflowId, step);
updateScheduledStepsAfterStepChange(workflowId);
log.debugf("Added step %s to workflow %s at position %d", addedStep.getId(), workflowId, targetPosition);
return addedStep;
}
public void removeStepFromWorkflow(String workflowId, String stepId) {
Objects.requireNonNull(workflowId, "workflowId cannot be null");
Objects.requireNonNull(stepId, "stepId cannot be null");
RealmModel realm = getRealm();
ComponentModel stepComponent = realm.getComponent(stepId);
if (stepComponent == null || !stepComponent.getParentId().equals(workflowId)) {
throw new BadRequestException("Step not found or not part of workflow: " + stepId);
}
realm.removeComponent(stepComponent);
// Reorder remaining steps and update state
reorderAllSteps(workflowId);
updateScheduledStepsAfterStepChange(workflowId);
log.debugf("Removed step %s from workflow %s", stepId, workflowId);
}
private void shiftStepsForInsertion(int insertPosition, List<WorkflowStep> existingSteps) {
RealmModel realm = getRealm();
// Shift all steps at and after the insertion position by +1 priority
for (int i = insertPosition; i < existingSteps.size(); i++) {
WorkflowStep step = existingSteps.get(i);
step.setPriority(step.getPriority() + 1);
updateStepComponent(realm, step);
}
}
private void reorderAllSteps(String workflowId) {
List<WorkflowStep> steps = getSteps(workflowId);
RealmModel realm = getRealm();
for (int i = 0; i < steps.size(); i++) {
WorkflowStep step = steps.get(i);
step.setPriority(i + 1);
updateStepComponent(realm, step);
}
}
private void updateStepComponent(RealmModel realm, WorkflowStep step) {
ComponentModel component = realm.getComponent(step.getId());
component.setConfig(step.getConfig());
realm.updateComponent(component);
}
private void updateScheduledStepsAfterStepChange(String workflowId) {
List<WorkflowStep> steps = getSteps(workflowId);
if (steps.isEmpty()) {
workflowStateProvider.remove(workflowId);
return;
}
for (ScheduledStep scheduled : workflowStateProvider.getScheduledStepsByWorkflow(workflowId)) {
boolean stepStillExists = steps.stream()
.anyMatch(step -> step.getId().equals(scheduled.stepId()));
if (!stepStillExists) {
Workflow workflow = getWorkflow(workflowId);
workflowStateProvider.scheduleStep(workflow, steps.get(0), scheduled.resourceId());
}
}
}
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<>();
for (WorkflowStepRepresentation subStep : ofNullable(rep.getSteps()).orElse(List.of())) {
subSteps.add(toStepModel(subStep));
}
return new WorkflowStep(rep.getProviderId(), rep.getConfig(), subSteps);
}
}

View File

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

View File

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

View File

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