diff --git a/services/src/main/java/org/keycloak/models/workflow/GrantRoleStepProvider.java b/services/src/main/java/org/keycloak/models/workflow/GrantRoleStepProvider.java new file mode 100644 index 00000000000..ecd92359c90 --- /dev/null +++ b/services/src/main/java/org/keycloak/models/workflow/GrantRoleStepProvider.java @@ -0,0 +1,23 @@ +package org.keycloak.models.workflow; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; + +import org.jboss.logging.Logger; + +public class GrantRoleStepProvider extends RoleBasedStepProvider { + + private final Logger log = Logger.getLogger(GrantRoleStepProvider.class); + + protected GrantRoleStepProvider(KeycloakSession session, ComponentModel model) { + super(session, model); + } + + @Override + protected void run(UserModel user, RoleModel role) { + log.debugv("Granting role %s to user %s)", role.getName(), user.getId()); + user.grantRole(role); + } +} diff --git a/services/src/main/java/org/keycloak/models/workflow/GrantRoleStepProviderFactory.java b/services/src/main/java/org/keycloak/models/workflow/GrantRoleStepProviderFactory.java new file mode 100644 index 00000000000..cb30a3f4757 --- /dev/null +++ b/services/src/main/java/org/keycloak/models/workflow/GrantRoleStepProviderFactory.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 GrantRoleStepProviderFactory implements WorkflowStepProviderFactory { + + public static final String ID = "grant-role"; + + @Override + public GrantRoleStepProvider create(KeycloakSession session, ComponentModel model) { + return new GrantRoleStepProvider(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 "Grant a role to a user."; + } + + @Override + public List getConfigProperties() { + return List.of(); + } +} diff --git a/services/src/main/java/org/keycloak/models/workflow/RevokeRoleStepProvider.java b/services/src/main/java/org/keycloak/models/workflow/RevokeRoleStepProvider.java new file mode 100644 index 00000000000..2218c6fe167 --- /dev/null +++ b/services/src/main/java/org/keycloak/models/workflow/RevokeRoleStepProvider.java @@ -0,0 +1,23 @@ +package org.keycloak.models.workflow; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; + +import org.jboss.logging.Logger; + +public class RevokeRoleStepProvider extends RoleBasedStepProvider { + + private final Logger log = Logger.getLogger(RevokeRoleStepProvider.class); + + protected RevokeRoleStepProvider(KeycloakSession session, ComponentModel model) { + super(session, model); + } + + @Override + protected void run(UserModel user, RoleModel role) { + log.debugv("Revoking role %s from user %s)", role.getName(), user.getId()); + user.deleteRoleMapping(role); + } +} diff --git a/services/src/main/java/org/keycloak/models/workflow/RevokeRoleStepProviderFactory.java b/services/src/main/java/org/keycloak/models/workflow/RevokeRoleStepProviderFactory.java new file mode 100644 index 00000000000..9712e15ba9f --- /dev/null +++ b/services/src/main/java/org/keycloak/models/workflow/RevokeRoleStepProviderFactory.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 RevokeRoleStepProviderFactory implements WorkflowStepProviderFactory { + + public static final String ID = "revoke-role"; + + @Override + public RevokeRoleStepProvider create(KeycloakSession session, ComponentModel model) { + return new RevokeRoleStepProvider(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 "Revoke a user role."; + } + + @Override + public List getConfigProperties() { + return List.of(); + } +} diff --git a/services/src/main/java/org/keycloak/models/workflow/RoleBasedStepProvider.java b/services/src/main/java/org/keycloak/models/workflow/RoleBasedStepProvider.java new file mode 100644 index 00000000000..a219bf6476e --- /dev/null +++ b/services/src/main/java/org/keycloak/models/workflow/RoleBasedStepProvider.java @@ -0,0 +1,77 @@ +package org.keycloak.models.workflow; + +import java.util.List; +import java.util.stream.Stream; + +import org.keycloak.component.ComponentModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; + +import org.jboss.logging.Logger; + +public abstract class RoleBasedStepProvider implements WorkflowStepProvider { + + private final Logger log = Logger.getLogger(RoleBasedStepProvider.class); + public static final String CONFIG_ROLE = "role"; + + private final KeycloakSession session; + private final ComponentModel model; + + public RoleBasedStepProvider(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 { + getRoles().forEach(role -> run(user, role)); + } catch (Exception e) { + log.errorf(e, "Failed to grant role to user %s", user.getId()); + } + } + } + + protected abstract void run(UserModel user, RoleModel role); + + @Override + public void close() { + } + + private Stream getRoles() { + return model.getConfig().getOrDefault(CONFIG_ROLE, List.of()).stream().map(this::getRole); + } + + private RoleModel getRole(String name) { + RoleModel role; + String[] parts = name.split("/"); + + if (parts.length > 1) { + ClientModel client = getRealm().getClientByClientId(parts[0]); + + if (client == null) { + throw new IllegalStateException("Client with clientId " + parts[0] + " not found"); + } + + role = client.getRole(parts[1]); + } else { + role = getRealm().getRole(name); + } + + if (role == null) { + throw new IllegalStateException("Role " + name + " not found"); + } + + return role; + } + + private RealmModel getRealm() { + return session.getContext().getRealm(); + } +} 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 f7ce390dec9..1c5dfd8d241 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 @@ -19,4 +19,6 @@ org.keycloak.models.workflow.DisableUserStepProviderFactory org.keycloak.models.workflow.NotifyUserStepProviderFactory org.keycloak.models.workflow.DeleteUserStepProviderFactory org.keycloak.models.workflow.SetUserAttributeStepProviderFactory -org.keycloak.models.workflow.AddRequiredActionStepProviderFactory \ No newline at end of file +org.keycloak.models.workflow.AddRequiredActionStepProviderFactory +org.keycloak.models.workflow.GrantRoleStepProviderFactory +org.keycloak.models.workflow.RevokeRoleStepProviderFactory \ No newline at end of file diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AbstractWorkflowTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AbstractWorkflowTest.java index 9b283d2814c..68b3fc659d8 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AbstractWorkflowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/AbstractWorkflowTest.java @@ -2,8 +2,12 @@ package org.keycloak.tests.admin.model.workflow; import java.time.Duration; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + import org.keycloak.common.util.Time; import org.keycloak.models.workflow.WorkflowProvider; +import org.keycloak.representations.workflows.WorkflowRepresentation; import org.keycloak.testframework.annotations.InjectRealm; import org.keycloak.testframework.injection.LifeCycle; import org.keycloak.testframework.oauth.OAuthClient; @@ -17,6 +21,9 @@ import org.keycloak.testframework.ui.annotations.InjectWebDriver; import org.keycloak.testframework.ui.page.LoginPage; import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + public abstract class AbstractWorkflowTest { protected static final String DEFAULT_REALM_NAME = "default"; @@ -36,6 +43,12 @@ public abstract class AbstractWorkflowTest { @InjectOAuthClient(realmRef = DEFAULT_REALM_NAME) OAuthClient oauth; + protected void create(WorkflowRepresentation workflow) { + try (Response response = managedRealm.admin().workflows().create(workflow)) { + assertThat(response.getStatus(), is(Status.CREATED.getStatusCode())); + } + } + protected void runScheduledSteps(Duration duration) { runOnServer.run((RunOnServer) session -> { WorkflowProvider provider = session.getProvider(WorkflowProvider.class); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/RoleBasedStepTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/RoleBasedStepTest.java new file mode 100644 index 00000000000..d3c91572959 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/RoleBasedStepTest.java @@ -0,0 +1,147 @@ +package org.keycloak.tests.admin.model.workflow; + +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.admin.client.resource.ClientsResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.RolesResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.models.workflow.GrantRoleStepProvider; +import org.keycloak.models.workflow.GrantRoleStepProviderFactory; +import org.keycloak.models.workflow.ResourceOperationType; +import org.keycloak.models.workflow.RevokeRoleStepProvider; +import org.keycloak.models.workflow.RevokeRoleStepProviderFactory; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +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.ClientConfigBuilder; +import org.keycloak.testframework.realm.RoleConfigBuilder; +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_ADDED; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.not; + +@KeycloakIntegrationTest(config = WorkflowsBlockingServerConfig.class) +public class RoleBasedStepTest extends AbstractWorkflowTest { + + @BeforeEach + public void setupRoles() { + RealmResource admin = managedRealm.admin(); + RolesResource realmRoles = admin.roles(); + List.of("a", "b", "c").forEach(name -> realmRoles.create(RoleConfigBuilder.create().name("realm-role-" + name).build())); + ClientsResource clients = admin.clients(); + clients.create(ClientConfigBuilder.create().clientId("myclient").build()).close(); + ClientRepresentation client = clients.findByClientId("myclient").get(0); + RolesResource clientRoles = clients.get(client.getId()).roles(); + List.of("a", "b", "c").forEach(name -> clientRoles.create(RoleConfigBuilder.create().name("client-role-" + name).build())); + } + + @Test + public void testGrantRole() { + List expectedRealmRoles = List.of("realm-role-a", "realm-role-b"); + List expectedClientRoles = List.of("myclient/client-role-a", "myclient/client-role-c"); + List expectedRoles = Stream.concat(expectedRealmRoles.stream(), expectedClientRoles.stream()).toList(); + + create(WorkflowRepresentation.withName("grant-roles") + .onEvent(USER_ADDED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(GrantRoleStepProviderFactory.ID) + .withConfig(GrantRoleStepProvider.CONFIG_ROLE, expectedRoles.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(() -> { + var actualRealmRoles = user.roles().getAll().getRealmMappings().stream() + .map(RoleRepresentation::getName).toList(); + assertThat(actualRealmRoles, hasItems(expectedRealmRoles.toArray(new String[0]))); + var actualClientRoles = user.roles().getAll().getClientMappings().get("myclient").getMappings().stream() + .map((r) -> "myclient/" + r.getName()).toList(); + assertThat(actualClientRoles, hasItems(expectedClientRoles.toArray(new String[0]))); + }); + } + + @Test + public void testRevokeRole() { + UserResource user = getUserResource(UserConfigBuilder.create() + .username("myuser") + .build()); + grantRole(user, "realm-role-a", "realm-role-b", "realm-role-c", "myclient/client-role-a", "myclient/client-role-c"); + + create(WorkflowRepresentation.withName("revoke-roles") + .onEvent(ResourceOperationType.USER_ROLE_REMOVED.name()) + .withSteps( + WorkflowStepRepresentation.create() + .of(RevokeRoleStepProviderFactory.ID) + .withConfig(RevokeRoleStepProvider.CONFIG_ROLE, "realm-role-a", "myclient/client-role-c") + .build() + ).build()); + + user.roles().realmLevel().remove(List.of( + managedRealm.admin().roles().get("realm-role-b").toRepresentation() + )); + + Awaitility.await() + .timeout(Duration.ofSeconds(30)) + .pollInterval(Duration.ofSeconds(1)) + .untilAsserted(() -> { + var actualRealmRoles = user.roles().getAll().getRealmMappings().stream() + .map(RoleRepresentation::getName).toList(); + assertThat(actualRealmRoles, not(hasItems(List.of("realm-role-a", "realm-role-b").toArray(new String[0])))); + assertThat(actualRealmRoles, hasItems(List.of("realm-role-c").toArray(new String[0]))); + var actualClientRoles = user.roles().getAll().getClientMappings().get("myclient").getMappings().stream() + .map((r) -> "myclient/" + r.getName()).toList(); + assertThat(actualClientRoles, containsInAnyOrder(List.of("myclient/client-role-a").toArray(new String[0]))); + }); + } + + 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 grantRole(UserResource user, String... roles) { + RealmResource admin = managedRealm.admin(); + + for (String name : roles) { + String[] parts = name.split("/"); + + if (parts.length > 1) { + ClientsResource clients = admin.clients(); + ClientRepresentation client = clients.findByClientId(parts[0]).get(0); + RoleRepresentation clientRole = clients.get(client.getId()).roles().get(parts[1]).toRepresentation(); + user.roles().clientLevel(client.getId()).add(List.of(clientRole)); + } else { + RoleRepresentation realmRole = admin.roles().get(name).toRepresentation(); + user.roles().realmLevel().add(List.of(realmRole)); + } + } + } +}