[RLM] Provide a action to notify users by email based on a configurable time

Closes #41788

Signed-off-by: Martin Kanis <mkanis@redhat.com>
This commit is contained in:
Martin Kanis
2025-09-01 11:56:52 +02:00
committed by Pedro Igor
parent 35e6d7512c
commit fc3914c439
11 changed files with 597 additions and 58 deletions

View File

@@ -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;

View File

@@ -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<String> userIds) {
RealmModel realm = session.getContext().getRealm();
EmailTemplateProvider emailProvider = session.getProvider(EmailTemplateProvider.class).setRealm(realm);
String subjectKey = getSubjectKey();
String bodyTemplate = getBodyTemplate();
Map<String, Object> 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<String, Object> getBodyAttributes() {
RealmModel realm = session.getContext().getRealm();
Map<String, Object> 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<ComponentModel> 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

View File

@@ -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<ProviderConfigProperty> 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, "")
);
}
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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<ResourcePolicyRepresentation> 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<MimeMessage> 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

View File

@@ -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);
}

View File

@@ -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<MimeMessage> 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) {

View File

@@ -0,0 +1,23 @@
<#import "template.ftl" as layout>
<@layout.emailLayout>
<h2>${kcSanitize(msg(subjectKey, daysRemaining, reason))?no_esc}</h2>
<p>Dear ${user.firstName!user.username},</p>
<#if messageKey == "customMessage">
<p>${kcSanitize(customMessage)?no_esc}</p>
<#else>
<p>${kcSanitize(msg(messageKey, daysRemaining, reason))?no_esc}</p>
</#if>
<#if daysRemaining gt 0>
<p><strong>Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s</#if></strong></p>
</#if>
<p>If you have questions, please contact your ${realmName} administrator.</p>
<p>
Best regards,<br>
${realmName} Administration
</p>
</@layout.emailLayout>

View File

@@ -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=<p>OTP was removed from your account on {0} from {1}. If this was not you, please contact an administrator.</p>
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=<p>Your password was changed on {0} from {1}. If this was not you, please contact an administrator.</p>
eventUpdateTotpSubject=Update OTP

View File

@@ -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>
<#if daysRemaining gt 0>
Time remaining: ${daysRemaining} day<#if daysRemaining != 1>s</#if>
</#if>
If you have questions, please contact your ${realmName} administrator.
Best regards,
${realmName} Administration