Rework getEligibleResourcesForInitialAction so it returns all resources that are eligible to be associated with a policy

Closes #42106

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen 2025-08-26 09:00:07 -03:00 committed by Pedro Igor
parent 360ff7050c
commit 8eb6ee619f
8 changed files with 244 additions and 271 deletions

View File

@ -23,6 +23,8 @@ import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.keycloak.component.ComponentModel;
@ -31,6 +33,7 @@ import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.jpa.entities.FederatedIdentityEntity;
import org.keycloak.models.jpa.entities.UserEntity;
public abstract class AbstractUserResourcePolicyProvider implements ResourcePolicyProvider {
@ -39,69 +42,67 @@ public abstract class AbstractUserResourcePolicyProvider implements ResourcePoli
private final EntityManager em;
private final KeycloakSession session;
private static final String BROKER_ALIASES = "broker-aliases";
public AbstractUserResourcePolicyProvider(KeycloakSession session, ComponentModel model) {
this.policyModel = model;
this.em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
this.session = session;
}
public abstract Predicate timePredicate(long time, CriteriaBuilder cb, CriteriaQuery<String> query, Root<UserEntity> userRoot);
// For each user row, a subquery is executed to check if a corresponding record exists in
// the state table. If no record is found, the condition is met -> user is eligible for initial action
@Override
public List<String> getEligibleResourcesForInitialAction(long time) {
public List<String> getEligibleResourcesForInitialAction() {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<String> query = cb.createQuery(String.class);
Root<UserEntity> userRoot = query.from(UserEntity.class);
List<Predicate> predicates = new ArrayList<>();
// Subquery will find if a state record exists for the user and policy
// SELECT 1 FROM ResourcePolicyStateEntity s WHERE s.resourceId = userRoot.id AND s.policyId = :policyId
Subquery<Integer> subquery = query.subquery(Integer.class);
Root<ResourcePolicyStateEntity> stateRoot = subquery.from(ResourcePolicyStateEntity.class);
subquery.select(cb.literal(1)); // Select 1 for existence check
subquery.select(cb.literal(1));
subquery.where(
cb.and(
cb.equal(stateRoot.get("resourceId"), userRoot.get("id")),
cb.equal(stateRoot.get("policyId"), policyModel.getId())
)
);
// Time-based condition
Predicate timePredicate = timePredicate(time, cb, query, userRoot);
// NOT EXISTS condition
Predicate notExistsPredicate = cb.not(cb.exists(subquery));
predicates.add(notExistsPredicate);
query.where(cb.and(timePredicate, notExistsPredicate));
query.select(userRoot.get("id"));
return em.createQuery(query).getResultList();
}
@Override
public List<String> filterEligibleResources(List<String> candidateResourceIds, long time) {
// If there are no candidates, return an empty list
if (candidateResourceIds == null || candidateResourceIds.isEmpty()) {
return Collections.emptyList();
// origin-based condition
Predicate originPredicate = buildOriginPredicate(cb, query, userRoot);
if (originPredicate != null) {
predicates.add(originPredicate);
}
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<String> query = cb.createQuery(String.class);
Root<UserEntity> userRoot = query.from(UserEntity.class);
// Time-based condition
Predicate timePredicate = timePredicate(time, cb, query, userRoot);
// IN clause with candidateResourceIds
Predicate inClausePredicate = userRoot.get("id").in(candidateResourceIds);
query.where(cb.and(timePredicate, inClausePredicate));
query.select(userRoot.get("id"));
// todo: add relationship predicates (groups, roles, perhaps user attribute?)
query.select(userRoot.get("id")).where(predicates);
return em.createQuery(query).getResultList();
}
protected Predicate buildOriginPredicate(CriteriaBuilder cb, CriteriaQuery<String> query, Root<UserEntity> userRoot) {
// As of now we check only if there are broker aliases configured for the policy. More complete approach should check
// a "origin" attribute for the origin type, and add the predicate accordingly
// e.g. "origin" = "broker", check broker aliases; "origin" = "any" no need to filter anything; "origin" = "fed-provider", check "fed-providers", etc
if (!this.getBrokerAliases().isEmpty()) {
Subquery<Integer> subquery = query.subquery(Integer.class);
Root<FederatedIdentityEntity> from = subquery.from(FederatedIdentityEntity.class);
subquery.select(cb.literal(1));
subquery.where(
cb.and(
cb.equal(from.get("user").get("id"), userRoot.get("id")),
from.get("identityProvider").in(getBrokerAliases())
)
);
return cb.exists(subquery);
}
return null;
}
/**
* Indicates whether the specified resource is in the scope of this policy. For example, a policy associated with a
* broker is applicable only to users with a federated identity associated with the same broker.
@ -172,6 +173,6 @@ public abstract class AbstractUserResourcePolicyProvider implements ResourcePoli
}
protected List<String> getBrokerAliases() {
return getModel().getConfig().getOrDefault("broker-aliases", List.of());
return getModel().getConfig().getOrDefault(BROKER_ALIASES, List.of());
}
}

View File

@ -89,6 +89,20 @@ public class JpaResourcePolicyStateProvider implements ResourcePolicyStateProvid
.toList();
}
@Override
public List<ScheduledAction> getScheduledActionsByPolicy(ResourcePolicy policy) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<ResourcePolicyStateEntity> query = cb.createQuery(ResourcePolicyStateEntity.class);
Root<ResourcePolicyStateEntity> stateRoot = query.from(ResourcePolicyStateEntity.class);
Predicate byPolicy = cb.equal(stateRoot.get("policyId"), policy.getId());
query.where(byPolicy);
return em.createQuery(query).getResultStream()
.map(s -> new ScheduledAction(s.getPolicyId(), s.getScheduledActionId(), s.getResourceId()))
.toList();
}
@Override
public List<ScheduledAction> getScheduledActionsByResource(String resourceId) {
CriteriaBuilder cb = em.getCriteriaBuilder();
@ -103,21 +117,6 @@ public class JpaResourcePolicyStateProvider implements ResourcePolicyStateProvid
.toList();
}
@Override
public List<String> findResourceIdsByScheduledAction(String policyId, String scheduledActionId) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<String> query = cb.createQuery(String.class);
Root<ResourcePolicyStateEntity> stateRoot = query.from(ResourcePolicyStateEntity.class);
Predicate policyPredicate = cb.equal(stateRoot.get("policyId"), policyId);
Predicate actionPredicate = cb.equal(stateRoot.get("scheduledActionId"), scheduledActionId);
query.select(stateRoot.get("resourceId"));
query.where(cb.and(policyPredicate, actionPredicate));
return em.createQuery(query).getResultList();
}
@Override
public void update(String policyId, String policyProviderId, List<String> resourceIds, String newLastCompletedActionId) {
for (String resourceId : resourceIds) {

View File

@ -17,15 +17,8 @@
package org.keycloak.models.policy;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.keycloak.common.util.Time;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.jpa.entities.UserEntity;
import java.util.List;
@ -37,13 +30,6 @@ public class UserCreationTimeResourcePolicyProvider extends AbstractUserResource
super(session, model);
}
@Override
public Predicate timePredicate(long time, CriteriaBuilder cb, CriteriaQuery<String> query, Root<UserEntity> userRoot) {
long currentTimeMillis = Time.currentTimeMillis();
Expression<Long> timeMoment = cb.sum(userRoot.get("createdTimestamp"), cb.literal(time));
return cb.lessThan(timeMoment, cb.literal(currentTimeMillis));
}
@Override
protected List<ResourceOperationType> getSupportedOperationsForScheduling() {
return List.of(CREATE);

View File

@ -17,19 +17,10 @@
package org.keycloak.models.policy;
import java.time.Duration;
import java.util.List;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.keycloak.common.util.Time;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.jpa.entities.UserEntity;
import static org.keycloak.models.policy.ResourceOperationType.CREATE;
import static org.keycloak.models.policy.ResourceOperationType.LOGIN;
@ -40,14 +31,6 @@ public class UserSessionRefreshTimeResourcePolicyProvider extends AbstractUserRe
super(session, model);
}
@Override
public Predicate timePredicate(long time, CriteriaBuilder cb, CriteriaQuery<String> query, Root<UserEntity> userRoot) {
long currentTimeSeconds = Time.currentTime();
Path<Long> lastSessionRefreshTime = userRoot.get("lastSessionRefreshTime");
Expression<Long> lastSessionRefreshTimeExpiration = cb.sum(lastSessionRefreshTime, cb.literal(Duration.ofMillis(time).toSeconds()));
return cb.and(cb.isNotNull(lastSessionRefreshTime), cb.lessThan(lastSessionRefreshTimeExpiration, cb.literal(currentTimeSeconds)));
}
@Override
protected List<ResourceOperationType> getSupportedOperationsForScheduling() {
return List.of(CREATE, LOGIN);

View File

@ -26,11 +26,6 @@ import java.util.Set;
*/
public interface ResourcePolicyStateProvider extends Provider {
/**
* Finds resource IDs scheduled to run the specified action within a policy.
*/
List<String> findResourceIdsByScheduledAction(String policyId, String scheduledActionId);
/**
* Updates the state for a list of resources that have just completed a new action.
* This will perform an update for existing states or an insert for new states.
@ -71,6 +66,8 @@ public interface ResourcePolicyStateProvider extends Provider {
List<ScheduledAction> getScheduledActionsByResource(String resourceId);
List<ScheduledAction> getScheduledActionsByPolicy(ResourcePolicy policy);
List<ScheduledAction> getDueScheduledActions(ResourcePolicy policy);
record ScheduledAction (String policyId, String actionId, String resourceId) {};

View File

@ -25,15 +25,9 @@ public interface ResourcePolicyProvider extends Provider {
/**
* Finds all resources that are eligible for the first action of a policy.
*
* @param time The time delay for the first action.
* @return A list of eligible resource IDs.
*/
List<String> getEligibleResourcesForInitialAction(long time);
/**
* This method checks a list of candidates and returns only those that are eligible based on time.
*/
List<String> filterEligibleResources(List<String> candidateResourceIds, long time);
List<String> getEligibleResourcesForInitialAction();
/**
* Checks if the provider supports resources of the specified type.

View File

@ -19,8 +19,6 @@ package org.keycloak.models.policy;
import jakarta.ws.rs.BadRequestException;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -36,12 +34,13 @@ import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.policy.ResourcePolicyStateProvider.ScheduledAction;
import org.keycloak.provider.ProviderFactory;
public class ResourcePolicyManager {
private static final Logger log = Logger.getLogger(ResourcePolicyManager.class);
private ResourcePolicyStateProvider policyStateProvider;
public static boolean isFeatureEnabled() {
return Profile.isFeatureEnabled(Feature.RESOURCE_LIFECYCLE);
}
@ -50,6 +49,7 @@ public class ResourcePolicyManager {
public ResourcePolicyManager(KeycloakSession session) {
this.session = session;
this.policyStateProvider = session.getKeycloakSessionFactory().getProviderFactory(ResourcePolicyStateProvider.class).create(session);
}
public ResourcePolicy addPolicy(String providerId) {
@ -108,11 +108,10 @@ public class ResourcePolicyManager {
// find which action IDs were deleted
oldActionIds.removeAll(newActionIds); // The remaining IDs are the deleted ones
ResourcePolicyStateProvider stateProvider = getResourcePolicyStateProvider();
// delete orphaned state records - this means that we actually reset the flow for users which completed the action which is being removed
// it seems like the best way to handle this
if (!oldActionIds.isEmpty()) {
stateProvider.removeByCompletedActions(policy.getId(), oldActionIds);
policyStateProvider.removeByCompletedActions(policy.getId(), oldActionIds);
}
RealmModel realm = getRealm();
@ -157,80 +156,8 @@ public class ResourcePolicyManager {
.map(ResourceAction::new).sorted().toList();
}
public void runPolicies() {
List<ResourcePolicy> policies = getPolicies();
for (ResourcePolicy policy : policies) {
runPolicy(policy);
}
}
private void runPolicy(ResourcePolicy policy) {
log.tracev("Running policy {0}", policy.getProviderId());
// no actions -> skip
List<ResourceAction> actions = getActions(policy);
if (actions.isEmpty()) {
return;
}
ResourcePolicyProvider policyProvider = getPolicyProvider(policy);
ResourcePolicyStateProvider stateProvider = getResourcePolicyStateProvider();
// fetch all candidate lists for subsequent actions
// need to load all candidates before creation a state record for initial action
// if we don't do this, we risk executing more actions for single resource (user) in one run (in case the actions were modified by admin)
Map<String, List<String>> candidatesForAction = new HashMap<>();
for (int i = 1; i < actions.size(); i++) {
ResourceAction previousAction = actions.get(i - 1);
List<String> candidateIds = stateProvider.findResourceIdsByScheduledAction(policy.getId(), previousAction.getId());
candidatesForAction.put(actions.get(i).getId(), candidateIds);
}
// Process the Initial action (State Zero) - look for eligable users NOT present in the state table.
ResourceAction initialAction = actions.get(0);
ResourceActionProvider actionProvider = getActionProvider(initialAction);
log.tracev("Initial action {0}", initialAction.getProviderId());
List<String> newResourceIds = policyProvider.getEligibleResourcesForInitialAction(initialAction.getAfter());
log.tracev("Eligable resource IDs for initial action {0}", newResourceIds);
// <comment> todo: do we want to wrap it into separate tx? So we have more granular approach for handling errors & possible retries??
if (!newResourceIds.isEmpty()) {
// run action
runAction(actionProvider, newResourceIds);
// create state record
stateProvider.update(policy.getId(), policy.getProviderId(), newResourceIds, initialAction.getId());
}
// </comment>
// Process the rest of the actions
for (ResourceAction action : actions) {
// Find all resources that have completed the PREVIOUS action.
List<String> candidateIds = candidatesForAction.getOrDefault(action.getId(), Collections.emptyList());
if (candidateIds.isEmpty()) {
continue; // No users are at this stage yet.
}
// Ask the policyProvider to filter these candidates based on time.
List<String> eligibleIds = policyProvider.filterEligibleResources(candidateIds, action.getAfter());
// <comment> todo: do we want to wrap it into separate tx? So we have more granular approach for handling errors & possible retries??
if (!eligibleIds.isEmpty()) {
// Get the action provider and run the action on the eligible users.
actionProvider = getActionProvider(action);
runAction(actionProvider, eligibleIds);
// Update the state for the users that were processed.
stateProvider.update(policy.getId(), policy.getProviderId(), eligibleIds, action.getId());
}
// </comment>
}
}
private void runAction(ResourceActionProvider actionProvider, List<String> newResourceIds) {
actionProvider.run(newResourceIds == null ? List.of() : newResourceIds);
private ResourceAction getFirstAction(ResourcePolicy policy) {
return getActions(policy).get(0);
}
private ResourcePolicyProvider getPolicyProvider(ResourcePolicy policy) {
@ -245,11 +172,6 @@ public class ResourcePolicyManager {
return (ResourceActionProvider) actionFactory.create(session, getRealm().getComponent(action.getId()));
}
private ResourcePolicyStateProvider getResourcePolicyStateProvider() {
ProviderFactory<ResourcePolicyStateProvider> providerFactory = session.getKeycloakSessionFactory().getProviderFactory(ResourcePolicyStateProvider.class);
return providerFactory.create(session);
}
private RealmModel getRealm() {
return session.getContext().getRealm();
}
@ -296,10 +218,18 @@ public class ResourcePolicyManager {
});
}
public void scheduleAllEligibleResources(ResourcePolicy policy) {
ResourcePolicyProvider provider = getPolicyProvider(policy);
ResourceAction firstAction = getFirstAction(policy);
provider.getEligibleResourcesForInitialAction().forEach(resourceId -> {
// TODO run each scheduling task in a separate tx as other txs might schedule an action while this is running.
this.policyStateProvider.scheduleAction(policy, firstAction, resourceId);
});
}
public void processEvent(ResourcePolicyEvent event) {
ResourcePolicyStateProvider state = getResourcePolicyStateProvider();
List<String> currentlyAssignedPolicies = state.getScheduledActionsByResource(event.getResourceId())
List<String> currentlyAssignedPolicies = policyStateProvider.getScheduledActionsByResource(event.getResourceId())
.stream().map(ScheduledAction::policyId).toList();
List<ResourcePolicy> policies = this.getPolicies();
@ -311,40 +241,35 @@ public class ResourcePolicyManager {
if (!currentlyAssignedPolicies.contains(policy.getId())) {
// if policy is not assigned, check if the provider allows assigning based on the event
if (provider.scheduleOnEvent(event)) {
state.scheduleAction(policy, getFirstAction(policy), event.getResourceId());
policyStateProvider.scheduleAction(policy, getFirstAction(policy), event.getResourceId());
}
} else {
if (provider.resetOnEvent(event)) {
state.scheduleAction(policy, getFirstAction(policy), event.getResourceId());
policyStateProvider.scheduleAction(policy, getFirstAction(policy), event.getResourceId());
}
// TODO add a removeOnEvent to allow policies to detach from resources on specific events (e.g. unlinking an identity)
}
});
}
private ResourceAction getFirstAction(ResourcePolicy policy) {
return getActions(policy).get(0);
}
public void runScheduledTasks() {
for (ResourcePolicy policy : getPolicies()) {
ResourcePolicyStateProvider state = getResourcePolicyStateProvider();
for (ScheduledAction scheduled : state.getDueScheduledActions(policy)) {
for (ScheduledAction scheduled : policyStateProvider.getDueScheduledActions(policy)) {
List<ResourceAction> actions = getActions(policy);
for (int i = 0; i < actions.size(); i++) {
ResourceAction currentAction = actions.get(i);
if (currentAction.getId().equals(scheduled.actionId())) {
runAction(getActionProvider(currentAction), List.of(scheduled.resourceId()));
getActionProvider(currentAction).run(List.of(scheduled.resourceId()));
if (actions.size() > i + 1) {
// schedule the next action using the time offset difference between the actions.
ResourceAction nextAction = actions.get(i + 1);
state.scheduleAction(policy, nextAction,nextAction.getAfter() - currentAction.getAfter(), scheduled.resourceId());
policyStateProvider.scheduleAction(policy, nextAction,nextAction.getAfter() - currentAction.getAfter(), scheduled.resourceId());
} else {
state.remove(policy.getId(), scheduled.resourceId());
policyStateProvider.remove(policy.getId(), scheduled.resourceId());
}
}
}

View File

@ -18,22 +18,19 @@
package org.keycloak.tests.admin.model.policy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import jakarta.persistence.EntityManager;
import jakarta.ws.rs.BadRequestException;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.keycloak.common.util.Time;
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
@ -42,7 +39,6 @@ import org.keycloak.models.policy.NotifyUserActionProviderFactory;
import org.keycloak.models.policy.ResourceAction;
import org.keycloak.models.policy.ResourcePolicy;
import org.keycloak.models.policy.ResourcePolicyManager;
import org.keycloak.models.policy.ResourcePolicyStateEntity;
import org.keycloak.models.policy.ResourcePolicyStateProvider;
import org.keycloak.models.policy.UserActionBuilder;
import org.keycloak.models.policy.UserCreationTimeResourcePolicyProviderFactory;
@ -54,6 +50,7 @@ import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.ManagedUser;
import org.keycloak.testframework.realm.UserConfig;
import org.keycloak.testframework.realm.UserConfigBuilder;
import org.keycloak.testframework.remote.providers.runonserver.RunOnServer;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
@ -130,82 +127,6 @@ public class ResourcePolicyManagementTest {
// em.flush();
// }
runOnServer.run(session -> {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
RealmModel realm = configureSessionContext(session);
ResourcePolicyManager manager = new ResourcePolicyManager(session);
ResourcePolicyStateProvider stateProvider = session.getProvider(ResourcePolicyStateProvider.class);
UserModel user = session.users().addUser(realm, "test");
// Create a policy with two actions
ResourcePolicy policy = manager.addPolicy(new ResourcePolicy(UserCreationTimeResourcePolicyProviderFactory.ID));
ResourceAction notify = UserActionBuilder.builder(NotifyUserActionProviderFactory.ID).after(Duration.ofDays(5)).build();
ResourceAction disable = UserActionBuilder.builder(DisableUserActionProviderFactory.ID).after(Duration.ofDays(10)).build();
manager.updateActions(policy, List.of(notify, disable));
// Get the created actions to access their IDs
List<ResourceAction> createdActions = manager.getActions(policy);
ResourceAction createdNotifyAction = createdActions.get(0);
ResourceAction createdDisableAction = createdActions.get(1);
// --- SIMULATE USER PROGRESS ---
// Manually set the user's state to have completed 'notify'
stateProvider.update(policy.getId(), policy.getProviderId(), List.of(user.getId()), createdNotifyAction.getId());
ResourcePolicyStateEntity.PrimaryKey pk = new ResourcePolicyStateEntity.PrimaryKey(user.getId(), policy.getId());
assertNotNull(em.find(ResourcePolicyStateEntity.class, pk), "State should exist before the update.");
// Admin deletes 'notify' by updating the policy with only 'disable'
manager.updateActions(policy, List.of(createdDisableAction));
//need to flush and clear the persistance context cache to get correct result in next call
em.flush();
em.clear();
// The user's state record should have been deleted because its last_completed_action (notify) no longer exists.
assertNull(em.find(ResourcePolicyStateEntity.class, pk), "State record should be deleted when its completed action is removed.");
});
}
@Test
public void testPolicyDoesNotFallThroughActionsInSingleRun() {
runOnServer.run(session -> {
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
RealmModel realm = configureSessionContext(session);
ResourcePolicyManager manager = new ResourcePolicyManager(session);
UserModel user = session.users().addUser(realm, "testuser");
user.setEnabled(true);
// Create a policy with notify (5 days) and disable (10 days) actions
ResourcePolicy policy = manager.addPolicy(new ResourcePolicy(UserCreationTimeResourcePolicyProviderFactory.ID));
ResourceAction notifyAction = UserActionBuilder.builder(NotifyUserActionProviderFactory.ID).after(Duration.ofDays(5)).build();
ResourceAction disableAction = UserActionBuilder.builder(DisableUserActionProviderFactory.ID).after(Duration.ofDays(10)).build();
manager.updateActions(policy, List.of(notifyAction, disableAction));
ResourceAction createdNotifyAction = manager.getActions(policy).get(0);
try {
// Simulate the user being 12 days old, making them eligible for both actions' time conditions.
Time.setOffset(Math.toIntExact(Duration.ofDays(12).toSeconds()));
manager.runPolicies();
user = session.users().getUserById(realm, user.getId());
// Verify that ONLY the first action (notify) was executed.
assertNotNull(user.getAttributes().get("message"), "The first action (notify) should have run.");
assertTrue(user.isEnabled(), "The second action (disable) should NOT have run.");
// Verify that the user's state is correctly paused after the first action.
ResourcePolicyStateEntity.PrimaryKey pk = new ResourcePolicyStateEntity.PrimaryKey(user.getId(), policy.getId());
ResourcePolicyStateEntity state = em.find(ResourcePolicyStateEntity.class, pk);
assertNotNull(state, "A state record should have been created for the user.");
assertEquals(createdNotifyAction.getId(), state.getScheduledActionId(), "The user's state should be at the first action.");
} finally {
Time.setOffset(0);
}
});
}
@Test
@ -230,6 +151,173 @@ public class ResourcePolicyManagementTest {
});
}
@Test
public void testPolicyDoesNotFallThroughActionsInSingleRun() {
// register policy to notify user in 5 days and disable in 10 days
runOnServer.run((RunOnServer) session -> {
configureSessionContext(session);
PolicyBuilder.create()
.of(UserCreationTimeResourcePolicyProviderFactory.ID)
.withActions(
UserActionBuilder.builder(NotifyUserActionProviderFactory.ID)
.after(Duration.ofDays(5))
.build(),
UserActionBuilder.builder(DisableUserActionProviderFactory.ID)
.after(Duration.ofDays(10))
.build()
).build(session);
});
// create a new user - should bind the user to the policy and setup the first action
managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser").build());
runOnServer.run((RunOnServer) session -> {
RealmModel realm = configureSessionContext(session);
ResourcePolicyManager manager = new ResourcePolicyManager(session);
UserModel user = session.users().getUserByUsername(realm,"testuser");
List<ResourcePolicy> registeredPolicies = manager.getPolicies();
assertEquals(1, registeredPolicies.size());
ResourcePolicy policy = registeredPolicies.get(0);
assertEquals(2, manager.getActions(policy).size());
ResourceAction notifyAction = manager.getActions(policy).get(0);
ResourcePolicyStateProvider stateProvider = session.getKeycloakSessionFactory().getProviderFactory(ResourcePolicyStateProvider.class).create(session);
ResourcePolicyStateProvider.ScheduledAction scheduledAction = stateProvider.getScheduledAction(policy.getId(), user.getId());
assertNotNull(scheduledAction, "An action should have been scheduled for the user " + user.getUsername());
assertEquals(notifyAction.getId(), scheduledAction.actionId());
try {
// Simulate the user being 12 days old, making them eligible for both actions' time conditions.
Time.setOffset(Math.toIntExact(Duration.ofDays(12).toSeconds()));
manager.runScheduledTasks();
user = session.users().getUserById(realm, user.getId());
// Verify that ONLY the first action (notify) was executed.
assertNotNull(user.getAttributes().get("message"), "The first action (notify) should have run.");
assertTrue(user.isEnabled(), "The second action (disable) should NOT have run.");
// Verify that the next action was scheduled for the user
ResourceAction disableAction = manager.getActions(policy).get(1);
scheduledAction = stateProvider.getScheduledAction(policy.getId(), user.getId());
assertNotNull(scheduledAction, "An action should have been scheduled for the user " + user.getUsername());
assertEquals(disableAction.getId(), scheduledAction.actionId(), "The second action should have been scheduled");
} finally {
Time.setOffset(0);
}
});
}
@Test
public void testAssignPolicyToExistingResources() {
// create some realm users
for (int i = 0; i < 10; i++) {
managedRealm.admin().users().create(UserConfigBuilder.create().username("user-" + i).build());
}
// create some users associated with a federated identity
for (int i = 0; i < 10; i++) {
managedRealm.admin().users().create(UserConfigBuilder.create().username("idp-user-" + i)
.federatedLink("someidp", UUID.randomUUID().toString(), "idp-user-" + i).build());
}
// register a policy to notify user in 5 days
runOnServer.run((RunOnServer) session -> {
configureSessionContext(session);
PolicyBuilder.create()
.of(UserCreationTimeResourcePolicyProviderFactory.ID)
.withConfig("broker-aliases", "someidp")
.withActions(
UserActionBuilder.builder(NotifyUserActionProviderFactory.ID)
.after(Duration.ofDays(5))
.build(),
UserActionBuilder.builder(DisableUserActionProviderFactory.ID)
.after(Duration.ofDays(10))
.build()
).build(session);
});
// now with the policy in place, let's create a couple more idp users - these will be attached to the policy on
// creation.
for (int i = 0; i < 3; i++) {
managedRealm.admin().users().create(UserConfigBuilder.create().username("new-idp-user-" + i)
.federatedLink("someidp", UUID.randomUUID().toString(), "new-idp-user-" + i).build());
}
// new realm users created after the policy - these should not be attached to the policy because they are not idp users.
for (int i = 0; i < 3; i++) {
managedRealm.admin().users().create(UserConfigBuilder.create().username("new-user-" + i).build());
}
runOnServer.run((RunOnServer) session -> {
RealmModel realm = configureSessionContext(session);
ResourcePolicyManager policyManager = new ResourcePolicyManager(session);
List<ResourcePolicy> registeredPolicies = policyManager.getPolicies();
assertEquals(1, registeredPolicies.size());
ResourcePolicy policy = registeredPolicies.get(0);
assertEquals(2, policyManager.getActions(policy).size());
ResourceAction notifyAction = policyManager.getActions(policy).get(0);
// check no policies are yet attached to the previous users, only to the ones created after the policy was in place
ResourcePolicyStateProvider stateProvider = session.getKeycloakSessionFactory().getProviderFactory(ResourcePolicyStateProvider.class).create(session);
List<ResourcePolicyStateProvider.ScheduledAction> scheduledActions = stateProvider.getScheduledActionsByPolicy(policy);
assertEquals(3, scheduledActions.size());
scheduledActions.forEach(scheduledAction -> {
assertEquals(notifyAction.getId(), scheduledAction.actionId());
UserModel user = session.users().getUserById(realm, scheduledAction.resourceId());
assertNotNull(user);
assertTrue(user.getUsername().startsWith("new-idp-user-"));
});
try {
// let's run the schedule actions for the new users so they transition to the next one.
Time.setOffset(Math.toIntExact(Duration.ofDays(6).toSeconds()));
policyManager.runScheduledTasks();
// check the same users are now scheduled to run the second action.
ResourceAction disableAction = policyManager.getActions(policy).get(1);
scheduledActions = stateProvider.getScheduledActionsByPolicy(policy);
assertEquals(3, scheduledActions.size());
scheduledActions.forEach(scheduledAction -> {
assertEquals(disableAction.getId(), scheduledAction.actionId());
UserModel user = session.users().getUserById(realm, scheduledAction.resourceId());
assertNotNull(user);
assertTrue(user.getUsername().startsWith("new-idp-user-"));
});
// assign the policy to the eligible users - i.e. only users from the same idp who are not yet assigned to the policy.
policyManager.scheduleAllEligibleResources(policy);
// check policy was correctly assigned to the old users, not affecting users already associated with the policy.
scheduledActions = stateProvider.getScheduledActionsByPolicy(policy);
assertEquals(13, scheduledActions.size());
List<ResourcePolicyStateProvider.ScheduledAction> scheduledToNotify = scheduledActions.stream()
.filter(action -> notifyAction.getId().equals(action.actionId())).toList();
assertEquals(10, scheduledToNotify.size());
scheduledToNotify.forEach(scheduledAction -> {
UserModel user = session.users().getUserById(realm, scheduledAction.resourceId());
assertNotNull(user);
assertTrue(user.getUsername().startsWith("idp-user-"));
});
List<ResourcePolicyStateProvider.ScheduledAction> scheduledToDisable = scheduledActions.stream()
.filter(action -> disableAction.getId().equals(action.actionId())).toList();
assertEquals(3, scheduledToDisable.size());
scheduledToDisable.forEach(scheduledAction -> {
UserModel user = session.users().getUserById(realm, scheduledAction.resourceId());
assertNotNull(user);
assertTrue(user.getUsername().startsWith("new-idp-user-"));
});
} finally {
Time.setOffset(0);
}
});
}
private static RealmModel configureSessionContext(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(REALM_NAME);
session.getContext().setRealm(realm);