Adding join and leave group steps (#44841)

Closes #44649

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2025-12-18 09:07:23 -03:00 committed by GitHub
parent 08e96435c8
commit f36819e943
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 390 additions and 1 deletions

View file

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

View file

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

View file

@ -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<JoinGroupStepProvider> {
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<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
}

View file

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

View file

@ -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<LeaveGroupStepProvider> {
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<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
}

View file

@ -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
org.keycloak.models.workflow.RevokeRoleStepProviderFactory
org.keycloak.models.workflow.JoinGroupStepProviderFactory
org.keycloak.models.workflow.LeaveGroupStepProviderFactory

View file

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