From 4759f53ec511f9e9dc723ebbe219ad086f652b08 Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Sat, 9 May 2026 16:14:04 +0200 Subject: [PATCH] Emit explicit DISABLE/ENABLE events on user enabled-flag transitions (#48855) Adds OperationType.DISABLE/ENABLE and EventType.USER_DISABLED/USER_ENABLED so listeners can detect user enable/disable transitions without diffing the JSON representation of a generic UPDATE event. UserResource.updateUser now emits an additional cloned AdminEvent (DISABLE or ENABLE) alongside the existing UPDATE on actual transitions only. The new user EventType entries are reserved for system-driven flips (workflow steps, custom SPI code) that have no admin context. Existing USER_DISABLED_BY_*_LOCKOUT events and the UPDATE emission are unchanged for backward compatibility. Fixes #48855 Signed-off-by: Thomas Darimont --- .../java/org/keycloak/events/EventType.java | 5 ++ .../keycloak/events/admin/OperationType.java | 4 +- .../resources/admin/UserResource.java | 11 +++ .../tests/admin/user/UserUpdateTest.java | 74 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/server-spi-private/src/main/java/org/keycloak/events/EventType.java b/server-spi-private/src/main/java/org/keycloak/events/EventType.java index 1bc36b39b131..d6f6517119f5 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/EventType.java +++ b/server-spi-private/src/main/java/org/keycloak/events/EventType.java @@ -203,6 +203,11 @@ public enum EventType implements EnumWithStableIndex { JWT_AUTHORIZATION_GRANT(70, true), JWT_AUTHORIZATION_GRANT_ERROR(0x10000 + JWT_AUTHORIZATION_GRANT.getStableIndex(), true), + + USER_DISABLED(71, true), + USER_DISABLED_ERROR(0x10000 + USER_DISABLED.getStableIndex(), false), + USER_ENABLED(72, true), + USER_ENABLED_ERROR(0x10000 + USER_ENABLED.getStableIndex(), false), ; private final int stableIndex; diff --git a/server-spi-private/src/main/java/org/keycloak/events/admin/OperationType.java b/server-spi-private/src/main/java/org/keycloak/events/admin/OperationType.java index b7893dfd16fa..dcb728fc3d6b 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/admin/OperationType.java +++ b/server-spi-private/src/main/java/org/keycloak/events/admin/OperationType.java @@ -30,7 +30,9 @@ public enum OperationType implements EnumWithStableIndex { CREATE(0), UPDATE(1), DELETE(2), - ACTION(3); + ACTION(3), + DISABLE(4), + ENABLE(5); private final int stableIndex; private static final Map BY_ID = EnumWithStableIndex.getReverseIndex(values()); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java index 1df536ea7293..057f47bf5af0 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java @@ -198,6 +198,8 @@ public Response updateUser(final UserRepresentation rep) { auth.users().requireManage(user); try { + boolean previousEnabled = user.isEnabled(); + boolean wasPermanentlyLockedOut = false; if (rep.isEnabled() != null && rep.isEnabled()) { if (!user.isEnabled() || session.getProvider(BruteForceProtector.class).isTemporarilyDisabled(session, realm, user)) { @@ -240,6 +242,15 @@ public Response updateUser(final UserRepresentation rep) { adminEvent.operation(OperationType.UPDATE).resourcePath(session.getContext().getUri()).representation(rep).success(); + if (rep.isEnabled() != null && rep.isEnabled() != previousEnabled) { + OperationType transition = user.isEnabled() ? OperationType.ENABLE : OperationType.DISABLE; + adminEvent.clone(session) + .operation(transition) + .resourcePath(session.getContext().getUri()) + .representation(rep) + .success(); + } + if (session.getTransactionManager().isActive()) { session.getTransactionManager().commit(); } diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/user/UserUpdateTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/user/UserUpdateTest.java index c837da896bf1..4e038a732308 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/user/UserUpdateTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/user/UserUpdateTest.java @@ -335,6 +335,80 @@ public void testAccessUserFromOtherRealm() { } } + @Test + public void updateUserDisableEmitsDisableEvent() { + String id = createUser(); + + UserResource user = managedRealm.admin().users().get(id); + UserRepresentation rep = new UserRepresentation(); + rep.setEnabled(false); + + user.update(rep); + + AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.UPDATE, + AdminEventPaths.userResourcePath(id), rep, ResourceType.USER); + AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.DISABLE, + AdminEventPaths.userResourcePath(id), rep, ResourceType.USER); + Assertions.assertNull(adminEvents.poll()); + + assertFalse(managedRealm.admin().users().get(id).toRepresentation().isEnabled()); + } + + @Test + public void updateUserEnableEmitsEnableEvent() { + String id = createUser(); + UserResource user = managedRealm.admin().users().get(id); + + UserRepresentation disable = new UserRepresentation(); + disable.setEnabled(false); + user.update(disable); + // drain UPDATE + DISABLE from the prior call + adminEvents.poll(); + adminEvents.poll(); + + UserRepresentation enable = new UserRepresentation(); + enable.setEnabled(true); + user.update(enable); + + AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.UPDATE, + AdminEventPaths.userResourcePath(id), enable, ResourceType.USER); + AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.ENABLE, + AdminEventPaths.userResourcePath(id), enable, ResourceType.USER); + Assertions.assertNull(adminEvents.poll()); + + assertTrue(managedRealm.admin().users().get(id).toRepresentation().isEnabled()); + } + + @Test + public void updateUserNonEnabledChangeEmitsNoTransitionEvent() { + String id = createUser(); + UserResource user = managedRealm.admin().users().get(id); + + UserRepresentation rep = new UserRepresentation(); + rep.setFirstName("Updated"); + + user.update(rep); + + AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.UPDATE, + AdminEventPaths.userResourcePath(id), rep, ResourceType.USER); + Assertions.assertNull(adminEvents.poll()); + } + + @Test + public void updateUserSameEnabledStateEmitsNoTransitionEvent() { + String id = createUser(); + UserResource user = managedRealm.admin().users().get(id); + + UserRepresentation rep = new UserRepresentation(); + rep.setEnabled(true); // user is already enabled + + user.update(rep); + + AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.UPDATE, + AdminEventPaths.userResourcePath(id), rep, ResourceType.USER); + Assertions.assertNull(adminEvents.poll()); + } + private void enableBruteForce(boolean enable) { RealmRepresentation rep = managedRealm.admin().toRepresentation(); managedRealm.cleanup().add(r -> r.update(rep));