From fc3914c439ff50ba6b1358e254d10143b87f94e7 Mon Sep 17 00:00:00 2001 From: Martin Kanis Date: Mon, 1 Sep 2025 11:56:52 +0200 Subject: [PATCH] [RLM] Provide a action to notify users by email based on a configurable time Closes #41788 Signed-off-by: Martin Kanis --- .../ResourcePolicyActionRepresentation.java | 9 + .../policy/NotifyUserActionProvider.java | 144 +++++++- .../NotifyUserActionProviderFactory.java | 17 +- .../models/policy/UserActionBuilder.java | 2 - .../policy/GroupMembershipJoinPolicyTest.java | 19 +- .../policy/ResourcePolicyManagementTest.java | 325 +++++++++++++++++- .../policy/UserCreationTimePolicyTest.java | 15 +- .../UserSessionRefreshTimePolicyTest.java | 73 ++-- .../html/resource-policy-notification.ftl | 23 ++ .../email/messages/messages_en.properties | 10 + .../text/resource-policy-notification.ftl | 18 + 11 files changed, 597 insertions(+), 58 deletions(-) create mode 100644 themes/src/main/resources/theme/base/email/html/resource-policy-notification.ftl create mode 100644 themes/src/main/resources/theme/base/email/text/resource-policy-notification.ftl diff --git a/core/src/main/java/org/keycloak/representations/resources/policies/ResourcePolicyActionRepresentation.java b/core/src/main/java/org/keycloak/representations/resources/policies/ResourcePolicyActionRepresentation.java index 62853819735..c87f0d674cf 100644 --- a/core/src/main/java/org/keycloak/representations/resources/policies/ResourcePolicyActionRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/resources/policies/ResourcePolicyActionRepresentation.java @@ -77,6 +77,15 @@ public class ResourcePolicyActionRepresentation { return this; } + public Builder before(ResourcePolicyActionRepresentation targetAction, Duration timeBeforeTarget) { + // Calculate absolute time: targetAction.after - timeBeforeTarget + String targetAfter = targetAction.getConfig().get(AFTER_KEY).get(0); + long targetTime = Long.parseLong(targetAfter); + long thisTime = targetTime - timeBeforeTarget.toMillis(); + action.setAfter(thisTime); + return this; + } + public Builder withConfig(String key, String value) { action.setConfig(key, value); return this; diff --git a/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProvider.java b/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProvider.java index 3f2a1e202fb..81de272705e 100644 --- a/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProvider.java +++ b/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProvider.java @@ -17,16 +17,27 @@ package org.keycloak.models.policy; +import java.time.Duration; +import java.util.HashMap; import java.util.List; +import java.util.Map; + import org.jboss.logging.Logger; import org.keycloak.component.ComponentModel; +import org.keycloak.email.EmailException; +import org.keycloak.email.EmailTemplateProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; public class NotifyUserActionProvider implements ResourceActionProvider { + private static final String ACCOUNT_DISABLE_NOTIFICATION_SUBJECT = "accountDisableNotificationSubject"; + private static final String ACCOUNT_DELETE_NOTIFICATION_SUBJECT = "accountDeleteNotificationSubject"; + private static final String ACCOUNT_DISABLE_NOTIFICATION_BODY = "accountDisableNotificationBody"; + private static final String ACCOUNT_DELETE_NOTIFICATION_BODY = "accountDeleteNotificationBody"; + private final KeycloakSession session; private final ComponentModel actionModel; private final Logger log = Logger.getLogger(NotifyUserActionProvider.class); @@ -43,23 +54,142 @@ public class NotifyUserActionProvider implements ResourceActionProvider { @Override public void run(List userIds) { RealmModel realm = session.getContext().getRealm(); + EmailTemplateProvider emailProvider = session.getProvider(EmailTemplateProvider.class).setRealm(realm); + + String subjectKey = getSubjectKey(); + String bodyTemplate = getBodyTemplate(); + Map bodyAttributes = getBodyAttributes(); 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()); + if (user != null && user.getEmail() != null) { + try { + emailProvider.setUser(user).send(subjectKey, bodyTemplate, bodyAttributes); + log.debugv("Notification email sent to user {0} ({1})", user.getUsername(), user.getEmail()); + } catch (EmailException e) { + log.errorv(e, "Failed to send notification email to user {0} ({1})", user.getUsername(), user.getEmail()); + } + } else if (user != null && user.getEmail() == null) { + log.warnv("User {0} has no email address, skipping notification", user.getUsername()); } } } - private String getMessageKey() { - return actionModel.getConfig().getFirstOrDefault("message_key", "message"); + private String getSubjectKey() { + String nextActionType = getNextActionType(); + String customSubjectKey = actionModel.getConfig().getFirst("custom_subject_key"); + + if (customSubjectKey != null && !customSubjectKey.trim().isEmpty()) { + return customSubjectKey; + } + + // Return default subject key based on next action type + String defaultSubjectKey = getDefaultSubjectKey(nextActionType); + return defaultSubjectKey; } - private String getMessage() { - return actionModel.getConfig().getFirstOrDefault(getMessageKey(), "sent"); + private String getBodyTemplate() { + return "resource-policy-notification.ftl"; + } + + private Map getBodyAttributes() { + RealmModel realm = session.getContext().getRealm(); + Map attributes = new HashMap<>(); + + String nextActionType = getNextActionType(); + + // Custom message override or default based on action type + String customMessage = actionModel.getConfig().getFirst("custom_message"); + if (customMessage != null && !customMessage.trim().isEmpty()) { + attributes.put("messageKey", "customMessage"); + attributes.put("customMessage", customMessage); + } else { + attributes.put("messageKey", getDefaultMessageKey(nextActionType)); + } + + // Calculate days remaining until next action + int daysRemaining = calculateDaysUntilNextAction(); + + // Message parameters for internationalization + attributes.put("daysRemaining", daysRemaining); + attributes.put("reason", actionModel.getConfig().getFirstOrDefault("reason", "inactivity")); + attributes.put("realmName", realm.getDisplayName() != null ? realm.getDisplayName() : realm.getName()); + attributes.put("nextActionType", nextActionType); + attributes.put("subjectKey", getSubjectKey()); + + return attributes; + } + + private String getNextActionType() { + ComponentModel nextAction = getNextNonNotificationAction(); + return nextAction != null ? nextAction.getProviderId() : "unknown-action"; + } + + private int calculateDaysUntilNextAction() { + ComponentModel nextAction = getNextNonNotificationAction(); + if (nextAction == null) { + return 0; + } + + String currentAfter = actionModel.get("after"); + String nextAfter = nextAction.get("after"); + + if (currentAfter == null || nextAfter == null) { + return 0; + } + + try { + long currentMillis = Long.parseLong(currentAfter); + long nextMillis = Long.parseLong(nextAfter); + Duration difference = Duration.ofMillis(nextMillis - currentMillis); + return Math.toIntExact(difference.toDays()); + } catch (NumberFormatException e) { + log.warnv("Invalid days format: current={0}, next={1}", currentAfter, nextAfter); + return 0; + } + } + + private ComponentModel getNextNonNotificationAction() { + RealmModel realm = session.getContext().getRealm(); + ComponentModel policyModel = realm.getComponent(actionModel.getParentId()); + + List actions = realm.getComponentsStream(policyModel.getId(), ResourceActionProvider.class.getName()) + .sorted((a, b) -> { + int priorityA = Integer.parseInt(a.get("priority", "0")); + int priorityB = Integer.parseInt(b.get("priority", "0")); + return Integer.compare(priorityA, priorityB); + }) + .toList(); + + // Find current action and return next non-notification action + boolean foundCurrent = false; + for (ComponentModel action : actions) { + if (foundCurrent && !action.getProviderId().equals("notify-user-action-provider")) { + return action; + } + if (action.getId().equals(actionModel.getId())) { + foundCurrent = true; + } + } + + return null; + } + + private String getDefaultSubjectKey(String actionType) { + return switch (actionType) { + case "disable-user-action-provider" -> ACCOUNT_DISABLE_NOTIFICATION_SUBJECT; + case "delete-user-action-provider" -> ACCOUNT_DELETE_NOTIFICATION_SUBJECT; + default -> "accountNotificationSubject"; + }; + } + + private String getDefaultMessageKey(String actionType) { + return switch (actionType) { + case "disable-user-action-provider" -> ACCOUNT_DISABLE_NOTIFICATION_BODY; + case "delete-user-action-provider" -> ACCOUNT_DELETE_NOTIFICATION_BODY; + default -> "accountNotificationBody"; + }; } @Override diff --git a/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProviderFactory.java b/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProviderFactory.java index b80b916aa22..291864ca2c0 100644 --- a/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProviderFactory.java +++ b/services/src/main/java/org/keycloak/models/policy/NotifyUserActionProviderFactory.java @@ -17,6 +17,7 @@ package org.keycloak.models.policy; +import java.util.Arrays; import java.util.List; import org.keycloak.Config; @@ -61,11 +62,23 @@ public class NotifyUserActionProviderFactory implements ResourceActionProviderFa @Override public String getHelpText() { - return ""; + return "Sends email notifications to users based on configurable templates"; } @Override public List getConfigProperties() { - return List.of(); + return Arrays.asList( + new ProviderConfigProperty("reason", "Reason", + "Reason for the action (inactivity, policy violation, compliance requirement)", + ProviderConfigProperty.STRING_TYPE, ""), + + new ProviderConfigProperty("custom_subject_key", "Custom Subject Message Key", + "Override default subject with custom message property key (optional)", + ProviderConfigProperty.STRING_TYPE, ""), + + new ProviderConfigProperty("custom_message", "Custom Message", + "Override default message with custom text (optional)", + ProviderConfigProperty.TEXT_TYPE, "") + ); } } diff --git a/services/src/main/java/org/keycloak/models/policy/UserActionBuilder.java b/services/src/main/java/org/keycloak/models/policy/UserActionBuilder.java index 289aea18a5e..1c69ec734bb 100644 --- a/services/src/main/java/org/keycloak/models/policy/UserActionBuilder.java +++ b/services/src/main/java/org/keycloak/models/policy/UserActionBuilder.java @@ -19,8 +19,6 @@ package org.keycloak.models.policy; import java.time.Duration; -import org.keycloak.common.util.KeycloakUriBuilder; - public class UserActionBuilder { private final ResourceAction action; diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/GroupMembershipJoinPolicyTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/GroupMembershipJoinPolicyTest.java index 43299ed7ed7..90e2b766bde 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/GroupMembershipJoinPolicyTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/GroupMembershipJoinPolicyTest.java @@ -3,11 +3,12 @@ package org.keycloak.tests.admin.model.policy; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.keycloak.tests.admin.model.policy.ResourcePolicyManagementTest.findEmailByRecipient; import java.time.Duration; import java.util.List; +import jakarta.mail.internet.MimeMessage; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import org.junit.jupiter.api.Test; @@ -27,6 +28,8 @@ import org.keycloak.representations.resources.policies.ResourcePolicyRepresentat import org.keycloak.testframework.annotations.InjectRealm; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.injection.LifeCycle; +import org.keycloak.testframework.mail.MailServer; +import org.keycloak.testframework.mail.annotations.InjectMailServer; import org.keycloak.testframework.realm.GroupConfigBuilder; import org.keycloak.testframework.realm.ManagedRealm; import org.keycloak.testframework.realm.UserConfigBuilder; @@ -45,6 +48,9 @@ public class GroupMembershipJoinPolicyTest { @InjectRealm(lifecycle = LifeCycle.METHOD) ManagedRealm managedRealm; + @InjectMailServer + private MailServer mailServer; + @Test public void testEventsOnGroupMembershipJoin() { String groupId; @@ -77,7 +83,7 @@ public class GroupMembershipJoinPolicyTest { String userId; try (Response response = managedRealm.admin().users().create(UserConfigBuilder.create() - .username("generic-user").build())) { + .username("generic-user").email("generic-user@example.com").build())) { userId = ApiUtil.getCreatedId(response); } @@ -88,18 +94,21 @@ public class GroupMembershipJoinPolicyTest { ResourcePolicyManager manager = new ResourcePolicyManager(session); UserModel user = session.users().getUserById(realm, userId); - assertNull(user.getAttributes().get("message")); try { // set offset to 7 days - notify action should run now Time.setOffset(Math.toIntExact(Duration.ofDays(6).toSeconds())); manager.runScheduledActions(); - user = session.users().getUserById(realm, userId); - assertNotNull(user.getAttributes().get("message")); } finally { Time.setOffset(0); } })); + + // Verify that the notify action was executed by checking email was sent + MimeMessage testUserMessage = findEmailByRecipient(mailServer, "generic-user@example.com"); + assertNotNull(testUserMessage, "The first action (notify) should have sent an email."); + + mailServer.runCleanup(); } private static RealmModel configureSessionContext(KeycloakSession session) { 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 index 3ff03fed785..b0662ce8e1b 100644 --- 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 @@ -19,24 +19,32 @@ package org.keycloak.tests.admin.model.policy; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.fail; 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 java.time.Duration; +import java.util.Arrays; import java.util.List; import java.util.UUID; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import org.hamcrest.Matchers; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import java.io.IOException; + import org.junit.jupiter.api.Test; import org.keycloak.admin.client.resource.RealmResourcePolicies; import org.keycloak.common.util.Time; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.models.policy.DeleteUserActionProviderFactory; import org.keycloak.models.policy.DisableUserActionProviderFactory; import org.keycloak.models.policy.NotifyUserActionProviderFactory; import org.keycloak.models.policy.ResourceAction; @@ -52,6 +60,8 @@ import org.keycloak.representations.resources.policies.ResourcePolicyConditionRe import org.keycloak.representations.resources.policies.ResourcePolicyRepresentation; import org.keycloak.testframework.annotations.InjectRealm; import org.keycloak.testframework.annotations.InjectUser; +import org.keycloak.testframework.mail.MailServer; +import org.keycloak.testframework.mail.annotations.InjectMailServer; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.injection.LifeCycle; import org.keycloak.testframework.realm.ManagedRealm; @@ -61,6 +71,7 @@ import org.keycloak.testframework.realm.UserConfigBuilder; import org.keycloak.testframework.remote.providers.runonserver.RunOnServer; import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; import org.keycloak.testframework.remote.runonserver.RunOnServerClient; +import org.keycloak.tests.utils.MailUtils; @KeycloakIntegrationTest(config = RLMServerConfig.class) public class ResourcePolicyManagementTest { @@ -76,6 +87,9 @@ public class ResourcePolicyManagementTest { @InjectUser(ref = "alice", config = DefaultUserConfig.class, lifecycle = LifeCycle.METHOD) private ManagedUser userAlice; + @InjectMailServer + private MailServer mailServer; + @Test public void testCreate() { List expectedPolicies = ResourcePolicyRepresentation.create() @@ -207,7 +221,7 @@ public class ResourcePolicyManagementTest { ).build()).close(); // create a new user - should bind the user to the policy and setup the first action - managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser").build()); + managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser").email("testuser@example.com").build()); runOnServer.run((RunOnServer) session -> { RealmModel realm = configureSessionContext(session); @@ -232,9 +246,6 @@ public class ResourcePolicyManagementTest { manager.runScheduledActions(); user = session.users().getUserById(realm, user.getId()); - // Verify that ONLY the first action (notify) was executed. - assertNotNull(user.getAttributes().get("message"), "The first action (notify) should have run."); - assertTrue(user.isEnabled(), "The second action (disable) should NOT have run."); // Verify that the next action was scheduled for the user ResourceAction disableAction = manager.getActions(policy).get(1); @@ -245,6 +256,12 @@ public class ResourcePolicyManagementTest { Time.setOffset(0); } }); + + // Verify that the first action (notify) was executed by checking email was sent + MimeMessage testUserMessage = findEmailByRecipient(mailServer, "testuser@example.com"); + assertNotNull(testUserMessage, "The first action (notify) should have sent an email."); + + mailServer.runCleanup(); } @Test @@ -380,7 +397,7 @@ public class ResourcePolicyManagementTest { assertThat(policy.getName(), is("test-policy")); // create a new user - should bind the user to the policy and setup the first action - managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser").build()); + managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser").email("testuser@example.com").build()); runOnServer.run((RunOnServer) session -> { RealmModel realm = configureSessionContext(session); @@ -392,14 +409,18 @@ public class ResourcePolicyManagementTest { manager.runScheduledActions(); UserModel user = session.users().getUserByUsername(realm, "testuser"); - // 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."); } finally { Time.setOffset(0); } }); + // Verify that the first action (notify) was executed by checking email was sent + MimeMessage testUserMessage = findEmailByRecipient(mailServer, "testuser@example.com"); + assertNotNull(testUserMessage, "The first action (notify) should have sent an email."); + + mailServer.runCleanup(); + // disable the policy - scheduled actions should be paused and policy should not activate for new users policy.getConfig().putSingle("enabled", "false"); managedRealm.admin().resources().policies().policy(policy.getId()).update(policy).close(); @@ -440,7 +461,7 @@ public class ResourcePolicyManagementTest { managedRealm.admin().resources().policies().policy(policy.getId()).update(policy).close(); // create a third user - should bind the user to the policy as it is enabled again - managedRealm.admin().users().create(UserConfigBuilder.create().username("thirduser").build()); + managedRealm.admin().users().create(UserConfigBuilder.create().username("thirduser").email("thirduser@example.com").build()); runOnServer.run((RunOnServer) session -> { RealmModel realm = configureSessionContext(session); @@ -455,14 +476,19 @@ public class ResourcePolicyManagementTest { // Verify that the action was executed as the policy was re-enabled. assertFalse(user.isEnabled(), "The second action (disable) should have run as the policy was re-enabled."); - // Verify that the third user was bound to the policy and had the first action executed. + // Verify that the third user was bound to the policy user = session.users().getUserByUsername(realm, "thirduser"); - assertNotNull(user.getAttributes().get("message"), "The first action (notify) should have run."); assertTrue(user.isEnabled(), "The second action (disable) should NOT have run"); } finally { Time.setOffset(0); } }); + + // Verify that the first action (notify) was executed by checking email was sent + testUserMessage = findEmailByRecipient(mailServer, "thirduser@example.com"); + assertNotNull(testUserMessage, "The first action (notify) should have sent an email."); + + mailServer.runCleanup(); } @Test @@ -478,7 +504,7 @@ public class ResourcePolicyManagementTest { ).build()).close(); // create a new user - should bind the user to the policy and setup the only action in the policy - managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser").build()); + managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser").email("testuser@example.com").build()); runOnServer.run((RunOnServer) session -> { RealmModel realm = configureSessionContext(session); @@ -489,9 +515,6 @@ public class ResourcePolicyManagementTest { manager.runScheduledActions(); UserModel user = session.users().getUserByUsername(realm, "testuser"); - // Verify that the action (notify) was executed. - assertNotNull(user.getAttributes().get("message"), "The action (notify) should have run."); - user.removeAttribute("message"); ResourcePolicy policy = manager.getPolicies().get(0); ResourceAction action = manager.getActions(policy).get(0); @@ -503,12 +526,258 @@ public class ResourcePolicyManagementTest { Time.setOffset(Math.toIntExact(Duration.ofDays(12).toSeconds())); manager.runScheduledActions(); - user = session.users().getUserByUsername(realm, "testuser"); - assertNotNull(user.getAttributes().get("message"), "The action (notify) should have run again."); } finally { Time.setOffset(0); } }); + + // Verify that there should be two emails sent + assertEquals(2, findEmailsByRecipient(mailServer, "testuser@example.com").size()); + mailServer.runCleanup(); + } + + @Test + public void testNotifyUserActionSendsEmailWithDefaultDisableMessage() { + // Create policy: disable at 10 days, notify 3 days before (at day 7) + managedRealm.admin().resources().policies().create(ResourcePolicyRepresentation.create() + .of(UserCreationTimeResourcePolicyProviderFactory.ID) + .withActions( + ResourcePolicyActionRepresentation.create().of(NotifyUserActionProviderFactory.ID) + .after(Duration.ofDays(7)) + .withConfig("reason", "inactivity") + .build(), + ResourcePolicyActionRepresentation.create().of(DisableUserActionProviderFactory.ID) + .after(Duration.ofDays(10)) + .build() + ).build()).close(); + + managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser").email("test@example.com").name("John", "").build()); + + runOnServer.run(session -> { + ResourcePolicyManager manager = new ResourcePolicyManager(session); + + try { + // Simulate user being 7 days old (eligible for notify action) + Time.setOffset(Math.toIntExact(Duration.ofDays(7).toSeconds())); + + manager.runScheduledActions(); + } finally { + Time.setOffset(0); + } + }); + + // Verify email was sent to our test user + MimeMessage testUserMessage = findEmailByRecipient(mailServer, "test@example.com"); + assertNotNull(testUserMessage, "No email found for test@example.com"); + verifyEmailContent(testUserMessage, "test@example.com", "Disable", "John", "3", "inactivity"); + + mailServer.runCleanup(); + } + + @Test + public void testNotifyUserActionSendsEmailWithDefaultDeleteMessage() { + // Create policy: delete at 30 days, notify 15 days before (at day 15) + managedRealm.admin().resources().policies().create(ResourcePolicyRepresentation.create() + .of(UserCreationTimeResourcePolicyProviderFactory.ID) + .withActions( + ResourcePolicyActionRepresentation.create().of(NotifyUserActionProviderFactory.ID) + .after(Duration.ofDays(15)) + .withConfig("reason", "inactivity") + .build(), + ResourcePolicyActionRepresentation.create().of(DeleteUserActionProviderFactory.ID) + .after(Duration.ofDays(30)) + .build() + ).build()).close(); + + managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser2").email("test2@example.com").name("Jane", "").build()); + + runOnServer.run(session -> { + + ResourcePolicyManager manager = new ResourcePolicyManager(session); + + try { + // Simulate user being 15 days old + Time.setOffset(Math.toIntExact(Duration.ofDays(15).toSeconds())); + manager.runScheduledActions(); + } finally { + Time.setOffset(0); + } + }); + + // Verify email was sent to our test user + MimeMessage testUserMessage = findEmailByRecipient(mailServer, "test2@example.com"); + assertNotNull(testUserMessage, "No email found for test2@example.com"); + verifyEmailContent(testUserMessage, "test2@example.com", "Deletion", "Jane", "15", "inactivity", "permanently deleted"); + + mailServer.runCleanup(); + } + + @Test + public void testNotifyUserActionWithCustomMessageOverride() { + // Create policy: disable at 7 days, notify 2 days before (at day 5) with custom message + managedRealm.admin().resources().policies().create(ResourcePolicyRepresentation.create() + .of(UserCreationTimeResourcePolicyProviderFactory.ID) + .withActions( + ResourcePolicyActionRepresentation.create().of(NotifyUserActionProviderFactory.ID) + .after(Duration.ofDays(5)) + .withConfig("reason", "compliance requirement") + .withConfig("custom_message", "Your account requires immediate attention due to new compliance policies.") + .withConfig("custom_subject_key", "customComplianceSubject") + .build(), + ResourcePolicyActionRepresentation.create().of(DisableUserActionProviderFactory.ID) + .after(Duration.ofDays(7)) + .build() + ).build()).close(); + + managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser3").email("test3@example.com").name("Bob", "").build()); + + runOnServer.run(session -> { + ResourcePolicyManager manager = new ResourcePolicyManager(session); + + try { + // Simulate user being 5 days old + Time.setOffset(Math.toIntExact(Duration.ofDays(5).toSeconds())); + manager.runScheduledActions(); + } finally { + Time.setOffset(0); + } + }); + + // Verify email was sent to our test user + MimeMessage testUserMessage = findEmailByRecipient(mailServer, "test3@example.com"); + assertNotNull(testUserMessage, "No email found for test3@example.com"); + verifyEmailContent(testUserMessage, "test3@example.com", "", "Bob", "2", "immediate attention due to new compliance policies"); + + mailServer.runCleanup(); + } + + @Test + public void testNotifyUserActionSkipsUsersWithoutEmailButLogsWarning() { + managedRealm.admin().resources().policies().create(ResourcePolicyRepresentation.create() + .of(UserCreationTimeResourcePolicyProviderFactory.ID) + .withActions( + ResourcePolicyActionRepresentation.create().of(NotifyUserActionProviderFactory.ID) + .after(Duration.ofDays(5)) + .build(), + ResourcePolicyActionRepresentation.create().of(DisableUserActionProviderFactory.ID) + .after(Duration.ofDays(10)) + .build() + ).build()).close(); + + managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser4").name("NoEmail", "").build()); + + runOnServer.run(session -> { + RealmModel realm = configureSessionContext(session); + ResourcePolicyManager manager = new ResourcePolicyManager(session); + + try { + Time.setOffset(Math.toIntExact(Duration.ofDays(5).toSeconds())); + manager.runScheduledActions(); + + // But should still create state record for the policy flow + UserModel user = session.users().getUserByUsername(realm, "testuser4"); + ResourcePolicyStateProvider stateProvider = session.getProvider(ResourcePolicyStateProvider.class); + var scheduledActions = stateProvider.getScheduledActionsByResource(user.getId()); + assertEquals(1, scheduledActions.size()); + } finally { + Time.setOffset(0); + } + }); + + // Should NOT send email to user without email address + MimeMessage testUserMessage = findEmailByRecipientContaining("testuser4"); + assertNull(testUserMessage, "No email should be sent to user without email address"); + } + + @Test + public void testCompleteUserLifecycleWithMultipleNotifications() { + // Create policy: just disable at 30 days with one notification before + managedRealm.admin().resources().policies().create(ResourcePolicyRepresentation.create() + .of(UserCreationTimeResourcePolicyProviderFactory.ID) + .withActions( + ResourcePolicyActionRepresentation.create().of(NotifyUserActionProviderFactory.ID) + .after(Duration.ofDays(15)) + .withConfig("reason", "inactivity") + .build(), + ResourcePolicyActionRepresentation.create().of(DisableUserActionProviderFactory.ID) + .after(Duration.ofDays(30)) + .build() + ).build()).close(); + + managedRealm.admin().users().create(UserConfigBuilder.create().username("testuser5").email("testuser5@example.com").name("TestUser5", "").build()); + + runOnServer.run(session -> { + RealmModel realm = configureSessionContext(session); + ResourcePolicyManager manager = new ResourcePolicyManager(session); + UserModel user = session.users().getUserByUsername(realm, "testuser5"); + + try { + // Day 15: First notification - this should run the notify action and schedule the disable action + Time.setOffset(Math.toIntExact(Duration.ofDays(15).toSeconds())); + manager.runScheduledActions(); + + // Check that user is still enabled after notification + user = session.users().getUserById(realm, user.getId()); + assertTrue(user.isEnabled(), "User should still be enabled after notification"); + + // Day 30 + 15 minutes: Disable user - run 15 minutes after the scheduled time to ensure it's due + Time.setOffset(Math.toIntExact(Duration.ofDays(30).toSeconds()) + Math.toIntExact(Duration.ofMinutes(15).toSeconds())); + manager.runScheduledActions(); + + // Verify user is disabled + user = session.users().getUserById(realm, user.getId()); + assertNotNull(user, "User should still exist after disable"); + assertFalse(user.isEnabled(), "User should be disabled"); + + } finally { + Time.setOffset(0); + } + }); + + // Verify notification was sent + MimeMessage testUserMessage = findEmailByRecipient(mailServer, "testuser5@example.com"); + assertNotNull(testUserMessage, "No email found for testuser5@example.com"); + verifyEmailContent(testUserMessage, "testuser5@example.com", "Disable", "TestUser5", "15", "inactivity"); + + mailServer.runCleanup(); + } + + public static List findEmailsByRecipient(MailServer mailServer, String expectedRecipient) { + return Arrays.stream(mailServer.getReceivedMessages()) + .filter(msg -> { + try { + return MailUtils.getRecipient(msg).equals(expectedRecipient); + } catch (Exception e) { + return false; + } + }) + .toList(); + } + + public static MimeMessage findEmailByRecipient(MailServer mailServer, String expectedRecipient) { + return Arrays.stream(mailServer.getReceivedMessages()) + .filter(msg -> { + try { + return MailUtils.getRecipient(msg).equals(expectedRecipient); + } catch (Exception e) { + return false; + } + }) + .findFirst() + .orElse(null); + } + + private MimeMessage findEmailByRecipientContaining(String recipientPart) { + return Arrays.stream(mailServer.getReceivedMessages()) + .filter(msg -> { + try { + return MailUtils.getRecipient(msg).contains(recipientPart); + } catch (Exception e) { + return false; + } + }) + .findFirst() + .orElse(null); } private static RealmModel configureSessionContext(KeycloakSession session) { @@ -517,6 +786,30 @@ public class ResourcePolicyManagementTest { return realm; } + public static void verifyEmailContent(MimeMessage message, String expectedRecipient, String subjectContains, + String... contentContains) { + try { + assertEquals(expectedRecipient, MailUtils.getRecipient(message)); + assertTrue(message.getSubject().contains(subjectContains), + "Subject should contain '" + subjectContains + "'"); + + MailUtils.EmailBody body = MailUtils.getBody(message); + String textContent = body.getText(); + String htmlContent = body.getHtml(); + + for (String expectedContent : contentContains) { + boolean foundInText = textContent.contains(expectedContent); + boolean foundInHtml = htmlContent.contains(expectedContent); + assertTrue(foundInText || foundInHtml, + "Email content should contain: " + expectedContent + + "\nText: " + textContent + + "\nHTML: " + htmlContent); + } + } catch (MessagingException | IOException e) { + fail("Failed to read email message: " + e.getMessage()); + } + } + private static class DefaultUserConfig implements UserConfig { @Override diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/UserCreationTimePolicyTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/UserCreationTimePolicyTest.java index c6e0ac14887..82b83af7785 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/UserCreationTimePolicyTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/policy/UserCreationTimePolicyTest.java @@ -1,5 +1,6 @@ package org.keycloak.tests.admin.model.policy; +import jakarta.mail.internet.MimeMessage; import org.junit.jupiter.api.Test; import org.keycloak.common.util.Time; import org.keycloak.models.KeycloakSession; @@ -15,6 +16,8 @@ import org.keycloak.representations.resources.policies.ResourcePolicyActionRepre import org.keycloak.representations.resources.policies.ResourcePolicyRepresentation; import org.keycloak.testframework.annotations.InjectRealm; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.mail.MailServer; +import org.keycloak.testframework.mail.annotations.InjectMailServer; import org.keycloak.testframework.oauth.OAuthClient; import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; import org.keycloak.testframework.realm.ManagedRealm; @@ -32,6 +35,7 @@ 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.keycloak.tests.admin.model.policy.ResourcePolicyManagementTest.findEmailByRecipient; @KeycloakIntegrationTest(config = RLMServerConfig.class) public class UserCreationTimePolicyTest { @@ -53,6 +57,9 @@ public class UserCreationTimePolicyTest { @InjectOAuthClient OAuthClient oauth; + @InjectMailServer + private MailServer mailServer; + @Test public void testDisableUserBasedOnCreationDate() { managedRealm.admin().resources().policies().create(ResourcePolicyRepresentation.create() @@ -91,12 +98,17 @@ public class UserCreationTimePolicyTest { manager.runScheduledActions(); user = session.users().getUserByUsername(realm, "alice"); assertTrue(user.isEnabled()); - assertNotNull(user.getAttributes().get("message")); } finally { Time.setOffset(0); } })); + // Verify that the notify action was executed by checking email was sent + MimeMessage testUserMessage = findEmailByRecipient(mailServer, "alice@wornderland.org"); + assertNotNull(testUserMessage, "The first action (notify) should have sent an email."); + + mailServer.runCleanup(); + // logging-in with alice should not reset the policy - we should still run the disable action next oauth.openLoginForm(); loginPage.fillLogin("alice", "alice"); @@ -114,7 +126,6 @@ public class UserCreationTimePolicyTest { manager.runScheduledActions(); UserModel user = session.users().getUserByUsername(realm, "alice"); assertFalse(user.isEnabled()); - assertNotNull(user.getAttributes().get("message")); } finally { Time.setOffset(0); } 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 index a3df24676d2..af191bba68d 100644 --- 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 @@ -17,13 +17,18 @@ 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.keycloak.tests.admin.model.policy.ResourcePolicyManagementTest.findEmailByRecipient; +import static org.keycloak.tests.admin.model.policy.ResourcePolicyManagementTest.findEmailsByRecipient; +import static org.keycloak.tests.admin.model.policy.ResourcePolicyManagementTest.verifyEmailContent; import java.time.Duration; +import java.util.List; +import jakarta.mail.internet.MimeMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.keycloak.common.util.Time; @@ -42,6 +47,8 @@ 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.mail.MailServer; +import org.keycloak.testframework.mail.annotations.InjectMailServer; import org.keycloak.testframework.oauth.OAuthClient; import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; import org.keycloak.testframework.realm.ManagedRealm; @@ -78,9 +85,17 @@ public class UserSessionRefreshTimePolicyTest { @InjectOAuthClient OAuthClient oauth; + @InjectMailServer + private MailServer mailServer; + @BeforeEach public void onBefore() { oauth.realm("default"); + + runOnServer.run(session -> { + ResourcePolicyManager manager = new ResourcePolicyManager(session); + manager.removePolicies(); + }); } @Test @@ -111,13 +126,11 @@ public class UserSessionRefreshTimePolicyTest { UserModel user = session.users().getUserByUsername(realm, username); assertTrue(user.isEnabled()); - assertNull(user.getAttributes().get("message")); // running the scheduled tasks now shouldn't pick up any action as none are due to run yet manager.runScheduledActions(); user = session.users().getUserByUsername(realm, username); assertTrue(user.isEnabled()); - assertNull(user.getAttributes().get("message")); try { // set offset to 6 days - notify action should run now @@ -125,12 +138,17 @@ public class UserSessionRefreshTimePolicyTest { manager.runScheduledActions(); user = session.users().getUserByUsername(realm, username); assertTrue(user.isEnabled()); - assertNotNull(user.getAttributes().get("message")); } finally { Time.setOffset(0); } })); + // Verify that the notify action was executed by checking email was sent + MimeMessage testUserMessage = findEmailByRecipient(mailServer, "master-admin@email.org"); + assertNotNull(testUserMessage, "The first action (notify) should have sent an email."); + + mailServer.runCleanup(); + // trigger a login event that should reset the flow of the policy oauth.openLoginForm(); @@ -170,14 +188,16 @@ public class UserSessionRefreshTimePolicyTest { .withActions( ResourcePolicyActionRepresentation.create().of(NotifyUserActionProviderFactory.ID) .after(Duration.ofDays(5)) - .withConfig("message_key", "notifier1") + .withConfig("custom_subject_key", "notifier1_subject") + .withConfig("custom_message", "notifier1_message") .build() ).of(UserSessionRefreshTimeResourcePolicyProviderFactory.ID) .onEvent(ResourceOperationType.LOGIN.toString()) .withActions( ResourcePolicyActionRepresentation.create().of(NotifyUserActionProviderFactory.ID) .after(Duration.ofDays(10)) - .withConfig("message_key", "notifier2") + .withConfig("custom_subject_key", "notifier2_subject") + .withConfig("custom_message", "notifier2_message") .build()) .build()).close(); @@ -195,42 +215,47 @@ public class UserSessionRefreshTimePolicyTest { UserProvider users = session.users(); UserModel user = users.getUserByUsername(realm, username); assertTrue(user.isEnabled()); - assertNull(user.getFirstAttribute("notifier1")); - assertNull(user.getFirstAttribute("notifier2")); try { Time.setOffset(Math.toIntExact(Duration.ofDays(7).toSeconds())); manager.runScheduledActions(); user = users.getUserByUsername(realm, username); assertTrue(user.isEnabled()); - assertNotNull(user.getFirstAttribute("notifier1")); - assertNull(user.getFirstAttribute("notifier2")); - user.removeAttribute("notifier1"); } finally { Time.setOffset(0); } + }); + // Verify that the first notify action was executed by checking email was sent + List testUserMessages = findEmailsByRecipient(mailServer, "master-admin@email.org"); + // Only one notify message should be sent + assertEquals(1, testUserMessages.size()); + assertNotNull(testUserMessages.get(0), "The first action (notify) should have sent an email."); + verifyEmailContent(testUserMessages.get(0), "master-admin@email.org", "notifier1_subject", "notifier1_message"); + + mailServer.runCleanup(); + + runOnServer.run(session -> { + RealmModel realm = configureSessionContext(session); + ResourcePolicyManager manager = new ResourcePolicyManager(session); + + UserModel user = session.users().getUserByUsername(realm, username); try { Time.setOffset(Math.toIntExact(Duration.ofDays(11).toSeconds())); manager.runScheduledActions(); - user = users.getUserByUsername(realm, username); + user = session.users().getUserByUsername(realm, username); assertTrue(user.isEnabled()); - assertNotNull(user.getFirstAttribute("notifier2")); - assertNull(user.getFirstAttribute("notifier1")); - user.removeAttribute("notifier2"); } finally { Time.setOffset(0); } - - try { - manager.runScheduledActions(); - assertNull(user.getFirstAttribute("notifier1")); - assertNull(user.getFirstAttribute("notifier2")); - } finally { - Time.setOffset(0); - } - }); + + // Verify that the second notify action was executed by checking email was sent + testUserMessages = findEmailsByRecipient(mailServer, "master-admin@email.org"); + // Only one notify message should be sent + assertEquals(1, testUserMessages.size()); + assertNotNull(testUserMessages.get(0), "The second action (notify) should have sent an email."); + verifyEmailContent(testUserMessages.get(0), "master-admin@email.org", "notifier2_subject", "notifier2_message"); } private static RealmModel configureSessionContext(KeycloakSession session) { diff --git a/themes/src/main/resources/theme/base/email/html/resource-policy-notification.ftl b/themes/src/main/resources/theme/base/email/html/resource-policy-notification.ftl new file mode 100644 index 00000000000..04e422274fd --- /dev/null +++ b/themes/src/main/resources/theme/base/email/html/resource-policy-notification.ftl @@ -0,0 +1,23 @@ +<#import "template.ftl" as layout> +<@layout.emailLayout> +

${kcSanitize(msg(subjectKey, daysRemaining, reason))?no_esc}

+ +

Dear ${user.firstName!user.username},

+ +<#if messageKey == "customMessage"> +

${kcSanitize(customMessage)?no_esc}

+<#else> +

${kcSanitize(msg(messageKey, daysRemaining, reason))?no_esc}

+ + +<#if daysRemaining gt 0> +

Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s

+ + +

If you have questions, please contact your ${realmName} administrator.

+ +

+Best regards,
+${realmName} Administration +

+ \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/email/messages/messages_en.properties b/themes/src/main/resources/theme/base/email/messages/messages_en.properties index f811bf84105..2599217fc8d 100755 --- a/themes/src/main/resources/theme/base/email/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/email/messages/messages_en.properties @@ -28,6 +28,16 @@ eventRemoveTotpSubject=Remove OTP eventRemoveTotpBody=OTP was removed from your account on {0} from {1}. If this was not you, please contact an administrator. eventRemoveTotpBodyHtml=

OTP was removed from your account on {0} from {1}. If this was not you, please contact an administrator.

eventUpdatePasswordSubject=Update password + +# Resource Policy Notifications +accountNotificationSubject=Account Notification +accountNotificationBody=Your account requires attention. Please contact your administrator if you have questions. + +accountDisableNotificationSubject=Account Disable Warning +accountDisableNotificationBody=Your account will be disabled in {0} days due to {1}. Please log in to prevent this action. + +accountDeleteNotificationSubject=Account Deletion Notice +accountDeleteNotificationBody=Your account will be permanently deleted in {0} days due to {1}. All data will be lost. Please contact your administrator immediately if this is unexpected. eventUpdatePasswordBody=Your password was changed on {0} from {1}. If this was not you, please contact an administrator. eventUpdatePasswordBodyHtml=

Your password was changed on {0} from {1}. If this was not you, please contact an administrator.

eventUpdateTotpSubject=Update OTP diff --git a/themes/src/main/resources/theme/base/email/text/resource-policy-notification.ftl b/themes/src/main/resources/theme/base/email/text/resource-policy-notification.ftl new file mode 100644 index 00000000000..a6267fe66ba --- /dev/null +++ b/themes/src/main/resources/theme/base/email/text/resource-policy-notification.ftl @@ -0,0 +1,18 @@ +${kcSanitize(msg(subjectKey, daysRemaining, reason))?no_esc} + +Dear ${user.firstName!user.username}, + +<#if messageKey == "customMessage"> +${kcSanitize(customMessage)?no_esc} +<#else> +${kcSanitize(msg(messageKey, daysRemaining, reason))?no_esc} + + +<#if daysRemaining gt 0> +Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s + + +If you have questions, please contact your ${realmName} administrator. + +Best regards, +${realmName} Administration \ No newline at end of file