Add toPredicate implementation for conditions

Closes #42696

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
vramik
2025-11-18 18:01:15 +01:00
committed by Pedro Igor
parent 49b694bf0a
commit 0825f22331
7 changed files with 272 additions and 3 deletions

View File

@@ -1,9 +1,16 @@
package org.keycloak.models.workflow.conditions;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.jpa.entities.UserGroupMembershipEntity;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.workflow.WorkflowConditionProvider;
import org.keycloak.models.workflow.WorkflowExecutionContext;
@@ -34,6 +41,29 @@ public class GroupMembershipWorkflowConditionProvider implements WorkflowConditi
return user.isMemberOf(group);
}
@Override
public Predicate toPredicate(CriteriaBuilder cb, CriteriaQuery<String> query, Root<?> path) {
validate();
GroupModel group = KeycloakModelUtils.findGroupByPath(session, session.getContext().getRealm(), expectedGroup);
if (group == null) {
return cb.disjunction(); // always false
}
Subquery<Integer> subquery = query.subquery(Integer.class);
Root<UserGroupMembershipEntity> from = subquery.from(UserGroupMembershipEntity.class);
subquery.select(cb.literal(1));
subquery.where(
cb.and(
cb.equal(from.get("user").get("id"), path.get("id")),
cb.equal(from.get("groupId"), group.getId())
)
);
return cb.exists(subquery);
}
@Override
public void validate() {
if (StringUtil.isBlank(this.expectedGroup)) {

View File

@@ -3,11 +3,18 @@ package org.keycloak.models.workflow.conditions;
import java.util.Set;
import java.util.stream.Collectors;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
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.jpa.entities.UserRoleMappingEntity;
import org.keycloak.models.utils.RoleUtils;
import org.keycloak.models.workflow.WorkflowConditionProvider;
import org.keycloak.models.workflow.WorkflowExecutionContext;
@@ -40,6 +47,29 @@ public class RoleWorkflowConditionProvider implements WorkflowConditionProvider
return role != null && RoleUtils.hasRole(roles, role);
}
@Override
public Predicate toPredicate(CriteriaBuilder cb, CriteriaQuery<String> query, Root<?> path) {
validate();
RoleModel role = getRole(expectedRole, session.getContext().getRealm());
if (role == null) {
return cb.disjunction(); // always false
}
Subquery<Integer> subquery = query.subquery(Integer.class);
Root<UserRoleMappingEntity> from = subquery.from(UserRoleMappingEntity.class);
subquery.select(cb.literal(1));
subquery.where(
cb.and(
cb.equal(from.get("user").get("id"), path.get("id")),
cb.equal(from.get("roleId"), role.getId())
)
);
return cb.exists(subquery);
}
@Override
public void validate() throws WorkflowInvalidStateException {
if (StringUtil.isBlank(expectedRole)) {

View File

@@ -1,15 +1,24 @@
package org.keycloak.models.workflow.conditions;
import java.io.StringReader;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.jpa.entities.UserAttributeEntity;
import org.keycloak.models.workflow.WorkflowConditionProvider;
import org.keycloak.models.workflow.WorkflowExecutionContext;
import org.keycloak.models.workflow.WorkflowInvalidStateException;
import org.keycloak.storage.jpa.JpaHashUtils;
import static org.keycloak.common.util.CollectionUtil.collectionEquals;
@@ -41,6 +50,63 @@ public class UserAttributeWorkflowConditionProvider implements WorkflowCondition
return collectionEquals(expectedValues, values);
}
@Override
public Predicate toPredicate(CriteriaBuilder cb, CriteriaQuery<String> query, Root<?> path) {
validate();
String[] parsedKeyValuePair = parseKeyValuePair(expectedAttribute);
String attributeName = parsedKeyValuePair[0];
List<String> expectedValues = Arrays.asList(parsedKeyValuePair[1].split(","));
// Subquery to count how many of the expected values the user has
// to check if there is no missing value
Subquery<Long> matchingCountSubquery = query.subquery(Long.class);
Root<UserAttributeEntity> attrRoot1 = matchingCountSubquery.from(UserAttributeEntity.class);
matchingCountSubquery.select(cb.count(attrRoot1));
// Build predicate for matching values
// For values <= 255 chars: compare against 'value' field
// For values > 255 chars: compare against 'longValueHash' field (to avoid Oracle NCLOB comparison issues)
Predicate[] valuePredicates = expectedValues.stream()
.map(expectedValue -> {
if (expectedValue.length() > 255) {
// Use hash comparison for long values to avoid NCLOB comparison issues in Oracle
return cb.equal(attrRoot1.get("longValueHash"), JpaHashUtils.hashForAttributeValue(expectedValue));
} else {
// For short values, compare directly
return cb.equal(attrRoot1.get("value"), expectedValue);
}
})
.toArray(Predicate[]::new);
matchingCountSubquery.where(
cb.and(
cb.equal(attrRoot1.get("user").get("id"), path.get("id")),
cb.equal(attrRoot1.get("name"), attributeName),
cb.or(valuePredicates)
)
);
// Subquery to count total attributes with this name for the user
// to check if there are no extra values
Subquery<Long> totalCountSubquery = query.subquery(Long.class);
Root<UserAttributeEntity> attrRoot2 = totalCountSubquery.from(UserAttributeEntity.class);
totalCountSubquery.select(cb.count(attrRoot2));
totalCountSubquery.where(
cb.and(
cb.equal(attrRoot2.get("user").get("id"), path.get("id")),
cb.equal(attrRoot2.get("name"), attributeName)
)
);
// Both counts must equal the expected count (exact match)
int expectedCount = expectedValues.size();
return cb.and(
cb.equal(matchingCountSubquery, expectedCount),
cb.equal(totalCountSubquery, expectedCount)
);
}
@Override
public void validate() {
if (expectedAttribute == null) {

View File

@@ -11,9 +11,7 @@ public interface WorkflowConditionProvider extends Provider {
boolean evaluate(WorkflowExecutionContext context);
default Predicate toPredicate(CriteriaBuilder cb, CriteriaQuery<String> query, Root<?> resourceRoot) {
return null;
}
Predicate toPredicate(CriteriaBuilder cb, CriteriaQuery<String> query, Root<?> resourceRoot);
void validate() throws WorkflowInvalidStateException;
}

View File

@@ -8,9 +8,13 @@ import jakarta.ws.rs.core.Response.Status;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.admin.client.resource.WorkflowsResource;
import org.keycloak.models.workflow.DisableUserStepProviderFactory;
import org.keycloak.models.workflow.NotifyUserStepProviderFactory;
import org.keycloak.models.workflow.ResourceOperationType;
import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory;
import org.keycloak.models.workflow.Workflow;
import org.keycloak.models.workflow.WorkflowProvider;
import org.keycloak.models.workflow.WorkflowStateProvider;
import org.keycloak.models.workflow.conditions.GroupMembershipWorkflowConditionFactory;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.userprofile.config.UPConfig;
@@ -21,6 +25,7 @@ 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.remote.providers.runonserver.RunOnServer;
import org.keycloak.testframework.util.ApiUtil;
import org.awaitility.Awaitility;
@@ -135,4 +140,49 @@ public class GroupMembershipJoinWorkflowTest extends AbstractWorkflowTest {
assertThat(status.getErrors().get(0), containsString("Group with name %s does not exist.".formatted("generic-group")));
});
}
@Test
public void testActivateWorkflowForEligibleResources() {
managedRealm.admin().groups().add(GroupConfigBuilder.create().name("groupA").build()).close();
// create some users associated with a group membership
for (int i = 0; i < 10; i++) {
managedRealm.admin().users().create(UserConfigBuilder.create().username("group-member-" + i)
.groups("groupA").build()).close();
}
managedRealm.admin().workflows().create(WorkflowRepresentation.withName("groupA-membership-workflow")
.onEvent(ResourceOperationType.USER_GROUP_MEMBERSHIP_ADDED.name())
.onCondition(GroupMembershipWorkflowConditionFactory.ID + "(groupA)")
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
.after(Duration.ofDays(5))
.build(),
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
.after(Duration.ofDays(10))
.build()
).build()).close();
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
// activate the workflow for all eligible users
provider.activateForAllEligibleResources(registeredWorkflows.get(0));
});
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
Workflow workflow = registeredWorkflows.get(0);
// check workflow was correctly assigned to the users
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<WorkflowStateProvider.ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow);
assertThat(scheduledSteps, hasSize(10));
});
}
}

View File

@@ -10,9 +10,14 @@ import org.keycloak.admin.client.resource.RolesResource;
import org.keycloak.admin.client.resource.WorkflowsResource;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.workflow.DisableUserStepProviderFactory;
import org.keycloak.models.workflow.NotifyUserStepProviderFactory;
import org.keycloak.models.workflow.ResourceOperationType;
import org.keycloak.models.workflow.RestartWorkflowStepProviderFactory;
import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory;
import org.keycloak.models.workflow.Workflow;
import org.keycloak.models.workflow.WorkflowProvider;
import org.keycloak.models.workflow.WorkflowStateProvider;
import org.keycloak.models.workflow.conditions.RoleWorkflowConditionFactory;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
@@ -23,12 +28,14 @@ import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.RoleConfigBuilder;
import org.keycloak.testframework.realm.UserConfigBuilder;
import org.keycloak.testframework.remote.providers.runonserver.RunOnServer;
import org.keycloak.testframework.util.ApiUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -62,6 +69,54 @@ public class RoleWorkflowConditionTest extends AbstractWorkflowTest {
assertUserRoles("user-3", true, expected);
}
@Test
public void testActivateWorkflowForEligibleResources() {
RoleRepresentation role = createRoleIfNotExists("testRole");
// create some users associated with the role
for (int i = 0; i < 10; i++) {
try (Response response = managedRealm.admin().users().create(UserConfigBuilder.create().username("user-with-role-" + i).build())) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
managedRealm.admin().users().get(ApiUtil.getCreatedId(response)).roles().realmLevel().add(List.of(role));
}
}
managedRealm.admin().workflows().create(WorkflowRepresentation.withName("test-role-workflow")
.onEvent(ResourceOperationType.USER_ROLE_ADDED.name())
.onCondition(RoleWorkflowConditionFactory.ID + "(testRole)")
.withSteps(
WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID)
.after(Duration.ofDays(5))
.build(),
WorkflowStepRepresentation.create().of(DisableUserStepProviderFactory.ID)
.after(Duration.ofDays(10))
.build()
).build()).close();
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
// activate the workflow for all eligible users
provider.activateForAllEligibleResources(registeredWorkflows.get(0));
});
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
Workflow workflow = registeredWorkflows.get(0);
// check workflow was correctly assigned to the users
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<WorkflowStateProvider.ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow);
assertThat(scheduledSteps, hasSize(10));
});
}
private void assertUserRoles(String username, boolean shouldExist, String... roles) {
assertUserRoles(username, shouldExist, List.of(roles));
}

View File

@@ -14,6 +14,9 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.workflow.ResourceOperationType;
import org.keycloak.models.workflow.RestartWorkflowStepProviderFactory;
import org.keycloak.models.workflow.SetUserAttributeStepProviderFactory;
import org.keycloak.models.workflow.Workflow;
import org.keycloak.models.workflow.WorkflowProvider;
import org.keycloak.models.workflow.WorkflowStateProvider;
import org.keycloak.models.workflow.conditions.UserAttributeWorkflowConditionFactory;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy;
@@ -21,11 +24,13 @@ import org.keycloak.representations.workflows.WorkflowRepresentation;
import org.keycloak.representations.workflows.WorkflowStepRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.UserConfigBuilder;
import org.keycloak.testframework.remote.providers.runonserver.RunOnServer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -71,6 +76,41 @@ public class UserAttributeWorkflowConditionTest extends AbstractWorkflowTest {
assertUserAttribute("user-4", true, values);
}
@Test
public void testActivateWorkflowForEligibleResources() {
// create some users with attributes
for (int i = 0; i < 10; i++) {
try (Response response = managedRealm.admin().users().create(UserConfigBuilder.create().username("user-with-attr-" + i)
.attribute("key", "value").build())) {
assertThat(response.getStatus(), is(Status.CREATED.getStatusCode()));
}
}
createWorkflow(Map.of("key", List.of("value")));
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
// activate the workflow for all eligible users
provider.activateForAllEligibleResources(registeredWorkflows.get(0));
});
runOnServer.run((RunOnServer) session -> {
// check the same users are now scheduled to run the second step.
WorkflowProvider provider = session.getProvider(WorkflowProvider.class);
List<Workflow> registeredWorkflows = provider.getWorkflows().toList();
assertThat(registeredWorkflows, hasSize(1));
Workflow workflow = registeredWorkflows.get(0);
// check workflow was correctly assigned to the users
WorkflowStateProvider stateProvider = session.getProvider(WorkflowStateProvider.class);
List<WorkflowStateProvider.ScheduledStep> scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow);
assertThat(scheduledSteps, hasSize(10));
});
}
private void assertUserAttribute(String username, boolean shouldExist, String... values) {
assertUserAttribute(username, shouldExist, Map.of("attribute", List.of(values)));
}