Add a policy condition based on user attributes

Closes #42118

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2025-09-09 10:02:11 -03:00
parent 60b29daed2
commit 58990a5544
7 changed files with 295 additions and 16 deletions

View File

@ -56,6 +56,13 @@ public class ResourcePolicyConditionRepresentation {
this.config.put(key, Collections.singletonList(value));
}
public void setConfig(String key, List<String> values) {
if (this.config == null) {
this.config = new HashMap<>();
}
this.config.put(key, values);
}
public static class Builder {
private ResourcePolicyConditionRepresentation action;
@ -70,6 +77,16 @@ public class ResourcePolicyConditionRepresentation {
return this;
}
public Builder withConfig(String key, List<String> value) {
action.setConfig(key, value);
return this;
}
public Builder withConfig(Map<String, List<String>> config) {
action.setConfig(config);
return this;
}
public ResourcePolicyConditionRepresentation build() {
return action;
}

View File

@ -0,0 +1,36 @@
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 UserAttributePolicyConditionFactory implements ResourcePolicyConditionProviderFactory<UserAttributePolicyConditionProvider> {
public static final String ID = "user-attribute-condition";
@Override
public UserAttributePolicyConditionProvider create(KeycloakSession session, Map<String, List<String>> config) {
return new UserAttributePolicyConditionProvider(session, config);
}
@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,56 @@
package org.keycloak.models.policy.conditions;
import static org.keycloak.common.util.CollectionUtil.collectionEquals;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.policy.ResourcePolicyConditionProvider;
import org.keycloak.models.policy.ResourcePolicyEvent;
import org.keycloak.models.policy.ResourceType;
public class UserAttributePolicyConditionProvider implements ResourcePolicyConditionProvider {
private final Map<String, List<String>> expectedAttributes;
private final KeycloakSession session;
public UserAttributePolicyConditionProvider(KeycloakSession session, Map<String, List<String>> expectedAttributes) {
this.session = session;
this.expectedAttributes = expectedAttributes;;
}
@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;
}
for (Entry<String, List<String>> expected : expectedAttributes.entrySet()) {
List<String> values = user.getAttributes().getOrDefault(expected.getKey(), List.of());
List<String> expectedValues = expected.getValue();
if (!collectionEquals(expectedValues, values)) {
return false;
}
}
return true;
}
@Override
public void close() {
}
}

View File

@ -16,4 +16,5 @@
#
org.keycloak.models.policy.conditions.GroupMembershipPolicyConditionFactory
org.keycloak.models.policy.conditions.IdentityProviderPolicyConditionFactory
org.keycloak.models.policy.conditions.IdentityProviderPolicyConditionFactory
org.keycloak.models.policy.conditions.UserAttributePolicyConditionFactory

View File

@ -8,6 +8,8 @@ import org.keycloak.representations.idm.FederatedIdentityRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
public class UserConfigBuilder {
@ -88,6 +90,11 @@ public class UserConfigBuilder {
return this;
}
public UserConfigBuilder attributes(Map<String, List<String>> attributes) {
rep.setAttributes(Collections.combine(rep.getAttributes(), attributes));
return this;
}
public UserConfigBuilder federatedLink(String identityProvider, String federatedUserId, String federatedUsername) {
FederatedIdentityRepresentation federatedIdentity = new FederatedIdentityRepresentation();
federatedIdentity.setUserId(federatedUserId);

View File

@ -3,33 +3,33 @@ 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.assertNotNull;
import static org.keycloak.tests.admin.model.policy.ResourcePolicyManagementTest.findEmailByRecipient;
import java.time.Duration;
import java.util.List;
import jakarta.mail.internet.MimeMessage;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.resource.RealmResourcePolicies;
import org.keycloak.admin.client.resource.UserResource;
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.SetUserAttributeActionProviderFactory;
import org.keycloak.models.policy.conditions.GroupMembershipPolicyConditionFactory;
import org.keycloak.models.policy.NotifyUserActionProviderFactory;
import org.keycloak.models.policy.ResourceOperationType;
import org.keycloak.models.policy.ResourcePolicyManager;
import org.keycloak.representations.idm.UserRepresentation;
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.mail.MailServer;
import org.keycloak.testframework.mail.annotations.InjectMailServer;
import org.keycloak.testframework.realm.GroupConfigBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.UserConfigBuilder;
@ -48,11 +48,11 @@ public class GroupMembershipJoinPolicyTest {
@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm managedRealm;
@InjectMailServer
private MailServer mailServer;
@Test
public void testEventsOnGroupMembershipJoin() {
UPConfig upConfig = managedRealm.admin().users().userProfile().getConfiguration();
upConfig.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ENABLED);
managedRealm.admin().users().userProfile().update(upConfig);
String groupId;
try (Response response = managedRealm.admin().groups().add(GroupConfigBuilder.create()
@ -69,7 +69,8 @@ public class GroupMembershipJoinPolicyTest {
.build())
.withActions(
ResourcePolicyActionRepresentation.create()
.of(NotifyUserActionProviderFactory.ID)
.of(SetUserAttributeActionProviderFactory.ID)
.withConfig("attribute", "attr1")
.after(Duration.ofDays(5))
.build()
).build();
@ -87,7 +88,9 @@ public class GroupMembershipJoinPolicyTest {
userId = ApiUtil.getCreatedId(response);
}
managedRealm.admin().users().get(userId).joinGroup(groupId);
UserResource userResource = managedRealm.admin().users().get(userId);
userResource.joinGroup(groupId);
runOnServer.run((session -> {
RealmModel realm = configureSessionContext(session);
@ -104,11 +107,8 @@ public class GroupMembershipJoinPolicyTest {
}
}));
// Verify that the notify action was executed by checking email was sent
MimeMessage testUserMessage = findEmailByRecipient(mailServer, "generic-user@example.com");
assertNotNull(testUserMessage, "The first action (notify) should have sent an email.");
mailServer.runCleanup();
UserRepresentation rep = userResource.toRepresentation();
assertNotNull(rep.getAttributes().get("attribute"));
}
private static RealmModel configureSessionContext(KeycloakSession session) {

View File

@ -0,0 +1,162 @@
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 java.time.Duration;
import java.util.HashMap;
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.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.UserAttributePolicyConditionFactory;
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.UserConfigBuilder;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
@KeycloakIntegrationTest(config = RLMServerConfig.class)
public class UserAttributePolicyConditionTest {
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 testConditionForSingleValuedAttribute() {
String expected = "valid";
createPolicy(expected);
assertUserAttribute("user-1", false);
assertUserAttribute("user-2", false, "not-valid");
assertUserAttribute("user-3", true, expected);
}
@Test
public void testConditionForMultiValuedAttribute() {
List<String> expected = List.of("v1", "v2", "v3");
createPolicy(expected);
assertUserAttribute("user-1", false, "v1");
assertUserAttribute("user-2", true, expected);
assertUserAttribute("user-3", false, "v1", "v2", "v3", "v4");
}
@Test
public void testConditionForMultipleAttributes() {
Map<String, List<String>> expected = Map.of("a", List.of("a1"), "b", List.of("b1"), "c", List.of("c11", "c2"));
createPolicy(expected);
assertUserAttribute("user-1", false, Map.of("a", List.of("a3"), "b", List.of("b1"), "c", List.of("c1", "c2")));
assertUserAttribute("user-2", true, expected);
assertUserAttribute("user-3", false, Map.of("a", List.of("a1"), "b", List.of("b1")));
HashMap<String, List<String>> values = new HashMap<>(expected);
values.put("d", List.of("d1"));
assertUserAttribute("user-4", true, values);
}
private void assertUserAttribute(String username, boolean shouldExist, String... values) {
assertUserAttribute(username, shouldExist, Map.of("attribute", List.of(values)));
}
private void assertUserAttribute(String username, boolean shouldExist, List<String> values) {
assertUserAttribute(username, shouldExist, Map.of("attribute", values));
}
private void assertUserAttribute(String username, boolean shouldExist, Map<String, List<String>> attributes) {
managedRealm.admin().users().create(UserConfigBuilder.create()
.username(username)
.email(username + "@example.com")
.attributes(attributes)
.build()).close();
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("attribute", List.of(expectedValues)));
}
private void createPolicy(List<String> expectedValues) {
createPolicy(Map.of("attribute", expectedValues));
}
private void createPolicy(Map<String, List<String>> attributes) {
List<ResourcePolicyRepresentation> expectedPolicies = ResourcePolicyRepresentation.create()
.of(EventBasedResourcePolicyProviderFactory.ID)
.onEvent(ResourceOperationType.CREATE.name())
.recurring()
.onCoditions(ResourcePolicyConditionRepresentation.create()
.of(UserAttributePolicyConditionFactory.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 static RealmModel configureSessionContext(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(REALM_NAME);
session.getContext().setRealm(realm);
return realm;
}
}