Add support for more update attribute user events#51017
Conversation
There was a problem hiding this comment.
Pull request overview
Adds workflow triggers for user credential, email, profile, and consent updates.
Changes:
- Adds five workflow event providers with optional filters.
- Registers the providers through Java SPI.
- Adds integration coverage for trigger activation.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
WorkflowProviderEventsTest.java |
Tests new triggers. |
WorkflowEventProviderFactory service file |
Registers providers. |
UserUpdateProfileWorkflowEventProvider.java |
Handles profile updates. |
UserUpdateProfileWorkflowEventFactory.java |
Defines profile trigger. |
UserUpdateEmailWorkflowEventProvider.java |
Handles email updates. |
UserUpdateEmailWorkflowEventFactory.java |
Defines email trigger. |
UserUpdateCredentialWorkflowEventProvider.java |
Handles credential updates. |
UserUpdateCredentialWorkflowEventFactory.java |
Defines credential-update trigger. |
UserUpdateConsentWorkflowEventProvider.java |
Handles consent updates. |
UserUpdateConsentWorkflowEventFactory.java |
Defines consent trigger. |
UserRemoveCredentialWorkflowEventProvider.java |
Handles credential removal. |
UserRemoveCredentialWorkflowEventFactory.java |
Defines credential-removal trigger. |
|
|
||
| @Override | ||
| public boolean supports(Event event) { | ||
| return EventType.UPDATE_CREDENTIAL.equals(event.getType()) || EventType.RESET_PASSWORD.equals(event.getType()); |
| return RESET_PASSWORD_PATH.matcher(path).matches() | ||
| || (CREDENTIALS_PATH.matcher(path).matches() && !OperationType.DELETE.equals(adminEvent.getOperationType())); |
| if (!org.keycloak.events.admin.ResourceType.USER.equals(adminEvent.getResourceType())) { | ||
| return false; | ||
| } | ||
| if (!OperationType.DELETE.equals(adminEvent.getOperationType())) { |
| UserRepresentation rep = JsonSerialization.readValue(representation, UserRepresentation.class); | ||
| return rep != null && rep.getEmail() != null; |
|
|
||
| @Override | ||
| public boolean supports(Event event) { | ||
| return EventType.UPDATE_CONSENT.equals(event.getType()) || EventType.GRANT_CONSENT.equals(event.getType()); |
| org.keycloak.models.workflow.events.UserUpdateCredentialWorkflowEventFactory | ||
| org.keycloak.models.workflow.events.UserRemoveCredentialWorkflowEventFactory | ||
| org.keycloak.models.workflow.events.UserUpdateEmailWorkflowEventFactory | ||
| org.keycloak.models.workflow.events.UserUpdateProfileWorkflowEventFactory | ||
| org.keycloak.models.workflow.events.UserUpdateConsentWorkflowEventFactory |
| org.keycloak.events.Event event = eventBuilder.event(org.keycloak.events.EventType.RESET_PASSWORD) | ||
| .user(userId) | ||
| .getEvent(); |
| if ("email".equalsIgnoreCase(configParameter)) { | ||
| return rep.getEmail() != null; | ||
| } else if ("firstName".equalsIgnoreCase(configParameter) || "first_name".equalsIgnoreCase(configParameter)) { | ||
| return rep.getFirstName() != null; |
| public boolean supports(AdminEvent adminEvent) { | ||
| if (!org.keycloak.events.admin.ResourceType.USER.equals(adminEvent.getResourceType())) { | ||
| return false; | ||
| } | ||
| String path = adminEvent.getResourcePath(); | ||
| if (path == null) { | ||
| return false; | ||
| } | ||
| return CONSENTS_PATH.matcher(path).matches(); | ||
| } |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (7)
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateCredentialWorkflowEventProvider.java:35
RESET_PASSWORDrepresents the password-reset flow, not a completed credential update; the actual update path emitsUPDATE_CREDENTIAL. Including it here can run update-credential workflows when a reset is merely initiated.
return EventType.UPDATE_CREDENTIAL.equals(event.getType()) || EventType.RESET_PASSWORD.equals(event.getType());
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateCredentialWorkflowEventProvider.java:48
- The admin credential-removal endpoint emits
OperationType.ACTION, so this condition classifies a removal as an update. Require the update operation explicitly to keepuser-update-credentialfrom firing on removals.
return RESET_PASSWORD_PATH.matcher(path).matches()
|| (CREDENTIALS_PATH.matcher(path).matches() && !OperationType.DELETE.equals(adminEvent.getOperationType()));
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserRemoveCredentialWorkflowEventProvider.java:42
- The actual admin removal endpoint records this operation as
ACTION(services/.../UserResource.java:890), so admin credential removals never activate this provider. Match the operation emitted by that endpoint.
if (!OperationType.DELETE.equals(adminEvent.getOperationType())) {
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateEmailWorkflowEventProvider.java:54
- A non-null email only means the submitted user representation contains an email, not that it changed. Admin clients commonly submit full representations, so unrelated user updates will incorrectly activate
user-update-email; the producer/provider contract needs actual before/after change data.
return rep != null && rep.getEmail() != null;
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateProfileWorkflowEventProvider.java:104
- This checks whether the requested attribute was present in the admin representation, not whether it was updated. A full user representation therefore activates filtered profile workflows for unchanged attributes, contrary to the attribute-update filter contract.
UserRepresentation rep = JsonSerialization.readValue(representation, UserRepresentation.class);
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateConsentWorkflowEventProvider.java:29
user-update-consentalso fires forGRANT_CONSENT, although issue #50453 requests the distinct Update consent event. A new consent grant should not execute workflows configured for updates.
return EventType.UPDATE_CONSENT.equals(event.getType()) || EventType.GRANT_CONSENT.equals(event.getType());
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateConsentWorkflowEventProvider.java:41
- The only matching admin mutation is consent revocation (
UserResource.java:644-666), so this makes auser-update-consentworkflow run for a revoke rather than an update. Do not claim these admin events unless a genuine admin consent-update operation is available.
return CONSENTS_PATH.matcher(path).matches();
| public boolean supports(AdminEvent adminEvent) { | ||
| if (!org.keycloak.events.admin.ResourceType.USER.equals(adminEvent.getResourceType())) { | ||
| return false; | ||
| } | ||
| if (!OperationType.UPDATE.equals(adminEvent.getOperationType())) { | ||
| return false; | ||
| } | ||
| String path = adminEvent.getResourcePath(); | ||
| if (path == null) { | ||
| return false; | ||
| } | ||
| if (!USER_PATH.matcher(path).matches()) { | ||
| return false; | ||
| } | ||
| String representation = adminEvent.getRepresentation(); | ||
| if (representation != null && !representation.isBlank()) { | ||
| try { | ||
| UserRepresentation rep = JsonSerialization.readValue(representation, UserRepresentation.class); | ||
| return rep != null && rep.getEmail() != null; | ||
| } catch (Exception e) { | ||
| // ignore and fallback to true | ||
| } | ||
| } | ||
| return true; | ||
| } |
| String representation = adminEvent.getRepresentation(); | ||
| if (representation == null || representation.isBlank()) { | ||
| // If representation is not enabled, we cannot filter by attribute, so we fallback to true | ||
| return true; | ||
| } | ||
| try { | ||
| UserRepresentation rep = JsonSerialization.readValue(representation, UserRepresentation.class); | ||
| if (rep == null) { | ||
| return true; | ||
| } |
| } catch (Exception e) { | ||
| return true; | ||
| } |
| if ("email".equalsIgnoreCase(configParameter)) { | ||
| return event.getDetails().containsKey(Details.UPDATED_EMAIL); | ||
| } else if ("firstName".equalsIgnoreCase(configParameter) || "first_name".equalsIgnoreCase(configParameter)) { | ||
| return event.getDetails().containsKey("updated_first_name"); | ||
| } else if ("lastName".equalsIgnoreCase(configParameter) || "last_name".equalsIgnoreCase(configParameter)) { | ||
| return event.getDetails().containsKey("updated_last_name"); | ||
| } else { | ||
| return event.getDetails().containsKey("updated_" + configParameter); | ||
| } |
| String userId = credsMatcher.group(1); | ||
| String credentialId = credsMatcher.group(2); | ||
| RealmModel realm = session.realms().getRealm(adminEvent.getRealmId()); | ||
| if (realm != null) { | ||
| UserModel user = session.users().getUserById(realm, userId); | ||
| if (user != null) { | ||
| org.keycloak.credential.CredentialModel stored = user.credentialManager().getStoredCredentialById(credentialId); | ||
| if (stored != null) { | ||
| return stored.getType(); | ||
| } | ||
| } | ||
| } |
| // 1. Create workflows for each of the new triggers | ||
| // update-credential with optional type password | ||
| String wCredentialId = createSimpleWorkflow("w-credential", "user-update-credential(password)"); | ||
| // remove-credential | ||
| String wRemoveCredentialId = createSimpleWorkflow("w-remove-credential", "user-remove-credential"); | ||
| // update-email | ||
| String wUpdateEmailId = createSimpleWorkflow("w-update-email", "user-update-email"); | ||
| // update-profile with optional type firstName | ||
| String wUpdateProfileId = createSimpleWorkflow("w-update-profile", "user-update-profile(firstName)"); | ||
| // update-consent | ||
| String wUpdateConsentId = createSimpleWorkflow("w-update-consent", "user-update-consent"); |
Closes keycloak#50453 Signed-off-by: ksushant881 <ksushant881@gmail.com>
Closes keycloak#50453 Signed-off-by: ksushant881 <ksushant881@gmail.com>
7d2068d to
b6ae639
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (6)
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateCredentialWorkflowEventProvider.java:35
RESET_PASSWORDis a distinct event used by the forgot-credentials flow, not the requestedUPDATE_CREDENTIALevent, so this activates the workflow for password-reset events that are not credential updates. Restrict user events toUPDATE_CREDENTIAL; the test must emit that event withDetails.CREDENTIAL_TYPE=password.
return EventType.UPDATE_CREDENTIAL.equals(event.getType()) || EventType.RESET_PASSWORD.equals(event.getType());
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateCredentialWorkflowEventProvider.java:48
- The admin credential-removal endpoint uses this exact path with
OperationType.ACTION(UserResource.java:871-894), so the!DELETEcheck classifies removals as credential updates. Only accept an actualUPDATEoperation for this branch.
return RESET_PASSWORD_PATH.matcher(path).matches()
|| (CREDENTIALS_PATH.matcher(path).matches() && !OperationType.DELETE.equals(adminEvent.getOperationType()));
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserRemoveCredentialWorkflowEventProvider.java:43
- Admin credential removal is emitted as
OperationType.ACTION, notDELETE(UserResource.java:890), so real admin removals never reach this workflow provider. Match the operation used by that endpoint.
if (!OperationType.DELETE.equals(adminEvent.getOperationType())) {
return false;
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateConsentWorkflowEventProvider.java:29
GRANT_CONSENTis a separate event for creating consent, while this trigger is specifically for the requestedUPDATE_CONSENTevent. Including it causesuser-update-consentworkflows to run on initial grants as well as updates.
return EventType.UPDATE_CONSENT.equals(event.getType()) || EventType.GRANT_CONSENT.equals(event.getType());
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateEmailWorkflowEventProvider.java:54
- Presence of
emailin an admin PUT representation does not mean the email changed; clients commonly submit a fullUserRepresentation, so an unrelated profile update will activateuser-update-email. Record or compare the old email when handling the admin update and filter on an actual change.
String representation = adminEvent.getRepresentation();
if (representation != null && !representation.isBlank()) {
try {
UserRepresentation rep = JsonSerialization.readValue(representation, UserRepresentation.class);
return rep != null && rep.getEmail() != null;
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateProfileWorkflowEventProvider.java:112
- An admin event representation contains submitted values, not the set of changed attributes. With a normal full user PUT, non-null
firstName/emailfields make these filters match even when only another attribute changed; capture changed-field details before the update and evaluate those instead.
if ("email".equalsIgnoreCase(configParameter)) {
return rep.getEmail() != null;
} else if ("firstName".equalsIgnoreCase(configParameter) || "first_name".equalsIgnoreCase(configParameter)) {
return rep.getFirstName() != null;
} else if ("lastName".equalsIgnoreCase(configParameter) || "last_name".equalsIgnoreCase(configParameter)) {
Unreported flaky test detectedIf the flaky tests below are affected by the changes, please review and update the changes accordingly. Otherwise, a maintainer should report the flaky tests prior to merging the PR. org.keycloak.testsuite.forms.MultipleTabsLoginTest#testEmptyBaseUrlKeycloak CI - Forms IT (firefox) |
Closes keycloak#50453 Signed-off-by: ksushant881 <ksushant881@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
model/jpa/src/main/java/org/keycloak/models/workflow/events/UserUpdateConsentWorkflowEventProvider.java:41
- This admin-event matcher classifies consent revocation as
user-update-consent: the only matching admin endpoint isDELETE users/{id}/consents/{client}, which emitsACTION, while the equivalent account operation emitsREVOKE_GRANT. Restrict this provider to actual consent updates (or omit admin support until such an operation exists).
return CONSENTS_PATH.matcher(path).matches();
| adminEvent.detail("updated_last_name", rep.getLastName()); | ||
| } | ||
| for (Map.Entry<String, String> attrEntry : changedAttributes.entrySet()) { | ||
| adminEvent.detail("updated_" + attrEntry.getKey(), attrEntry.getValue()); |
| adminEvent.detail("updated_last_name", rep.getLastName()); | ||
| } | ||
| for (Map.Entry<String, String> attrEntry : changedAttributes.entrySet()) { | ||
| adminEvent.detail("updated_" + attrEntry.getKey(), attrEntry.getValue()); |
| if (path == null) { | ||
| return false; | ||
| } | ||
| return USER_PATH.matcher(path).matches(); |
Closes #50453