diff --git a/core/src/main/java/org/keycloak/representations/account/UserRepresentation.java b/core/src/main/java/org/keycloak/representations/account/UserRepresentation.java index 6276e36396b..6040cf8d376 100755 --- a/core/src/main/java/org/keycloak/representations/account/UserRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/account/UserRepresentation.java @@ -17,128 +17,11 @@ package org.keycloak.representations.account; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import org.keycloak.json.StringListMapDeserializer; -import org.keycloak.representations.idm.UserProfileMetadata; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import org.keycloak.representations.idm.AbstractUserRepresentation; /** * @author Stian Thorgersen */ -public class UserRepresentation { +public class UserRepresentation extends AbstractUserRepresentation { - private String id; - private String username; - private String firstName; - private String lastName; - private String email; - private boolean emailVerified; - private UserProfileMetadata userProfileMetadata; - - @JsonDeserialize(using = StringListMapDeserializer.class) - private Map> attributes; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - - public String getFirstName() { - return firstName; - } - - public void setFirstName(String firstName) { - this.firstName = firstName; - } - - public String getLastName() { - return lastName; - } - - public void setLastName(String lastName) { - this.lastName = lastName; - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } - - public boolean isEmailVerified() { - return emailVerified; - } - - public void setEmailVerified(boolean emailVerified) { - this.emailVerified = emailVerified; - } - - public Map> getAttributes() { - return attributes; - } - - public void setAttributes(Map> attributes) { - this.attributes = attributes; - } - - public void singleAttribute(String name, String value) { - if (this.attributes == null) this.attributes=new HashMap<>(); - attributes.put(name, (value == null ? new ArrayList() : Arrays.asList(value))); - } - - public String firstAttribute(String key) { - return this.attributes == null ? null : this.attributes.containsKey(key) ? this.attributes.get(key).get(0) : null; - } - - public Map> toAttributes() { - Map> attrs = new HashMap<>(); - - if (getAttributes() != null) attrs.putAll(getAttributes()); - - if (getUsername() != null) - attrs.put("username", Collections.singletonList(getUsername())); - else - attrs.remove("username"); - - if (getEmail() != null) - attrs.put("email", Collections.singletonList(getEmail())); - else - attrs.remove("email"); - - if (getLastName() != null) - attrs.put("lastName", Collections.singletonList(getLastName())); - - if (getFirstName() != null) - attrs.put("firstName", Collections.singletonList(getFirstName())); - - - return attrs; - } - - public UserProfileMetadata getUserProfileMetadata() { - return userProfileMetadata; - } - - public void setUserProfileMetadata(UserProfileMetadata userProfileMetadata) { - this.userProfileMetadata = userProfileMetadata; - } } diff --git a/core/src/main/java/org/keycloak/representations/idm/AbstractUserRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/AbstractUserRepresentation.java new file mode 100644 index 00000000000..c4fe9cb7a76 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/AbstractUserRepresentation.java @@ -0,0 +1,157 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.representations.idm; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.keycloak.json.StringListMapDeserializer; + +public abstract class AbstractUserRepresentation { + + public static String USERNAME = "username"; + public static String FIRST_NAME = "firstName"; + public static String LAST_NAME = "lastName"; + public static String EMAIL = "email"; + public static String LOCALE = "locale"; + + protected String id; + protected String username; + protected String firstName; + protected String lastName; + protected String email; + protected Boolean emailVerified; + @JsonDeserialize(using = StringListMapDeserializer.class) + protected Map> attributes; + private UserProfileMetadata userProfileMetadata; + + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Boolean isEmailVerified() { + return emailVerified; + } + + public void setEmailVerified(Boolean emailVerified) { + this.emailVerified = emailVerified; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + /** + * Returns all the attributes set to this user except the root attributes. + * + * @return the user attributes. + */ + public Map> getAttributes() { + return attributes; + } + + /** + * Returns all the user attributes including the root attributes. + * + * @return all the user attributes. + */ + @JsonIgnore + public Map> getRawAttributes() { + Map> attrs = new HashMap<>(Optional.ofNullable(attributes).orElse(new HashMap<>())); + + if (username != null) + attrs.put(USERNAME, Collections.singletonList(getUsername())); + else + attrs.remove(USERNAME); + + if (email != null) + attrs.put(EMAIL, Collections.singletonList(getEmail())); + else + attrs.remove(EMAIL); + + if (lastName != null) + attrs.put(LAST_NAME, Collections.singletonList(getLastName())); + + if (firstName != null) + attrs.put(FIRST_NAME, Collections.singletonList(getFirstName())); + + return attrs; + } + + public void setAttributes(Map> attributes) { + this.attributes = attributes; + } + + @SuppressWarnings("unchecked") + public R singleAttribute(String name, String value) { + if (this.attributes == null) this.attributes=new HashMap<>(); + attributes.put(name, (value == null ? Collections.emptyList() : Arrays.asList(value))); + return (R) this; + } + + public String firstAttribute(String key) { + return this.attributes == null ? null : this.attributes.get(key) == null ? null : this.attributes.get(key).isEmpty()? null : this.attributes.get(key).get(0); + } + + public void setUserProfileMetadata(UserProfileMetadata userProfileMetadata) { + this.userProfileMetadata = userProfileMetadata; + } + + public UserProfileMetadata getUserProfileMetadata() { + return userProfileMetadata; + } +} diff --git a/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java index 6871796e35a..8ab1110bde9 100755 --- a/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java @@ -17,14 +17,7 @@ package org.keycloak.representations.idm; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import org.keycloak.json.StringListMapDeserializer; - -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.ArrayList; import java.util.Map; import java.util.Set; @@ -32,24 +25,16 @@ import java.util.Set; * @author Bill Burke * @version $Revision: 1 $ */ -public class UserRepresentation { +public class UserRepresentation extends AbstractUserRepresentation{ protected String self; // link - protected String id; protected String origin; protected Long createdTimestamp; - protected String username; protected Boolean enabled; protected Boolean totp; - protected Boolean emailVerified; - protected String firstName; - protected String lastName; - protected String email; protected String federationLink; protected String serviceAccountClientId; // For rep, it points to clientId (not DB ID) - @JsonDeserialize(using = StringListMapDeserializer.class) - protected Map> attributes; protected List credentials; protected Set disableableCredentialTypes; protected List requiredActions; @@ -66,7 +51,6 @@ public class UserRepresentation { protected List groups; private Map access; - private UserProfileMetadata userProfileMetadata; public String getSelf() { return self; @@ -76,14 +60,6 @@ public class UserRepresentation { this.self = self; } - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - public Long getCreatedTimestamp() { return createdTimestamp; } @@ -92,38 +68,6 @@ public class UserRepresentation { this.createdTimestamp = createdTimestamp; } - public String getFirstName() { - return firstName; - } - - public void setFirstName(String firstName) { - this.firstName = firstName; - } - - public String getLastName() { - return lastName; - } - - public void setLastName(String lastName) { - this.lastName = lastName; - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - public Boolean isEnabled() { return enabled; } @@ -142,32 +86,6 @@ public class UserRepresentation { this.totp = totp; } - public Boolean isEmailVerified() { - return emailVerified; - } - - public void setEmailVerified(Boolean emailVerified) { - this.emailVerified = emailVerified; - } - - public Map> getAttributes() { - return attributes; - } - - public void setAttributes(Map> attributes) { - this.attributes = attributes; - } - - public UserRepresentation singleAttribute(String name, String value) { - if (this.attributes == null) this.attributes=new HashMap<>(); - attributes.put(name, (value == null ? new ArrayList() : Arrays.asList(value))); - return this; - } - - public String firstAttribute(String key) { - return this.attributes == null ? null : this.attributes.get(key) == null ? null : this.attributes.get(key).isEmpty()? null : this.attributes.get(key).get(0); - } - public List getCredentials() { return credentials; } @@ -289,36 +207,4 @@ public class UserRepresentation { public void setAccess(Map access) { this.access = access; } - - public Map> toAttributes() { - Map> attrs = new HashMap<>(); - - if (getAttributes() != null) attrs.putAll(getAttributes()); - - if (getUsername() != null) - attrs.put("username", Collections.singletonList(getUsername())); - else - attrs.remove("username"); - - if (getEmail() != null) - attrs.put("email", Collections.singletonList(getEmail())); - else - attrs.remove("email"); - - if (getLastName() != null) - attrs.put("lastName", Collections.singletonList(getLastName())); - - if (getFirstName() != null) - attrs.put("firstName", Collections.singletonList(getFirstName())); - - return attrs; - } - - public void setUserProfileMetadata(UserProfileMetadata userProfileMetadata) { - this.userProfileMetadata = userProfileMetadata; - } - - public UserProfileMetadata getUserProfileMetadata() { - return userProfileMetadata; - } } diff --git a/core/src/main/java/org/keycloak/representations/userprofile/config/UPConfig.java b/core/src/main/java/org/keycloak/representations/userprofile/config/UPConfig.java index 86a9af8f758..0992e31d4c8 100644 --- a/core/src/main/java/org/keycloak/representations/userprofile/config/UPConfig.java +++ b/core/src/main/java/org/keycloak/representations/userprofile/config/UPConfig.java @@ -32,8 +32,20 @@ import com.fasterxml.jackson.annotation.JsonIgnore; public class UPConfig { public enum UnmanagedAttributePolicy { + + /** + * Unmanaged attributes are enabled and available from any context. + */ ENABLED, + + /** + * Unmanaged attributes are only available as read-only and only through the management interfaces. + */ ADMIN_VIEW, + + /** + * Unmanaged attributes are only available as read-write and only through the management interfaces. + */ ADMIN_EDIT } diff --git a/docs/documentation/release_notes/topics/24_0_0.adoc b/docs/documentation/release_notes/topics/24_0_0.adoc index 7bfe4b6c565..b726d3c83fe 100644 --- a/docs/documentation/release_notes/topics/24_0_0.adoc +++ b/docs/documentation/release_notes/topics/24_0_0.adoc @@ -29,3 +29,17 @@ Proceed to https://www.keycloak.org/server/features[Enabling and disabling featu The Keycloak CR now includes an `startOptimized` field, which may be used to override the default assumption about whether to use the `--optimized` flag for the start command. As a result, you can use the CR to configure build time options also when a custom Keycloak image is used. += Breaking changes to the User Profile SPI + +In this release, there are changes to the User Profile SPI that might impact existing implementations based on this SPI. For more details, check the +link:{upgradingguide_link}[{upgradingguide_name}]. + += Changes to the user representation in both Admin API and Account contexts + +In this release, we are encapsulating the root user attributes (such as `username`, `email`, `firstName`, `lastName`, and `locale`) by moving them to a base/abstract class in order to align how these attributes +are marshalled and unmarshalled when using both Admin and Account REST APIs. + +This strategy provides consistency in how attributes are managed by clients and makes sure they conform to the user profile +configuration set to a realm. + +For more details, see link:{upgradingguide_link}[{upgradingguide_name}]. diff --git a/docs/documentation/upgrading/topics/keycloak/changes-24_0_0.adoc b/docs/documentation/upgrading/topics/keycloak/changes-24_0_0.adoc index fcd28313cc5..40ddb88e360 100644 --- a/docs/documentation/upgrading/topics/keycloak/changes-24_0_0.adoc +++ b/docs/documentation/upgrading/topics/keycloak/changes-24_0_0.adoc @@ -18,3 +18,28 @@ import AuthZ from 'keycloak-js/authz'; The `spi-truststore-file-*` options and the truststore related options `https-trust-store-*` are deprecated, please use the new default location for truststore material, `conf/truststores`, or specify your desired paths via the `truststore-paths` option. For details refer to the relevant https://www.keycloak.org/server/keycloak-truststore[guide]. The `tls-hostname-verifier` property should be used instead of the `spi-truststore-file-hostname-verification-policy` property. + += Breaking changes to the User Profile SPI + +If you are using the User Profile SPI in your extension, you might be impacted by the API changes introduced in this release. + +The `org.keycloak.userprofile.Attributes` interface includes the following changes: + +* Method `getValues` was renamed to `get` to make it more aligned with the same operation from a regular Java `Map` +* Method `isRootAttribute` was moved to the utility class `org.keycloak.userprofile.UserProfileUtil.isRootAttribute` +* Method `getFirstValue` was renamed to `getFirst` to make it less verbose +* Method `getReadable(boolean)` was removed and now all attributes (including root attributes) are returned whenever they have read rights. + += Changes to the user representation in both Admin API and Account contexts + +Both `org.keycloak.representations.idm.UserRepresentation` and `org.keycloak.representations.account.UserRepresentation` representation classes have changed +so that the root user attributes (such as `username`, `email`, `firstName`, `lastName`, and `locale`) have a consistent representation when fetching or sending +the representation payload to the Admin and Account APIS, respectively. + +The `username`, `email`, `firstName`, `lastName`, and `locale` attributes were moved to a new `org.keycloak.representations.idm.AbstractUserRepresentation` base class. + +Also the `getAttributes` method is targeted for representing only custom attributes, so you should not expect any root attribute in the map returned by this method. This method is +mainly targeted for clients when updating or fetching any custom attribute for a give user. + +In order to resolve all the attributes including the root attributes, a new `getRawAttributes` method was added so that the resulting map also includes the root attributes. However, +this method is not available from the representation payload and it is targeted to be used by the server when managing user profiles. \ No newline at end of file diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UserResource.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UserResource.java index 3b8be05a460..61b6ffe4d5a 100644 --- a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UserResource.java +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UserResource.java @@ -1,11 +1,15 @@ package org.keycloak.admin.ui.rest; +import static java.util.Collections.emptyList; +import static java.util.Optional.ofNullable; import static org.keycloak.userprofile.UserProfileContext.USER_API; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; @@ -18,6 +22,7 @@ import org.keycloak.models.UserModel; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfileProvider; +import org.keycloak.utils.StringUtil; /** * @author Pedro Igor @@ -42,7 +47,7 @@ public class UserResource { if (provider.isEnabled(realm)) { UserProfile profile = provider.create(USER_API, user); - Map> managedAttributes = profile.getAttributes().getReadable(false); + Map> managedAttributes = profile.getAttributes().getReadable(); Map> attributes = new HashMap<>(user.getAttributes()); UPConfig upConfig = provider.getConfiguration(); @@ -56,10 +61,10 @@ public class UserResource { attributes.remove(UserModel.USERNAME); attributes.remove(UserModel.EMAIL); - attributes.remove(UserModel.FIRST_NAME); - attributes.remove(UserModel.LAST_NAME); - return attributes; + return attributes.entrySet().stream() + .filter(entry -> ofNullable(entry.getValue()).orElse(emptyList()).stream().anyMatch(StringUtil::isNotBlank)) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); } return Collections.emptyMap(); diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java index 9c6113142b3..c6015b800fe 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultAttributes.java @@ -19,6 +19,8 @@ package org.keycloak.userprofile; +import static java.util.Collections.emptyList; + import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -29,6 +31,8 @@ import java.util.Optional; import java.util.Set; import java.util.function.Consumer; import java.util.stream.Collectors; +import java.util.stream.Stream; + import org.jboss.logging.Logger; import org.keycloak.common.util.CollectionUtil; import org.keycloak.models.Constants; @@ -38,6 +42,7 @@ import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy; +import org.keycloak.utils.StringUtil; import org.keycloak.validate.ValidationContext; import org.keycloak.validate.ValidationError; @@ -68,7 +73,7 @@ public class DefaultAttributes extends HashMap> implements private final Map metadataByAttribute; private final UPConfig upConfig; protected final UserModel user; - private Map> unmanagedAttributes = new HashMap<>(); + private final Map> unmanagedAttributes = new HashMap<>(); public DefaultAttributes(UserProfileContext context, Map attributes, UserModel user, UserProfileMetadata profileMetadata, @@ -82,28 +87,28 @@ public class DefaultAttributes extends HashMap> implements } @Override - public boolean isReadOnly(String attributeName) { - if (!isManagedAttribute(attributeName)) { + public boolean isReadOnly(String name) { + if (!isManagedAttribute(name)) { return !isAllowEditUnmanagedAttribute(); } - if (UserModel.USERNAME.equals(attributeName)) { + if (UserModel.USERNAME.equals(name)) { if (isServiceAccountUser()) { return true; } } - if (UserModel.EMAIL.equals(attributeName)) { + if (UserModel.EMAIL.equals(name)) { if (isServiceAccountUser()) { return false; } } - if (isReadOnlyFromMetadata(attributeName) || isReadOnlyInternalAttribute(attributeName)) { + if (isReadOnlyFromMetadata(name) || isReadOnlyInternalAttribute(name)) { return true; } - return getMetadata(attributeName) == null; + return getMetadata(name) == null; } private boolean isAllowEditUnmanagedAttribute() { @@ -156,9 +161,9 @@ public class DefaultAttributes extends HashMap> implements List metadatas = new ArrayList<>(); metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(attribute.getKey())) - .map(Collections::singletonList).orElse(Collections.emptyList())); + .map(Collections::singletonList).orElse(emptyList())); metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(READ_ONLY_ATTRIBUTE_KEY)) - .map(Collections::singletonList).orElse(Collections.emptyList())); + .map(Collections::singletonList).orElse(emptyList())); Boolean result = null; @@ -172,12 +177,15 @@ public class DefaultAttributes extends HashMap> implements continue; } - if (user != null && metadata.isReadOnly(attributeContext) - && CollectionUtil.collectionEquals(user.getAttributeStream(name).collect(Collectors.toList()), attribute.getValue())) { - // allow update if the value was already wrong in the user and is read-only in this context - logger.warnf("User '%s' attribute '%s' has previous validation errors %s but is read-only in context %s.", - user.getUsername(), name, vc.getErrors(), attributeContext.getContext()); - continue; + if (user != null && metadata.isReadOnly(attributeContext)) { + List value = user.getAttributeStream(name).filter(StringUtil::isNotBlank).collect(Collectors.toList()); + List newValue = attribute.getValue().stream().filter(StringUtil::isNotBlank).collect(Collectors.toList()); + if (CollectionUtil.collectionEquals(value, newValue)) { + // allow update if the value was already wrong in the user and is read-only in this context + logger.debugf("User '%s' attribute '%s' has previous validation errors %s but is read-only in context %s.", + user.getUsername(), name, vc.getErrors(), attributeContext.getContext()); + continue; + } } if (result == null) { @@ -198,7 +206,7 @@ public class DefaultAttributes extends HashMap> implements } @Override - public List getValues(String name) { + public List get(String name) { return getOrDefault(name, EMPTY_VALUE); } @@ -236,21 +244,7 @@ public class DefaultAttributes extends HashMap> implements @Override public AttributeMetadata getMetadata(String name) { if (unmanagedAttributes.containsKey(name)) { - return new AttributeMetadata(name, Integer.MAX_VALUE) { - final UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy(); - - @Override - public boolean canView(AttributeContext context) { - return canEdit(context) - || (UnmanagedAttributePolicy.ADMIN_VIEW.equals(unmanagedAttributePolicy) && UserProfileContext.USER_API.equals(context.getContext())); - } - - @Override - public boolean canEdit(AttributeContext context) { - return UnmanagedAttributePolicy.ENABLED.equals(unmanagedAttributePolicy) - || (UnmanagedAttributePolicy.ADMIN_EDIT.equals(unmanagedAttributePolicy) && UserProfileContext.USER_API.equals(context.getContext())); - } - }; + return createUnmanagedAttributeMetadata(name); } return Optional.ofNullable(metadataByAttribute.get(name)) @@ -265,9 +259,14 @@ public class DefaultAttributes extends HashMap> implements for (String name : nameSet()) { AttributeMetadata metadata = getMetadata(name); - if (metadata == null - || !metadata.canView(createAttributeContext(metadata)) - || !metadata.isSelected(createAttributeContext(metadata))) { + if (metadata == null) { + attributes.remove(name); + continue; + } + + AttributeContext attributeContext = createAttributeContext(metadata); + + if (!metadata.canView(attributeContext) || !metadata.isSelected(attributeContext)) { attributes.remove(name); } } @@ -277,7 +276,7 @@ public class DefaultAttributes extends HashMap> implements @Override public Map> toMap() { - return this; + return Collections.unmodifiableMap(this); } protected boolean isServiceAccountUser() { @@ -342,7 +341,7 @@ public class DefaultAttributes extends HashMap> implements if (!isSupportedAttribute(key)) { if (!isManagedAttribute(key) && isAllowUnmanagedAttribute()) { - unmanagedAttributes.put(key, (List) entry.getValue()); + unmanagedAttributes.put(key, normalizeAttributeValues(key, entry.getValue())); } continue; } @@ -351,18 +350,7 @@ public class DefaultAttributes extends HashMap> implements key = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length()); } - Object value = entry.getValue(); - List values; - - if (value instanceof String) { - values = Collections.singletonList((String) value); - } else { - values = (List) value; - } - - if (UserModel.USERNAME.equals(key) || UserModel.EMAIL.equals(key)) { - values = values.stream().map(KeycloakModelUtils::toLowerCaseSafe).collect(Collectors.toList()); - } + List values = normalizeAttributeValues(key, entry.getValue()); newAttributes.put(key, Collections.unmodifiableList(values)); } @@ -378,28 +366,24 @@ public class DefaultAttributes extends HashMap> implements AttributeMetadata metadata = metadataByAttribute.get(attributeName); if (user != null && isIncludeAttributeIfNotProvided(metadata)) { - values = user.getAttributes().getOrDefault(attributeName, EMPTY_VALUE); + values = normalizeAttributeValues(attributeName, user.getAttributes().getOrDefault(attributeName, EMPTY_VALUE)); } newAttributes.put(attributeName, values); } if (user != null) { - List username = newAttributes.getOrDefault(UserModel.USERNAME, Collections.emptyList()); + List username = newAttributes.getOrDefault(UserModel.USERNAME, emptyList()); if (username.isEmpty() && isReadOnly(UserModel.USERNAME)) { setUserName(newAttributes, Collections.singletonList(user.getUsername())); } } - List email = newAttributes.getOrDefault(UserModel.EMAIL, Collections.emptyList()); + List email = newAttributes.getOrDefault(UserModel.EMAIL, emptyList()); if (!email.isEmpty() && realm.isRegistrationEmailAsUsername()) { - List lowerCaseEmailList = email.stream() - .filter(Objects::nonNull) - .collect(Collectors.toList()); - - setUserName(newAttributes, lowerCaseEmailList); + setUserName(newAttributes, email); if (user != null && isReadOnly(UserModel.EMAIL)) { newAttributes.put(UserModel.EMAIL, Collections.singletonList(user.getEmail())); @@ -414,6 +398,24 @@ public class DefaultAttributes extends HashMap> implements return newAttributes; } + private List normalizeAttributeValues(String name, Object value) { + List values; + + if (value instanceof String) { + values = Collections.singletonList((String) value); + } else { + values = (List) value; + } + + Stream valuesStream = Optional.ofNullable(values).orElse(EMPTY_VALUE).stream().filter(Objects::nonNull); + + if (UserModel.USERNAME.equals(name) || UserModel.EMAIL.equals(name)) { + valuesStream = valuesStream.map(KeycloakModelUtils::toLowerCaseSafe); + } + + return valuesStream.collect(Collectors.toList()); + } + private boolean isAllowUnmanagedAttribute() { UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy(); @@ -466,12 +468,7 @@ public class DefaultAttributes extends HashMap> implements return true; } - if (isReadOnlyInternalAttribute(name)) { - return true; - } - - // checks whether the attribute is a core attribute - return isRootAttribute(name); + return isReadOnlyInternalAttribute(name); } private boolean isManagedAttribute(String name) { @@ -511,4 +508,22 @@ public class DefaultAttributes extends HashMap> implements public Map> getUnmanagedAttributes() { return unmanagedAttributes; } + + private AttributeMetadata createUnmanagedAttributeMetadata(String name) { + return new AttributeMetadata(name, Integer.MAX_VALUE) { + final UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy(); + + @Override + public boolean canView(AttributeContext context) { + return canEdit(context) + || (UnmanagedAttributePolicy.ADMIN_VIEW.equals(unmanagedAttributePolicy) && UserProfileContext.USER_API.equals(context.getContext())); + } + + @Override + public boolean canEdit(AttributeContext context) { + return UnmanagedAttributePolicy.ENABLED.equals(unmanagedAttributePolicy) + || (UnmanagedAttributePolicy.ADMIN_EDIT.equals(unmanagedAttributePolicy) && UserProfileContext.USER_API.equals(context.getContext())); + } + }; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java index 92792cc8300..1c1ad6a31b7 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/DefaultUserProfile.java @@ -19,6 +19,10 @@ package org.keycloak.userprofile; +import static org.keycloak.userprofile.UserProfileUtil.createUserProfileMetadata; +import static org.keycloak.userprofile.UserProfileUtil.isRootAttribute; + +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -31,7 +35,10 @@ import java.util.stream.Collectors; import org.keycloak.common.util.CollectionUtil; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; +import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.representations.idm.AbstractUserRepresentation; import org.keycloak.storage.ReadOnlyException; import org.keycloak.utils.StringUtil; @@ -45,10 +52,11 @@ import org.keycloak.utils.StringUtil; */ public final class DefaultUserProfile implements UserProfile { - protected final UserProfileMetadata metadata; + private final UserProfileMetadata metadata; private final Function userSupplier; private final Attributes attributes; private final KeycloakSession session; + private final boolean isUserProfileEnabled; private boolean validated; private UserModel user; @@ -59,6 +67,8 @@ public final class DefaultUserProfile implements UserProfile { this.attributes = attributes; this.user = user; this.session = session; + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + isUserProfileEnabled = provider.isEnabled(session.getContext().getRealm()); } @Override @@ -144,16 +154,27 @@ public final class DefaultUserProfile implements UserProfile { attrsToRemove.removeAll(attributes.nameSet()); - for (String attr : attrsToRemove) { - if (attributes.isReadOnly(attr)) { + for (String name : attrsToRemove) { + if (attributes.isReadOnly(name)) { continue; } - List currentValue = user.getAttributeStream(attr).filter(Objects::nonNull).collect(Collectors.toList()); - user.removeAttribute(attr); + List currentValue = user.getAttributeStream(name).filter(Objects::nonNull).collect(Collectors.toList()); + + if (isRootAttribute(name)) { + if (UserModel.FIRST_NAME.equals(name)) { + user.setFirstName(null); + } else if (UserModel.LAST_NAME.equals(name)) { + user.setLastName(null); + } else if (UserModel.LOCALE.equals(name)) { + user.removeAttribute(name); + } + } else { + user.removeAttribute(name); + } for (AttributeChangeListener listener : changeListener) { - listener.onChange(attr, user, currentValue); + listener.onChange(name, user, currentValue); } } } @@ -168,11 +189,88 @@ public final class DefaultUserProfile implements UserProfile { } private boolean isCustomAttribute(String name) { - return !getAttributes().isRootAttribute(name); + return !isRootAttribute(name); } @Override public Attributes getAttributes() { return attributes; } + + @Override + public R toRepresentation() { + if (user == null) { + throw new IllegalStateException("Can not create the representation because the user is not yet created"); + } + + R rep = createUserRepresentation(); + Map> readable = attributes.getReadable(); + Map> attributesRep = new HashMap<>(readable); + + // all the attributes here have read access and might be available in the representation + for (String name : readable.keySet()) { + List values = attributesRep.getOrDefault(name, Collections.emptyList()) + .stream().filter(StringUtil::isNotBlank) + .collect(Collectors.toList()); + + if (values.isEmpty()) { + // make sure empty attributes are not in the representation + attributesRep.remove(name); + continue; + } + + if (isRootAttribute(name)) { + if (UserModel.LOCALE.equals(name)) { + // local is a special root attribute as it does not have a field in the user representation + // it should be available as a regular attribute if set + continue; + } + + boolean isUnmanagedAttribute = isUserProfileEnabled && metadata.getAttribute(name).isEmpty(); + String value = isUnmanagedAttribute ? null : values.stream().findFirst().orElse(null); + + if (UserModel.USERNAME.equals(name)) { + rep.setUsername(value); + } else if (UserModel.EMAIL.equals(name)) { + rep.setEmail(value); + rep.setEmailVerified(user.isEmailVerified()); + } else if (UserModel.FIRST_NAME.equals(name)) { + rep.setFirstName(value); + } else if (UserModel.LAST_NAME.equals(name)) { + rep.setLastName(value); + } + + // we don't have root attributes as a regular attribute in the representation as they have their own fields + attributesRep.remove(name); + } + } + + rep.setId(user.getId()); + rep.setAttributes(attributesRep.isEmpty() ? null : attributesRep); + rep.setUserProfileMetadata(createUserProfileMetadata(session, this)); + + return rep; + } + + @SuppressWarnings("unchecked") + private R createUserRepresentation() { + UserProfileContext context = metadata.getContext(); + R rep; + + if (UserProfileContext.USER_API.equals(context)) { + RealmModel realm = session.getContext().getRealm(); + rep = (R) ModelToRepresentation.toRepresentation(session, realm, user); + } else { + // by default, we build the simplest representation without exposing much information about users + rep = (R) new org.keycloak.representations.account.UserRepresentation(); + } + + // reset the root attribute values so that they are calculated based on the user profile configuration + rep.setUsername(null); + rep.setEmail(null); + rep.setFirstName(null); + rep.setLastName(null); + + return rep; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfile.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfile.java index 34c9ee05c51..761ae31004c 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfile.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfile.java @@ -17,22 +17,38 @@ package org.keycloak.userprofile; -import java.util.function.BiConsumer; +import java.util.List; +import java.util.Map; import org.keycloak.models.UserModel; +import org.keycloak.representations.idm.AbstractUserRepresentation; /** - *

An interface providing as an entry point for managing users. + *

An interface that serves an entry point for managing users and their attributes. * - *

A {@code UserProfile} provides a manageable view for user information that also takes into account the context where it is being used. - * The context represents the different places in Keycloak where users are created, updated, or validated. + *

A {@code UserProfile} provides methods for creating, and updating users as well as for accessing their attributes. + * All its operations are based the {@link UserProfileContext}. By taking the context into account, the state and behavior of + * {@link UserProfile} instances depend on the context they are associated with where creating, updating, validating, and + * accessing the attribute set of a user is based on the configuration (see {@link org.keycloak.representations.userprofile.config.UPConfig}) + * and the constraints associated with a given context. + * + *

The {@link UserProfileContext} represents the different areas in Keycloak where users, and their attributes are managed. * Examples of contexts are: managing users through the Admin API, or through the Account API. * - *

By taking the context into account, the state and behavior of {@link UserProfile} instances depend on the context they - * are associated with, where validating, updating, creating, or obtaining representations of users is based on the configuration - * and constraints associated with a context. + *

A {@code UserProfile} instance can be obtained through the {@link UserProfileProvider}: * - *

A {@code UserProfile} instance can be obtained through the {@link UserProfileProvider}. + *

 {@code
+ * // resolve an existing user
+ * UserModel user = getExistingUser();
+ * // obtain the user profile provider
+ * UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
+ * // create a instance for managing the user profile through the USER_API context
+ * UserProfile profile = provider.create(USER_API, user);
+ * }
+ * + *

The {@link UserProfileProvider} provides different methods for creating {@link UserProfile} instances, each one + * target for a specific scenario such as creating a new user, updating an existing one, or only for accessing the attributes + * for an existing user as shown in the above example. * * @see UserProfileContext * @see UserProfileProvider @@ -69,20 +85,23 @@ public interface UserProfile { void update(boolean removeAttributes, AttributeChangeListener... changeListener) throws ValidationException; /** - *

The same as {@link #update(boolean, BiConsumer[])} but forcing the removal of attributes. + *

The same as {@link #update(boolean, AttributeChangeListener...)}} but forcing the removal of attributes. * * @param changeListener a set of one or more listeners to listen for attribute changes * @throws ValidationException in case of any validation error */ - default void update(AttributeChangeListener... changeListener) throws ValidationException, RuntimeException { + default void update(AttributeChangeListener... changeListener) throws ValidationException { update(true, changeListener); } /** * Returns the attributes associated with this instance. Note that the attributes returned by this method are not necessarily - * the same from the {@link UserModel}, but those that should be validated and possibly updated to the {@link UserModel}. + * the same from the {@link UserModel} as they are based on the configurations set in the {@link org.keycloak.representations.userprofile.config.UPConfig} and + * the context this instance is based on. * * @return the attributes associated with this instance. */ Attributes getAttributes(); + + R toRepresentation(); } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileUtil.java b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileUtil.java index cfa6e0f4bd6..45059631acc 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileUtil.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/UserProfileUtil.java @@ -20,11 +20,23 @@ package org.keycloak.userprofile; import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; import java.util.function.Predicate; +import java.util.stream.Collectors; import org.jboss.logging.Logger; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.provider.ConfiguredProvider; +import org.keycloak.representations.idm.UserProfileAttributeGroupMetadata; +import org.keycloak.representations.idm.UserProfileAttributeMetadata; import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.representations.userprofile.config.UPGroup; +import org.keycloak.validate.Validators; /** * @author Marek Posolda @@ -82,4 +94,70 @@ public class UserProfileUtil { return true; } } + + /** + * Returns whether the attribute with the given {@code name} is a root attribute. + * + * @param name the attribute name + * @return + */ + public static boolean isRootAttribute(String name) { + return UserModel.USERNAME.equals(name) + || UserModel.EMAIL.equals(name) + || UserModel.FIRST_NAME.equals(name) + || UserModel.LAST_NAME.equals(name) + || UserModel.LOCALE.equals(name); + } + + public static org.keycloak.representations.idm.UserProfileMetadata createUserProfileMetadata(KeycloakSession session, UserProfile profile) { + Attributes profileAttributes = profile.getAttributes(); + Map> am = profileAttributes.getReadable(); + + if(am == null) + return null; + Map> unmanagedAttributes = profileAttributes.getUnmanagedAttributes(); + + List attributes = am.keySet().stream() + .map(profileAttributes::getMetadata) + .filter(Objects::nonNull) + .filter(attributeMetadata -> !unmanagedAttributes.containsKey(attributeMetadata.getName())) + .sorted(Comparator.comparingInt(AttributeMetadata::getGuiOrder)) + .map(sam -> toRestMetadata(sam, session, profile)) + .collect(Collectors.toList()); + + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + UPConfig config = provider.getConfiguration(); + + List groups = config.getGroups().stream().map(new Function() { + @Override + public UserProfileAttributeGroupMetadata apply(UPGroup upGroup) { + return new UserProfileAttributeGroupMetadata(upGroup.getName(), upGroup.getDisplayHeader(), upGroup.getDisplayDescription(), upGroup.getAnnotations()); + } + }).collect(Collectors.toList()); + + return new org.keycloak.representations.idm.UserProfileMetadata(attributes, groups); + } + + private static UserProfileAttributeMetadata toRestMetadata(AttributeMetadata am, KeycloakSession session, UserProfile profile) { + String group = null; + + if (am.getAttributeGroupMetadata() != null) { + group = am.getAttributeGroupMetadata().getName(); + } + + return new UserProfileAttributeMetadata(am.getName(), + am.getAttributeDisplayName(), + profile.getAttributes().isRequired(am.getName()), + profile.getAttributes().isReadOnly(am.getName()), + group, + am.getAnnotations(), + toValidatorMetadata(am, session)); + } + + private static Map> toValidatorMetadata(AttributeMetadata am, KeycloakSession session){ + // we return only validators which are instance of ConfiguredProvider. Others are expected as internal. + return am.getValidators() == null ? null : am.getValidators().stream() + .filter(avm -> (Validators.validator(session, avm.getValidatorId()) instanceof ConfiguredProvider)) + .collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig)); + } } diff --git a/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java b/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java index af5a560b0a3..a55da0e31bc 100644 --- a/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java +++ b/server-spi/src/main/java/org/keycloak/userprofile/Attributes.java @@ -19,20 +19,38 @@ package org.keycloak.userprofile; +import static java.util.Optional.ofNullable; + import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; -import java.util.stream.Collectors; -import org.keycloak.models.UserModel; import org.keycloak.validate.ValidationError; /** *

This interface wraps the attributes associated with a user profile. Different operations are provided to access and * manage these attributes. * + *

Attributes are classified as:

+ *
    + *
  • Managed + *
  • Unmanaged + *
+ * + *

A managed attribute is any attribute defined in the user profile configuration. Therefore, they are known by + * the server and can be managed accordingly. + * + *

A unmanaged attributes is any attribute not defined in the user profile configuration. Therefore, the server + * does not know about them and they cannot use capabilities provided by the server. However, they can still be managed by + * administrators by setting any of the {@link org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy}. + * + *

Any attribute available from this interface has a corresponding {@link AttributeMetadata}

. The metadata describes + * the settings for a given attribute so that the server can communicate to a caller the constraints + * (see {@link org.keycloak.representations.userprofile.config.UPConfig} and the availability of the attribute in + * a given {@link UserProfileContext}. + * * @author Pedro Igor */ public interface Attributes { @@ -49,8 +67,8 @@ public interface Attributes { * * @return the first value */ - default String getFirstValue(String name) { - List values = getValues(name); + default String getFirst(String name) { + List values = ofNullable(get(name)).orElse(List.of()); if (values.isEmpty()) { return null; @@ -66,16 +84,16 @@ public interface Attributes { * * @return the attribute values */ - List getValues(String name); + List get(String name); /** * Checks whether an attribute is read-only. * - * @param key + * @param name the attribute name * - * @return + * @return {@code true} if the attribute is read-only. Otherwise, {@code false} */ - boolean isReadOnly(String key); + boolean isReadOnly(String name); /** * Validates the attribute with the given {@code name}. @@ -105,7 +123,7 @@ public interface Attributes { Set nameSet(); /** - * Returns all attributes that can be written. + * Returns all the attributes with read-write permissions in a particular {@link UserProfileContext}. * * @return the attributes */ @@ -131,52 +149,23 @@ public interface Attributes { boolean isRequired(String name); /** - * Similar to {{@link #getReadable(boolean)}} but with the possibility to add or remove - * the root attributes. + * Returns only the attributes that have read permissions in a particular {@link UserProfileContext}. * - * @param includeBuiltin if the root attributes should be included. - * @return the attributes with read/write permission. - */ - default Map> getReadable(boolean includeBuiltin) { - return getReadable().entrySet().stream().filter(entry -> { - if (includeBuiltin) { - return true; - } - if (isRootAttribute(entry.getKey())) { - if (UserModel.LOCALE.equals(entry.getKey()) && !entry.getValue().isEmpty()) { - // locale is different form of built-in attribute in the sense it is related to a - // specific feature (i18n) and does not have a top-level attribute in the user representation - // the locale should be available from the attribute map if not empty - return true; - } - return false; - } - return true; - }).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - - /** - * Returns only the attributes that have read/write permissions. - * - * @return the attributes with read/write permission. + * @return the attributes with read permission. */ Map> getReadable(); /** - * Returns whether the attribute with the given {@code name} is a root attribute. + * Returns the attributes as a {@link Map} that are accessible to a particular {@link UserProfileContext}. * - * @param name the attribute name - * @return + * @return a map with all the attributes */ - default boolean isRootAttribute(String name) { - return UserModel.USERNAME.equals(name) - || UserModel.EMAIL.equals(name) - || UserModel.FIRST_NAME.equals(name) - || UserModel.LAST_NAME.equals(name) - || UserModel.LOCALE.equals(name); - } - Map> toMap(); + /** + * Returns a {@link Map} holding any unmanaged attribute. + * + * @return a map with any unmanaged attribute + */ Map> getUnmanagedAttributes(); } diff --git a/server-spi/src/main/java/org/keycloak/userprofile/UserProfileContext.java b/server-spi/src/main/java/org/keycloak/userprofile/UserProfileContext.java index 537f4ebea68..3b6e5317c0a 100644 --- a/server-spi/src/main/java/org/keycloak/userprofile/UserProfileContext.java +++ b/server-spi/src/main/java/org/keycloak/userprofile/UserProfileContext.java @@ -21,7 +21,7 @@ package org.keycloak.userprofile; /** *

This interface represents the different contexts from where user profiles are managed. The core contexts are already - * available here representing the different parts in Keycloak where user profiles are managed. + * available here representing the different areas in Keycloak where user profiles are managed. * *

The context is crucial to drive the conditions that should be respected when managing user profiles. It might be possible * to include in the future metadata about contexts. As well as support custom contexts. @@ -30,16 +30,39 @@ package org.keycloak.userprofile; */ public enum UserProfileContext { + /** + * In this context, a user profile is managed by themselves during an authentication flow such as when updating the user profile. + */ UPDATE_PROFILE(true), + + /** + * In this context, a user profile is managed through the management interface such as the Admin API. + */ USER_API(false), + + /** + * In this context, a user profile is managed by themselves through the account console. + */ ACCOUNT(true), + + /** + * In this context, a user profile is managed by themselves when authenticating through a broker. + */ IDP_REVIEW(false), + + /** + * In this context, a user profile is managed by themselves when registering to a realm. + */ REGISTRATION(false), + + /** + * In this context, a user profile is managed by themselves when updating their email through an application initiated action. + */ UPDATE_EMAIL(false); - protected boolean resetEmailVerified; + private boolean resetEmailVerified; - private UserProfileContext(boolean resetEmailVerified){ + UserProfileContext(boolean resetEmailVerified){ this.resetEmailVerified = resetEmailVerified; } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpReviewProfileAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpReviewProfileAuthenticator.java index 5dd627daa92..e528e27c778 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpReviewProfileAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpReviewProfileAuthenticator.java @@ -166,7 +166,7 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator { profile.update((attributeName, userModel, oldValue) -> { if (attributeName.equals(UserModel.EMAIL)) { context.getAuthenticationSession().setAuthNote(UPDATE_PROFILE_EMAIL_CHANGED, "true"); - event.clone().event(EventType.UPDATE_EMAIL).detail(Details.CONTEXT, UserProfileContext.IDP_REVIEW.name()).detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, profile.getAttributes().getFirstValue(UserModel.EMAIL)).success(); + event.clone().event(EventType.UPDATE_EMAIL).detail(Details.CONTEXT, UserProfileContext.IDP_REVIEW.name()).detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, profile.getAttributes().getFirst(UserModel.EMAIL)).success(); } }); } catch (ValidationException pve) { @@ -187,7 +187,7 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator { logger.debugf("Profile updated successfully after first authentication with identity provider '%s' for broker user '%s'.", brokerContext.getIdpConfig().getAlias(), userCtx.getUsername()); - String newEmail = profile.getAttributes().getFirstValue(UserModel.EMAIL); + String newEmail = profile.getAttributes().getFirst(UserModel.EMAIL); event.detail(Details.UPDATED_EMAIL, newEmail); diff --git a/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java b/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java index 21fd8c1601c..cd40519f929 100755 --- a/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java +++ b/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java @@ -39,6 +39,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.services.messages.Messages; import org.keycloak.services.validation.Validation; +import org.keycloak.userprofile.Attributes; import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.ValidationException; @@ -71,11 +72,11 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory { context.getEvent().detail(Details.REGISTER_METHOD, "form"); UserProfile profile = getOrCreateUserProfile(context, formData); - String email = profile.getAttributes().getFirstValue(UserModel.EMAIL); - - String username = profile.getAttributes().getFirstValue(UserModel.USERNAME); - String firstName = profile.getAttributes().getFirstValue(UserModel.FIRST_NAME); - String lastName = profile.getAttributes().getFirstValue(UserModel.LAST_NAME); + Attributes attributes = profile.getAttributes(); + String email = attributes.getFirst(UserModel.EMAIL); + String username = attributes.getFirst(UserModel.USERNAME); + String firstName = attributes.getFirst(UserModel.FIRST_NAME); + String lastName = attributes.getFirst(UserModel.LAST_NAME); context.getEvent().detail(Details.EMAIL, email); context.getEvent().detail(Details.USERNAME, username); @@ -92,7 +93,7 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory { List errors = Validation.getFormErrorsFromValidation(pve.getErrors()); if (pve.hasError(Messages.EMAIL_EXISTS, Messages.INVALID_EMAIL)) { - context.getEvent().detail(Details.EMAIL, profile.getAttributes().getFirstValue(UserModel.EMAIL)); + context.getEvent().detail(Details.EMAIL, attributes.getFirst(UserModel.EMAIL)); } if (pve.hasError(Messages.EMAIL_EXISTS)) { diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateEmail.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateEmail.java index d136af06ed6..d2066af3f01 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateEmail.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateEmail.java @@ -168,7 +168,7 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor public static void updateEmailNow(EventBuilder event, UserModel user, UserProfile emailUpdateValidationResult) { String oldEmail = user.getEmail(); - String newEmail = emailUpdateValidationResult.getAttributes().getFirstValue(UserModel.EMAIL); + String newEmail = emailUpdateValidationResult.getAttributes().getFirst(UserModel.EMAIL); event.event(EventType.UPDATE_EMAIL).detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, newEmail); emailUpdateValidationResult.update(false, new EventAuditingAttributeChangeListener(emailUpdateValidationResult, event)); } diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java index 43a416411bc..f3a38985e79 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java @@ -24,12 +24,9 @@ import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; import java.util.Properties; import java.util.Set; -import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -66,12 +63,9 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserConsentModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.ModelToRepresentation; -import org.keycloak.provider.ConfiguredProvider; import org.keycloak.representations.account.ClientRepresentation; import org.keycloak.representations.account.ConsentRepresentation; import org.keycloak.representations.account.ConsentScopeRepresentation; -import org.keycloak.representations.idm.UserProfileAttributeMetadata; -import org.keycloak.representations.idm.UserProfileMetadata; import org.keycloak.representations.account.UserRepresentation; import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.representations.idm.GroupRepresentation; @@ -80,7 +74,6 @@ import org.keycloak.services.managers.Auth; import org.keycloak.services.managers.UserConsentManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.account.resources.ResourcesService; -import org.keycloak.services.resources.admin.UserProfileResource; import org.keycloak.services.util.ResolveRelative; import org.keycloak.storage.ReadOnlyException; import org.keycloak.theme.Theme; @@ -92,8 +85,6 @@ import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.EventAuditingAttributeChangeListener; import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.ValidationException.Error; -import org.keycloak.utils.GroupUtils; -import org.keycloak.validate.Validators; /** * @author Stian Thorgersen @@ -142,36 +133,15 @@ public class AccountRestService { auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE); UserModel user = auth.getUser(); - - UserRepresentation rep = new UserRepresentation(); - rep.setId(user.getId()); - UserProfileProvider provider = session.getProvider(UserProfileProvider.class); UserProfile profile = provider.create(UserProfileContext.ACCOUNT, user); + UserRepresentation rep = profile.toRepresentation(); - rep.setAttributes(profile.getAttributes().getReadable(false)); - - addReadableBuiltinAttributes(user, rep, profile.getAttributes().getReadable(true).keySet()); - - if(userProfileMetadata == null || userProfileMetadata.booleanValue()) - rep.setUserProfileMetadata(UserProfileResource.createUserProfileMetadata(session, profile)); - - return rep; - } - - private void addReadableBuiltinAttributes(UserModel user, UserRepresentation rep, Set readableAttributes) { - setIfReadable(UserModel.USERNAME, readableAttributes, rep::setUsername, user::getUsername); - setIfReadable(UserModel.FIRST_NAME, readableAttributes, rep::setFirstName, user::getFirstName); - setIfReadable(UserModel.LAST_NAME, readableAttributes, rep::setLastName, user::getLastName); - setIfReadable(UserModel.EMAIL, readableAttributes, rep::setEmail, user::getEmail); - // emailVerified is readable when email is readable - setIfReadable(UserModel.EMAIL, readableAttributes, rep::setEmailVerified, user::isEmailVerified); - } - - private void setIfReadable(String attributeName, Set readableAttributes, Consumer setter, Supplier getter) { - if (readableAttributes.contains(attributeName)) { - setter.accept(getter.get()); + if (userProfileMetadata != null && !userProfileMetadata) { + rep.setUserProfileMetadata(null); } + + return rep; } @Path("/") @@ -185,7 +155,7 @@ public class AccountRestService { event.event(EventType.UPDATE_PROFILE).detail(Details.CONTEXT, UserProfileContext.ACCOUNT.name()); UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); - UserProfile profile = profileProvider.create(UserProfileContext.ACCOUNT, rep.toAttributes(), auth.getUser()); + UserProfile profile = profileProvider.create(UserProfileContext.ACCOUNT, rep.getRawAttributes(), auth.getUser()); try { diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java index a5beb764e40..7c86ba6abb8 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserProfileResource.java @@ -16,13 +16,9 @@ */ package org.keycloak.services.resources.admin; +import static org.keycloak.userprofile.UserProfileUtil.createUserProfileMetadata; + import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.function.Function; -import java.util.stream.Collectors; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; @@ -117,56 +113,4 @@ public class UserProfileResource { return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build(); } - - public static UserProfileMetadata createUserProfileMetadata(KeycloakSession session, UserProfile profile) { - Attributes profileAttributes = profile.getAttributes(); - Map> am = profileAttributes.getReadable(); - - if(am == null) - return null; - Map> unmanagedAttributes = profileAttributes.getUnmanagedAttributes(); - - List attributes = am.keySet().stream() - .map(profileAttributes::getMetadata) - .filter(Objects::nonNull) - .filter(attributeMetadata -> !unmanagedAttributes.containsKey(attributeMetadata.getName())) - .sorted(Comparator.comparingInt(AttributeMetadata::getGuiOrder)) - .map(sam -> toRestMetadata(sam, session, profile)) - .collect(Collectors.toList()); - - UserProfileProvider provider = session.getProvider(UserProfileProvider.class); - UPConfig config = provider.getConfiguration(); - - List groups = config.getGroups().stream().map(new Function() { - @Override - public UserProfileAttributeGroupMetadata apply(UPGroup upGroup) { - return new UserProfileAttributeGroupMetadata(upGroup.getName(), upGroup.getDisplayHeader(), upGroup.getDisplayDescription(), upGroup.getAnnotations()); - } - }).collect(Collectors.toList()); - - return new UserProfileMetadata(attributes, groups); - } - - private static UserProfileAttributeMetadata toRestMetadata(AttributeMetadata am, KeycloakSession session, UserProfile profile) { - String group = null; - - if (am.getAttributeGroupMetadata() != null) { - group = am.getAttributeGroupMetadata().getName(); - } - - return new UserProfileAttributeMetadata(am.getName(), - am.getAttributeDisplayName(), - profile.getAttributes().isRequired(am.getName()), - profile.getAttributes().isReadOnly(am.getName()), - group, - am.getAnnotations(), - toValidatorMetadata(am, session)); - } - - private static Map> toValidatorMetadata(AttributeMetadata am, KeycloakSession session){ - // we return only validators which are instance of ConfiguredProvider. Others are expected as internal. - return am.getValidators() == null ? null : am.getValidators().stream() - .filter(avm -> (Validators.validator(session, avm.getValidatorId()) instanceof ConfiguredProvider)) - .collect(Collectors.toMap(AttributeValidatorMetadata::getValidatorId, AttributeValidatorMetadata::getValidatorConfig)); - } } 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 7d21d4eb4a9..70e1479aee2 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 @@ -123,7 +123,6 @@ import java.util.stream.Stream; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME; -import static org.keycloak.services.resources.admin.UserProfileResource.createUserProfileMetadata; import static org.keycloak.userprofile.UserProfileContext.USER_API; /** @@ -184,7 +183,7 @@ public class UserResource { wasPermanentlyLockedOut = session.getProvider(BruteForceProtector.class).isPermanentlyLockedOut(session, realm, user); } - Map> attributes = new HashMap<>(rep.toAttributes()); + Map> attributes = new HashMap<>(rep.getRawAttributes()); if (rep.getAttributes() == null) { // include existing attributes in case no attributes are set so that validation takes into account the existing @@ -302,7 +301,9 @@ public class UserResource { ) { auth.users().requireView(user); - UserRepresentation rep = ModelToRepresentation.toRepresentation(session, realm, user); + UserProfileProvider provider = session.getProvider(UserProfileProvider.class); + UserProfile profile = provider.create(USER_API, user); + UserRepresentation rep = profile.toRepresentation(); if (realm.isIdentityFederationEnabled()) { List reps = getFederatedIdentities(user).collect(Collectors.toList()); @@ -314,16 +315,8 @@ public class UserResource { } rep.setAccess(auth.users().getAccess(user)); - UserProfileProvider provider = session.getProvider(UserProfileProvider.class); - UserProfile profile = provider.create(USER_API, user); - Map> readableAttributes = profile.getAttributes().getReadable(false); - - if (rep.getAttributes() != null) { - rep.setAttributes(readableAttributes); - } - - if (userProfileMetadata) { - rep.setUserProfileMetadata(createUserProfileMetadata(session, profile)); + if (!userProfileMetadata) { + rep.setUserProfileMetadata(null); } return rep; diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java index 55aded752a4..6b180ac8656 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java @@ -154,7 +154,7 @@ public class UsersResource { UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); - UserProfile profile = profileProvider.create(USER_API, rep.toAttributes()); + UserProfile profile = profileProvider.create(USER_API, rep.getRawAttributes()); try { Response response = UserResource.validateUserProfile(profile, session, auth.adminAuth()); diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java index 8da699d60c2..f96417691c9 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProvider.java @@ -117,6 +117,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider { } return new DefaultAttributes(context, attributes, user, metadata, session); } + return new LegacyAttributes(context, attributes, user, metadata, session); } @@ -153,11 +154,11 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider { @Override public UserModel apply(Attributes attributes) { if (user == null) { - String userName = attributes.getFirstValue(UserModel.USERNAME); + String userName = attributes.getFirst(UserModel.USERNAME); // fallback to email in case email is allowed if (userName == null) { - userName = attributes.getFirstValue(UserModel.EMAIL); + userName = attributes.getFirst(UserModel.EMAIL); } user = session.users().addUser(session.getContext().getRealm(), userName); diff --git a/services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java b/services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java index d76d5575eb9..a753ccb5c8e 100644 --- a/services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java +++ b/services/src/main/java/org/keycloak/userprofile/validator/ImmutableAttributeValidator.java @@ -75,7 +75,7 @@ public class ImmutableAttributeValidator implements SimpleValidator { return context; } - List email = attributeContext.getAttributes().getValues(UserModel.EMAIL); + List email = attributeContext.getAttributes().get(UserModel.EMAIL); if (UserModel.USERNAME.equals(attributeName) && collectionEquals(values, email)) { return context; diff --git a/services/src/main/java/org/keycloak/userprofile/validator/UsernameMutationValidator.java b/services/src/main/java/org/keycloak/userprofile/validator/UsernameMutationValidator.java index f06da402d3f..4843484877f 100644 --- a/services/src/main/java/org/keycloak/userprofile/validator/UsernameMutationValidator.java +++ b/services/src/main/java/org/keycloak/userprofile/validator/UsernameMutationValidator.java @@ -70,7 +70,7 @@ public class UsernameMutationValidator implements SimpleValidator { if (!realm.isEditUsernameAllowed() && user != null && !value.equals(user.getFirstAttribute(UserModel.USERNAME))) { Attributes attributes = attributeContext.getAttributes(); - if (realm.isRegistrationEmailAsUsername() && value.equals(attributes.getFirstValue(UserModel.EMAIL))) { + if (realm.isRegistrationEmailAsUsername() && value.equals(attributes.getFirst(UserModel.EMAIL))) { // if username changed is because email as username is allowed so no validation should happen for update profile // it is expected that username changes when attributes are normalized by the provider return context; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceReadOnlyAttributesTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceReadOnlyAttributesTest.java index 642fc6171da..68dc72f3225 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceReadOnlyAttributesTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceReadOnlyAttributesTest.java @@ -19,6 +19,8 @@ package org.keycloak.testsuite.account; import java.io.IOException; +import java.util.Map; +import java.util.Optional; import jakarta.ws.rs.BadRequestException; @@ -99,7 +101,7 @@ public class AccountRestServiceReadOnlyAttributesTest extends AbstractRestServic private void testAccountUpdateAttributeExpectFailure(String attrName, boolean deniedForAdminAsWell) throws IOException { // Attribute not yet supposed to be on the user UserRepresentation user = SimpleHttp.doGet(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).asJson(UserRepresentation.class); - assertThat(user.getAttributes().keySet(), not(contains(attrName))); + assertThat(Optional.ofNullable(user.getAttributes()).orElse(Map.of()).keySet(), not(contains(attrName))); // Assert not possible to add the attribute to the user user.singleAttribute(attrName, "foo"); @@ -147,7 +149,7 @@ public class AccountRestServiceReadOnlyAttributesTest extends AbstractRestServic private void testAccountUpdateAttributeExpectSuccess(String attrName) throws IOException { // Attribute not yet supposed to be on the user UserRepresentation user = SimpleHttp.doGet(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).asJson(UserRepresentation.class); - assertThat(user.getAttributes().keySet(), not(contains(attrName))); + assertThat(Optional.ofNullable(user.getAttributes()).orElse(Map.of()).keySet(), not(contains(attrName))); // Assert not possible to add the attribute to the user user.singleAttribute(attrName, "foo"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java index f4cc39539ff..5228ce3e779 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java @@ -79,6 +79,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import static org.hamcrest.CoreMatchers.is; @@ -278,7 +279,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { assertEquals("Brady", user.getLastName()); assertEquals("test-user@localhost", user.getEmail()); assertFalse(user.isEmailVerified()); - assertTrue(user.getAttributes().isEmpty()); + assertNull(user.getAttributes()); } @Test @@ -288,7 +289,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { String originalFirstName = user.getFirstName(); String originalLastName = user.getLastName(); String originalEmail = user.getEmail(); - Map> originalAttributes = new HashMap<>(user.getAttributes()); + user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>())); try { RealmRepresentation realmRep = adminClient.realm("test").toRepresentation(); @@ -316,7 +317,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { user.setFirstName(originalFirstName); user.setLastName(originalLastName); user.setEmail(originalEmail); - user.setAttributes(originalAttributes); SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse(); System.out.println(response.asString()); assertEquals(204, response.getStatus()); @@ -379,7 +379,8 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { String originalFirstName = user.getFirstName(); String originalLastName = user.getLastName(); String originalEmail = user.getEmail(); - Map> originalAttributes = new HashMap<>(user.getAttributes()); + assertNull(user.getAttributes()); + user.setAttributes(new HashMap<>()); try { RealmRepresentation realmRep = adminClient.realm("test").toRepresentation(); @@ -417,7 +418,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { user.setFirstName(originalFirstName); user.setLastName(originalLastName); user.setEmail(originalEmail); - user.setAttributes(originalAttributes); SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse(); System.out.println(response.asString()); assertEquals(204, response.getStatus()); @@ -431,7 +431,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { String originalFirstName = user.getFirstName(); String originalLastName = user.getLastName(); String originalEmail = user.getEmail(); - Map> originalAttributes = new HashMap<>(user.getAttributes()); + user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>())); try { RealmRepresentation realmRep = adminClient.realm("test").toRepresentation(); @@ -460,12 +460,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { user = updateAndGet(user); - if (isDeclarativeUserProfile()) { - assertEquals(2, user.getAttributes().size()); - assertTrue(user.getAttributes().get("attr1").isEmpty()); - } else { - assertEquals(1, user.getAttributes().size()); - } + assertEquals(1, user.getAttributes().size()); assertEquals(2, user.getAttributes().get("attr2").size()); assertThat(user.getAttributes().get("attr2"), containsInAnyOrder("val2", "val3")); @@ -522,7 +517,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { user.setFirstName(originalFirstName); user.setLastName(originalLastName); user.setEmail(originalEmail); - user.setAttributes(originalAttributes); SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse(); System.out.println(response.asString()); assertEquals(204, response.getStatus()); @@ -556,6 +550,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { public void testUpdateProfileCannotChangeThroughAttributes() throws IOException { UserRepresentation user = getUser(); String originalUsername = user.getUsername(); + user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>())); Map> originalAttributes = new HashMap<>(user.getAttributes()); try { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java index ba7d5bc8cf5..39e8c0d5946 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceWithUserProfileTest.java @@ -29,6 +29,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import org.junit.Before; import org.junit.Test; @@ -292,6 +293,7 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes String originalFirstName = user.getFirstName(); String originalLastName = user.getLastName(); String originalEmail = user.getEmail(); + user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>())); Map> originalAttributes = new HashMap<>(user.getAttributes()); try { @@ -303,13 +305,13 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes user.setEmail("bobby@localhost"); user.setFirstName("Homer"); user.setLastName("Simpsons"); - user.getAttributes().put("attr1", Collections.singletonList("val1")); - user.getAttributes().put("attr2", Collections.singletonList("val2")); + user.getAttributes().put("attr1", Collections.singletonList("val11")); + user.getAttributes().put("attr2", Collections.singletonList("val22")); + events.clear(); user = updateAndGet(user); //skip login to the REST API event - events.poll(); events.expectAccount(EventType.UPDATE_PROFILE).user(user.getId()) .detail(Details.CONTEXT, UserProfileContext.ACCOUNT.name()) .detail(Details.PREVIOUS_EMAIL, originalEmail) @@ -318,7 +320,7 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes .detail(Details.PREVIOUS_LAST_NAME, originalLastName) .detail(Details.UPDATED_FIRST_NAME, "Homer") .detail(Details.UPDATED_LAST_NAME, "Simpsons") - .detail(Details.PREF_UPDATED+"attr2", "val2") + .detail(Details.PREF_UPDATED+"attr2", "val22") .assertEvent(); events.assertEmpty(); @@ -379,22 +381,23 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes realmRep.setInternationalizationEnabled(false); testRealm().update(realmRep); UserRepresentation user = getUser(); + user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>())); try { user.getAttributes().put(UserModel.LOCALE, List.of("pt_BR")); user = updateAndGet(user); - assertNull(user.getAttributes().get(UserModel.LOCALE)); + assertNull(user.getAttributes()); realmRep.setInternationalizationEnabled(true); testRealm().update(realmRep); - user.getAttributes().put(UserModel.LOCALE, List.of("pt_BR")); + user.singleAttribute(UserModel.LOCALE, "pt_BR"); user = updateAndGet(user); assertEquals("pt_BR", user.getAttributes().get(UserModel.LOCALE).get(0)); user.getAttributes().remove(UserModel.LOCALE); user = updateAndGet(user); - assertNull(user.getAttributes().get(UserModel.LOCALE)); + assertNull(user.getAttributes()); UserProfileMetadata metadata = user.getUserProfileMetadata(); @@ -406,7 +409,6 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes } finally { realmRep.setInternationalizationEnabled(internationalizationEnabled); testRealm().update(realmRep); - user.getAttributes().remove(UserModel.LOCALE); updateAndGet(user); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/DeclarativeUserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/DeclarativeUserTest.java index c01783cfa8b..6439718964d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/DeclarativeUserTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/DeclarativeUserTest.java @@ -37,6 +37,7 @@ import org.keycloak.userprofile.UserProfileProvider; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -52,7 +53,6 @@ import jakarta.ws.rs.core.Response; @EnableFeature(value = Profile.Feature.DECLARATIVE_USER_PROFILE) public class DeclarativeUserTest extends AbstractAdminTest { - private static final String LOCALE_ATTR_KEY = "locale"; private static final String TEST_REALM_USER_MANAGER_NAME = "test-realm-user-manager"; private static final String REQUIRED_ATTR_KEY = "required-attr"; @@ -120,29 +120,6 @@ public class DeclarativeUserTest extends AbstractAdminTest { } } - @Test - public void testReturnAllConfiguredAttributesEvenIfNotSet() { - UserRepresentation user1 = new UserRepresentation(); - user1.setUsername("user1"); - user1.singleAttribute("attr1", "value1user1"); - user1.singleAttribute("attr2", "value2user1"); - String user1Id = createUser(user1); - - user1 = realm.users().get(user1Id).toRepresentation(); - Map> attributes = user1.getAttributes(); - assertEquals(4, attributes.size()); - List attr1 = attributes.get("attr1"); - assertEquals(1, attr1.size()); - assertEquals("value1user1", attr1.get(0)); - List attr2 = attributes.get("attr2"); - assertEquals(1, attr2.size()); - assertEquals("value2user1", attr2.get(0)); - List attrCustomA = attributes.get("custom-a"); - assertTrue(attrCustomA.isEmpty()); - assertTrue(attributes.containsKey("custom-a")); - assertTrue(attributes.containsKey("aName")); - } - @Test public void testDoNotReturnAttributeIfNotReadble() { UserRepresentation user1 = new UserRepresentation(); @@ -153,7 +130,7 @@ public class DeclarativeUserTest extends AbstractAdminTest { user1 = realm.users().get(user1Id).toRepresentation(); Map> attributes = user1.getAttributes(); - assertEquals(4, attributes.size()); + assertEquals(2, attributes.size()); assertFalse(attributes.containsKey("custom-hidden")); setUserProfileConfiguration(this.realm, "{\"attributes\": [" @@ -170,8 +147,8 @@ public class DeclarativeUserTest extends AbstractAdminTest { user1 = realm.users().get(user1Id).toRepresentation(); attributes = user1.getAttributes(); - assertEquals(5, attributes.size()); - assertTrue(attributes.containsKey("custom-hidden")); + assertEquals(2, attributes.size()); + assertFalse(attributes.containsKey("custom-hidden")); } @Test @@ -200,12 +177,17 @@ public class DeclarativeUserTest extends AbstractAdminTest { UserResource userResource = realm.users().get(user1Id); user1 = userResource.toRepresentation(); - Map> attributes = user1.getAttributes(); - attributes.put("attr2", Collections.singletonList("")); + assertNull(user1.getAttributes()); + user1.singleAttribute("attr2", ""); // should be able to update the user when a read-only attribute has an empty or null value userResource.update(user1); - attributes.put("attr2", null); + user1 = userResource.toRepresentation(); + assertNull(user1.getAttributes()); + user1.setAttributes(new HashMap<>()); + user1.getAttributes().put("attr2", null); userResource.update(user1); + user1 = userResource.toRepresentation(); + assertNull(user1.getAttributes()); } @Test @@ -288,7 +270,7 @@ public class DeclarativeUserTest extends AbstractAdminTest { realm.update(realmRep); user1 = userResource.toRepresentation(); - assertNull(user1.getAttributes().get(UserModel.LOCALE)); + assertNull(user1.getAttributes()); } finally { realmRep.setInternationalizationEnabled(internationalizationEnabled); realm.update(realmRep); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java index 4ac685b991b..78a6e134fb0 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java @@ -1529,20 +1529,12 @@ public class UserTest extends AbstractAdminTest { String user2Id = createUser(user2); user1 = realm.users().get(user1Id).toRepresentation(); - if (isDeclarativeUserProfile()) { - assertEquals(managedAttributes.size(), user1.getAttributes().size()); - } else { - assertEquals(2, user1.getAttributes().size()); - } + assertEquals(2, user1.getAttributes().size()); assertAttributeValue("value1user1", user1.getAttributes().get("attr1")); assertAttributeValue("value2user1", user1.getAttributes().get("attr2")); user2 = realm.users().get(user2Id).toRepresentation(); - if (isDeclarativeUserProfile()) { - assertEquals(managedAttributes.size(), user2.getAttributes().size()); - } else { - assertEquals(2, user2.getAttributes().size()); - } + assertEquals(2, user2.getAttributes().size()); assertAttributeValue("value1user2", user2.getAttributes().get("attr1")); vals = user2.getAttributes().get("attr2"); assertEquals(2, vals.size()); @@ -1554,11 +1546,7 @@ public class UserTest extends AbstractAdminTest { updateUser(realm.users().get(user1Id), user1); user1 = realm.users().get(user1Id).toRepresentation(); - if (isDeclarativeUserProfile()) { - assertEquals(managedAttributes.size(), user1.getAttributes().size()); - } else { - assertEquals(3, user1.getAttributes().size()); - } + assertEquals(3, user1.getAttributes().size()); assertAttributeValue("value3user1", user1.getAttributes().get("attr1")); assertAttributeValue("value2user1", user1.getAttributes().get("attr2")); assertAttributeValue("value4user1", user1.getAttributes().get("attr3")); @@ -1567,11 +1555,7 @@ public class UserTest extends AbstractAdminTest { updateUser(realm.users().get(user1Id), user1); user1 = realm.users().get(user1Id).toRepresentation(); - if (isDeclarativeUserProfile()) { - assertEquals(managedAttributes.size(), user1.getAttributes().size()); - } else { - assertEquals(2, user1.getAttributes().size()); - } + assertEquals(2, user1.getAttributes().size()); assertAttributeValue("value2user1", user1.getAttributes().get("attr2")); assertAttributeValue("value4user1", user1.getAttributes().get("attr3")); @@ -1580,11 +1564,7 @@ public class UserTest extends AbstractAdminTest { updateUser(realm.users().get(user1Id), user1); user1 = realm.users().get(user1Id).toRepresentation(); assertNotNull(user1.getAttributes()); - if (isDeclarativeUserProfile()) { - assertEquals(managedAttributes.size(), user1.getAttributes().size()); - } else { - assertEquals(2, user1.getAttributes().size()); - } + assertEquals(2, user1.getAttributes().size()); // empty attributes should remove attributes user1.setAttributes(Collections.emptyMap()); @@ -1602,21 +1582,13 @@ public class UserTest extends AbstractAdminTest { realm.users().get(user1Id).update(user1); user1 = realm.users().get(user1Id).toRepresentation(); - if (isDeclarativeUserProfile()) { - assertEquals(managedAttributes.size(), user1.getAttributes().size()); - } else { - assertEquals(2, user1.getAttributes().size()); - } + assertEquals(2, user1.getAttributes().size()); user1.getAttributes().remove("foo"); realm.users().get(user1Id).update(user1); user1 = realm.users().get(user1Id).toRepresentation(); - if (isDeclarativeUserProfile()) { - assertEquals(managedAttributes.size(), user1.getAttributes().size()); - } else { - assertEquals(1, user1.getAttributes().size()); - } + assertEquals(1, user1.getAttributes().size()); } @Test @@ -1669,11 +1641,7 @@ public class UserTest extends AbstractAdminTest { user1 = realm.users().get(user1Id).toRepresentation(); assertEquals("foo", user1.getAttributes().get("usercertificate").get(0)); assertEquals("bar", user1.getAttributes().get("saml.persistent.name.id.for.foo").get(0)); - if (isDeclarativeUserProfile()) { - assertTrue(user1.getAttributes().get(LDAPConstants.LDAP_ID).isEmpty()); - } else { - assertFalse(user1.getAttributes().containsKey(LDAPConstants.LDAP_ID)); - } + assertFalse(user1.getAttributes().containsKey(LDAPConstants.LDAP_ID)); } @Test diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPAdminRestApiWithUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPAdminRestApiWithUserProfileTest.java index 00779ebd38c..e24648ffbd5 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPAdminRestApiWithUserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPAdminRestApiWithUserProfileTest.java @@ -18,6 +18,8 @@ package org.keycloak.testsuite.federation.ldap; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.keycloak.testsuite.forms.VerifyProfileTest.disableDynamicUserProfile; @@ -71,14 +73,16 @@ public class LDAPAdminRestApiWithUserProfileTest extends LDAPAdminRestApiTest { UserResource user = testRealm().users().get(newUserId); UserRepresentation userRep = user.toRepresentation(); - - assertTrue(userRep.getAttributes().containsKey(LDAPConstants.LDAP_ID)); - assertTrue(userRep.getAttributes().get(LDAPConstants.LDAP_ID).isEmpty()); + assertNull(userRep.getAttributes()); userRep.singleAttribute(LDAPConstants.LDAP_ID, ""); user.update(userRep); + userRep = testRealm().users().get(newUserId).toRepresentation(); + assertNull(userRep.getAttributes()); userRep.singleAttribute(LDAPConstants.LDAP_ID, null); user.update(userRep); + userRep = testRealm().users().get(newUserId).toRepresentation(); + assertNull(userRep.getAttributes()); try { userRep.singleAttribute(LDAPConstants.LDAP_ID, "should-fail"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java index fb56277ae6d..7d38e5edba9 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/user/profile/UserProfileTest.java @@ -55,6 +55,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.LDAPConstants; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.representations.idm.AbstractUserRepresentation; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; @@ -114,6 +115,7 @@ public class UserProfileTest extends AbstractUserProfileTest { // create a user with attribute foo value 123 allowed by the profile now but disallowed later UPConfig config = parseDefaultConfig(); config.addOrReplaceAttribute(new UPAttribute("foo", new UPAttributePermissions(Set.of(), Set.of(ROLE_ADMIN)))); + config.getAttribute(UserModel.EMAIL).setPermissions(new UPAttributePermissions(Set.of(ROLE_USER), Set.of(ROLE_ADMIN))); RealmResource realmRes = testRealm(); realmRes.users().userProfile().update(config); @@ -142,6 +144,18 @@ public class UserProfileTest extends AbstractUserProfileTest { profile.validate(); }); + // it should work if foo is read-only in the context + getTestingClient().server(TEST_REALM_NAME).run(session -> { + RealmModel realm = session.getContext().getRealm(); + UserModel user = session.users().getUserById(realm, userId); + user.setEmail(null); + UserProfileProvider provider = getUserProfileProvider(session); + Map attributes = new HashMap<>(user.getAttributes()); + attributes.put("email", ""); + UserProfile profile = provider.create(UserProfileContext.ACCOUNT, attributes, user); + profile.validate(); + }); + // it should fail if foo can be modified getTestingClient().server(TEST_REALM_NAME).run(session -> { RealmModel realm = session.getContext().getRealm(); @@ -174,7 +188,7 @@ public class UserProfileTest extends AbstractUserProfileTest { // once created, profile attributes can not be changed assertTrue(profile.getAttributes().contains(UserModel.USERNAME)); - assertNull(profile.getAttributes().getFirstValue(UserModel.USERNAME)); + assertNull(profile.getAttributes().getFirst(UserModel.USERNAME)); } @Test @@ -413,11 +427,11 @@ public class UserProfileTest extends AbstractUserProfileTest { assertTrue(ve.isAttributeOnError("address")); } - assertNotNull(attributes.getFirstValue(UserModel.USERNAME)); - assertNotNull(attributes.getFirstValue(UserModel.EMAIL)); - assertNotNull(attributes.getFirstValue(UserModel.FIRST_NAME)); - assertNotNull(attributes.getFirstValue(UserModel.LAST_NAME)); - assertNull(attributes.getFirstValue("address")); + assertNotNull(attributes.getFirst(UserModel.USERNAME)); + assertNotNull(attributes.getFirst(UserModel.EMAIL)); + assertNotNull(attributes.getFirst(UserModel.FIRST_NAME)); + assertNotNull(attributes.getFirst(UserModel.LAST_NAME)); + assertNull(attributes.getFirst("address")); user.setAttribute("address", Arrays.asList("fixed-address")); @@ -426,7 +440,7 @@ public class UserProfileTest extends AbstractUserProfileTest { profile.validate(); - assertNotNull(attributes.getFirstValue("address")); + assertNotNull(attributes.getFirst("address")); } @Test @@ -1516,20 +1530,20 @@ public class UserProfileTest extends AbstractUserProfileTest { profile = provider.create(UserProfileContext.USER_API, user); Attributes userAttributes = profile.getAttributes(); - assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL)); - assertEquals("Test Value", userAttributes.getFirstValue("test-attribute")); - assertEquals("changed", userAttributes.getFirstValue("foo")); + assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL)); + assertEquals("Test Value", userAttributes.getFirst("test-attribute")); + assertEquals("changed", userAttributes.getFirst("foo")); attributes.remove("foo"); - attributes.put("test-attribute", userAttributes.getFirstValue("test-attribute")); + attributes.put("test-attribute", userAttributes.getFirst("test-attribute")); profile = provider.create(UserProfileContext.USER_API, attributes, user); profile.update(true); profile = provider.create(UserProfileContext.USER_API, user); userAttributes = profile.getAttributes(); // remove attribute if not set - assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL)); - assertEquals("Test Value", userAttributes.getFirstValue("test-attribute")); - assertNull(userAttributes.getFirstValue("foo")); + assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL)); + assertEquals("Test Value", userAttributes.getFirst("test-attribute")); + assertNull(userAttributes.getFirst("foo")); config.addOrReplaceAttribute(new UPAttribute("test-attribute", new UPAttributePermissions(Set.of(), Set.of(ROLE_USER)))); provider.setConfiguration(JsonSerialization.writeValueAsString(config)); @@ -1539,8 +1553,8 @@ public class UserProfileTest extends AbstractUserProfileTest { profile = provider.create(UserProfileContext.USER_API, user); userAttributes = profile.getAttributes(); // do not remove test-attribute because admin does not have write permissions - assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL)); - assertEquals("Test Value", userAttributes.getFirstValue("test-attribute")); + assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL)); + assertEquals("Test Value", userAttributes.getFirst("test-attribute")); config.addOrReplaceAttribute(new UPAttribute("test-attribute", new UPAttributePermissions(Set.of(), Set.of(ROLE_USER, ROLE_ADMIN)))); provider.setConfiguration(JsonSerialization.writeValueAsString(config)); @@ -1550,8 +1564,8 @@ public class UserProfileTest extends AbstractUserProfileTest { profile = provider.create(UserProfileContext.USER_API, user); userAttributes = profile.getAttributes(); // removes the test-attribute attribute because now admin has write permission - assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL)); - assertNull(userAttributes.getFirstValue("test-attribute")); + assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL)); + assertNull(userAttributes.getFirst("test-attribute")); } @Test @@ -1594,11 +1608,11 @@ public class UserProfileTest extends AbstractUserProfileTest { } private static void assertRemoveEmptyRootAttribute(Map> attributes, UserModel user, Attributes upAttributes) { - assertNull(upAttributes.getFirstValue(UserModel.LAST_NAME)); + assertNull(upAttributes.getFirst(UserModel.LAST_NAME)); assertNull(user.getLastName()); - assertNull(upAttributes.getFirstValue(UserModel.EMAIL)); + assertNull(upAttributes.getFirst(UserModel.EMAIL)); assertNull(user.getEmail()); - assertEquals(upAttributes.getFirstValue(UserModel.FIRST_NAME), attributes.get(UserModel.FIRST_NAME).get(0)); + assertEquals(upAttributes.getFirst(UserModel.FIRST_NAME), attributes.get(UserModel.FIRST_NAME).get(0)); } @Test @@ -1693,6 +1707,77 @@ public class UserProfileTest extends AbstractUserProfileTest { assertFalse(profile.getAttributes().isReadOnly("foo")); } + @Test + public void testOptionalRootAttributesAsUnmanagedAttribute() { + getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testOptionalRootAttributesAsUnmanagedAttribute); + } + + private static void testOptionalRootAttributesAsUnmanagedAttribute(KeycloakSession session) throws IOException { + UPConfig config = parseDefaultConfig(); + UserProfileProvider provider = getUserProfileProvider(session); + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + Map rawAttributes = new HashMap<>(); + rawAttributes.put(UserModel.USERNAME, org.keycloak.models.utils.KeycloakModelUtils.generateId() + "@keycloak.org"); + rawAttributes.put(UserModel.EMAIL, org.keycloak.models.utils.KeycloakModelUtils.generateId() + "@keycloak.org"); + rawAttributes.put(UserModel.FIRST_NAME, "firstName"); + rawAttributes.put(UserModel.LAST_NAME, "lastName"); + UserProfile profile = provider.create(UserProfileContext.USER_API, rawAttributes); + UserModel user = profile.create(); + assertEquals(rawAttributes.get(UserModel.FIRST_NAME), user.getFirstName()); + assertEquals(rawAttributes.get(UserModel.LAST_NAME), user.getLastName()); + AbstractUserRepresentation rep = profile.toRepresentation(); + assertEquals(rawAttributes.get(UserModel.FIRST_NAME), rep.getFirstName()); + assertEquals(rawAttributes.get(UserModel.LAST_NAME), rep.getLastName()); + assertNull(rep.getAttributes()); + + config.removeAttribute(UserModel.FIRST_NAME); + config.removeAttribute(UserModel.LAST_NAME); + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + profile = provider.create(UserProfileContext.USER_API, user); + Attributes attributes = profile.getAttributes(); + assertNull(attributes.getFirst(UserModel.FIRST_NAME)); + assertNull(attributes.getFirst(UserModel.LAST_NAME)); + rep = profile.toRepresentation(); + assertNull(rep.getFirstName()); + assertNull(rep.getLastName()); + assertNull(rep.getAttributes()); + + rawAttributes.put(UserModel.FIRST_NAME, "firstName"); + rawAttributes.put(UserModel.LAST_NAME, "lastName"); + config.setUnmanagedAttributePolicy(UnmanagedAttributePolicy.ADMIN_EDIT); + provider.setConfiguration(JsonSerialization.writeValueAsString(config)); + profile = provider.create(UserProfileContext.USER_API, user); + attributes = profile.getAttributes(); + assertEquals(rawAttributes.get(UserModel.FIRST_NAME), attributes.getFirst(UserModel.FIRST_NAME)); + assertEquals(rawAttributes.get(UserModel.LAST_NAME), attributes.getFirst(UserModel.LAST_NAME)); + rep = profile.toRepresentation(); + assertNull(rep.getFirstName()); + assertNull(rep.getLastName()); + assertNull(rep.getAttributes()); + + rawAttributes.remove(UserModel.LAST_NAME); + rawAttributes.put(UserModel.FIRST_NAME, "firstName"); + profile = provider.create(UserProfileContext.USER_API, rawAttributes, user); + attributes = profile.getAttributes(); + assertEquals(rawAttributes.get(UserModel.FIRST_NAME), attributes.getFirst(UserModel.FIRST_NAME)); + assertNull(attributes.getFirst(UserModel.LAST_NAME)); + rep = profile.toRepresentation(); + assertNull(rep.getFirstName()); + assertNull(rep.getLastName()); + assertNull(rep.getAttributes()); + + rawAttributes.put(UserModel.LAST_NAME, "lastNameChanged"); + rawAttributes.put(UserModel.FIRST_NAME, "firstNameChanged"); + profile = provider.create(UserProfileContext.USER_API, rawAttributes, user); + attributes = profile.getAttributes(); + assertEquals(rawAttributes.get(UserModel.FIRST_NAME), attributes.getFirst(UserModel.FIRST_NAME)); + assertEquals(rawAttributes.get(UserModel.LAST_NAME), attributes.getFirst(UserModel.LAST_NAME)); + rep = profile.toRepresentation(); + assertNull(rep.getFirstName()); + assertNull(rep.getLastName()); + assertNull(rep.getAttributes()); + } + @Test public void testAttributeNormalization() { getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::testAttributeNormalization); @@ -1705,7 +1790,7 @@ public class UserProfileTest extends AbstractUserProfileTest { attributes.put(UserModel.EMAIL, "TesT@TesT.org"); UserProfile profile = provider.create(UserProfileContext.USER_API, attributes); Attributes profileAttributes = profile.getAttributes(); - assertEquals(attributes.get(UserModel.USERNAME).toLowerCase(), profileAttributes.getFirstValue(UserModel.USERNAME)); - assertEquals(attributes.get(UserModel.EMAIL).toLowerCase(), profileAttributes.getFirstValue(UserModel.EMAIL)); + assertEquals(attributes.get(UserModel.USERNAME).toLowerCase(), profileAttributes.getFirst(UserModel.USERNAME)); + assertEquals(attributes.get(UserModel.EMAIL).toLowerCase(), profileAttributes.getFirst(UserModel.EMAIL)); } }