diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 2d7363719a0..89c301eaa8b 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -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), /** diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java index a3a72aca549..f94b5824c11 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/UserAdapter.java @@ -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; diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java index fbbffcfe380..e9157fe5a88 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/PersistentUserSessionProvider.java @@ -252,6 +252,8 @@ public class PersistentUserSessionProvider implements UserSessionProvider, Sessi entity.setStarted(currentTime); entity.setLastSessionRefresh(currentTime); + + user.setLastSessionRefreshTime(entity.getLastSessionRefresh()); } @Override diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java index 763f6524bfd..fdec73ecff5 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/UserSessionAdapter.java @@ -264,6 +264,8 @@ public class UserSessionAdapter { return new UserCredentialManager(session, realm, this); } + @Override + public void setLastSessionRefreshTime(int lastSessionRefreshTime) { + if (ResourcePolicyManager.isFeatureEnabled()) { + user.setLastSessionRefreshTime(lastSessionRefreshTime); + } + } @Override public boolean equals(Object o) { diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java index 42b63e57e8a..e1cc09df2e5 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/UserEntity.java @@ -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 getCredentials() { if (credentials == null) { credentials = new LinkedList<>(); diff --git a/model/jpa/src/main/java/org/keycloak/models/policy/AbstractUserResourcePolicyProvider.java b/model/jpa/src/main/java/org/keycloak/models/policy/AbstractUserResourcePolicyProvider.java new file mode 100644 index 00000000000..d4eb3706ba9 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/policy/AbstractUserResourcePolicyProvider.java @@ -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 query, Root 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 getEligibleResourcesForInitialAction(long time) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery query = cb.createQuery(String.class); + Root 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 subquery = query.subquery(Integer.class); + Root 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 filterEligibleResources(List 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 query = cb.createQuery(String.class); + Root 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; + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/policy/FederatedIdentityPolicyProvider.java b/model/jpa/src/main/java/org/keycloak/models/policy/FederatedIdentityPolicyProvider.java new file mode 100644 index 00000000000..82888d20f54 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/policy/FederatedIdentityPolicyProvider.java @@ -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 query, Root 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 query, Root userRoot) { + Subquery subquery = query.subquery(Integer.class); + Root from = subquery.from(FederatedIdentityEntity.class); + + subquery.select(cb.literal(1)); + + List 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 getBrokerAliases() { + return getModel().getConfig().getOrDefault("broker-aliases", List.of()); + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/policy/FederatedIdentityPolicyProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/policy/FederatedIdentityPolicyProviderFactory.java new file mode 100644 index 00000000000..12cfb18221e --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/policy/FederatedIdentityPolicyProviderFactory.java @@ -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 { + + 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 getConfigProperties() { + return List.of(); + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/policy/JpaResourcePolicyStateProvider.java b/model/jpa/src/main/java/org/keycloak/models/policy/JpaResourcePolicyStateProvider.java new file mode 100644 index 00000000000..d7dbfe41e0f --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/policy/JpaResourcePolicyStateProvider.java @@ -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 findResourceIdsByLastCompletedAction(String policyId, String lastCompletedActionId) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery query = cb.createQuery(String.class); + Root 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 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 deletedActionIds) { + if (deletedActionIds == null || deletedActionIds.isEmpty()) { + return; + } + + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaDelete delete = cb.createCriteriaDelete(ResourcePolicyStateEntity.class); + Root 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 delete = cb.createCriteriaDelete(ResourcePolicyStateEntity.class); + Root 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 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() { + } + +} diff --git a/model/jpa/src/main/java/org/keycloak/models/policy/JpaResourcePolicyStateProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/policy/JpaResourcePolicyStateProviderFactory.java new file mode 100644 index 00000000000..7ad875783d9 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/policy/JpaResourcePolicyStateProviderFactory.java @@ -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()); + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/policy/ResourcePolicyStateEntity.java b/model/jpa/src/main/java/org/keycloak/models/policy/ResourcePolicyStateEntity.java new file mode 100644 index 00000000000..32a0acdfc46 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/policy/ResourcePolicyStateEntity.java @@ -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); + } +} + diff --git a/model/jpa/src/main/java/org/keycloak/models/policy/UserCreationDateResourcePolicyProvider.java b/model/jpa/src/main/java/org/keycloak/models/policy/UserCreationDateResourcePolicyProvider.java new file mode 100644 index 00000000000..f935765bdad --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/policy/UserCreationDateResourcePolicyProvider.java @@ -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 query, Root userRoot) { + long currentTimeMillis = Time.currentTimeMillis(); + Expression timeMoment = cb.sum(userRoot.get("createdTimestamp"), cb.literal(time)); + return cb.lessThan(timeMoment, cb.literal(currentTimeMillis)); + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/policy/UserCreationDateResourcePolicyProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/policy/UserCreationDateResourcePolicyProviderFactory.java new file mode 100644 index 00000000000..12d7c06fedd --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/policy/UserCreationDateResourcePolicyProviderFactory.java @@ -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 { + + 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 getConfigProperties() { + return List.of(); + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/policy/UserLastSessionRefreshTimeResourcePolicyProvider.java b/model/jpa/src/main/java/org/keycloak/models/policy/UserLastSessionRefreshTimeResourcePolicyProvider.java new file mode 100644 index 00000000000..bf67e11f2e5 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/policy/UserLastSessionRefreshTimeResourcePolicyProvider.java @@ -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 query, Root userRoot) { + long currentTimeSeconds = Time.currentTime(); + Path lastSessionRefreshTime = userRoot.get("lastSessionRefreshTime"); + Expression lastSessionRefreshTimeExpiration = cb.sum(lastSessionRefreshTime, cb.literal(Duration.ofMillis(time).toSeconds())); + return cb.and(cb.isNotNull(lastSessionRefreshTime), cb.lessThan(lastSessionRefreshTimeExpiration, cb.literal(currentTimeSeconds))); + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/policy/UserLastSessionRefreshTimeResourcePolicyProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/policy/UserLastSessionRefreshTimeResourcePolicyProviderFactory.java new file mode 100644 index 00000000000..dbcb0ca9dc8 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/policy/UserLastSessionRefreshTimeResourcePolicyProviderFactory.java @@ -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 { + + 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 getConfigProperties() { + return List.of(); + } +} diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.4.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.4.0.xml index c79e1438a78..81db6af86ea 100644 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.4.0.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.4.0.xml @@ -28,4 +28,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.policy.ResourcePolicyProviderFactory b/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.policy.ResourcePolicyProviderFactory new file mode 100644 index 00000000000..bf0c89b97b8 --- /dev/null +++ b/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.policy.ResourcePolicyProviderFactory @@ -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 diff --git a/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.policy.ResourcePolicyStateProviderFactory b/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.policy.ResourcePolicyStateProviderFactory new file mode 100644 index 00000000000..66f641dcd55 --- /dev/null +++ b/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.policy.ResourcePolicyStateProviderFactory @@ -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 diff --git a/model/jpa/src/main/resources/default-persistence.xml b/model/jpa/src/main/resources/default-persistence.xml index 40ff6d60a2c..c2fd76046de 100644 --- a/model/jpa/src/main/resources/default-persistence.xml +++ b/model/jpa/src/main/resources/default-persistence.xml @@ -92,6 +92,9 @@ org.keycloak.storage.configuration.jpa.entity.ServerConfigEntity + + org.keycloak.models.policy.ResourcePolicyStateEntity + true diff --git a/model/storage-private/src/main/java/org/keycloak/models/policy/ResourcePolicyStateProvider.java b/model/storage-private/src/main/java/org/keycloak/models/policy/ResourcePolicyStateProvider.java new file mode 100644 index 00000000000..add99b0c053 --- /dev/null +++ b/model/storage-private/src/main/java/org/keycloak/models/policy/ResourcePolicyStateProvider.java @@ -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 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 resourceIds, String newLastCompletedActionId); + + /** + * Deletes the orphaned state records. + */ + void removeByCompletedActions(String policyId, Set 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(); +} diff --git a/model/storage-private/src/main/java/org/keycloak/models/policy/ResourcePolicyStateProviderFactory.java b/model/storage-private/src/main/java/org/keycloak/models/policy/ResourcePolicyStateProviderFactory.java new file mode 100644 index 00000000000..518a187d0cd --- /dev/null +++ b/model/storage-private/src/main/java/org/keycloak/models/policy/ResourcePolicyStateProviderFactory.java @@ -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, EnvironmentDependentProviderFactory { + + @Override + default boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.RESOURCE_LIFECYCLE); + } + +} diff --git a/model/storage-private/src/main/java/org/keycloak/models/policy/ResourcePolicyStateSpi.java b/model/storage-private/src/main/java/org/keycloak/models/policy/ResourcePolicyStateSpi.java new file mode 100644 index 00000000000..ed590b634cd --- /dev/null +++ b/model/storage-private/src/main/java/org/keycloak/models/policy/ResourcePolicyStateSpi.java @@ -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 getProviderClass() { + return ResourcePolicyStateProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return ResourcePolicyStateProviderFactory.class; + } +} diff --git a/model/storage-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/model/storage-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi index e815bb7beaa..5589d6b415a 100644 --- a/model/storage-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/model/storage-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -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 diff --git a/model/storage/src/main/java/org/keycloak/storage/adapter/AbstractUserAdapter.java b/model/storage/src/main/java/org/keycloak/storage/adapter/AbstractUserAdapter.java index 1c95cf3326e..49514c1206b 100644 --- a/model/storage/src/main/java/org/keycloak/storage/adapter/AbstractUserAdapter.java +++ b/model/storage/src/main/java/org/keycloak/storage/adapter/AbstractUserAdapter.java @@ -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; diff --git a/model/storage/src/main/java/org/keycloak/storage/adapter/AbstractUserAdapterFederatedStorage.java b/model/storage/src/main/java/org/keycloak/storage/adapter/AbstractUserAdapterFederatedStorage.java index 55381b3d5f5..42b08e4f860 100644 --- a/model/storage/src/main/java/org/keycloak/storage/adapter/AbstractUserAdapterFederatedStorage.java +++ b/model/storage/src/main/java/org/keycloak/storage/adapter/AbstractUserAdapterFederatedStorage.java @@ -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; diff --git a/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceAction.java b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceAction.java new file mode 100644 index 00000000000..c55dcea48ca --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceAction.java @@ -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 { + + private static final String AFTER_KEY = "after"; + private static final String PRIORITY_KEY = "priority"; + + private String id; + private String providerId; + private MultivaluedHashMap 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 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()); + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceActionProvider.java b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceActionProvider.java new file mode 100644 index 00000000000..9890fbfba41 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceActionProvider.java @@ -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 resourceIds); + + boolean isRunnable(); +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceActionProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceActionProviderFactory.java new file mode 100644 index 00000000000..4051efeaf62 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceActionProviderFactory.java @@ -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

extends ComponentFactory, EnvironmentDependentProviderFactory { + + ResourceType getType(); + + @Override + default boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.RESOURCE_LIFECYCLE); + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceActionSpi.java b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceActionSpi.java new file mode 100644 index 00000000000..30f9b595e72 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceActionSpi.java @@ -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 getProviderClass() { + return ResourceActionProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return ResourceActionProviderFactory.class; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicy.java b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicy.java new file mode 100644 index 00000000000..db1894ee37f --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicy.java @@ -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 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> config) { + this.providerId = providerId; + MultivaluedHashMap c = new MultivaluedHashMap<>(); + config.forEach(c::addAll); + this.config = c; + } + + public String getId() { + return id; + } + + public String getProviderId() { + return providerId; + } + + public MultivaluedHashMap getConfig() { + return config; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicyProvider.java b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicyProvider.java new file mode 100644 index 00000000000..69df14a5b0a --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicyProvider.java @@ -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 getEligibleResourcesForInitialAction(long time); + + /** + * This method checks a list of candidates and returns only those that are eligible based on time. + */ + List filterEligibleResources(List candidateResourceIds, long time); +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicyProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicyProviderFactory.java new file mode 100644 index 00000000000..77e6efb68de --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicyProviderFactory.java @@ -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

extends ComponentFactory, EnvironmentDependentProviderFactory { + + ResourceType getType(); + + @Override + default boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.RESOURCE_LIFECYCLE); + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicySpi.java b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicySpi.java new file mode 100644 index 00000000000..7af7b54852c --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourcePolicySpi.java @@ -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 getProviderClass() { + return ResourcePolicyProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return ResourcePolicyProviderFactory.class; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceType.java b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceType.java new file mode 100644 index 00000000000..4a5de2cb40b --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/policy/ResourceType.java @@ -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 +} diff --git a/server-spi-private/src/main/java/org/keycloak/storage/adapter/AbstractInMemoryUserAdapter.java b/server-spi-private/src/main/java/org/keycloak/storage/adapter/AbstractInMemoryUserAdapter.java index 64dda538082..0bc24db7100 100644 --- a/server-spi-private/src/main/java/org/keycloak/storage/adapter/AbstractInMemoryUserAdapter.java +++ b/server-spi-private/src/main/java/org/keycloak/storage/adapter/AbstractInMemoryUserAdapter.java @@ -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; diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi index bc4d14b4f43..12d3ae2f1ab 100755 --- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -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 diff --git a/server-spi/src/main/java/org/keycloak/models/UserModel.java b/server-spi/src/main/java/org/keycloak/models/UserModel.java index 5148838b370..b820ca1a96f 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserModel.java +++ b/server-spi/src/main/java/org/keycloak/models/UserModel.java @@ -232,6 +232,8 @@ public interface UserModel extends RoleMapperModel { */ SubjectCredentialManager credentialManager(); + void setLastSessionRefreshTime(int lastSessionRefreshTime); + enum RequiredAction { VERIFY_EMAIL, UPDATE_PROFILE, diff --git a/server-spi/src/main/java/org/keycloak/models/utils/UserModelDelegate.java b/server-spi/src/main/java/org/keycloak/models/utils/UserModelDelegate.java index 3404319689d..fa403a81212 100755 --- a/server-spi/src/main/java/org/keycloak/models/utils/UserModelDelegate.java +++ b/server-spi/src/main/java/org/keycloak/models/utils/UserModelDelegate.java @@ -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; diff --git a/services/src/main/java/org/keycloak/models/policy/DeleteUserActionProvider.java b/services/src/main/java/org/keycloak/models/policy/DeleteUserActionProvider.java new file mode 100644 index 00000000000..719ee8a8369 --- /dev/null +++ b/services/src/main/java/org/keycloak/models/policy/DeleteUserActionProvider.java @@ -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 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; + } +} diff --git a/services/src/main/java/org/keycloak/models/policy/DeleteUserActionProviderFactory.java b/services/src/main/java/org/keycloak/models/policy/DeleteUserActionProviderFactory.java new file mode 100644 index 00000000000..6205d053635 --- /dev/null +++ b/services/src/main/java/org/keycloak/models/policy/DeleteUserActionProviderFactory.java @@ -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 { + + 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 getConfigProperties() { + return List.of(); + } +} diff --git a/services/src/main/java/org/keycloak/models/policy/DisableUserActionProvider.java b/services/src/main/java/org/keycloak/models/policy/DisableUserActionProvider.java new file mode 100644 index 00000000000..e1904d17008 --- /dev/null +++ b/services/src/main/java/org/keycloak/models/policy/DisableUserActionProvider.java @@ -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 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; + } +} diff --git a/services/src/main/java/org/keycloak/models/policy/DisableUserActionProviderFactory.java b/services/src/main/java/org/keycloak/models/policy/DisableUserActionProviderFactory.java new file mode 100644 index 00000000000..c8f98542d62 --- /dev/null +++ b/services/src/main/java/org/keycloak/models/policy/DisableUserActionProviderFactory.java @@ -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 { + + 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 getConfigProperties() { + return List.of(); + } +} diff --git a/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProvider.java b/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProvider.java new file mode 100644 index 00000000000..3f2a1e202fb --- /dev/null +++ b/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProvider.java @@ -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 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; + } +} diff --git a/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProviderFactory.java b/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProviderFactory.java new file mode 100644 index 00000000000..b80b916aa22 --- /dev/null +++ b/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProviderFactory.java @@ -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 { + + 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 getConfigProperties() { + return List.of(); + } +} diff --git a/services/src/main/java/org/keycloak/models/policy/ResourcePolicyManager.java b/services/src/main/java/org/keycloak/models/policy/ResourcePolicyManager.java new file mode 100644 index 00000000000..e3118e60dcb --- /dev/null +++ b/services/src/main/java/org/keycloak/models/policy/ResourcePolicyManager.java @@ -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> 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 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 actions) { + + validateActions(actions); + + // get the stable IDs of the new actions + Set newActionIds = actions.stream() + .map(ResourceAction::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // get the stable IDs of the old actions + List oldActions = getActions(policy); + Set 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 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 getPolicies() { + RealmModel realm = getRealm(); + return realm.getComponentsStream(realm.getId(), ResourcePolicyProvider.class.getName()) + .map(ResourcePolicy::new).toList(); + } + + public List getActions(ResourcePolicy policy) { + RealmModel realm = getRealm(); + return realm.getComponentsStream(policy.getId(), ResourceActionProvider.class.getName()) + .map(ResourceAction::new).sorted().toList(); + } + + public void runPolicies() { + List policies = getPolicies(); + + for (ResourcePolicy policy : policies) { + runPolicy(policy); + } + } + + private void runPolicy(ResourcePolicy policy) { + log.tracev("Running policy {0}", policy.getProviderId()); + + // no actions -> skip + List 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> candidatesForAction = new HashMap<>(); + for (int i = 1; i < actions.size(); i++) { + ResourceAction previousAction = actions.get(i - 1); + List 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 newResourceIds = policyProvider.getEligibleResourcesForInitialAction(initialAction.getAfter()); + log.tracev("Eligable resource IDs for initial action {0}", newResourceIds); + // 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()); + } + // + + // Process the rest of the actions + for (ResourceAction action : actions) { + // Find all resources that have completed the PREVIOUS action. + List 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 eligibleIds = policyProvider.filterEligibleResources(candidateIds, action.getAfter()); + + // 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()); + } + // + } + } + + private void runAction(ResourceActionProvider actionProvider, List 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 providerFactory = session.getKeycloakSessionFactory().getProviderFactory(ResourcePolicyStateProvider.class); + return providerFactory.create(session); + } + + private RealmModel getRealm() { + return session.getContext().getRealm(); + } + + private void validateActions(List 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); + }); + } +} diff --git a/services/src/main/java/org/keycloak/models/policy/UserActionBuilder.java b/services/src/main/java/org/keycloak/models/policy/UserActionBuilder.java new file mode 100644 index 00000000000..289aea18a5e --- /dev/null +++ b/services/src/main/java/org/keycloak/models/policy/UserActionBuilder.java @@ -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; + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.models.policy.ResourceActionProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.models.policy.ResourceActionProviderFactory new file mode 100644 index 00000000000..cd9abcc39d6 --- /dev/null +++ b/services/src/main/resources/META-INF/services/org.keycloak.models.policy.ResourceActionProviderFactory @@ -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 + diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/PolicyBuilder.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/PolicyBuilder.java new file mode 100644 index 00000000000..c2ae7611509 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/PolicyBuilder.java @@ -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> config = new HashMap<>(); + private final Map> 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 value) { + config.put(key, value); + return this; + } + + public ResourcePolicyManager build(KeycloakSession session) { + ResourcePolicyManager manager = new ResourcePolicyManager(session); + + for (Entry> entry : actions.entrySet()) { + ResourcePolicy policy = manager.addPolicy(entry.getKey(), config); + manager.updateActions(policy, entry.getValue()); + } + + return manager; + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/RLMServerConfig.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/RLMServerConfig.java new file mode 100644 index 00000000000..0a69feecac8 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/RLMServerConfig.java @@ -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); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/ResourcePolicyManagementTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/ResourcePolicyManagementTest.java new file mode 100644 index 00000000000..8ed2d0d6fc7 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/ResourcePolicyManagementTest.java @@ -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 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 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 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 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; + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/TransientUserTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/TransientUserTest.java new file mode 100644 index 00000000000..ef75b4d8573 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/TransientUserTest.java @@ -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 Marko Strukelj + */ +@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 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 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; + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/UserSessionRefreshTimePolicyTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/UserSessionRefreshTimePolicyTest.java new file mode 100644 index 00000000000..415afc99cf6 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/UserSessionRefreshTimePolicyTest.java @@ -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; + } + } +}