Skip to content

Commit e2808e6

Browse files
committed
feat: allow partial profile updates
1 parent b32afaf commit e2808e6

File tree

6 files changed

+186
-27
lines changed

6 files changed

+186
-27
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,19 @@ Supported query parameters:
183183

184184
Required roles: `hawk-manage-profile-data`
185185

186+
#### PATCH User Profile Data
187+
`/realms/{realm}/hawk/profile/{userId}`
188+
189+
This endpoint works similar to the `PUT` variant, but also accepts partial updates.
190+
Only the fields that are provided in the request body will be validated and updated.
191+
192+
The body of the request should be a [UserRepresentation](https://www.keycloak.org/docs-api/latest/rest-api/index.html#UserRepresentation)
193+
194+
Supported query parameters:
195+
* **mode** - user|admin - If set to "admin" the update will be done as an admin, otherwise as the user itself
196+
197+
Required roles: `hawk-manage-profile-data`
198+
186199
#### GET Cache Buster
187200
`/realms/{realm}/hawk/cache-buster`
188201

src/main/java/com/hawk/keycloak/ApiRoot.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,6 @@ public Stream<String> getRoleMembers(
164164
@Parameter(description = "Pagination offset") @QueryParam("first") Integer firstResult,
165165
@Parameter(description = "Maximum results size (defaults to 100)") @QueryParam("max") Integer maxResults
166166
) {
167-
168167
return requestHandlerFactory
169168
.rolesRequestHandler(authenticate())
170169
.handleRoleMembersRequest(
@@ -210,13 +209,30 @@ public AbstractUserRepresentation getUserProfile(
210209
@Path("profile/{user}")
211210
@Consumes(MediaType.APPLICATION_JSON)
212211
@Produces(MediaType.APPLICATION_JSON)
213-
public Response updateUserProfile(
212+
public Response putUserProfile(
213+
@Parameter(description = "The id of the user to update the profile for") @PathParam("user") String userId,
214+
@Parameter(description = "Defines the requesting role. Can be 'user' (default) to update the profile as the user, or 'admin' to update the profile as admin") @QueryParam("mode") String mode,
215+
UserRepresentation rep
216+
) {
217+
return requestHandlerFactory.profileDataRequestHandler(authenticate())
218+
.handleProfilePutRequest(
219+
session.users().getUserById(session.getContext().getRealm(), userId),
220+
ProfileMode.fromString(mode),
221+
rep
222+
);
223+
}
224+
225+
@PATCH
226+
@Path("profile/{user}")
227+
@Consumes(MediaType.APPLICATION_JSON)
228+
@Produces(MediaType.APPLICATION_JSON)
229+
public Response patchUserProfile(
214230
@Parameter(description = "The id of the user to update the profile for") @PathParam("user") String userId,
215231
@Parameter(description = "Defines the requesting role. Can be 'user' (default) to update the profile as the user, or 'admin' to update the profile as admin") @QueryParam("mode") String mode,
216232
UserRepresentation rep
217233
) {
218234
return requestHandlerFactory.profileDataRequestHandler(authenticate())
219-
.handleProfileUpdateRequest(
235+
.handleProfilePatchRequest(
220236
session.users().getUserById(session.getContext().getRealm(), userId),
221237
ProfileMode.fromString(mode),
222238
rep
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.hawk.keycloak.profiles;
2+
3+
import org.keycloak.models.KeycloakSession;
4+
import org.keycloak.models.UserModel;
5+
import org.keycloak.representations.idm.UserRepresentation;
6+
import org.keycloak.userprofile.Attributes;
7+
import org.keycloak.userprofile.DeclarativeUserProfileProvider;
8+
import org.keycloak.userprofile.UserProfileContext;
9+
import org.keycloak.userprofile.UserProfileMetadata;
10+
11+
import java.util.Map;
12+
13+
public class PartialUpdateUserProfileProvider extends DeclarativeUserProfileProvider {
14+
private UserRepresentation partialRep;
15+
16+
public PartialUpdateUserProfileProvider(
17+
KeycloakSession session,
18+
PartialUpdateUserProfileProviderFactory factory
19+
) {
20+
super(session, factory);
21+
}
22+
23+
/**
24+
* Setting this will make the provider only write fields that are present in the provided representation.
25+
* This allows for partial updates of user profiles.
26+
* @param rep The given update request.
27+
*/
28+
public void onlyWriteFieldsOf(UserRepresentation rep) {
29+
partialRep = rep;
30+
}
31+
32+
/**
33+
* Overrides the default attribute creation, by defining all attributes that are not present in the partial representation as read-only.
34+
*/
35+
@Override
36+
protected Attributes createAttributes(UserProfileContext context, Map<String, ?> attributes, UserModel user, UserProfileMetadata metadata) {
37+
if (partialRep == null) {
38+
return super.createAttributes(context, attributes, user, metadata);
39+
}
40+
41+
UserProfileMetadata metadataClone = metadata.clone();
42+
metadataClone.getAttributes().forEach((value) -> {
43+
switch (value.getName()) {
44+
case "emailVerified":
45+
if (partialRep.isEmailVerified() == null) {
46+
value.addWriteCondition(context1 -> false);
47+
}
48+
break;
49+
case "username":
50+
if (partialRep.getUsername() == null || partialRep.getUsername().isEmpty()) {
51+
value.addWriteCondition(context1 -> false);
52+
}
53+
break;
54+
case "email":
55+
if (partialRep.getEmail() == null || partialRep.getEmail().isEmpty()) {
56+
value.addWriteCondition(context1 -> false);
57+
}
58+
break;
59+
case "firstName":
60+
if (partialRep.getFirstName() == null || partialRep.getFirstName().isEmpty()) {
61+
value.addWriteCondition(context1 -> false);
62+
}
63+
break;
64+
case "lastName":
65+
if (partialRep.getLastName() == null || partialRep.getLastName().isEmpty()) {
66+
value.addWriteCondition(context1 -> false);
67+
}
68+
break;
69+
default:
70+
if (!partialRep.getAttributes().containsKey(value.getName())) {
71+
value.addWriteCondition(context1 -> false);
72+
}
73+
break;
74+
}
75+
76+
});
77+
78+
return super.createAttributes(context, attributes, user, metadataClone);
79+
}
80+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.hawk.keycloak.profiles;
2+
3+
import org.keycloak.models.KeycloakSession;
4+
import org.keycloak.userprofile.DeclarativeUserProfileProvider;
5+
import org.keycloak.userprofile.DeclarativeUserProfileProviderFactory;
6+
7+
public class PartialUpdateUserProfileProviderFactory extends DeclarativeUserProfileProviderFactory {
8+
@Override
9+
public int order() {
10+
return 10;
11+
}
12+
13+
@Override
14+
public DeclarativeUserProfileProvider create(KeycloakSession session) {
15+
return new PartialUpdateUserProfileProvider(session, this);
16+
}
17+
}

src/main/java/com/hawk/keycloak/profiles/ProfileDataRequestHandler.java

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
import java.util.ArrayList;
2121
import java.util.List;
22+
import java.util.function.BiFunction;
23+
import java.util.function.Supplier;
2224

2325
@RequiredArgsConstructor
2426
public class ProfileDataRequestHandler {
@@ -28,34 +30,50 @@ public class ProfileDataRequestHandler {
2830

2931
public AbstractUserRepresentation handleProfileRequest(UserModel user, ProfileMode mode) {
3032
auth.admin().users().requireView();
31-
32-
if(user == null){
33+
if (user == null) {
3334
throw new NotFoundException("User not found");
3435
}
36+
return getProfileForRead(user, mode).toRepresentation();
37+
}
3538

36-
return getProfile(user, mode, null).toRepresentation();
39+
public Response handleProfilePutRequest(UserModel user, ProfileMode mode, UserRepresentation rep) {
40+
return handleProfileUpdateRequest(
41+
user,
42+
() -> getProfileForWrite(user, mode, rep),
43+
(profile, event) -> {
44+
profile.update(new EventAuditingAttributeChangeListener(getProfileForRead(user, mode), event));
45+
return null;
46+
});
3747
}
3848

39-
public Response handleProfileUpdateRequest(UserModel user, ProfileMode mode, UserRepresentation rep) {
40-
auth.requireManageProfileData();
49+
public Response handleProfilePatchRequest(UserModel user, ProfileMode mode, UserRepresentation rep) {
50+
return handleProfileUpdateRequest(
51+
user,
52+
() -> getProfileForPartialWrite(user, mode, rep),
53+
(profile, event) -> {
54+
profile.update(true, new EventAuditingAttributeChangeListener(getProfileForRead(user, mode), event));
55+
return null;
56+
}
57+
);
58+
}
4159

42-
if(user == null){
60+
private Response handleProfileUpdateRequest(
61+
UserModel user,
62+
Supplier<UserProfile> getProfile,
63+
BiFunction<UserProfile, EventBuilder, Void> callback) {
64+
auth.requireManageProfileData();
65+
if (user == null) {
4366
throw new NotFoundException("User not found");
4467
}
45-
68+
UserProfile profile = getProfile.get();
4669
event.event(EventType.UPDATE_PROFILE).detail(Details.CONTEXT, UserProfileContext.ACCOUNT.name());
47-
48-
UserProfile profile = getProfile(user, mode, rep);
49-
5070
try {
51-
profile.update(new EventAuditingAttributeChangeListener(profile, event));
52-
71+
callback.apply(profile, event);
5372
event.success();
54-
5573
return Response.noContent().build();
5674
} catch (ValidationException pve) {
5775
List<ErrorRepresentation> errors = new ArrayList<>();
58-
for(ValidationException.Error err: pve.getErrors()) {
76+
for (ValidationException.Error err : pve.getErrors()) {
5977
errors.add(new ErrorRepresentation(err.getAttribute(), err.getMessage(), validationErrorParamsToString(err.getMessageParameters(), profile.getAttributes())));
6078
}
6179
throw ErrorResponse.errors(errors, pve.getStatusCode(), false);
@@ -64,28 +82,43 @@ public Response handleProfileUpdateRequest(UserModel user, ProfileMode mode, Use
6482
}
6583
}
6684

67-
private UserProfile getProfile(UserModel user, ProfileMode mode, UserRepresentation rep) {
85+
private UserProfile getProfileForRead(UserModel user, ProfileMode mode) {
6886
UserProfileContext context = mode == ProfileMode.USER ? UserProfileContext.ACCOUNT : UserProfileContext.USER_API;
6987
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
88+
return profileProvider.create(context, user);
89+
}
7090

71-
if(rep == null){
72-
return profileProvider.create(context, user);
73-
}
91+
private UserProfile getProfileForWrite(UserModel user, ProfileMode mode, UserRepresentation rep) {
92+
UserProfileContext context = mode == ProfileMode.USER ? UserProfileContext.ACCOUNT : UserProfileContext.USER_API;
93+
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
94+
return profileProvider.create(context, rep.getRawAttributes(), user);
95+
}
7496

97+
private UserProfile getProfileForPartialWrite(UserModel user, ProfileMode mode, UserRepresentation rep) {
98+
UserProfileContext context = mode == ProfileMode.USER ? UserProfileContext.ACCOUNT : UserProfileContext.USER_API;
99+
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
100+
if (profileProvider instanceof PartialUpdateUserProfileProvider) {
101+
try {
102+
((PartialUpdateUserProfileProvider) profileProvider).onlyWriteFieldsOf(rep);
103+
return profileProvider.create(context, rep.getRawAttributes(), user);
104+
} finally {
105+
((PartialUpdateUserProfileProvider) profileProvider).onlyWriteFieldsOf(null);
106+
}
107+
}
75108
return profileProvider.create(context, rep.getRawAttributes(), user);
76109
}
77110

78111
private String[] validationErrorParamsToString(Object[] messageParameters, Attributes userProfileAttributes) {
79-
if(messageParameters == null)
112+
if (messageParameters == null)
80113
return null;
81114
String[] ret = new String[messageParameters.length];
82115
int i = 0;
83-
for(Object p: messageParameters) {
84-
if(p != null) {
116+
for (Object p : messageParameters) {
117+
if (p != null) {
85118
//first parameter is user profile attribute name, we have to take Display Name for it
86-
if(i==0) {
119+
if (i == 0) {
87120
AttributeMetadata am = userProfileAttributes.getMetadata(p.toString());
88-
if(am != null)
121+
if (am != null)
89122
ret[i++] = am.getAttributeDisplayName();
90123
else
91124
ret[i++] = p.toString();
@@ -98,5 +131,4 @@ private String[] validationErrorParamsToString(Object[] messageParameters, Attri
98131
}
99132
return ret;
100133
}
101-
102134
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
com.hawk.keycloak.profiles.PartialUpdateUserProfileProviderFactory

0 commit comments

Comments
 (0)