Initial commit for the RLM feature

Closes #40340
Closes #40341

Co-authored-by: Stefan Guilhen <sguilhen@redhat.com>
Co-authored-by: Pedro Igor <pigor.craveiro@gmail.com>

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
vramik 2025-07-29 20:44:00 +02:00 committed by Pedro Igor
parent 20cb13e8dc
commit a8225655cf
54 changed files with 3298 additions and 0 deletions

View file

@ -136,6 +136,8 @@ public class Profile {
ROLLING_UPDATES_V1("Rolling Updates", Type.DEFAULT, 1),
ROLLING_UPDATES_V2("Rolling Updates for patch releases", Type.PREVIEW, 2),
RESOURCE_LIFECYCLE("Resource lifecycle management", Type.EXPERIMENTAL),
LOG_MDC("Mapped Diagnostic Context (MDC) information in logs", Type.PREVIEW),
/**

View file

@ -29,6 +29,7 @@ import org.keycloak.models.SubjectCredentialManager;
import org.keycloak.models.UserModel;
import org.keycloak.models.cache.CachedUserModel;
import org.keycloak.models.cache.infinispan.entities.CachedUser;
import org.keycloak.models.policy.ResourcePolicyManager;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.RoleUtils;
@ -471,6 +472,17 @@ public class UserAdapter implements CachedUserModel {
return cached.getGroups(keycloakSession, modelSupplier).contains(group.getId()) || RoleUtils.isMember(getGroupsStream(), group);
}
@Override
public void setLastSessionRefreshTime(int lastSessionRefreshTime) {
if (ResourcePolicyManager.isFeatureEnabled()) {
UserModel delegate = modelSupplier.get();
if (delegate != null) {
delegate.setLastSessionRefreshTime(lastSessionRefreshTime);
}
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View file

@ -252,6 +252,8 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi
entity.setStarted(currentTime);
entity.setLastSessionRefresh(currentTime);
user.setLastSessionRefreshTime(entity.getLastSessionRefresh());
}
@Override

View file

@ -264,6 +264,8 @@ public class UserSessionAdapter<T extends SessionRefreshStore & UserSessionProvi
}
};
getUser().setLastSessionRefreshTime(lastSessionRefresh);
update(task);
}

View file

@ -272,6 +272,8 @@ public class UserSessionEntity extends SessionEntity {
entity.setStarted(currentTime);
entity.setLastSessionRefresh(currentTime);
user.setLastSessionRefreshTime(entity.getLastSessionRefresh());
}
public static UserSessionEntity createFromModel(UserSessionModel userSession) {

View file

@ -38,6 +38,7 @@ 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;
@ -576,6 +577,12 @@ public class UserAdapter implements UserModel, JpaModel<UserEntity> {
return new UserCredentialManager(session, realm, this);
}
@Override
public void setLastSessionRefreshTime(int lastSessionRefreshTime) {
if (ResourcePolicyManager.isFeatureEnabled()) {
user.setLastSessionRefreshTime(lastSessionRefreshTime);
}
}
@Override
public boolean equals(Object o) {

View file

@ -91,6 +91,9 @@ public class UserEntity {
@Column(name = "REALM_ID")
protected String realmId;
@Column(name = "LAST_SESSION_REFRESH_TIME")
private Integer lastSessionRefreshTime;
// Explicitly not using OrphanRemoval as we're handling the removal manually through HQL but at the same time we still
// want to remove elements from the entity's collection in a manual way. Without this, Hibernate would do a duplicit
// delete query.
@ -226,6 +229,14 @@ public class UserEntity {
this.realmId = realmId;
}
public Integer getLastSessionRefreshTime() {
return lastSessionRefreshTime;
}
public void setLastSessionRefreshTime(int lastAuthenticationTime) {
this.lastSessionRefreshTime = lastAuthenticationTime;
}
public Collection<CredentialEntity> getCredentials() {
if (credentials == null) {
credentials = new LinkedList<>();

View file

@ -0,0 +1,112 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import jakarta.persistence.EntityManager;
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 java.util.Collections;
import java.util.List;
import org.keycloak.component.ComponentModel;
import org.keycloak.connections.jpa.JpaConnectionProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.jpa.entities.UserEntity;
public abstract class AbstractUserResourcePolicyProvider implements ResourcePolicyProvider {
private final ComponentModel policyModel;
private final EntityManager em;
public AbstractUserResourcePolicyProvider(KeycloakSession session, ComponentModel model) {
this.policyModel = model;
this.em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
}
public abstract Predicate timePredicate(long time, CriteriaBuilder cb, CriteriaQuery<String> query, Root<UserEntity> userRoot);
@Override
public void close() {
// no-op
}
// 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 eligable for initial action
@Override
public List<String> getEligibleResourcesForInitialAction(long time) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<String> query = cb.createQuery(String.class);
Root<UserEntity> userRoot = query.from(UserEntity.class);
// 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.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));
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();
}
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"));
return em.createQuery(query).getResultList();
}
protected EntityManager getEntityManager() {
return em;
}
public ComponentModel getModel() {
return policyModel;
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import java.util.ArrayList;
import java.util.List;
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.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.jpa.entities.FederatedIdentityEntity;
import org.keycloak.models.jpa.entities.UserEntity;
public class FederatedIdentityPolicyProvider extends UserLastSessionRefreshTimeResourcePolicyProvider {
public FederatedIdentityPolicyProvider(KeycloakSession session, ComponentModel model) {
super(session, model);
}
@Override
public Predicate timePredicate(long time, CriteriaBuilder cb, CriteriaQuery<String> query, Root<UserEntity> userRoot) {
Predicate lastSessionRefreshTimePredicate = super.timePredicate(time, cb, query, userRoot);
Predicate federatedIdentityByBrokerPredicate = createFederatedIdentityByBrokerPredicate(cb, query, userRoot);
return cb.and(lastSessionRefreshTimePredicate, federatedIdentityByBrokerPredicate);
}
private Predicate createFederatedIdentityByBrokerPredicate(CriteriaBuilder cb, CriteriaQuery<String> query, Root<UserEntity> userRoot) {
Subquery<Integer> subquery = query.subquery(Integer.class);
Root<?> from = subquery.from(FederatedIdentityEntity.class);
subquery.select(cb.literal(1));
List<Predicate> finalPredicates = new ArrayList<>();
finalPredicates.add(cb.equal(from.get("user").get("id"), userRoot.get("id")));
finalPredicates.add(from.get("identityProvider").in(getBrokerAliases()));
subquery.where(finalPredicates.toArray(Predicate[]::new));
return cb.exists(subquery);
}
private List<String> getBrokerAliases() {
return getModel().getConfig().getOrDefault("broker-aliases", List.of());
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
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 FederatedIdentityPolicyProviderFactory implements ResourcePolicyProviderFactory<FederatedIdentityPolicyProvider> {
public static final String ID = "federated-identity-policy";
@Override
public ResourceType getType() {
return ResourceType.USERS;
}
@Override
public FederatedIdentityPolicyProvider create(KeycloakSession session, ComponentModel model) {
return new FederatedIdentityPolicyProvider(session, model);
}
@Override
public void init(Config.Scope config) {
// no-op
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
// no-op
}
@Override
public String getId() {
return ID;
}
@Override
public String getHelpText() {
return "";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
}

View file

@ -0,0 +1,146 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import java.util.List;
import java.util.Set;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaDelete;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.jboss.logging.Logger;
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;
public class JpaResourcePolicyStateProvider implements ResourcePolicyStateProvider {
private final EntityManager em;
private static final Logger LOGGER = Logger.getLogger(JpaResourcePolicyStateProvider.class);
private final KeycloakSession session;
public JpaResourcePolicyStateProvider(KeycloakSession session) {
this.session = session;
this.em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
}
@Override
public List<String> findResourceIdsByLastCompletedAction(String policyId, String lastCompletedActionId) {
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("lastCompletedActionId"), lastCompletedActionId);
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) {
ResourcePolicyStateEntity.PrimaryKey pk = new ResourcePolicyStateEntity.PrimaryKey(resourceId, policyId);
ResourcePolicyStateEntity entity = em.find(ResourcePolicyStateEntity.class, pk);
if (entity == null) {
if (LOGGER.isTraceEnabled()) {
LOGGER.tracev("Initial record for policyId ({0}), new_last_compl_actionId ({1}), userId ({2})", policyId, newLastCompletedActionId, resourceId);
}
entity = new ResourcePolicyStateEntity();
entity.setResourceId(resourceId);
entity.setPolicyId(policyId);
entity.setPolicyProviderId(policyProviderId);
em.persist(entity);
} else {
if (LOGGER.isTraceEnabled()) {
LOGGER.tracev("Changing record for policyId ({0}), last_compl_actionId ({1}), new_last_compl_actionId ({2}), userId ({3})",
entity.getPolicyId(), entity.getLastCompletedActionId(), newLastCompletedActionId, resourceId);
}
}
entity.setLastCompletedActionId(newLastCompletedActionId);
entity.setLastUpdatedTimestamp(Time.currentTimeMillis());
}
}
@Override
public void removeByCompletedActions(String policyId, Set<String> deletedActionIds) {
if (deletedActionIds == null || deletedActionIds.isEmpty()) {
return;
}
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaDelete<ResourcePolicyStateEntity> delete = cb.createCriteriaDelete(ResourcePolicyStateEntity.class);
Root<ResourcePolicyStateEntity> stateRoot = delete.from(ResourcePolicyStateEntity.class);
Predicate policyPredicate = cb.equal(stateRoot.get("policyId"), policyId);
Predicate inClausePredicate = stateRoot.get("lastCompletedActionId").in(deletedActionIds);
delete.where(cb.and(policyPredicate, inClausePredicate));
int deletedCount = em.createQuery(delete).executeUpdate();
if (LOGGER.isTraceEnabled()) {
if (deletedCount > 0) {
LOGGER.tracev("Deleted {0} orphaned state records for policy {1}", deletedCount, policyId);
}
}
}
@Override
public void removeByUser(UserModel user) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaDelete<ResourcePolicyStateEntity> delete = cb.createCriteriaDelete(ResourcePolicyStateEntity.class);
Root<ResourcePolicyStateEntity> root = delete.from(ResourcePolicyStateEntity.class);
delete.where(cb.equal(root.get("resourceId"), user.getId()));
int deletedCount = em.createQuery(delete).executeUpdate();
if (LOGGER.isTraceEnabled()) {
if (deletedCount > 0) {
LOGGER.tracev("Deleted {0} orphaned state records for user {1}", deletedCount, user.getId());
}
}
}
@Override
public void removeAll() {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaDelete<ResourcePolicyStateEntity> delete = cb.createCriteriaDelete(ResourcePolicyStateEntity.class);
int deletedCount = em.createQuery(delete).executeUpdate();
if (LOGGER.isTraceEnabled()) {
if (deletedCount > 0) {
RealmModel realm = session.getContext().getRealm();
LOGGER.tracev("Deleted {0} state records for realm {1}", deletedCount, realm.getId());
}
}
}
@Override
public void close() {
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel.RealmRemovedEvent;
import org.keycloak.models.UserModel.UserRemovedEvent;
public class JpaResourcePolicyStateProviderFactory implements ResourcePolicyStateProviderFactory {
public static final String PROVIDER_ID = "jpa";
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
factory.register(fired -> {
if (fired instanceof UserRemovedEvent event) {
onUserRemovedEvent(event);
} if (fired instanceof RealmRemovedEvent event) {
onRealmRemovedEvent(event);
}
});
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public ResourcePolicyStateProvider create(KeycloakSession session) {
return new JpaResourcePolicyStateProvider(session);
}
@Override
public void close() {
}
private void onRealmRemovedEvent(RealmRemovedEvent event) {
KeycloakSession session = event.getKeycloakSession();
ResourcePolicyStateProvider provider = session.getProvider(ResourcePolicyStateProvider.class);
provider.removeAll();
}
private void onUserRemovedEvent(UserRemovedEvent event) {
KeycloakSession session = event.getKeycloakSession();
ResourcePolicyStateProvider provider = session.getProvider(ResourcePolicyStateProvider.class);
provider.removeByUser(event.getUser());
}
}

View file

@ -0,0 +1,160 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import java.io.Serializable;
import java.util.Objects;
/**
* Represents the state of a resource within a time-based policy flow.
*/
@Entity
@Table(name = "RESOURCE_POLICY_STATE")
@IdClass(ResourcePolicyStateEntity.PrimaryKey.class)
public class ResourcePolicyStateEntity {
@Id
@Column(name = "RESOURCE_ID")
private String resourceId;
@Id
@Column(name = "POLICY_ID")
private String policyId;
@Column(name = "RESOURCE_TYPE")
private String resourceType; // do we want/need to store this?
@Column(name = "POLICY_PROVIDER_ID")
private String policyProviderId;
@Column(name = "LAST_COMPLETED_ACTION_ID")
private String lastCompletedActionId;
@Column(name = "LAST_UPDATED_TIMESTAMP")// might be useful?? - audit?
private long lastUpdatedTimestamp;
public String getResourceId() {
return resourceId;
}
public void setResourceId(String resourceId) {
this.resourceId = resourceId;
}
public String getPolicyId() {
return policyId;
}
public void setPolicyId(String policyId) {
this.policyId = policyId;
}
public String getPolicyProviderId() {
return policyProviderId;
}
public void setPolicyProviderId(String policyProviderId) {
this.policyProviderId = policyProviderId;
}
public String getResourceType() {
return resourceType;
}
public void setResourceType(String resourceType) {
this.resourceType = resourceType;
}
public String getLastCompletedActionId() {
return lastCompletedActionId;
}
public void setLastCompletedActionId(String lastCompletedActionId) {
this.lastCompletedActionId = lastCompletedActionId;
}
public long getLastUpdatedTimestamp() {
return lastUpdatedTimestamp;
}
public void setLastUpdatedTimestamp(long lastUpdatedTimestamp) {
this.lastUpdatedTimestamp = lastUpdatedTimestamp;
}
public static class PrimaryKey implements Serializable {
private String resourceId;
private String policyId;
public PrimaryKey() {
}
public PrimaryKey(String resourceId, String policyId) {
this.resourceId = resourceId;
this.policyId = policyId;
}
public String getResourceId() {
return resourceId;
}
public void setResourceId(String resourceId) {
this.resourceId = resourceId;
}
public String getPolicyId() {
return policyId;
}
public void setPolicyId(String policyId) {
this.policyId = policyId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PrimaryKey that = (PrimaryKey) o;
return Objects.equals(resourceId, that.resourceId) && Objects.equals(policyId, that.policyId);
}
@Override
public int hashCode() {
return Objects.hash(resourceId, policyId);
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ResourcePolicyStateEntity that = (ResourcePolicyStateEntity) o;
return Objects.equals(resourceId, that.resourceId) && Objects.equals(policyId, that.policyId);
}
@Override
public int hashCode() {
return Objects.hash(resourceId, policyId);
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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;
public class UserCreationDateResourcePolicyProvider extends AbstractUserResourcePolicyProvider {
public UserCreationDateResourcePolicyProvider(KeycloakSession session, ComponentModel model) {
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));
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
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 UserCreationDateResourcePolicyProviderFactory implements ResourcePolicyProviderFactory<UserCreationDateResourcePolicyProvider> {
public static final String ID = "user-creation-date-resource-policy";
@Override
public ResourceType getType() {
return ResourceType.USERS;
}
@Override
public UserCreationDateResourcePolicyProvider create(KeycloakSession session, ComponentModel model) {
return new UserCreationDateResourcePolicyProvider(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 String getHelpText() {
return "";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import java.time.Duration;
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;
public class UserLastSessionRefreshTimeResourcePolicyProvider extends AbstractUserResourcePolicyProvider {
public UserLastSessionRefreshTimeResourcePolicyProvider(KeycloakSession session, ComponentModel model) {
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)));
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
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 UserLastSessionRefreshTimeResourcePolicyProviderFactory implements ResourcePolicyProviderFactory<UserLastSessionRefreshTimeResourcePolicyProvider> {
public static final String ID = "user-last-auth-time-resource-policy";
@Override
public ResourceType getType() {
return ResourceType.USERS;
}
@Override
public UserLastSessionRefreshTimeResourcePolicyProvider create(KeycloakSession session, ComponentModel model) {
return new UserLastSessionRefreshTimeResourcePolicyProvider(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 String getHelpText() {
return "";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
}

View file

@ -28,4 +28,54 @@
</createIndex>
</changeSet>
<changeSet id="40343-RLM-resource-policy-state-table" author="keycloak">
<createTable tableName="RESOURCE_POLICY_STATE">
<column name="RESOURCE_ID" type="VARCHAR(255)">
<constraints nullable="false" />
</column>
<column name="POLICY_ID" type="VARCHAR(255)">
<constraints nullable="false" />
</column>
<column name="POLICY_PROVIDER_ID" type="VARCHAR(255)" />
<column name="RESOURCE_TYPE" type="VARCHAR(255)" />
<column name="LAST_COMPLETED_ACTION_ID" type="VARCHAR(255)" />
<column name="LAST_UPDATED_TIMESTAMP" type="BIGINT" />
</createTable>
<addPrimaryKey
constraintName="PK_RESOURCE_POLICY_STATE"
tableName="RESOURCE_POLICY_STATE"
columnNames="RESOURCE_ID, POLICY_ID" />
<createIndex indexName="IDX_RES_POLICY_STATE_ACTION"
tableName="RESOURCE_POLICY_STATE">
<column name="POLICY_ID" />
<column name="LAST_COMPLETED_ACTION_ID" />
</createIndex>
<createIndex indexName="IDX_RES_POLICY_STATE_PROVIDER"
tableName="RESOURCE_POLICY_STATE">
<column name="RESOURCE_ID" />
<column name="POLICY_PROVIDER_ID" />
</createIndex>
<!-- constraint to ensure that each resource has only one policy of a specific type -->
<addUniqueConstraint
constraintName="UC_RES_POLICY_PROVIDER_ID"
tableName="RESOURCE_POLICY_STATE"
columnNames="RESOURCE_ID, POLICY_PROVIDER_ID"
/>
</changeSet>
<changeSet id="RLM-add-last-session-refresh-time-to-user-entity" author="keycloak">
<addColumn tableName="USER_ENTITY">
<column name="LAST_SESSION_REFRESH_TIME" type="BIGINT" />
</addColumn>
<createIndex indexName="IDX_USER_LAST_SESSION_REFRESH_TIME"
tableName="USER_ENTITY">
<column name="LAST_SESSION_REFRESH_TIME" />
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -0,0 +1,20 @@
#
# Copyright 2025 Red Hat, Inc. and/or its affiliates
# and other contributors as indicated by the @author tags.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
org.keycloak.models.policy.UserCreationDateResourcePolicyProviderFactory
org.keycloak.models.policy.UserLastSessionRefreshTimeResourcePolicyProviderFactory
org.keycloak.models.policy.FederatedIdentityPolicyProviderFactory

View file

@ -0,0 +1,18 @@
#
# Copyright 2025 Red Hat, Inc. and/or its affiliates
# and other contributors as indicated by the @author tags.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
org.keycloak.models.policy.JpaResourcePolicyStateProviderFactory

View file

@ -92,6 +92,9 @@
<!-- Server Configuration -->
<class>org.keycloak.storage.configuration.jpa.entity.ServerConfigEntity</class>
<!-- Resource Lifecycle Management -->
<class>org.keycloak.models.policy.ResourcePolicyStateEntity</class>
<exclude-unlisted-classes>true</exclude-unlisted-classes>
<properties>

View file

@ -0,0 +1,57 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import org.keycloak.models.UserModel;
import org.keycloak.provider.Provider;
import java.util.List;
import java.util.Set;
/**
* Interface serves as state check for policy actions.
*/
public interface ResourcePolicyStateProvider extends Provider {
/**
* Finds resource IDs that have completed a specific action within a policy.
*/
List<String> findResourceIdsByLastCompletedAction(String policyId, String lastCompletedActionId);
/**
* 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.
*/
void update(String policyId, String policyProviderId, List<String> resourceIds, String newLastCompletedActionId);
/**
* Deletes the orphaned state records.
*/
void removeByCompletedActions(String policyId, Set<String> deletedActionIds);
/**
* Deletes the state records associated with the given {@code user}.
*
* @param user the user
*/
void removeByUser(UserModel user);
/**
* Deletes all state records associated with the current realm bound to the session.
*/
void removeAll();
}

View file

@ -0,0 +1,32 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderFactory;
public interface ResourcePolicyStateProviderFactory extends ProviderFactory<ResourcePolicyStateProvider>, EnvironmentDependentProviderFactory {
@Override
default boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.RESOURCE_LIFECYCLE);
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ResourcePolicyStateSpi implements Spi {
public static final String NAME = "resource-policy-state";
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return ResourcePolicyStateProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return ResourcePolicyStateProviderFactory.class;
}
}

View file

@ -26,3 +26,4 @@ org.keycloak.storage.clientscope.ClientScopeStorageProviderSpi
org.keycloak.models.session.UserSessionPersisterSpi
org.keycloak.models.session.RevokedTokenPersisterSpi
org.keycloak.cluster.ClusterSpi
org.keycloak.models.policy.ResourcePolicyStateSpi

View file

@ -400,6 +400,11 @@ public abstract class AbstractUserAdapter extends UserModelDefaultMethods {
}
@Override
public void setLastSessionRefreshTime(int lastSessionRefreshTime) {
// no-op
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View file

@ -404,6 +404,11 @@ public abstract class AbstractUserAdapterFederatedStorage extends UserModelDefau
return new UserCredentialManager(session, realm, this);
}
@Override
public void setLastSessionRefreshTime(int lastSessionRefreshTime) {
// no-op
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View file

@ -0,0 +1,98 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel;
public class ResourceAction implements Comparable<ResourceAction> {
private static final String AFTER_KEY = "after";
private static final String PRIORITY_KEY = "priority";
private String id;
private String providerId;
private MultivaluedHashMap<String, String> config;
public ResourceAction() {
// reflection
}
public ResourceAction(String providerId) {
this.providerId = providerId;
}
public ResourceAction(ComponentModel model) {
this.id = model.getId();
this.providerId = model.getProviderId();
this.config = model.getConfig();
}
public String getId() {
return id;
}
public String getProviderId() {
return providerId;
}
public ResourceAction setConfig(String key, String value) {
if (config == null) {
config = new MultivaluedHashMap<>();
}
this.config.putSingle(key, value);
return this;
}
public MultivaluedHashMap<String, String> getConfig() {
if (config == null) {
return new MultivaluedHashMap<>();
}
return config;
}
public void setPriority(int priority) {
setConfig(PRIORITY_KEY, String.valueOf(priority));
}
// todo, do not expose the priority to user?? in export etc. the order of actions should define the priority
public int getPriority() {
String value = getConfig().getFirst(PRIORITY_KEY);
if (value == null) {
return Integer.MAX_VALUE;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException ignored) {
return Integer.MAX_VALUE;
}
}
public void setAfter(Long ms) {
setConfig(AFTER_KEY, String.valueOf(ms));
}
public Long getAfter() {
return Long.valueOf(getConfig().getFirstOrDefault(AFTER_KEY, "0"));
}
@Override
public int compareTo(ResourceAction other) {
return Integer.compare(this.getPriority(), other.getPriority());
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import java.util.List;
import org.keycloak.provider.Provider;
public interface ResourceActionProvider extends Provider {
void run(List<String> resourceIds);
boolean isRunnable();
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.component.ComponentFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
public interface ResourceActionProviderFactory<P extends ResourceActionProvider> extends ComponentFactory<P, ResourceActionProvider>, EnvironmentDependentProviderFactory {
ResourceType getType();
@Override
default boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.RESOURCE_LIFECYCLE);
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ResourceActionSpi implements Spi {
public static String NAME = "rlm-action";
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return ResourceActionProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return ResourceActionProviderFactory.class;
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import java.util.List;
import java.util.Map;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel;
public class ResourcePolicy {
private MultivaluedHashMap<String, String> config;
private String providerId;
private String id;
public ResourcePolicy() {
// reflection
}
public ResourcePolicy(String providerId) {
this.providerId = providerId;
this.id = null;
this.config = null;
}
public ResourcePolicy(ComponentModel c) {
this.id = c.getId();
this.providerId = c.getProviderId();
this.config = c.getConfig();
}
public ResourcePolicy(String providerId, Map<String, List<String>> config) {
this.providerId = providerId;
MultivaluedHashMap<String, String> c = new MultivaluedHashMap<>();
config.forEach(c::addAll);
this.config = c;
}
public String getId() {
return id;
}
public String getProviderId() {
return providerId;
}
public MultivaluedHashMap<String, String> getConfig() {
return config;
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import java.util.List;
import org.keycloak.provider.Provider;
/**
* TODO: Maybe we want to split the provider into two???
* * Time based
* ** UserCreationDatePolicyProvider, LastAuthenticationTimePolicyProvider ...
* * Origin based
* ** IdpResourceFilterProvider, LdapResourceFilterProvider, AllResourceFilterProvider
*
*/
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);
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.component.ComponentFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
public interface ResourcePolicyProviderFactory<P extends ResourcePolicyProvider> extends ComponentFactory<P, ResourcePolicyProvider>, EnvironmentDependentProviderFactory {
ResourceType getType();
@Override
default boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.RESOURCE_LIFECYCLE);
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ResourcePolicySpi implements Spi {
public static final String NAME = "rlm-policy";
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return ResourcePolicyProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return ResourcePolicyProviderFactory.class;
}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
public enum ResourceType {
USERS
}

View file

@ -292,6 +292,11 @@ public abstract class AbstractInMemoryUserAdapter extends UserModelDefaultMethod
}
@Override
public void setLastSessionRefreshTime(int lastSessionRefreshTime) {
// no-op
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View file

@ -105,3 +105,5 @@ org.keycloak.cookie.CookieSpi
org.keycloak.organization.OrganizationSpi
org.keycloak.securityprofile.SecurityProfileSpi
org.keycloak.logging.MappedDiagnosticContextSpi
org.keycloak.models.policy.ResourceActionSpi
org.keycloak.models.policy.ResourcePolicySpi

View file

@ -232,6 +232,8 @@ public interface UserModel extends RoleMapperModel {
*/
SubjectCredentialManager credentialManager();
void setLastSessionRefreshTime(int lastSessionRefreshTime);
enum RequiredAction {
VERIFY_EMAIL,
UPDATE_PROFILE,

View file

@ -251,6 +251,11 @@ public class UserModelDelegate implements UserModel {
return delegate.isMemberOf(group);
}
@Override
public void setLastSessionRefreshTime(int lastSessionRefreshTime) {
delegate.setLastSessionRefreshTime(lastSessionRefreshTime);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View file

@ -0,0 +1,63 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
public class DeleteUserActionProvider implements ResourceActionProvider {
private final KeycloakSession session;
private final ComponentModel actionModel;
private final Logger log = Logger.getLogger(DeleteUserActionProvider.class);
public DeleteUserActionProvider(KeycloakSession session, ComponentModel model) {
this.session = session;
this.actionModel = model;
}
@Override
public void close() {
}
@Override
public void run(List<String> ids) {
RealmModel realm = session.getContext().getRealm();
for (String id : ids) {
UserModel user = session.users().getUserById(realm, id);
if (user == null) {
continue;
}
log.debugv("Deleting user {0} ({1})", user.getUsername(), user.getId());
session.users().removeUser(realm, user);
}
}
@Override
public boolean isRunnable() {
return actionModel.get("after") != null;
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
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 DeleteUserActionProviderFactory implements ResourceActionProviderFactory<DeleteUserActionProvider> {
public static final String ID = "delete-user-action-provider";
@Override
public DeleteUserActionProvider create(KeycloakSession session, ComponentModel model) {
return new DeleteUserActionProvider(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 "";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
public class DisableUserActionProvider implements ResourceActionProvider {
private final KeycloakSession session;
private final ComponentModel actionModel;
private final Logger log = Logger.getLogger(DisableUserActionProvider.class);
public DisableUserActionProvider(KeycloakSession session, ComponentModel model) {
this.session = session;
this.actionModel = model;
}
@Override
public void close() {
}
@Override
public void run(List<String> userIds) {
RealmModel realm = session.getContext().getRealm();
for (String id : userIds) {
UserModel user = session.users().getUserById(realm, id);
if (user != null && user.isEnabled()) {
log.debugv("Disabling user {0} ({1})", user.getUsername(), user.getId());
user.setEnabled(false);
}
}
}
@Override
public boolean isRunnable() {
return actionModel.get("after") != null;
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
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 DisableUserActionProviderFactory implements ResourceActionProviderFactory<DisableUserActionProvider> {
public static final String ID = "disable-user-action-provider";
@Override
public DisableUserActionProvider create(KeycloakSession session, ComponentModel model) {
return new DisableUserActionProvider(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 "";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
public class NotifyUserActionProvider implements ResourceActionProvider {
private final KeycloakSession session;
private final ComponentModel actionModel;
private final Logger log = Logger.getLogger(NotifyUserActionProvider.class);
public NotifyUserActionProvider(KeycloakSession session, ComponentModel model) {
this.session = session;
this.actionModel = model;
}
@Override
public void close() {
}
@Override
public void run(List<String> userIds) {
RealmModel realm = session.getContext().getRealm();
for (String id : userIds) {
UserModel user = session.users().getUserById(realm, id);
if (user != null) {
log.debugv("Disabling user {0} ({1})", user.getUsername(), user.getId());
user.setSingleAttribute(getMessageKey(), getMessage());
}
}
}
private String getMessageKey() {
return actionModel.getConfig().getFirstOrDefault("message_key", "message");
}
private String getMessage() {
return actionModel.getConfig().getFirstOrDefault(getMessageKey(), "sent");
}
@Override
public boolean isRunnable() {
return actionModel.get("after") != null;
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
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 NotifyUserActionProviderFactory implements ResourceActionProviderFactory<NotifyUserActionProvider> {
public static final String ID = "notify-user-action-provider";
@Override
public NotifyUserActionProvider create(KeycloakSession session, ComponentModel model) {
return new NotifyUserActionProvider(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 "";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return List.of();
}
}

View file

@ -0,0 +1,298 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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;
import java.util.Set;
import java.util.stream.Collectors;
import org.jboss.logging.Logger;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentFactory;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.provider.ProviderFactory;
public class ResourcePolicyManager {
private static final Logger log = Logger.getLogger(ResourcePolicyManager.class);
public static boolean isFeatureEnabled() {
return Profile.isFeatureEnabled(Feature.RESOURCE_LIFECYCLE);
}
private final KeycloakSession session;
public ResourcePolicyManager(KeycloakSession session) {
this.session = session;
}
public ResourcePolicy addPolicy(String providerId) {
return addPolicy(new ResourcePolicy(providerId));
}
public ResourcePolicy addPolicy(String providerId, Map<String, List<String>> config) {
return addPolicy(new ResourcePolicy(providerId, config));
}
public ResourcePolicy addPolicy(ResourcePolicy policy) {
RealmModel realm = getRealm();
ComponentModel model = new ComponentModel();
model.setParentId(realm.getId());
model.setProviderId(policy.getProviderId());
model.setProviderType(ResourcePolicyProvider.class.getName());
MultivaluedHashMap<String, String> config = policy.getConfig();
if (config != null) {
model.setConfig(config);
}
return new ResourcePolicy(realm.addComponentModel(model));
}
/*
This method takes an ordered list of actions. First action in the list has the highest priority, last action has the lowest priority
It is used for both create and update actions
---------------------------------------------------------------------------------------
using delete-and-recreate approach for now as it seems more simple and robust solution
todo: consider changing it to "diff-and-update" (more complex) approach where we'd need to
* keep existing actions
* create newly added actions
* delete removed actions
* reorder existing action according to new order (we may add gaps between priority so that we won't need to update all existing actions)
* with the gap approach, it may eventually happen that there won't be any space between the two action, in that case we'd have to trigger recalculation of priorities
*/
public void updateActions(ResourcePolicy policy, List<ResourceAction> actions) {
validateActions(actions);
// get the stable IDs of the new actions
Set<String> newActionIds = actions.stream()
.map(ResourceAction::getId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
// get the stable IDs of the old actions
List<ResourceAction> oldActions = getActions(policy);
Set<String> oldActionIds = oldActions.stream()
.map(ResourceAction::getId)
.collect(Collectors.toSet());
// find which action IDs were deleted
oldActionIds.removeAll(newActionIds); // The remaining IDs are the deleted ones
Set<String> deletedActionIds = oldActionIds;
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 (!deletedActionIds.isEmpty()) {
stateProvider.removeByCompletedActions(policy.getId(), deletedActionIds);
}
RealmModel realm = getRealm();
// remove all existing actions of the policy
realm.removeComponents(policy.getId());
// add the new actions
for (int i = 0; i < actions.size(); i++) {
ResourceAction action = actions.get(i);
// assign priority based on index.
action.setPriority(i + 1);
// persist the new action component.
addAction(policy, action);
}
}
private ResourceAction addAction(ResourcePolicy policy, ResourceAction action) {
RealmModel realm = getRealm();
ComponentModel policyModel = realm.getComponent(policy.getId());
ComponentModel actionModel = new ComponentModel();
actionModel.setId(action.getId());//need to keep stable UUIDs not to break a link in state table
actionModel.setParentId(policyModel.getId());
actionModel.setProviderId(action.getProviderId());
actionModel.setProviderType(ResourceActionProvider.class.getName());
actionModel.setConfig(action.getConfig());
return new ResourceAction(realm.addComponentModel(actionModel));
}
public List<ResourcePolicy> getPolicies() {
RealmModel realm = getRealm();
return realm.getComponentsStream(realm.getId(), ResourcePolicyProvider.class.getName())
.map(ResourcePolicy::new).toList();
}
public List<ResourceAction> getActions(ResourcePolicy policy) {
RealmModel realm = getRealm();
return realm.getComponentsStream(policy.getId(), ResourceActionProvider.class.getName())
.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.findResourceIdsByLastCompletedAction(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 ResourcePolicyProvider getPolicyProvider(ResourcePolicy policy) {
ComponentFactory<?, ?> factory = (ComponentFactory<?, ?>) session.getKeycloakSessionFactory()
.getProviderFactory(ResourcePolicyProvider.class, policy.getProviderId());
return (ResourcePolicyProvider) factory.create(session, getRealm().getComponent(policy.getId()));
}
private ResourceActionProvider getActionProvider(ResourceAction action) {
ComponentFactory<?, ?> actionFactory = (ComponentFactory<?, ?>) session.getKeycloakSessionFactory()
.getProviderFactory(ResourceActionProvider.class, action.getProviderId());
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();
}
private void validateActions(List<ResourceAction> actions) {
// the list should be in the desired priority order
for (int i = 0; i < actions.size(); i++) {
ResourceAction currentAction = actions.get(i);
// check that each action's duration is positive.
if (currentAction.getAfter() <= 0) {
throw new BadRequestException("Validation Error: 'after' duration must be positive.");
}
if (i > 0) {// skip for initial action
ResourceAction previousAction = actions.get(i - 1);
// compare current with the previous action in the list
if (currentAction.getAfter() < previousAction.getAfter()) {
throw new BadRequestException(
String.format("Validation Error: The 'after' duration for action #%d (%s) cannot be less than the duration of the preceding action #%d (%s).",
i + 1, formatDuration(currentAction.getAfter()),
i, formatDuration(previousAction.getAfter()))
);
}
}
}
}
private String formatDuration(long millis) {
long days = Duration.ofMillis(millis).toDays();
if (days > 0) {
return String.format("%d day(s)", days);
} else {
long hours = Duration.ofMillis(millis).toHours();
return String.format("%d hour(s)", hours);
}
}
public void removePolicies() {
RealmModel realm = getRealm();
realm.getComponentsStream(realm.getId(), ResourcePolicyProvider.class.getName()).forEach(policy -> {
realm.getComponentsStream(policy.getId(), ResourceActionProvider.class.getName()).forEach(realm::removeComponent);
realm.removeComponent(policy);
});
}
}

View file

@ -0,0 +1,50 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models.policy;
import java.time.Duration;
import org.keycloak.common.util.KeycloakUriBuilder;
public class UserActionBuilder {
private final ResourceAction action;
private UserActionBuilder(ResourceAction action) {
this.action = action;
}
public static UserActionBuilder builder(String providerId) {
ResourceAction action = new ResourceAction(providerId);
return new UserActionBuilder(action);
}
public ResourceAction build() {
return action;
}
public UserActionBuilder after(Duration duration) {
action.setAfter(duration.toMillis());
return this;
}
public UserActionBuilder withConfig(String key, String value) {
action.setConfig(key, value);
return this;
}
}

View file

@ -0,0 +1,21 @@
#
# Copyright 2025 Red Hat, Inc. and/or its affiliates
# and other contributors as indicated by the @author tags.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
org.keycloak.models.policy.DisableUserActionProviderFactory
org.keycloak.models.policy.NotifyUserActionProviderFactory
org.keycloak.models.policy.DeleteUserActionProviderFactory

View file

@ -0,0 +1,74 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.tests.admin.model.policy;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.policy.ResourceAction;
import org.keycloak.models.policy.ResourcePolicy;
import org.keycloak.models.policy.ResourcePolicyManager;
public class PolicyBuilder {
public static PolicyBuilder create() {
return new PolicyBuilder();
}
private String providerId;
private Map<String, List<String>> config = new HashMap<>();
private final Map<String, List<ResourceAction>> actions = new HashMap<>();
private PolicyBuilder() {
}
public PolicyBuilder of(String providerId) {
this.providerId = providerId;
return this;
}
public PolicyBuilder withActions(ResourceAction... actions) {
this.actions.computeIfAbsent(providerId, (k) -> new ArrayList<>()).addAll(List.of(actions));
return this;
}
public PolicyBuilder withConfig(String key, String value) {
config.put(key, List.of(value));
return this;
}
public PolicyBuilder withConfig(String key, List<String> value) {
config.put(key, value);
return this;
}
public ResourcePolicyManager build(KeycloakSession session) {
ResourcePolicyManager manager = new ResourcePolicyManager(session);
for (Entry<String, List<ResourceAction>> entry : actions.entrySet()) {
ResourcePolicy policy = manager.addPolicy(entry.getKey(), config);
manager.updateActions(policy, entry.getValue());
}
return manager;
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.tests.admin.model.policy;
import org.keycloak.common.Profile.Feature;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
public class RLMServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Feature.RESOURCE_LIFECYCLE);
}
}

View file

@ -0,0 +1,363 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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 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;
import org.keycloak.models.UserProvider;
import org.keycloak.models.policy.DisableUserActionProviderFactory;
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.UserCreationDateResourcePolicyProviderFactory;
import org.keycloak.models.policy.UserLastSessionRefreshTimeResourcePolicyProviderFactory;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.InjectUser;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.injection.LifeCycle;
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.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
@KeycloakIntegrationTest(config = RLMServerConfig.class)
public class ResourcePolicyManagementTest {
private static final String REALM_NAME = "default";
@InjectRunOnServer(permittedPackages = "org.keycloak.tests")
RunOnServerClient runOnServer;
@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm managedRealm;
@InjectUser(ref = "alice", config = DefaultUserConfig.class, lifecycle = LifeCycle.METHOD)
private ManagedUser userAlice;
@Test
public void testCreatePolicy() {
runOnServer.run(session -> {
RealmModel realm = configureSessionContext(session);
ResourcePolicyManager manager = new ResourcePolicyManager(session);
ResourcePolicy created = manager.addPolicy(new ResourcePolicy(UserCreationDateResourcePolicyProviderFactory.ID));
assertNotNull(created.getId());
List<ResourcePolicy> policies = manager.getPolicies();
assertEquals(1, policies.size());
ResourcePolicy policy = policies.get(0);
assertNotNull(policy.getId());
assertEquals(created.getId(), policy.getId());
assertNotNull(realm.getComponent(policy.getId()));
assertEquals(UserCreationDateResourcePolicyProviderFactory.ID, policy.getProviderId());
});
}
@Test
public void testCreateAction() {
runOnServer.run(session -> {
RealmModel realm = configureSessionContext(session);
ResourcePolicyManager manager = new ResourcePolicyManager(session);
ResourcePolicy policy = manager.addPolicy(new ResourcePolicy(UserCreationDateResourcePolicyProviderFactory.ID));
int expectedActionsSize = 5;
List<ResourceAction> expectedActions = new ArrayList<>();
for (int i = 0; i < expectedActionsSize; i++) {
expectedActions.add(UserActionBuilder.builder(DisableUserActionProviderFactory.ID)
.after(Duration.ofDays(i + 1))
.build());
}
manager.updateActions(policy, expectedActions);
List<ResourceAction> actions = manager.getActions(policy);
assertEquals(expectedActionsSize, actions.size());
ResourceAction action = actions.get(0);
assertNotNull(action.getId());
assertNotNull(realm.getComponent(action.getId()));
assertEquals(DisableUserActionProviderFactory.ID, action.getProviderId());
});
}
@Test
@Disabled("We need to flush component removals to make this test pass. For that, we need to evaluate a flush as per the TODO in the body of this method")
public void testDeleteActionResetsOrphanedState() {
//TODO: Evaluate and change org.keycloak.models.jpa.RealmAdapter.removeComponents to flush changes in the persistence context:
// if (getEntity().getComponents().removeIf(sameParent)) {
// 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(UserCreationDateResourcePolicyProviderFactory.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(UserCreationDateResourcePolicyProviderFactory.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.getLastCompletedActionId(), "The user's state should be at the first action.");
} finally {
Time.setOffset(0);
}
});
}
@Test
public void testTimeVsPriorityConflictingActions() {
runOnServer.run(session -> {
configureSessionContext(session);
ResourcePolicyManager manager = new ResourcePolicyManager(session);
ResourcePolicy policy = manager.addPolicy(UserCreationDateResourcePolicyProviderFactory.ID);
ResourceAction action1 = UserActionBuilder.builder(DisableUserActionProviderFactory.ID)
.after(Duration.ofDays(10))
.build();
ResourceAction action2 = UserActionBuilder.builder(DisableUserActionProviderFactory.ID)
.after(Duration.ofDays(5))
.build();
try {
manager.updateActions(policy, List.of(action1, action2));
fail("Expected exception was not thrown");
} catch (BadRequestException expected) {}
});
}
@Test
public void testRunSinglePolicy() {
runOnServer.run(session -> {
RealmModel realm = configureSessionContext(session);
ResourcePolicyManager manager = PolicyBuilder.create()
.of(UserLastSessionRefreshTimeResourcePolicyProviderFactory.ID)
.withActions(
UserActionBuilder.builder(NotifyUserActionProviderFactory.ID)
.after(Duration.ofDays(5))
.build(),
UserActionBuilder.builder(DisableUserActionProviderFactory.ID)
.after(Duration.ofDays(10))
.build()
).build(session);
UserProvider users = session.users();
UserModel user = users.getUserByUsername(realm, "alice");
assertTrue(user.isEnabled());
assertNull(user.getAttributes().get("message"));
user.setLastSessionRefreshTime(Time.currentTime());
try {
Time.setOffset(Math.toIntExact(Duration.ofDays(7).toSeconds()));
manager.runPolicies();
user = users.getUserByUsername(realm, "alice");
assertTrue(user.isEnabled());
assertNotNull(user.getAttributes().get("message"));
Time.setOffset(Math.toIntExact(Duration.ofDays(12).toSeconds()));
manager.runPolicies();
user = users.getUserByUsername(realm, "alice");
assertFalse(user.isEnabled());
assertNotNull(user.getAttributes().get("message"));
} finally {
Time.setOffset(0);
}
});
}
@Test
public void testMultiplePolicies() {
runOnServer.run(session -> {
RealmModel realm = configureSessionContext(session);
ResourcePolicyManager manager = PolicyBuilder.create()
.of(UserLastSessionRefreshTimeResourcePolicyProviderFactory.ID)
.withActions(
UserActionBuilder.builder(NotifyUserActionProviderFactory.ID)
.after(Duration.ofDays(5))
.withConfig("message_key", "notifier1")
.build()
).of(UserLastSessionRefreshTimeResourcePolicyProviderFactory.ID)
.withActions(
UserActionBuilder.builder(NotifyUserActionProviderFactory.ID)
.after(Duration.ofDays(10))
.withConfig("message_key", "notifier2")
.build())
.build(session);
UserProvider users = session.users();
UserModel user = users.getUserByUsername(realm, "alice");
assertTrue(user.isEnabled());
assertNull(user.getFirstAttribute("notifier1"));
assertNull(user.getFirstAttribute("notifier2"));
user.setLastSessionRefreshTime(Time.currentTime());
try {
Time.setOffset(Math.toIntExact(Duration.ofDays(7).toSeconds()));
manager.runPolicies();
user = users.getUserByUsername(realm, "alice");
assertTrue(user.isEnabled());
assertNotNull(user.getFirstAttribute("notifier1"));
assertNull(user.getFirstAttribute("notifier2"));
user.removeAttribute("notifier1");
} finally {
Time.setOffset(0);
}
try {
Time.setOffset(Math.toIntExact(Duration.ofDays(11).toSeconds()));
manager.runPolicies();
user = users.getUserByUsername(realm, "alice");
assertTrue(user.isEnabled());
assertNotNull(user.getFirstAttribute("notifier2"));
assertNull(user.getFirstAttribute("notifier1"));
user.removeAttribute("notifier2");
} finally {
Time.setOffset(0);
}
try {
manager.runPolicies();
assertNull(user.getFirstAttribute("notifier1"));
assertNull(user.getFirstAttribute("notifier2"));
} finally {
Time.setOffset(0);
}
// try {
//TODO: test re-run policies based on the last time the action was executed?
// Time.setOffset(Math.toIntExact(Duration.ofDays(40).toSeconds()));
// manager.runPolicies();
// assertNotNull(user.getFirstAttribute("notifier1"));
// assertNotNull(user.getFirstAttribute("notifier2"));
// } finally {
// Time.setOffset(0);
// }
});
}
private static RealmModel configureSessionContext(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(REALM_NAME);
session.getContext().setRealm(realm);
return realm;
}
private static class DefaultUserConfig implements UserConfig {
@Override
public UserConfigBuilder configure(UserConfigBuilder user) {
user.username("alice");
user.password("alice");
user.name("alice", "alice");
user.email("master-admin@email.org");
return user;
}
}
}

View file

@ -0,0 +1,233 @@
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.tests.admin.model.policy;
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 java.time.Duration;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.resource.UsersResource;
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.DeleteUserActionProviderFactory;
import org.keycloak.models.policy.FederatedIdentityPolicyProviderFactory;
import org.keycloak.models.policy.ResourcePolicyManager;
import org.keycloak.models.policy.UserActionBuilder;
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testframework.annotations.InjectClient;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.InjectUser;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testframework.realm.ClientConfig;
import org.keycloak.testframework.realm.ClientConfigBuilder;
import org.keycloak.testframework.realm.ManagedClient;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.ManagedUser;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.RealmConfigBuilder;
import org.keycloak.testframework.realm.UserConfig;
import org.keycloak.testframework.realm.UserConfigBuilder;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import org.keycloak.testframework.ui.annotations.InjectPage;
import org.keycloak.testframework.ui.annotations.InjectWebDriver;
import org.keycloak.testframework.ui.page.ConsentPage;
import org.keycloak.testframework.ui.page.LoginPage;
import org.openqa.selenium.WebDriver;
/**
* @author <a href="mailto:mstrukel@redhat.com">Marko Strukelj</a>
*/
@KeycloakIntegrationTest(config = RLMServerConfig.class)
public class TransientUserTest {
private static final String REALM_NAME = "consumer";
@InjectRunOnServer(permittedPackages = "org.keycloak.tests")
RunOnServerClient runOnServer;
@InjectRealm(ref = "consumer", config = ConsumerRealmConf.class, lifecycle = LifeCycle.METHOD)
ManagedRealm consumerRealm;
@InjectRealm(ref = "provider", lifecycle = LifeCycle.METHOD)
ManagedRealm providerRealm;
@InjectUser(ref = "provider", realmRef = "provider", config = ProviderRealmUserConf.class)
ManagedUser userFromProviderRealm;
@InjectOAuthClient(ref = "consumer", realmRef = "consumer")
OAuthClient consumerRealmOAuth;
@InjectClient(realmRef = "provider", config = ProviderRealmClientConf.class)
ManagedClient providerRealmClient;
@InjectWebDriver
WebDriver driver;
@InjectPage
LoginPage loginPage;
@InjectPage
ConsentPage consentPage;
private static final String REALM_PROV_NAME = "provider";
private static final String REALM_CONS_NAME = "consumer";
private static final String IDP_OIDC_ALIAS = "kc-oidc-idp";
private static final String IDP_OIDC_PROVIDER_ID = "keycloak-oidc";
private static final String CLIENT_ID = "brokerapp";
private static final String CLIENT_SECRET = "secret";
@Test
public void tesRunActionOnFederatedUser() {
consumerRealmOAuth.openLoginForm();
loginPage.clickSocial(IDP_OIDC_ALIAS);
Assertions.assertTrue(driver.getCurrentUrl().contains("/realms/" + providerRealm.getName() + "/"), "Driver should be on the provider realm page right now");
String username = userFromProviderRealm.getUsername();
loginPage.fillLogin(username, userFromProviderRealm.getPassword());
loginPage.submit();
consentPage.waitForPage();
consentPage.assertCurrent();
consentPage.confirm();
assertTrue(driver.getPageSource().contains("Happy days"), "Test user should be successfully logged in.");
UsersResource users = consumerRealm.admin().users();
UserRepresentation federatedUser = users.search(username).get(0);
List<FederatedIdentityRepresentation> federatedIdentities = users.get(federatedUser.getId()).getFederatedIdentity();
assertFalse(federatedIdentities.isEmpty());
runOnServer.run((session -> {
RealmModel realm = configureSessionContext(session);
ResourcePolicyManager manager = PolicyBuilder.create()
.of(FederatedIdentityPolicyProviderFactory.ID)
.withConfig("source", "broker")
.withConfig("source-id", List.of("kc-oidc-alias"))
.withConfig("broker-aliases", IDP_OIDC_ALIAS)
.withActions(
UserActionBuilder.builder(DeleteUserActionProviderFactory.ID)
.after(Duration.ofDays(1))
.build()
).build(session);
manager.runPolicies();
UserModel user = session.users().getUserByUsername(realm, username);
assertNotNull(user);
assertTrue(user.isEnabled());
try {
manager = new ResourcePolicyManager(session);
Time.setOffset(Math.toIntExact(Duration.ofDays(2).toSeconds()));
manager.runPolicies();
user = session.users().getUserByUsername(realm, username);
assertNull(user);
} finally {
Time.setOffset(0);
}
}));
}
private static IdentityProviderRepresentation setUpIdentityProvider() {
IdentityProviderRepresentation idp = createIdentityProvider(IDP_OIDC_ALIAS, IDP_OIDC_PROVIDER_ID);
Map<String, String> config = idp.getConfig();
config.put("clientId", CLIENT_ID);
config.put("clientSecret", CLIENT_SECRET);
config.put("prompt", "login");
config.put("authorizationUrl", "http://localhost:8080/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/auth");
config.put("tokenUrl", "http://localhost:8080/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/token");
config.put("logoutUrl", "http://localhost:8080/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/logout");
config.put("userInfoUrl", "http://localhost:8080/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/userinfo");
config.put("defaultScope", "email profile");
config.put("backchannelSupported", "true");
return idp;
}
private static IdentityProviderRepresentation createIdentityProvider(String alias, String providerId) {
IdentityProviderRepresentation identityProviderRepresentation = new IdentityProviderRepresentation();
identityProviderRepresentation.setAlias(alias);
identityProviderRepresentation.setDisplayName(providerId);
identityProviderRepresentation.setProviderId(providerId);
identityProviderRepresentation.setEnabled(true);
return identityProviderRepresentation;
}
private static class ProviderRealmUserConf implements UserConfig {
@Override
public UserConfigBuilder configure(UserConfigBuilder builder) {
builder.username("provider");
builder.password("password");
builder.email("provider@local");
builder.emailVerified(true);
builder.name("Provider", "User");
return builder;
}
}
private static class ProviderRealmClientConf implements ClientConfig {
@Override
public ClientConfigBuilder configure(ClientConfigBuilder builder) {
builder.clientId(CLIENT_ID);
builder.name(CLIENT_ID);
builder.secret(CLIENT_SECRET);
builder.consentRequired(true);
builder.redirectUris( "http://localhost:8080/realms/" + REALM_CONS_NAME + "/broker/" + IDP_OIDC_ALIAS + "/endpoint/*");
builder.adminUrl("http://localhost:8080/realms/" + REALM_CONS_NAME + "/broker/" + IDP_OIDC_ALIAS + "/endpoint");
return builder;
}
}
private static class ConsumerRealmConf implements RealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder builder) {
builder.identityProvider(setUpIdentityProvider());
return builder;
}
}
private static RealmModel configureSessionContext(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(REALM_NAME);
session.getContext().setRealm(realm);
return realm;
}
}

View file

@ -0,0 +1,261 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.tests.admin.model.policy;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
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 java.time.Duration;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
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;
import org.keycloak.models.jpa.entities.UserEntity;
import org.keycloak.models.policy.DisableUserActionProviderFactory;
import org.keycloak.models.policy.NotifyUserActionProviderFactory;
import org.keycloak.models.policy.ResourcePolicyManager;
import org.keycloak.models.policy.UserActionBuilder;
import org.keycloak.models.policy.UserLastSessionRefreshTimeResourcePolicyProviderFactory;
import org.keycloak.testframework.annotations.InjectUser;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
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.FetchOnServer;
import org.keycloak.testframework.remote.providers.runonserver.RunOnServer;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import org.keycloak.testframework.ui.annotations.InjectPage;
import org.keycloak.testframework.ui.annotations.InjectWebDriver;
import org.keycloak.testframework.ui.page.LoginPage;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.openqa.selenium.WebDriver;
@KeycloakIntegrationTest(config = RLMServerConfig.class)
public class UserSessionRefreshTimePolicyTest {
private static final String REALM_NAME = "default";
@InjectRunOnServer(permittedPackages = "org.keycloak.tests")
RunOnServerClient runOnServer;
@InjectUser(ref = "alice", config = DefaultUserConfig.class, lifecycle = LifeCycle.METHOD)
private ManagedUser userAlice;
@InjectWebDriver
WebDriver driver;
@InjectPage
LoginPage loginPage;
@InjectOAuthClient
OAuthClient oauth;
@BeforeEach
public void onBefore() {
oauth.realm("default");
}
@Test
public void testDisabledUserAfterInactivityPeriod() {
runOnServer.run((RunOnServer) session -> {
RealmModel realm = configureSessionContext(session);
UserModel user = session.users().getUserByUsername(realm, "alice");
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
UserEntity entity = em.find(UserEntity.class, user.getId());
assertNull(entity.getLastSessionRefreshTime());
});
oauth.openLoginForm();
loginPage.fillLogin("alice", "alice");
loginPage.submit();
assertTrue(driver.getPageSource().contains("Happy days"));
// test run policy
runOnServer.run((session -> {
RealmModel realm = configureSessionContext(session);
ResourcePolicyManager manager = PolicyBuilder.create()
.of(UserLastSessionRefreshTimeResourcePolicyProviderFactory.ID)
.withActions(
UserActionBuilder.builder(NotifyUserActionProviderFactory.ID)
.after(Duration.ofDays(5))
.build(),
UserActionBuilder.builder(DisableUserActionProviderFactory.ID)
.after(Duration.ofDays(10))
.build()
).build(session);
UserModel user = session.users().getUserByUsername(realm, "alice");
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
UserEntity entity = em.find(UserEntity.class, user.getId());
assertNotNull(entity.getLastSessionRefreshTime());
assertTrue(user.isEnabled());
assertNull(user.getAttributes().get("message"));
manager.runPolicies();
user = session.users().getUserByUsername(realm, "alice");
assertTrue(user.isEnabled());
assertNull(user.getAttributes().get("message"));
try {
manager = new ResourcePolicyManager(session);
Time.setOffset(Math.toIntExact(Duration.ofDays(7).toSeconds()));
manager.runPolicies();
user = session.users().getUserByUsername(realm, "alice");
assertTrue(user.isEnabled());
assertNotNull(user.getAttributes().get("message"));
} finally {
Time.setOffset(0);
}
try {
entity.setLastSessionRefreshTime(Math.toIntExact(Time.currentTime() + Duration.ofDays(11).toSeconds()));
Time.setOffset(Math.toIntExact(Duration.ofDays(11).toSeconds()));
manager.runPolicies();
user = session.users().getUserByUsername(realm, "alice");
assertTrue(user.isEnabled());
} finally {
Time.setOffset(0);
}
try {
entity = em.find(UserEntity.class, user.getId());
entity.setLastSessionRefreshTime(Math.toIntExact(Time.currentTime() - Duration.ofDays(10).toSeconds()));
manager.runPolicies();
user = session.users().getUserByUsername(realm, "alice");
assertTrue(user.isEnabled());
} finally {
Time.setOffset(0);
}
try {
entity = em.find(UserEntity.class, user.getId());
entity.setLastSessionRefreshTime(Math.toIntExact(Time.currentTime() - Duration.ofDays(11).toSeconds()));
manager.runPolicies();
user = session.users().getUserByUsername(realm, "alice");
assertFalse(user.isEnabled());
} finally {
Time.setOffset(0);
}
}));
}
@Test
public void testUpdateUserLastRefreshTimeOnReAuthentication() {
oauth.openLoginForm();
loginPage.fillLogin("alice", "alice");
loginPage.submit();
assertTrue(driver.getPageSource().contains("Happy days"));
Integer lastSessionRefreshTime = runOnServer.fetch((FetchOnServer) session -> {
RealmModel realm = configureSessionContext(session);
UserModel user = session.users().getUserByUsername(realm, "alice");
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
UserEntity entity = em.find(UserEntity.class, user.getId());
assertNotNull(entity.getLastSessionRefreshTime());
return entity.getLastSessionRefreshTime();
}, Integer.class);
try {
runOnServer.run((session) -> {
Time.setOffset(Math.toIntExact(Duration.ofMinutes(10).toSeconds()));
});
oauth.openLoginForm();
assertTrue(driver.getPageSource().contains("Happy days"));
} finally {
runOnServer.run((session) -> {
Time.setOffset(0);
});
}
runOnServer.run((session) -> {
RealmModel realm = configureSessionContext(session);
UserModel user = session.users().getUserByUsername(realm, "alice");
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
UserEntity entity = em.find(UserEntity.class, user.getId());
assertNotEquals(lastSessionRefreshTime, entity.getLastSessionRefreshTime());
});
}
@Test
public void testUpdateUserLastRefreshTimeOnRefreshToken() {
AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("alice", "alice");
assertNotNull(tokenResponse);
Integer lastSessionRefreshTime = runOnServer.fetch((FetchOnServer) session -> {
RealmModel realm = configureSessionContext(session);
UserModel user = session.users().getUserByUsername(realm, "alice");
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
UserEntity entity = em.find(UserEntity.class, user.getId());
assertNotNull(entity.getLastSessionRefreshTime());
return entity.getLastSessionRefreshTime();
}, Integer.class);
assertNotNull(tokenResponse.getRefreshToken());
try {
runOnServer.run((session) -> {
Time.setOffset(Math.toIntExact(Duration.ofMinutes(10).toSeconds()));
});
oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
} finally {
runOnServer.run((session) -> {
Time.setOffset(0);
});
}
runOnServer.run((session) -> {
RealmModel realm = configureSessionContext(session);
UserModel user = session.users().getUserByUsername(realm, "alice");
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
UserEntity entity = em.find(UserEntity.class, user.getId());
assertNotEquals(lastSessionRefreshTime, entity.getLastSessionRefreshTime());
});
}
private static RealmModel configureSessionContext(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(REALM_NAME);
session.getContext().setRealm(realm);
return realm;
}
private static class DefaultUserConfig implements UserConfig {
@Override
public UserConfigBuilder configure(UserConfigBuilder user) {
user.username("alice");
user.password("alice");
user.name("alice", "alice");
user.email("master-admin@email.org");
return user;
}
}
}