Add a policy condition based on user roles (#42487)

Closes #42117

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2025-09-09 22:23:56 -03:00 committed by GitHub
parent d05c3b5a9e
commit 1b17a3c9a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 406 additions and 4 deletions

View File

@ -31,6 +31,8 @@ import org.keycloak.models.MembershipMetadata;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.RoleModel.RoleGrantedEvent;
import org.keycloak.models.RoleModel.RoleRevokedEvent;
import org.keycloak.models.SubjectCredentialManager;
import org.keycloak.models.UserModel;
import org.keycloak.models.jpa.entities.UserAttributeEntity;
@ -38,7 +40,6 @@ import org.keycloak.models.jpa.entities.UserEntity;
import org.keycloak.models.jpa.entities.UserGroupMembershipEntity;
import org.keycloak.models.jpa.entities.UserRequiredActionEntity;
import org.keycloak.models.jpa.entities.UserRoleMappingEntity;
import org.keycloak.models.policy.ResourcePolicyManager;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.RoleUtils;
@ -507,6 +508,7 @@ public class UserAdapter implements UserModel, JpaModel<UserEntity> {
public void grantRole(RoleModel role) {
if (hasDirectRole(role)) return;
grantRoleImpl(role);
RoleGrantedEvent.fire(role, this, session);
}
public void grantRoleImpl(RoleModel role) {
@ -545,6 +547,7 @@ public class UserAdapter implements UserModel, JpaModel<UserEntity> {
em.remove(entity);
}
em.flush();
RoleRevokedEvent.fire(role, this, session);
}
@Override

View File

@ -0,0 +1,37 @@
package org.keycloak.models.policy.conditions;
import java.util.List;
import java.util.Map;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.policy.ResourcePolicyConditionProviderFactory;
public class RolePolicyConditionFactory implements ResourcePolicyConditionProviderFactory<RolePolicyConditionProvider> {
public static final String ID = "role-condition";
public static final String EXPECTED_ROLES = "roles";
@Override
public RolePolicyConditionProvider create(KeycloakSession session, Map<String, List<String>> config) {
return new RolePolicyConditionProvider(session, config.get(EXPECTED_ROLES));
}
@Override
public String getId() {
return ID;
}
@Override
public void init(org.keycloak.Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@ -0,0 +1,83 @@
package org.keycloak.models.policy.conditions;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
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.keycloak.models.policy.ResourcePolicyConditionProvider;
import org.keycloak.models.policy.ResourcePolicyEvent;
import org.keycloak.models.policy.ResourceType;
import org.keycloak.models.utils.RoleUtils;
public class RolePolicyConditionProvider implements ResourcePolicyConditionProvider {
private final List<String> expectedRoles;
private final KeycloakSession session;
public RolePolicyConditionProvider(KeycloakSession session, List<String> expectedRoles) {
this.session = session;
this.expectedRoles = expectedRoles;
}
@Override
public boolean evaluate(ResourcePolicyEvent event) {
if (!ResourceType.USERS.equals(event.getResourceType())) {
return false;
}
String userId = event.getResourceId();
RealmModel realm = session.getContext().getRealm();
UserModel user = session.users().getUserById(realm, userId);
if (user == null) {
return false;
}
Set<RoleModel> roles = user.getRoleMappingsStream().collect(Collectors.toSet());
for (String name : expectedRoles) {
RoleModel expectedRole = getRole(name, realm);
if (expectedRole == null || !RoleUtils.hasRole(roles, expectedRole)) {
return false;
}
}
return true;
}
private RoleModel getRole(String expectedRole, RealmModel realm) {
boolean isClientRole = expectedRole.indexOf('/') != -1;
if (isClientRole) {
String[] parts = expectedRole.split("/");
if (parts.length != 2) {
return null;
}
String clientId = parts[0];
String roleName = parts[1];
ClientModel client = session.clients().getClientByClientId(realm, clientId);
if (client == null) {
return null;
}
return client.getRole(roleName);
}
return realm.getRole(expectedRole);
}
@Override
public void close() {
}
}

View File

@ -17,4 +17,5 @@
org.keycloak.models.policy.conditions.GroupMembershipPolicyConditionFactory
org.keycloak.models.policy.conditions.IdentityProviderPolicyConditionFactory
org.keycloak.models.policy.conditions.UserAttributePolicyConditionFactory
org.keycloak.models.policy.conditions.UserAttributePolicyConditionFactory
org.keycloak.models.policy.conditions.RolePolicyConditionFactory

View File

@ -1,7 +1,6 @@
package org.keycloak.models.policy;
import java.util.List;
import java.util.Map;
import org.keycloak.events.EventType;
import org.keycloak.events.admin.OperationType;
@ -9,6 +8,9 @@ import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.FederatedIdentityModel.FederatedIdentityCreatedEvent;
import org.keycloak.models.FederatedIdentityModel.FederatedIdentityRemovedEvent;
import org.keycloak.models.GroupModel.GroupMemberJoinEvent;
import org.keycloak.models.RoleModel;
import org.keycloak.models.RoleModel.RoleGrantedEvent;
import org.keycloak.models.RoleModel.RoleRevokedEvent;
import org.keycloak.provider.ProviderEvent;
public enum ResourceOperationType {
@ -17,7 +19,8 @@ public enum ResourceOperationType {
LOGIN(EventType.LOGIN),
ADD_FEDERATED_IDENTITY(new Class[] {FederatedIdentityCreatedEvent.class}, new Class[] {FederatedIdentityRemovedEvent.class}),
REMOVE_FEDERATED_IDENTITY(FederatedIdentityRemovedEvent.class),
GROUP_MEMBERSHIP_JOIN(GroupMemberJoinEvent.class);
GROUP_MEMBERSHIP_JOIN(GroupMemberJoinEvent.class),
ROLE_GRANTED(new Class[] {RoleGrantedEvent.class}, new Class[] {RoleRevokedEvent.class});
private final List<Object> types;
private final List<Object> deactivationTypes;
@ -71,6 +74,12 @@ public enum ResourceOperationType {
if (event instanceof FederatedIdentityModel.FederatedIdentityRemovedEvent fie) {
return fie.getUser().getId();
}
if (event instanceof RoleModel.RoleGrantedEvent rge) {
return rge.getUser().getId();
}
if (event instanceof RoleModel.RoleRevokedEvent rre) {
return rre.getUser().getId();
}
return null;
}

View File

@ -40,6 +40,68 @@ public interface RoleModel {
KeycloakSession getKeycloakSession();
}
interface RoleEvent extends ProviderEvent {
RealmModel getRealm();
RoleModel getRole();
KeycloakSession getKeycloakSession();
}
interface RoleGrantedEvent extends RoleModel.RoleEvent {
static void fire(RoleModel role, UserModel user, KeycloakSession session) {
session.getKeycloakSessionFactory().publish(new RoleModel.RoleGrantedEvent() {
@Override
public RealmModel getRealm() {
return session.getContext().getRealm();
}
@Override
public RoleModel getRole() {
return role;
}
@Override
public UserModel getUser() {
return user;
}
@Override
public KeycloakSession getKeycloakSession() {
return session;
}
});
}
UserModel getUser();
}
interface RoleRevokedEvent extends RoleModel.RoleEvent {
static void fire(RoleModel role, UserModel user, KeycloakSession session) {
session.getKeycloakSessionFactory().publish(new RoleModel.RoleRevokedEvent() {
@Override
public RealmModel getRealm() {
return session.getContext().getRealm();
}
@Override
public RoleModel getRole() {
return role;
}
@Override
public UserModel getUser() {
return user;
}
@Override
public KeycloakSession getKeycloakSession() {
return session;
}
});
}
UserModel getUser();
}
String getName();
String getDescription();

View File

@ -0,0 +1,207 @@
package org.keycloak.tests.admin.model.policy;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.keycloak.models.policy.conditions.RolePolicyConditionFactory.EXPECTED_ROLES;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.resource.RealmResourcePolicies;
import org.keycloak.admin.client.resource.RolesResource;
import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.policy.EventBasedResourcePolicyProviderFactory;
import org.keycloak.models.policy.ResourceOperationType;
import org.keycloak.models.policy.ResourcePolicyManager;
import org.keycloak.models.policy.SetUserAttributeActionProviderFactory;
import org.keycloak.models.policy.conditions.RolePolicyConditionFactory;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.resources.policies.ResourcePolicyActionRepresentation;
import org.keycloak.representations.resources.policies.ResourcePolicyConditionRepresentation;
import org.keycloak.representations.resources.policies.ResourcePolicyRepresentation;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy;
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.realm.RoleConfigBuilder;
import org.keycloak.testframework.realm.UserConfigBuilder;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import org.keycloak.testframework.util.ApiUtil;
@KeycloakIntegrationTest(config = RLMServerConfig.class)
public class RolePolicyConditionTest {
private static final String REALM_NAME = "default";
@InjectRunOnServer(permittedPackages = "org.keycloak.tests")
RunOnServerClient runOnServer;
@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm managedRealm;
@BeforeEach
public void onBefore() {
UPConfig upConfig = managedRealm.admin().users().userProfile().getConfiguration();
upConfig.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ENABLED);
managedRealm.admin().users().userProfile().update(upConfig);
}
@Test
public void testConditionForSingleRole() {
String expected = "realm-role-1";
createPolicy(expected);
assertUserRoles("user-1", false);
assertUserRoles("user-2", false, "not-valid-role");
assertUserRoles("user-3", true, expected);
}
@Test
public void testConditionForMultipleRole() {
List<String> expected = List.of("realm-role-1", "realm-role-2", "client-a/client-role-1");
createPolicy(expected);
assertUserRoles("user-1", false, List.of("realm-role-1", "realm-role-2"));
assertUserRoles("user-2", false, List.of("realm-role-1", "realm-role-2", "client-b/client-role-1"));
assertUserRoles("user-3", true, expected);
}
private void assertUserRoles(String username, boolean shouldExist, String... roles) {
assertUserRoles(username, shouldExist, List.of(roles));
}
private void assertUserRoles(String username, boolean shouldExist, List<String> roles) {
try (Response response = managedRealm.admin().users().create(UserConfigBuilder.create()
.username(username)
.email(username + "@example.com")
.build())) {
String id = ApiUtil.getCreatedId(response);
for (String roleName : roles) {
RoleRepresentation role = createRoleIfNotExists(roleName);
if (role.getClientRole()) {
managedRealm.admin().users().get(id).roles().clientLevel(role.getContainerId()).add(List.of(role));
} else {
managedRealm.admin().users().get(id).roles().realmLevel().add(List.of(role));
}
}
}
runOnServer.run((session -> {
RealmModel realm = configureSessionContext(session);
try {
// set offset to 7 days - notify action should run now
Time.setOffset(Math.toIntExact(Duration.ofDays(6).toSeconds()));
new ResourcePolicyManager(session).runScheduledActions();
} finally {
Time.setOffset(0);
}
UserModel user = session.users().getUserByUsername(realm, username);
assertNotNull(user);
if (shouldExist) {
assertTrue(user.getAttributes().containsKey("notified"));
} else {
assertFalse(user.getAttributes().containsKey("notified"));
}
}));
}
private void createPolicy(String... expectedValues) {
createPolicy(Map.of(EXPECTED_ROLES, List.of(expectedValues)));
}
private void createPolicy(List<String> expectedValues) {
createPolicy(Map.of(EXPECTED_ROLES, expectedValues));
}
private void createPolicy(Map<String, List<String>> attributes) {
for (String roleName : attributes.getOrDefault(EXPECTED_ROLES, List.of())) {
createRoleIfNotExists(roleName);
}
List<ResourcePolicyRepresentation> expectedPolicies = ResourcePolicyRepresentation.create()
.of(EventBasedResourcePolicyProviderFactory.ID)
.onEvent(ResourceOperationType.ROLE_GRANTED.name())
.recurring()
.onCoditions(ResourcePolicyConditionRepresentation.create()
.of(RolePolicyConditionFactory.ID)
.withConfig(attributes)
.build())
.withActions(
ResourcePolicyActionRepresentation.create()
.of(SetUserAttributeActionProviderFactory.ID)
.withConfig("notified", "true")
.after(Duration.ofDays(5))
.build()
).build();
RealmResourcePolicies policies = managedRealm.admin().resources().policies();
try (Response response = policies.create(expectedPolicies)) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
}
}
private RoleRepresentation createRoleIfNotExists(String roleName) {
if (roleName.indexOf('/') != -1) {
String[] parts = roleName.split("/");
String clientId = parts[0];
String clientRoleName = parts[1];
List<ClientRepresentation> clients = managedRealm.admin().clients().findByClientId(clientId);
if (clients.isEmpty()) {
ClientRepresentation client = new ClientRepresentation();
client.setClientId(clientId);
client.setName(clientId);
client.setProtocol("openid-connect");
managedRealm.admin().clients().create(client).close();
clients = managedRealm.admin().clients().findByClientId(clientId);
}
assertThat(clients.isEmpty(), is(false));
RolesResource roles = managedRealm.admin().clients().get(clients.get(0).getId()).roles();
if (roles.list(clientRoleName, -1, -1).isEmpty()) {
roles.create(RoleConfigBuilder.create()
.name(clientRoleName)
.build());
}
return roles.get(clientRoleName).toRepresentation();
} else {
RolesResource roles = managedRealm.admin().roles();
if (roles.list(roleName, -1, -1).isEmpty()) {
roles.create(RoleConfigBuilder.create()
.name(roleName)
.build());
}
return roles.get(roleName).toRepresentation();
}
}
private static RealmModel configureSessionContext(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(REALM_NAME);
session.getContext().setRealm(realm);
return realm;
}
}