From f36819e943849bac9ee8ea9e64e2b5ebc49459cc Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Thu, 18 Dec 2025 09:07:23 -0300 Subject: [PATCH] Adding join and leave group steps (#44841) Closes #44649 Signed-off-by: Pedro Igor --- .../workflow/GroupBasedStepProvider.java | 74 +++++++++ .../workflow/JoinGroupStepProvider.java | 25 +++ .../JoinGroupStepProviderFactory.java | 54 ++++++ .../workflow/LeaveGroupStepProvider.java | 25 +++ .../LeaveGroupStepProviderFactory.java | 54 ++++++ ...odels.workflow.WorkflowStepProviderFactory | 4 +- .../tests/workflow/GroupBasedStepTest.java | 155 ++++++++++++++++++ 7 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 services/src/main/java/org/keycloak/models/workflow/GroupBasedStepProvider.java create mode 100644 services/src/main/java/org/keycloak/models/workflow/JoinGroupStepProvider.java create mode 100644 services/src/main/java/org/keycloak/models/workflow/JoinGroupStepProviderFactory.java create mode 100644 services/src/main/java/org/keycloak/models/workflow/LeaveGroupStepProvider.java create mode 100644 services/src/main/java/org/keycloak/models/workflow/LeaveGroupStepProviderFactory.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/workflow/GroupBasedStepTest.java diff --git a/services/src/main/java/org/keycloak/models/workflow/GroupBasedStepProvider.java b/services/src/main/java/org/keycloak/models/workflow/GroupBasedStepProvider.java new file mode 100644 index 00000000000..153e05eca3f --- /dev/null +++ b/services/src/main/java/org/keycloak/models/workflow/GroupBasedStepProvider.java @@ -0,0 +1,74 @@ +package org.keycloak.models.workflow; + +import java.util.List; +import java.util.stream.Stream; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.GroupModel; +import org.keycloak.models.GroupProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +import org.jboss.logging.Logger; + +public abstract class GroupBasedStepProvider implements WorkflowStepProvider { + + private final Logger log = Logger.getLogger(GroupBasedStepProvider.class); + public static final String CONFIG_GROUP = "group"; + + private final KeycloakSession session; + private final ComponentModel model; + + public GroupBasedStepProvider(KeycloakSession session, ComponentModel model) { + this.session = session; + this.model = model; + } + + @Override + public void run(WorkflowExecutionContext context) { + UserModel user = session.users().getUserById(getRealm(), context.getResourceId()); + + if (user != null) { + try { + getGroups().forEach(group -> run(user, group)); + } catch (Exception e) { + log.errorf(e, "Failed to manage group membership for user %s", user.getId()); + } + } + } + + protected abstract void run(UserModel user, GroupModel group); + + @Override + public void close() { + } + + private Stream getGroups() { + return model.getConfig().getOrDefault(CONFIG_GROUP, List.of()).stream().map(this::getGroup); + } + + private GroupModel getGroup(String name) { + GroupProvider groups = session.groups(); + String[] paths = name.split("/"); + RealmModel realm = getRealm(); + GroupModel group = null; + + for (String part : paths) { + if (part.isEmpty()) { + continue; + } + group = groups.getGroupByName(realm, group, part); + } + + if (group == null) { + throw new IllegalStateException("Could not find group for name or path: " + name); + } + + return group; + } + + private RealmModel getRealm() { + return session.getContext().getRealm(); + } +} diff --git a/services/src/main/java/org/keycloak/models/workflow/JoinGroupStepProvider.java b/services/src/main/java/org/keycloak/models/workflow/JoinGroupStepProvider.java new file mode 100644 index 00000000000..a5c41dd1546 --- /dev/null +++ b/services/src/main/java/org/keycloak/models/workflow/JoinGroupStepProvider.java @@ -0,0 +1,25 @@ +package org.keycloak.models.workflow; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; + +import org.jboss.logging.Logger; + +import static org.keycloak.models.utils.ModelToRepresentation.buildGroupPath; + +public class JoinGroupStepProvider extends GroupBasedStepProvider { + + private final Logger log = Logger.getLogger(JoinGroupStepProvider.class); + + protected JoinGroupStepProvider(KeycloakSession session, ComponentModel model) { + super(session, model); + } + + @Override + protected void run(UserModel user, GroupModel group) { + log.debugv("Adding user %s to group %s)", user.getId(), buildGroupPath(group)); + user.joinGroup(group); + } +} diff --git a/services/src/main/java/org/keycloak/models/workflow/JoinGroupStepProviderFactory.java b/services/src/main/java/org/keycloak/models/workflow/JoinGroupStepProviderFactory.java new file mode 100644 index 00000000000..88d0e24c9ca --- /dev/null +++ b/services/src/main/java/org/keycloak/models/workflow/JoinGroupStepProviderFactory.java @@ -0,0 +1,54 @@ +package org.keycloak.models.workflow; + +import java.util.List; + +import org.keycloak.Config; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +public class JoinGroupStepProviderFactory implements WorkflowStepProviderFactory { + + public static final String ID = "join-group"; + + @Override + public JoinGroupStepProvider create(KeycloakSession session, ComponentModel model) { + return new JoinGroupStepProvider(session, model); + } + + @Override + public void init(Config.Scope config) { + // no-op + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + // no-op + } + + @Override + public void close() { + // no-op + } + + @Override + public String getId() { + return ID; + } + + @Override + public ResourceType getType() { + return ResourceType.USERS; + } + + @Override + public String getHelpText() { + return "Join a user to a group."; + } + + @Override + public List getConfigProperties() { + return List.of(); + } +} diff --git a/services/src/main/java/org/keycloak/models/workflow/LeaveGroupStepProvider.java b/services/src/main/java/org/keycloak/models/workflow/LeaveGroupStepProvider.java new file mode 100644 index 00000000000..8f0697736de --- /dev/null +++ b/services/src/main/java/org/keycloak/models/workflow/LeaveGroupStepProvider.java @@ -0,0 +1,25 @@ +package org.keycloak.models.workflow; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.GroupModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; + +import org.jboss.logging.Logger; + +import static org.keycloak.models.utils.ModelToRepresentation.buildGroupPath; + +public class LeaveGroupStepProvider extends GroupBasedStepProvider { + + private final Logger log = Logger.getLogger(LeaveGroupStepProvider.class); + + protected LeaveGroupStepProvider(KeycloakSession session, ComponentModel model) { + super(session, model); + } + + @Override + protected void run(UserModel user, GroupModel group) { + log.debugv("Removing user %s from group %s)", user.getId(), buildGroupPath(group)); + user.leaveGroup(group); + } +} diff --git a/services/src/main/java/org/keycloak/models/workflow/LeaveGroupStepProviderFactory.java b/services/src/main/java/org/keycloak/models/workflow/LeaveGroupStepProviderFactory.java new file mode 100644 index 00000000000..ca835258bf2 --- /dev/null +++ b/services/src/main/java/org/keycloak/models/workflow/LeaveGroupStepProviderFactory.java @@ -0,0 +1,54 @@ +package org.keycloak.models.workflow; + +import java.util.List; + +import org.keycloak.Config; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +public class LeaveGroupStepProviderFactory implements WorkflowStepProviderFactory { + + public static final String ID = "leave-group"; + + @Override + public LeaveGroupStepProvider create(KeycloakSession session, ComponentModel model) { + return new LeaveGroupStepProvider(session, model); + } + + @Override + public void init(Config.Scope config) { + // no-op + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + // no-op + } + + @Override + public void close() { + // no-op + } + + @Override + public String getId() { + return ID; + } + + @Override + public ResourceType getType() { + return ResourceType.USERS; + } + + @Override + public String getHelpText() { + return "Remove a user from a group."; + } + + @Override + public List getConfigProperties() { + return List.of(); + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.models.workflow.WorkflowStepProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.models.workflow.WorkflowStepProviderFactory index 1c5dfd8d241..d610644e9d2 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.models.workflow.WorkflowStepProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.models.workflow.WorkflowStepProviderFactory @@ -21,4 +21,6 @@ org.keycloak.models.workflow.DeleteUserStepProviderFactory org.keycloak.models.workflow.SetUserAttributeStepProviderFactory org.keycloak.models.workflow.AddRequiredActionStepProviderFactory org.keycloak.models.workflow.GrantRoleStepProviderFactory -org.keycloak.models.workflow.RevokeRoleStepProviderFactory \ No newline at end of file +org.keycloak.models.workflow.RevokeRoleStepProviderFactory +org.keycloak.models.workflow.JoinGroupStepProviderFactory +org.keycloak.models.workflow.LeaveGroupStepProviderFactory \ No newline at end of file diff --git a/tests/base/src/test/java/org/keycloak/tests/workflow/GroupBasedStepTest.java b/tests/base/src/test/java/org/keycloak/tests/workflow/GroupBasedStepTest.java new file mode 100644 index 00000000000..0ef8267044a --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/workflow/GroupBasedStepTest.java @@ -0,0 +1,155 @@ +package org.keycloak.tests.workflow; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.admin.client.resource.GroupsResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.models.workflow.JoinGroupStepProvider; +import org.keycloak.models.workflow.JoinGroupStepProviderFactory; +import org.keycloak.models.workflow.LeaveGroupStepProvider; +import org.keycloak.models.workflow.LeaveGroupStepProviderFactory; +import org.keycloak.models.workflow.ResourceOperationType; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.workflows.WorkflowRepresentation; +import org.keycloak.representations.workflows.WorkflowStepRepresentation; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.realm.GroupConfigBuilder; +import org.keycloak.testframework.realm.UserConfigBuilder; +import org.keycloak.testframework.util.ApiUtil; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.keycloak.models.workflow.ResourceOperationType.USER_CREATED; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; + +@KeycloakIntegrationTest(config = WorkflowsBlockingServerConfig.class) +public class GroupBasedStepTest extends AbstractWorkflowTest { + + @BeforeEach + public void setupRoles() { + RealmResource admin = managedRealm.admin(); + GroupsResource groups = admin.groups(); + for (Entry> group : Map.of("a", List.of("a1"), "b", List.of("b1", "b2"), "c", List.of()).entrySet()) { + GroupRepresentation rep = GroupConfigBuilder.create().name(group.getKey()).build(); + try (Response response = groups.add(rep)) { + rep.setId(ApiUtil.getCreatedId(response)); + } + for (Object subGroup : group.getValue()) { + groups.group(rep.getId()).subGroup(GroupConfigBuilder.create().name(subGroup.toString()).build()).close(); + } + } + } + + @Test + public void testJoinGroup() { + List expectedGroups = List.of("/a", "/b/b1", "c"); + + create(WorkflowRepresentation.withName("join-group") + .onEvent(USER_CREATED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(JoinGroupStepProviderFactory.ID) + .withConfig(JoinGroupStepProvider.CONFIG_GROUP, expectedGroups.toArray(new String[0])) + .build() + ).build()); + + UserResource user = getUserResource(UserConfigBuilder.create().username("myuser").build()); + + Awaitility.await() + .timeout(Duration.ofSeconds(30)) + .pollInterval(Duration.ofSeconds(1)) + .untilAsserted(() -> { + GroupsResource groups = managedRealm.admin().groups(); + var actualGroups = user.groups().stream().map(g -> groups.group(g.getId()).toRepresentation().getPath()).toList(); + assertThat(actualGroups, containsInAnyOrder(expectedGroups.stream().map(n -> { + if (!n.startsWith("/")) { + return "/" + n; + } + return n; + }).toArray(String[]::new))); + }); + } + + @Test + public void testLeaveGroup() { + UserResource user = getUserResource(UserConfigBuilder.create() + .username("myuser") + .build()); + joinGroup(user, "a", "/a/a1", "b/b1", "b/b2", "/c"); + + create(WorkflowRepresentation.withName("leave-group") + .onEvent(ResourceOperationType.USER_GROUP_MEMBERSHIP_REMOVED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(LeaveGroupStepProviderFactory.ID) + .withConfig(LeaveGroupStepProvider.CONFIG_GROUP, "a", "/b/b1", "/b/b2") + .build() + ).build()); + + user.leaveGroup(managedRealm.admin().groups().groups("c", true, null, null, true).get(0).getId()); + + Awaitility.await() + .timeout(Duration.ofSeconds(30)) + .pollInterval(Duration.ofSeconds(1)) + .untilAsserted(() -> { + GroupsResource groups = managedRealm.admin().groups(); + var actualGroups = user.groups().stream().map(g -> groups.group(g.getId()).toRepresentation().getPath()).toList(); + assertThat(actualGroups, containsInAnyOrder("/a/a1")); + }); + } + + private UserResource getUserResource(UserRepresentation user) { + UsersResource users = managedRealm.admin().users(); + + try (Response response = users.create(user)) { + user.setId(ApiUtil.getCreatedId(response)); + } + + return users.get(user.getId()); + } + + private void joinGroup(UserResource user, String... groups) { + RealmResource admin = managedRealm.admin(); + + for (String name : groups) { + String[] parts = name.split("/"); + + if (parts.length == 1) { + GroupsResource groupsResource = admin.groups(); + GroupRepresentation group = groupsResource.groups(parts[0], true, null, null, true).get(0); + user.joinGroup(group.getId()); + } else { + GroupsResource groupsResource = admin.groups(); + GroupRepresentation group = null; + + for (String part : parts) { + if (part.isEmpty()) { + continue; + } + if (group == null) { + group = groupsResource.groups(part, true, null, null, true).get(0); + } else { + group = groupsResource.group(group.getId()).getSubGroups(part, true, null, null, true).get(0); + } + } + + if (group != null) { + user.joinGroup(group.getId()); + } + } + + } + } +}