Refactoring user profile interfaces and consolidating user representation for both admin and account context

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2023-12-12 14:15:01 -03:00 committed by Marek Posolda
parent 1545b32a64
commit fa79b686b6
30 changed files with 760 additions and 609 deletions

View file

@ -17,128 +17,11 @@
package org.keycloak.representations.account; package org.keycloak.representations.account;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import org.keycloak.representations.idm.AbstractUserRepresentation;
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;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */
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<String, List<String>> 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<String, List<String>> getAttributes() {
return attributes;
}
public void setAttributes(Map<String, List<String>> 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<String>() : 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<String, List<String>> toAttributes() {
Map<String, List<String>> 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;
}
} }

View file

@ -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<String, List<String>> 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<String, List<String>> getAttributes() {
return attributes;
}
/**
* Returns all the user attributes including the root attributes.
*
* @return all the user attributes.
*/
@JsonIgnore
public Map<String, List<String>> getRawAttributes() {
Map<String, List<String>> 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<String, List<String>> attributes) {
this.attributes = attributes;
}
@SuppressWarnings("unchecked")
public <R extends AbstractUserRepresentation> 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;
}
}

View file

@ -17,14 +17,7 @@
package org.keycloak.representations.idm; 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.List;
import java.util.ArrayList;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -32,24 +25,16 @@ import java.util.Set;
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a> * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $ * @version $Revision: 1 $
*/ */
public class UserRepresentation { public class UserRepresentation extends AbstractUserRepresentation{
protected String self; // link protected String self; // link
protected String id;
protected String origin; protected String origin;
protected Long createdTimestamp; protected Long createdTimestamp;
protected String username;
protected Boolean enabled; protected Boolean enabled;
protected Boolean totp; protected Boolean totp;
protected Boolean emailVerified;
protected String firstName;
protected String lastName;
protected String email;
protected String federationLink; protected String federationLink;
protected String serviceAccountClientId; // For rep, it points to clientId (not DB ID) protected String serviceAccountClientId; // For rep, it points to clientId (not DB ID)
@JsonDeserialize(using = StringListMapDeserializer.class)
protected Map<String, List<String>> attributes;
protected List<CredentialRepresentation> credentials; protected List<CredentialRepresentation> credentials;
protected Set<String> disableableCredentialTypes; protected Set<String> disableableCredentialTypes;
protected List<String> requiredActions; protected List<String> requiredActions;
@ -66,7 +51,6 @@ public class UserRepresentation {
protected List<String> groups; protected List<String> groups;
private Map<String, Boolean> access; private Map<String, Boolean> access;
private UserProfileMetadata userProfileMetadata;
public String getSelf() { public String getSelf() {
return self; return self;
@ -76,14 +60,6 @@ public class UserRepresentation {
this.self = self; this.self = self;
} }
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public Long getCreatedTimestamp() { public Long getCreatedTimestamp() {
return createdTimestamp; return createdTimestamp;
} }
@ -92,38 +68,6 @@ public class UserRepresentation {
this.createdTimestamp = createdTimestamp; 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() { public Boolean isEnabled() {
return enabled; return enabled;
} }
@ -142,32 +86,6 @@ public class UserRepresentation {
this.totp = totp; this.totp = totp;
} }
public Boolean isEmailVerified() {
return emailVerified;
}
public void setEmailVerified(Boolean emailVerified) {
this.emailVerified = emailVerified;
}
public Map<String, List<String>> getAttributes() {
return attributes;
}
public void setAttributes(Map<String, List<String>> 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<String>() : 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<CredentialRepresentation> getCredentials() { public List<CredentialRepresentation> getCredentials() {
return credentials; return credentials;
} }
@ -289,36 +207,4 @@ public class UserRepresentation {
public void setAccess(Map<String, Boolean> access) { public void setAccess(Map<String, Boolean> access) {
this.access = access; this.access = access;
} }
public Map<String, List<String>> toAttributes() {
Map<String, List<String>> 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;
}
} }

View file

@ -32,8 +32,20 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
public class UPConfig { public class UPConfig {
public enum UnmanagedAttributePolicy { public enum UnmanagedAttributePolicy {
/**
* Unmanaged attributes are enabled and available from any context.
*/
ENABLED, ENABLED,
/**
* Unmanaged attributes are only available as read-only and only through the management interfaces.
*/
ADMIN_VIEW, ADMIN_VIEW,
/**
* Unmanaged attributes are only available as read-write and only through the management interfaces.
*/
ADMIN_EDIT ADMIN_EDIT
} }

View file

@ -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. 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. 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}].

View file

@ -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 `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. 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.

View file

@ -1,11 +1,15 @@
package org.keycloak.admin.ui.rest; 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 static org.keycloak.userprofile.UserProfileContext.USER_API;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
@ -18,6 +22,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.utils.StringUtil;
/** /**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a> * @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
@ -42,7 +47,7 @@ public class UserResource {
if (provider.isEnabled(realm)) { if (provider.isEnabled(realm)) {
UserProfile profile = provider.create(USER_API, user); UserProfile profile = provider.create(USER_API, user);
Map<String, List<String>> managedAttributes = profile.getAttributes().getReadable(false); Map<String, List<String>> managedAttributes = profile.getAttributes().getReadable();
Map<String, List<String>> attributes = new HashMap<>(user.getAttributes()); Map<String, List<String>> attributes = new HashMap<>(user.getAttributes());
UPConfig upConfig = provider.getConfiguration(); UPConfig upConfig = provider.getConfiguration();
@ -56,10 +61,10 @@ public class UserResource {
attributes.remove(UserModel.USERNAME); attributes.remove(UserModel.USERNAME);
attributes.remove(UserModel.EMAIL); 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(); return Collections.emptyMap();

View file

@ -19,6 +19,8 @@
package org.keycloak.userprofile; package org.keycloak.userprofile;
import static java.util.Collections.emptyList;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
@ -29,6 +31,8 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.common.util.CollectionUtil; import org.keycloak.common.util.CollectionUtil;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
@ -38,6 +42,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.userprofile.config.UPConfig; import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy; import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy;
import org.keycloak.utils.StringUtil;
import org.keycloak.validate.ValidationContext; import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError; import org.keycloak.validate.ValidationError;
@ -68,7 +73,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
private final Map<String, AttributeMetadata> metadataByAttribute; private final Map<String, AttributeMetadata> metadataByAttribute;
private final UPConfig upConfig; private final UPConfig upConfig;
protected final UserModel user; protected final UserModel user;
private Map<String, List<String>> unmanagedAttributes = new HashMap<>(); private final Map<String, List<String>> unmanagedAttributes = new HashMap<>();
public DefaultAttributes(UserProfileContext context, Map<String, ?> attributes, UserModel user, public DefaultAttributes(UserProfileContext context, Map<String, ?> attributes, UserModel user,
UserProfileMetadata profileMetadata, UserProfileMetadata profileMetadata,
@ -82,28 +87,28 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
} }
@Override @Override
public boolean isReadOnly(String attributeName) { public boolean isReadOnly(String name) {
if (!isManagedAttribute(attributeName)) { if (!isManagedAttribute(name)) {
return !isAllowEditUnmanagedAttribute(); return !isAllowEditUnmanagedAttribute();
} }
if (UserModel.USERNAME.equals(attributeName)) { if (UserModel.USERNAME.equals(name)) {
if (isServiceAccountUser()) { if (isServiceAccountUser()) {
return true; return true;
} }
} }
if (UserModel.EMAIL.equals(attributeName)) { if (UserModel.EMAIL.equals(name)) {
if (isServiceAccountUser()) { if (isServiceAccountUser()) {
return false; return false;
} }
} }
if (isReadOnlyFromMetadata(attributeName) || isReadOnlyInternalAttribute(attributeName)) { if (isReadOnlyFromMetadata(name) || isReadOnlyInternalAttribute(name)) {
return true; return true;
} }
return getMetadata(attributeName) == null; return getMetadata(name) == null;
} }
private boolean isAllowEditUnmanagedAttribute() { private boolean isAllowEditUnmanagedAttribute() {
@ -156,9 +161,9 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
List<AttributeMetadata> metadatas = new ArrayList<>(); List<AttributeMetadata> metadatas = new ArrayList<>();
metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(attribute.getKey())) 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)) 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; Boolean result = null;
@ -172,13 +177,16 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
continue; continue;
} }
if (user != null && metadata.isReadOnly(attributeContext) if (user != null && metadata.isReadOnly(attributeContext)) {
&& CollectionUtil.collectionEquals(user.getAttributeStream(name).collect(Collectors.toList()), attribute.getValue())) { List<String> value = user.getAttributeStream(name).filter(StringUtil::isNotBlank).collect(Collectors.toList());
List<String> 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 // 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.", 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()); user.getUsername(), name, vc.getErrors(), attributeContext.getContext());
continue; continue;
} }
}
if (result == null) { if (result == null) {
result = false; result = false;
@ -198,7 +206,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
} }
@Override @Override
public List<String> getValues(String name) { public List<String> get(String name) {
return getOrDefault(name, EMPTY_VALUE); return getOrDefault(name, EMPTY_VALUE);
} }
@ -236,21 +244,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
@Override @Override
public AttributeMetadata getMetadata(String name) { public AttributeMetadata getMetadata(String name) {
if (unmanagedAttributes.containsKey(name)) { if (unmanagedAttributes.containsKey(name)) {
return new AttributeMetadata(name, Integer.MAX_VALUE) { return createUnmanagedAttributeMetadata(name);
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 Optional.ofNullable(metadataByAttribute.get(name)) return Optional.ofNullable(metadataByAttribute.get(name))
@ -265,9 +259,14 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
for (String name : nameSet()) { for (String name : nameSet()) {
AttributeMetadata metadata = getMetadata(name); AttributeMetadata metadata = getMetadata(name);
if (metadata == null if (metadata == null) {
|| !metadata.canView(createAttributeContext(metadata)) attributes.remove(name);
|| !metadata.isSelected(createAttributeContext(metadata))) { continue;
}
AttributeContext attributeContext = createAttributeContext(metadata);
if (!metadata.canView(attributeContext) || !metadata.isSelected(attributeContext)) {
attributes.remove(name); attributes.remove(name);
} }
} }
@ -277,7 +276,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
@Override @Override
public Map<String, List<String>> toMap() { public Map<String, List<String>> toMap() {
return this; return Collections.unmodifiableMap(this);
} }
protected boolean isServiceAccountUser() { protected boolean isServiceAccountUser() {
@ -342,7 +341,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
if (!isSupportedAttribute(key)) { if (!isSupportedAttribute(key)) {
if (!isManagedAttribute(key) && isAllowUnmanagedAttribute()) { if (!isManagedAttribute(key) && isAllowUnmanagedAttribute()) {
unmanagedAttributes.put(key, (List<String>) entry.getValue()); unmanagedAttributes.put(key, normalizeAttributeValues(key, entry.getValue()));
} }
continue; continue;
} }
@ -351,18 +350,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
key = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length()); key = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length());
} }
Object value = entry.getValue(); List<String> values = normalizeAttributeValues(key, entry.getValue());
List<String> values;
if (value instanceof String) {
values = Collections.singletonList((String) value);
} else {
values = (List<String>) value;
}
if (UserModel.USERNAME.equals(key) || UserModel.EMAIL.equals(key)) {
values = values.stream().map(KeycloakModelUtils::toLowerCaseSafe).collect(Collectors.toList());
}
newAttributes.put(key, Collections.unmodifiableList(values)); newAttributes.put(key, Collections.unmodifiableList(values));
} }
@ -378,28 +366,24 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
AttributeMetadata metadata = metadataByAttribute.get(attributeName); AttributeMetadata metadata = metadataByAttribute.get(attributeName);
if (user != null && isIncludeAttributeIfNotProvided(metadata)) { 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); newAttributes.put(attributeName, values);
} }
if (user != null) { if (user != null) {
List<String> username = newAttributes.getOrDefault(UserModel.USERNAME, Collections.emptyList()); List<String> username = newAttributes.getOrDefault(UserModel.USERNAME, emptyList());
if (username.isEmpty() && isReadOnly(UserModel.USERNAME)) { if (username.isEmpty() && isReadOnly(UserModel.USERNAME)) {
setUserName(newAttributes, Collections.singletonList(user.getUsername())); setUserName(newAttributes, Collections.singletonList(user.getUsername()));
} }
} }
List<String> email = newAttributes.getOrDefault(UserModel.EMAIL, Collections.emptyList()); List<String> email = newAttributes.getOrDefault(UserModel.EMAIL, emptyList());
if (!email.isEmpty() && realm.isRegistrationEmailAsUsername()) { if (!email.isEmpty() && realm.isRegistrationEmailAsUsername()) {
List<String> lowerCaseEmailList = email.stream() setUserName(newAttributes, email);
.filter(Objects::nonNull)
.collect(Collectors.toList());
setUserName(newAttributes, lowerCaseEmailList);
if (user != null && isReadOnly(UserModel.EMAIL)) { if (user != null && isReadOnly(UserModel.EMAIL)) {
newAttributes.put(UserModel.EMAIL, Collections.singletonList(user.getEmail())); newAttributes.put(UserModel.EMAIL, Collections.singletonList(user.getEmail()));
@ -414,6 +398,24 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return newAttributes; return newAttributes;
} }
private List<String> normalizeAttributeValues(String name, Object value) {
List<String> values;
if (value instanceof String) {
values = Collections.singletonList((String) value);
} else {
values = (List<String>) value;
}
Stream<String> 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() { private boolean isAllowUnmanagedAttribute() {
UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy(); UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy();
@ -466,12 +468,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return true; return true;
} }
if (isReadOnlyInternalAttribute(name)) { return isReadOnlyInternalAttribute(name);
return true;
}
// checks whether the attribute is a core attribute
return isRootAttribute(name);
} }
private boolean isManagedAttribute(String name) { private boolean isManagedAttribute(String name) {
@ -511,4 +508,22 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
public Map<String, List<String>> getUnmanagedAttributes() { public Map<String, List<String>> getUnmanagedAttributes() {
return unmanagedAttributes; 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()));
}
};
}
} }

View file

@ -19,6 +19,10 @@
package org.keycloak.userprofile; 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.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@ -31,7 +35,10 @@ import java.util.stream.Collectors;
import org.keycloak.common.util.CollectionUtil; import org.keycloak.common.util.CollectionUtil;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException; import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; 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.storage.ReadOnlyException;
import org.keycloak.utils.StringUtil; import org.keycloak.utils.StringUtil;
@ -45,10 +52,11 @@ import org.keycloak.utils.StringUtil;
*/ */
public final class DefaultUserProfile implements UserProfile { public final class DefaultUserProfile implements UserProfile {
protected final UserProfileMetadata metadata; private final UserProfileMetadata metadata;
private final Function<Attributes, UserModel> userSupplier; private final Function<Attributes, UserModel> userSupplier;
private final Attributes attributes; private final Attributes attributes;
private final KeycloakSession session; private final KeycloakSession session;
private final boolean isUserProfileEnabled;
private boolean validated; private boolean validated;
private UserModel user; private UserModel user;
@ -59,6 +67,8 @@ public final class DefaultUserProfile implements UserProfile {
this.attributes = attributes; this.attributes = attributes;
this.user = user; this.user = user;
this.session = session; this.session = session;
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
isUserProfileEnabled = provider.isEnabled(session.getContext().getRealm());
} }
@Override @Override
@ -144,16 +154,27 @@ public final class DefaultUserProfile implements UserProfile {
attrsToRemove.removeAll(attributes.nameSet()); attrsToRemove.removeAll(attributes.nameSet());
for (String attr : attrsToRemove) { for (String name : attrsToRemove) {
if (attributes.isReadOnly(attr)) { if (attributes.isReadOnly(name)) {
continue; continue;
} }
List<String> currentValue = user.getAttributeStream(attr).filter(Objects::nonNull).collect(Collectors.toList()); List<String> currentValue = user.getAttributeStream(name).filter(Objects::nonNull).collect(Collectors.toList());
user.removeAttribute(attr);
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) { 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) { private boolean isCustomAttribute(String name) {
return !getAttributes().isRootAttribute(name); return !isRootAttribute(name);
} }
@Override @Override
public Attributes getAttributes() { public Attributes getAttributes() {
return attributes; return attributes;
} }
@Override
public <R extends AbstractUserRepresentation> R toRepresentation() {
if (user == null) {
throw new IllegalStateException("Can not create the representation because the user is not yet created");
}
R rep = createUserRepresentation();
Map<String, List<String>> readable = attributes.getReadable();
Map<String, List<String>> attributesRep = new HashMap<>(readable);
// all the attributes here have read access and might be available in the representation
for (String name : readable.keySet()) {
List<String> 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 extends AbstractUserRepresentation> 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;
}
} }

View file

@ -17,22 +17,38 @@
package org.keycloak.userprofile; 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.models.UserModel;
import org.keycloak.representations.idm.AbstractUserRepresentation;
/** /**
* <p>An interface providing as an entry point for managing users. * <p>An interface that serves an entry point for managing users and their attributes.
* *
* <p>A {@code UserProfile} provides a manageable view for user information that also takes into account the context where it is being used. * <p>A {@code UserProfile} provides methods for creating, and updating users as well as for accessing their attributes.
* The context represents the different places in Keycloak where users are created, updated, or validated. * 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.
*
* <p>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. * Examples of contexts are: managing users through the Admin API, or through the Account API.
* *
* <p>By taking the context into account, the state and behavior of {@link UserProfile} instances depend on the context they * <p>A {@code UserProfile} instance can be obtained through the {@link UserProfileProvider}:
* are associated with, where validating, updating, creating, or obtaining representations of users is based on the configuration
* and constraints associated with a context.
* *
* <p>A {@code UserProfile} instance can be obtained through the {@link UserProfileProvider}. * <pre> {@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);
* }</pre>
*
* <p>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 UserProfileContext
* @see UserProfileProvider * @see UserProfileProvider
@ -69,20 +85,23 @@ public interface UserProfile {
void update(boolean removeAttributes, AttributeChangeListener... changeListener) throws ValidationException; void update(boolean removeAttributes, AttributeChangeListener... changeListener) throws ValidationException;
/** /**
* <p>The same as {@link #update(boolean, BiConsumer[])} but forcing the removal of attributes. * <p>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 * @param changeListener a set of one or more listeners to listen for attribute changes
* @throws ValidationException in case of any validation error * @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); update(true, changeListener);
} }
/** /**
* Returns the attributes associated with this instance. Note that the attributes returned by this method are not necessarily * 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. * @return the attributes associated with this instance.
*/ */
Attributes getAttributes(); Attributes getAttributes();
<R extends AbstractUserRepresentation> R toRepresentation();
} }

View file

@ -20,11 +20,23 @@
package org.keycloak.userprofile; package org.keycloak.userprofile;
import java.util.Collections; 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.function.Predicate;
import java.util.stream.Collectors;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession; 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.UPConfig;
import org.keycloak.representations.userprofile.config.UPGroup;
import org.keycloak.validate.Validators;
/** /**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a> * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -82,4 +94,70 @@ public class UserProfileUtil {
return true; 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<String, List<String>> am = profileAttributes.getReadable();
if(am == null)
return null;
Map<String, List<String>> unmanagedAttributes = profileAttributes.getUnmanagedAttributes();
List<UserProfileAttributeMetadata> 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<UserProfileAttributeGroupMetadata> groups = config.getGroups().stream().map(new Function<UPGroup, UserProfileAttributeGroupMetadata>() {
@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<String, Map<String, Object>> 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));
}
} }

View file

@ -19,20 +19,38 @@
package org.keycloak.userprofile; package org.keycloak.userprofile;
import static java.util.Optional.ofNullable;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.keycloak.models.UserModel;
import org.keycloak.validate.ValidationError; import org.keycloak.validate.ValidationError;
/** /**
* <p>This interface wraps the attributes associated with a user profile. Different operations are provided to access and * <p>This interface wraps the attributes associated with a user profile. Different operations are provided to access and
* manage these attributes. * manage these attributes.
* *
* <p>Attributes are classified as:</p>
* <ul>
* <li>Managed
* <li>Unmanaged
* </ul>
*
* <p>A <i>managed</i> attribute is any attribute defined in the user profile configuration. Therefore, they are known by
* the server and can be managed accordingly.
*
* <p>A <i>unmanaged</i> attributes is any attribute <b>not</b> 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}.
*
* <p>Any attribute available from this interface has a corresponding {@link AttributeMetadata}</p>. 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 <a href="mailto:psilva@redhat.com">Pedro Igor</a> * @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/ */
public interface Attributes { public interface Attributes {
@ -49,8 +67,8 @@ public interface Attributes {
* *
* @return the first value * @return the first value
*/ */
default String getFirstValue(String name) { default String getFirst(String name) {
List<String> values = getValues(name); List<String> values = ofNullable(get(name)).orElse(List.of());
if (values.isEmpty()) { if (values.isEmpty()) {
return null; return null;
@ -66,16 +84,16 @@ public interface Attributes {
* *
* @return the attribute values * @return the attribute values
*/ */
List<String> getValues(String name); List<String> get(String name);
/** /**
* Checks whether an attribute is read-only. * 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}. * Validates the attribute with the given {@code name}.
@ -105,7 +123,7 @@ public interface Attributes {
Set<String> nameSet(); Set<String> nameSet();
/** /**
* Returns all attributes that can be written. * Returns all the attributes with read-write permissions in a particular {@link UserProfileContext}.
* *
* @return the attributes * @return the attributes
*/ */
@ -131,52 +149,23 @@ public interface Attributes {
boolean isRequired(String name); boolean isRequired(String name);
/** /**
* Similar to {{@link #getReadable(boolean)}} but with the possibility to add or remove * Returns only the attributes that have read permissions in a particular {@link UserProfileContext}.
* the root attributes.
* *
* @param includeBuiltin if the root attributes should be included. * @return the attributes with read permission.
* @return the attributes with read/write permission.
*/
default Map<String, List<String>> 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.
*/ */
Map<String, List<String>> getReadable(); Map<String, List<String>> 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 a map with all the attributes
* @return
*/ */
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<String, List<String>> toMap(); Map<String, List<String>> toMap();
/**
* Returns a {@link Map} holding any unmanaged attribute.
*
* @return a map with any unmanaged attribute
*/
Map<String, List<String>> getUnmanagedAttributes(); Map<String, List<String>> getUnmanagedAttributes();
} }

View file

@ -21,7 +21,7 @@ package org.keycloak.userprofile;
/** /**
* <p>This interface represents the different contexts from where user profiles are managed. The core contexts are already * <p>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.
* *
* <p>The context is crucial to drive the conditions that should be respected when managing user profiles. It might be possible * <p>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. * 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 { 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), UPDATE_PROFILE(true),
/**
* In this context, a user profile is managed through the management interface such as the Admin API.
*/
USER_API(false), USER_API(false),
/**
* In this context, a user profile is managed by themselves through the account console.
*/
ACCOUNT(true), ACCOUNT(true),
/**
* In this context, a user profile is managed by themselves when authenticating through a broker.
*/
IDP_REVIEW(false), IDP_REVIEW(false),
/**
* In this context, a user profile is managed by themselves when registering to a realm.
*/
REGISTRATION(false), REGISTRATION(false),
/**
* In this context, a user profile is managed by themselves when updating their email through an application initiated action.
*/
UPDATE_EMAIL(false); UPDATE_EMAIL(false);
protected boolean resetEmailVerified; private boolean resetEmailVerified;
private UserProfileContext(boolean resetEmailVerified){ UserProfileContext(boolean resetEmailVerified){
this.resetEmailVerified = resetEmailVerified; this.resetEmailVerified = resetEmailVerified;
} }

View file

@ -166,7 +166,7 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
profile.update((attributeName, userModel, oldValue) -> { profile.update((attributeName, userModel, oldValue) -> {
if (attributeName.equals(UserModel.EMAIL)) { if (attributeName.equals(UserModel.EMAIL)) {
context.getAuthenticationSession().setAuthNote(UPDATE_PROFILE_EMAIL_CHANGED, "true"); 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) { } 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()); 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); event.detail(Details.UPDATED_EMAIL, newEmail);

View file

@ -39,6 +39,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation; import org.keycloak.services.validation.Validation;
import org.keycloak.userprofile.Attributes;
import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.ValidationException;
@ -71,11 +72,11 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
context.getEvent().detail(Details.REGISTER_METHOD, "form"); context.getEvent().detail(Details.REGISTER_METHOD, "form");
UserProfile profile = getOrCreateUserProfile(context, formData); UserProfile profile = getOrCreateUserProfile(context, formData);
String email = profile.getAttributes().getFirstValue(UserModel.EMAIL); Attributes attributes = profile.getAttributes();
String email = attributes.getFirst(UserModel.EMAIL);
String username = profile.getAttributes().getFirstValue(UserModel.USERNAME); String username = attributes.getFirst(UserModel.USERNAME);
String firstName = profile.getAttributes().getFirstValue(UserModel.FIRST_NAME); String firstName = attributes.getFirst(UserModel.FIRST_NAME);
String lastName = profile.getAttributes().getFirstValue(UserModel.LAST_NAME); String lastName = attributes.getFirst(UserModel.LAST_NAME);
context.getEvent().detail(Details.EMAIL, email); context.getEvent().detail(Details.EMAIL, email);
context.getEvent().detail(Details.USERNAME, username); context.getEvent().detail(Details.USERNAME, username);
@ -92,7 +93,7 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
List<FormMessage> errors = Validation.getFormErrorsFromValidation(pve.getErrors()); List<FormMessage> errors = Validation.getFormErrorsFromValidation(pve.getErrors());
if (pve.hasError(Messages.EMAIL_EXISTS, Messages.INVALID_EMAIL)) { 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)) { if (pve.hasError(Messages.EMAIL_EXISTS)) {

View file

@ -168,7 +168,7 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor
public static void updateEmailNow(EventBuilder event, UserModel user, UserProfile emailUpdateValidationResult) { public static void updateEmailNow(EventBuilder event, UserModel user, UserProfile emailUpdateValidationResult) {
String oldEmail = user.getEmail(); 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); event.event(EventType.UPDATE_EMAIL).detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, newEmail);
emailUpdateValidationResult.update(false, new EventAuditingAttributeChangeListener(emailUpdateValidationResult, event)); emailUpdateValidationResult.update(false, new EventAuditingAttributeChangeListener(emailUpdateValidationResult, event));
} }

View file

@ -24,12 +24,9 @@ import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Properties; import java.util.Properties;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -66,12 +63,9 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserConsentModel; import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.provider.ConfiguredProvider;
import org.keycloak.representations.account.ClientRepresentation; import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation; import org.keycloak.representations.account.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation; 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.account.UserRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.GroupRepresentation; 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.managers.UserConsentManager;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.account.resources.ResourcesService; import org.keycloak.services.resources.account.resources.ResourcesService;
import org.keycloak.services.resources.admin.UserProfileResource;
import org.keycloak.services.util.ResolveRelative; import org.keycloak.services.util.ResolveRelative;
import org.keycloak.storage.ReadOnlyException; import org.keycloak.storage.ReadOnlyException;
import org.keycloak.theme.Theme; import org.keycloak.theme.Theme;
@ -92,8 +85,6 @@ import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.userprofile.EventAuditingAttributeChangeListener; import org.keycloak.userprofile.EventAuditingAttributeChangeListener;
import org.keycloak.userprofile.ValidationException; import org.keycloak.userprofile.ValidationException;
import org.keycloak.userprofile.ValidationException.Error; import org.keycloak.userprofile.ValidationException.Error;
import org.keycloak.utils.GroupUtils;
import org.keycloak.validate.Validators;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -142,38 +133,17 @@ public class AccountRestService {
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE); auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
UserModel user = auth.getUser(); UserModel user = auth.getUser();
UserRepresentation rep = new UserRepresentation();
rep.setId(user.getId());
UserProfileProvider provider = session.getProvider(UserProfileProvider.class); UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UserProfile profile = provider.create(UserProfileContext.ACCOUNT, user); UserProfile profile = provider.create(UserProfileContext.ACCOUNT, user);
UserRepresentation rep = profile.toRepresentation();
rep.setAttributes(profile.getAttributes().getReadable(false)); if (userProfileMetadata != null && !userProfileMetadata) {
rep.setUserProfileMetadata(null);
addReadableBuiltinAttributes(user, rep, profile.getAttributes().getReadable(true).keySet()); }
if(userProfileMetadata == null || userProfileMetadata.booleanValue())
rep.setUserProfileMetadata(UserProfileResource.createUserProfileMetadata(session, profile));
return rep; return rep;
} }
private void addReadableBuiltinAttributes(UserModel user, UserRepresentation rep, Set<String> 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 <T> void setIfReadable(String attributeName, Set<String> readableAttributes, Consumer<T> setter, Supplier<T> getter) {
if (readableAttributes.contains(attributeName)) {
setter.accept(getter.get());
}
}
@Path("/") @Path("/")
@POST @POST
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@ -185,7 +155,7 @@ public class AccountRestService {
event.event(EventType.UPDATE_PROFILE).detail(Details.CONTEXT, UserProfileContext.ACCOUNT.name()); event.event(EventType.UPDATE_PROFILE).detail(Details.CONTEXT, UserProfileContext.ACCOUNT.name());
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); 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 { try {

View file

@ -16,13 +16,9 @@
*/ */
package org.keycloak.services.resources.admin; package org.keycloak.services.resources.admin;
import static org.keycloak.userprofile.UserProfileUtil.createUserProfileMetadata;
import java.util.Collections; 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.Consumes;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
@ -117,56 +113,4 @@ public class UserProfileResource {
return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build(); return Response.ok(t.getConfiguration()).type(MediaType.APPLICATION_JSON).build();
} }
public static UserProfileMetadata createUserProfileMetadata(KeycloakSession session, UserProfile profile) {
Attributes profileAttributes = profile.getAttributes();
Map<String, List<String>> am = profileAttributes.getReadable();
if(am == null)
return null;
Map<String, List<String>> unmanagedAttributes = profileAttributes.getUnmanagedAttributes();
List<UserProfileAttributeMetadata> 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<UserProfileAttributeGroupMetadata> groups = config.getGroups().stream().map(new Function<UPGroup, UserProfileAttributeGroupMetadata>() {
@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<String, Map<String, Object>> 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));
}
} }

View file

@ -123,7 +123,6 @@ import java.util.stream.Stream;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME; 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; import static org.keycloak.userprofile.UserProfileContext.USER_API;
/** /**
@ -184,7 +183,7 @@ public class UserResource {
wasPermanentlyLockedOut = session.getProvider(BruteForceProtector.class).isPermanentlyLockedOut(session, realm, user); wasPermanentlyLockedOut = session.getProvider(BruteForceProtector.class).isPermanentlyLockedOut(session, realm, user);
} }
Map<String, List<String>> attributes = new HashMap<>(rep.toAttributes()); Map<String, List<String>> attributes = new HashMap<>(rep.getRawAttributes());
if (rep.getAttributes() == null) { if (rep.getAttributes() == null) {
// include existing attributes in case no attributes are set so that validation takes into account the existing // 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); 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()) { if (realm.isIdentityFederationEnabled()) {
List<FederatedIdentityRepresentation> reps = getFederatedIdentities(user).collect(Collectors.toList()); List<FederatedIdentityRepresentation> reps = getFederatedIdentities(user).collect(Collectors.toList());
@ -314,16 +315,8 @@ public class UserResource {
} }
rep.setAccess(auth.users().getAccess(user)); rep.setAccess(auth.users().getAccess(user));
UserProfileProvider provider = session.getProvider(UserProfileProvider.class); if (!userProfileMetadata) {
UserProfile profile = provider.create(USER_API, user); rep.setUserProfileMetadata(null);
Map<String, List<String>> readableAttributes = profile.getAttributes().getReadable(false);
if (rep.getAttributes() != null) {
rep.setAttributes(readableAttributes);
}
if (userProfileMetadata) {
rep.setUserProfileMetadata(createUserProfileMetadata(session, profile));
} }
return rep; return rep;

View file

@ -154,7 +154,7 @@ public class UsersResource {
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class); UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
UserProfile profile = profileProvider.create(USER_API, rep.toAttributes()); UserProfile profile = profileProvider.create(USER_API, rep.getRawAttributes());
try { try {
Response response = UserResource.validateUserProfile(profile, session, auth.adminAuth()); Response response = UserResource.validateUserProfile(profile, session, auth.adminAuth());

View file

@ -117,6 +117,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
} }
return new DefaultAttributes(context, attributes, user, metadata, session); return new DefaultAttributes(context, attributes, user, metadata, session);
} }
return new LegacyAttributes(context, attributes, user, metadata, session); return new LegacyAttributes(context, attributes, user, metadata, session);
} }
@ -153,11 +154,11 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
@Override @Override
public UserModel apply(Attributes attributes) { public UserModel apply(Attributes attributes) {
if (user == null) { if (user == null) {
String userName = attributes.getFirstValue(UserModel.USERNAME); String userName = attributes.getFirst(UserModel.USERNAME);
// fallback to email in case email is allowed // fallback to email in case email is allowed
if (userName == null) { if (userName == null) {
userName = attributes.getFirstValue(UserModel.EMAIL); userName = attributes.getFirst(UserModel.EMAIL);
} }
user = session.users().addUser(session.getContext().getRealm(), userName); user = session.users().addUser(session.getContext().getRealm(), userName);

View file

@ -75,7 +75,7 @@ public class ImmutableAttributeValidator implements SimpleValidator {
return context; return context;
} }
List<String> email = attributeContext.getAttributes().getValues(UserModel.EMAIL); List<String> email = attributeContext.getAttributes().get(UserModel.EMAIL);
if (UserModel.USERNAME.equals(attributeName) && collectionEquals(values, email)) { if (UserModel.USERNAME.equals(attributeName) && collectionEquals(values, email)) {
return context; return context;

View file

@ -70,7 +70,7 @@ public class UsernameMutationValidator implements SimpleValidator {
if (!realm.isEditUsernameAllowed() && user != null && !value.equals(user.getFirstAttribute(UserModel.USERNAME))) { if (!realm.isEditUsernameAllowed() && user != null && !value.equals(user.getFirstAttribute(UserModel.USERNAME))) {
Attributes attributes = attributeContext.getAttributes(); 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 // 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 // it is expected that username changes when attributes are normalized by the provider
return context; return context;

View file

@ -19,6 +19,8 @@
package org.keycloak.testsuite.account; package org.keycloak.testsuite.account;
import java.io.IOException; import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.BadRequestException;
@ -99,7 +101,7 @@ public class AccountRestServiceReadOnlyAttributesTest extends AbstractRestServic
private void testAccountUpdateAttributeExpectFailure(String attrName, boolean deniedForAdminAsWell) throws IOException { private void testAccountUpdateAttributeExpectFailure(String attrName, boolean deniedForAdminAsWell) throws IOException {
// Attribute not yet supposed to be on the user // Attribute not yet supposed to be on the user
UserRepresentation user = SimpleHttp.doGet(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).asJson(UserRepresentation.class); 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 // Assert not possible to add the attribute to the user
user.singleAttribute(attrName, "foo"); user.singleAttribute(attrName, "foo");
@ -147,7 +149,7 @@ public class AccountRestServiceReadOnlyAttributesTest extends AbstractRestServic
private void testAccountUpdateAttributeExpectSuccess(String attrName) throws IOException { private void testAccountUpdateAttributeExpectSuccess(String attrName) throws IOException {
// Attribute not yet supposed to be on the user // Attribute not yet supposed to be on the user
UserRepresentation user = SimpleHttp.doGet(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).asJson(UserRepresentation.class); 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 // Assert not possible to add the attribute to the user
user.singleAttribute(attrName, "foo"); user.singleAttribute(attrName, "foo");

View file

@ -79,6 +79,7 @@ import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.is;
@ -278,7 +279,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
assertEquals("Brady", user.getLastName()); assertEquals("Brady", user.getLastName());
assertEquals("test-user@localhost", user.getEmail()); assertEquals("test-user@localhost", user.getEmail());
assertFalse(user.isEmailVerified()); assertFalse(user.isEmailVerified());
assertTrue(user.getAttributes().isEmpty()); assertNull(user.getAttributes());
} }
@Test @Test
@ -288,7 +289,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
String originalFirstName = user.getFirstName(); String originalFirstName = user.getFirstName();
String originalLastName = user.getLastName(); String originalLastName = user.getLastName();
String originalEmail = user.getEmail(); String originalEmail = user.getEmail();
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes()); user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>()));
try { try {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation(); RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
@ -316,7 +317,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
user.setFirstName(originalFirstName); user.setFirstName(originalFirstName);
user.setLastName(originalLastName); user.setLastName(originalLastName);
user.setEmail(originalEmail); user.setEmail(originalEmail);
user.setAttributes(originalAttributes);
SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse(); SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse();
System.out.println(response.asString()); System.out.println(response.asString());
assertEquals(204, response.getStatus()); assertEquals(204, response.getStatus());
@ -379,7 +379,8 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
String originalFirstName = user.getFirstName(); String originalFirstName = user.getFirstName();
String originalLastName = user.getLastName(); String originalLastName = user.getLastName();
String originalEmail = user.getEmail(); String originalEmail = user.getEmail();
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes()); assertNull(user.getAttributes());
user.setAttributes(new HashMap<>());
try { try {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation(); RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
@ -417,7 +418,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
user.setFirstName(originalFirstName); user.setFirstName(originalFirstName);
user.setLastName(originalLastName); user.setLastName(originalLastName);
user.setEmail(originalEmail); user.setEmail(originalEmail);
user.setAttributes(originalAttributes);
SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse(); SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse();
System.out.println(response.asString()); System.out.println(response.asString());
assertEquals(204, response.getStatus()); assertEquals(204, response.getStatus());
@ -431,7 +431,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
String originalFirstName = user.getFirstName(); String originalFirstName = user.getFirstName();
String originalLastName = user.getLastName(); String originalLastName = user.getLastName();
String originalEmail = user.getEmail(); String originalEmail = user.getEmail();
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes()); user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>()));
try { try {
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation(); RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
@ -460,12 +460,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
user = updateAndGet(user); 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()); assertEquals(2, user.getAttributes().get("attr2").size());
assertThat(user.getAttributes().get("attr2"), containsInAnyOrder("val2", "val3")); assertThat(user.getAttributes().get("attr2"), containsInAnyOrder("val2", "val3"));
@ -522,7 +517,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
user.setFirstName(originalFirstName); user.setFirstName(originalFirstName);
user.setLastName(originalLastName); user.setLastName(originalLastName);
user.setEmail(originalEmail); user.setEmail(originalEmail);
user.setAttributes(originalAttributes);
SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse(); SimpleHttp.Response response = SimpleHttp.doPost(getAccountUrl(null), httpClient).auth(tokenUtil.getToken()).json(user).asResponse();
System.out.println(response.asString()); System.out.println(response.asString());
assertEquals(204, response.getStatus()); assertEquals(204, response.getStatus());
@ -556,6 +550,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
public void testUpdateProfileCannotChangeThroughAttributes() throws IOException { public void testUpdateProfileCannotChangeThroughAttributes() throws IOException {
UserRepresentation user = getUser(); UserRepresentation user = getUser();
String originalUsername = user.getUsername(); String originalUsername = user.getUsername();
user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>()));
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes()); Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes());
try { try {

View file

@ -29,6 +29,7 @@ import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
@ -292,6 +293,7 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
String originalFirstName = user.getFirstName(); String originalFirstName = user.getFirstName();
String originalLastName = user.getLastName(); String originalLastName = user.getLastName();
String originalEmail = user.getEmail(); String originalEmail = user.getEmail();
user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>()));
Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes()); Map<String, List<String>> originalAttributes = new HashMap<>(user.getAttributes());
try { try {
@ -303,13 +305,13 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
user.setEmail("bobby@localhost"); user.setEmail("bobby@localhost");
user.setFirstName("Homer"); user.setFirstName("Homer");
user.setLastName("Simpsons"); user.setLastName("Simpsons");
user.getAttributes().put("attr1", Collections.singletonList("val1")); user.getAttributes().put("attr1", Collections.singletonList("val11"));
user.getAttributes().put("attr2", Collections.singletonList("val2")); user.getAttributes().put("attr2", Collections.singletonList("val22"));
events.clear();
user = updateAndGet(user); user = updateAndGet(user);
//skip login to the REST API event //skip login to the REST API event
events.poll();
events.expectAccount(EventType.UPDATE_PROFILE).user(user.getId()) events.expectAccount(EventType.UPDATE_PROFILE).user(user.getId())
.detail(Details.CONTEXT, UserProfileContext.ACCOUNT.name()) .detail(Details.CONTEXT, UserProfileContext.ACCOUNT.name())
.detail(Details.PREVIOUS_EMAIL, originalEmail) .detail(Details.PREVIOUS_EMAIL, originalEmail)
@ -318,7 +320,7 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
.detail(Details.PREVIOUS_LAST_NAME, originalLastName) .detail(Details.PREVIOUS_LAST_NAME, originalLastName)
.detail(Details.UPDATED_FIRST_NAME, "Homer") .detail(Details.UPDATED_FIRST_NAME, "Homer")
.detail(Details.UPDATED_LAST_NAME, "Simpsons") .detail(Details.UPDATED_LAST_NAME, "Simpsons")
.detail(Details.PREF_UPDATED+"attr2", "val2") .detail(Details.PREF_UPDATED+"attr2", "val22")
.assertEvent(); .assertEvent();
events.assertEmpty(); events.assertEmpty();
@ -379,22 +381,23 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
realmRep.setInternationalizationEnabled(false); realmRep.setInternationalizationEnabled(false);
testRealm().update(realmRep); testRealm().update(realmRep);
UserRepresentation user = getUser(); UserRepresentation user = getUser();
user.setAttributes(Optional.ofNullable(user.getAttributes()).orElse(new HashMap<>()));
try { try {
user.getAttributes().put(UserModel.LOCALE, List.of("pt_BR")); user.getAttributes().put(UserModel.LOCALE, List.of("pt_BR"));
user = updateAndGet(user); user = updateAndGet(user);
assertNull(user.getAttributes().get(UserModel.LOCALE)); assertNull(user.getAttributes());
realmRep.setInternationalizationEnabled(true); realmRep.setInternationalizationEnabled(true);
testRealm().update(realmRep); testRealm().update(realmRep);
user.getAttributes().put(UserModel.LOCALE, List.of("pt_BR")); user.singleAttribute(UserModel.LOCALE, "pt_BR");
user = updateAndGet(user); user = updateAndGet(user);
assertEquals("pt_BR", user.getAttributes().get(UserModel.LOCALE).get(0)); assertEquals("pt_BR", user.getAttributes().get(UserModel.LOCALE).get(0));
user.getAttributes().remove(UserModel.LOCALE); user.getAttributes().remove(UserModel.LOCALE);
user = updateAndGet(user); user = updateAndGet(user);
assertNull(user.getAttributes().get(UserModel.LOCALE)); assertNull(user.getAttributes());
UserProfileMetadata metadata = user.getUserProfileMetadata(); UserProfileMetadata metadata = user.getUserProfileMetadata();
@ -406,7 +409,6 @@ public class AccountRestServiceWithUserProfileTest extends AccountRestServiceTes
} finally { } finally {
realmRep.setInternationalizationEnabled(internationalizationEnabled); realmRep.setInternationalizationEnabled(internationalizationEnabled);
testRealm().update(realmRep); testRealm().update(realmRep);
user.getAttributes().remove(UserModel.LOCALE);
updateAndGet(user); updateAndGet(user);
} }
} }

View file

@ -37,6 +37,7 @@ import org.keycloak.userprofile.UserProfileProvider;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -52,7 +53,6 @@ import jakarta.ws.rs.core.Response;
@EnableFeature(value = Profile.Feature.DECLARATIVE_USER_PROFILE) @EnableFeature(value = Profile.Feature.DECLARATIVE_USER_PROFILE)
public class DeclarativeUserTest extends AbstractAdminTest { 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 TEST_REALM_USER_MANAGER_NAME = "test-realm-user-manager";
private static final String REQUIRED_ATTR_KEY = "required-attr"; 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<String, List<String>> attributes = user1.getAttributes();
assertEquals(4, attributes.size());
List<String> attr1 = attributes.get("attr1");
assertEquals(1, attr1.size());
assertEquals("value1user1", attr1.get(0));
List<String> attr2 = attributes.get("attr2");
assertEquals(1, attr2.size());
assertEquals("value2user1", attr2.get(0));
List<String> attrCustomA = attributes.get("custom-a");
assertTrue(attrCustomA.isEmpty());
assertTrue(attributes.containsKey("custom-a"));
assertTrue(attributes.containsKey("aName"));
}
@Test @Test
public void testDoNotReturnAttributeIfNotReadble() { public void testDoNotReturnAttributeIfNotReadble() {
UserRepresentation user1 = new UserRepresentation(); UserRepresentation user1 = new UserRepresentation();
@ -153,7 +130,7 @@ public class DeclarativeUserTest extends AbstractAdminTest {
user1 = realm.users().get(user1Id).toRepresentation(); user1 = realm.users().get(user1Id).toRepresentation();
Map<String, List<String>> attributes = user1.getAttributes(); Map<String, List<String>> attributes = user1.getAttributes();
assertEquals(4, attributes.size()); assertEquals(2, attributes.size());
assertFalse(attributes.containsKey("custom-hidden")); assertFalse(attributes.containsKey("custom-hidden"));
setUserProfileConfiguration(this.realm, "{\"attributes\": [" setUserProfileConfiguration(this.realm, "{\"attributes\": ["
@ -170,8 +147,8 @@ public class DeclarativeUserTest extends AbstractAdminTest {
user1 = realm.users().get(user1Id).toRepresentation(); user1 = realm.users().get(user1Id).toRepresentation();
attributes = user1.getAttributes(); attributes = user1.getAttributes();
assertEquals(5, attributes.size()); assertEquals(2, attributes.size());
assertTrue(attributes.containsKey("custom-hidden")); assertFalse(attributes.containsKey("custom-hidden"));
} }
@Test @Test
@ -200,12 +177,17 @@ public class DeclarativeUserTest extends AbstractAdminTest {
UserResource userResource = realm.users().get(user1Id); UserResource userResource = realm.users().get(user1Id);
user1 = userResource.toRepresentation(); user1 = userResource.toRepresentation();
Map<String, List<String>> attributes = user1.getAttributes(); assertNull(user1.getAttributes());
attributes.put("attr2", Collections.singletonList("")); user1.singleAttribute("attr2", "");
// should be able to update the user when a read-only attribute has an empty or null value // should be able to update the user when a read-only attribute has an empty or null value
userResource.update(user1); 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); userResource.update(user1);
user1 = userResource.toRepresentation();
assertNull(user1.getAttributes());
} }
@Test @Test
@ -288,7 +270,7 @@ public class DeclarativeUserTest extends AbstractAdminTest {
realm.update(realmRep); realm.update(realmRep);
user1 = userResource.toRepresentation(); user1 = userResource.toRepresentation();
assertNull(user1.getAttributes().get(UserModel.LOCALE)); assertNull(user1.getAttributes());
} finally { } finally {
realmRep.setInternationalizationEnabled(internationalizationEnabled); realmRep.setInternationalizationEnabled(internationalizationEnabled);
realm.update(realmRep); realm.update(realmRep);

View file

@ -1529,20 +1529,12 @@ public class UserTest extends AbstractAdminTest {
String user2Id = createUser(user2); String user2Id = createUser(user2);
user1 = realm.users().get(user1Id).toRepresentation(); 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("value1user1", user1.getAttributes().get("attr1"));
assertAttributeValue("value2user1", user1.getAttributes().get("attr2")); assertAttributeValue("value2user1", user1.getAttributes().get("attr2"));
user2 = realm.users().get(user2Id).toRepresentation(); 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")); assertAttributeValue("value1user2", user2.getAttributes().get("attr1"));
vals = user2.getAttributes().get("attr2"); vals = user2.getAttributes().get("attr2");
assertEquals(2, vals.size()); assertEquals(2, vals.size());
@ -1554,11 +1546,7 @@ public class UserTest extends AbstractAdminTest {
updateUser(realm.users().get(user1Id), user1); updateUser(realm.users().get(user1Id), user1);
user1 = realm.users().get(user1Id).toRepresentation(); 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("value3user1", user1.getAttributes().get("attr1"));
assertAttributeValue("value2user1", user1.getAttributes().get("attr2")); assertAttributeValue("value2user1", user1.getAttributes().get("attr2"));
assertAttributeValue("value4user1", user1.getAttributes().get("attr3")); assertAttributeValue("value4user1", user1.getAttributes().get("attr3"));
@ -1567,11 +1555,7 @@ public class UserTest extends AbstractAdminTest {
updateUser(realm.users().get(user1Id), user1); updateUser(realm.users().get(user1Id), user1);
user1 = realm.users().get(user1Id).toRepresentation(); 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("value2user1", user1.getAttributes().get("attr2"));
assertAttributeValue("value4user1", user1.getAttributes().get("attr3")); assertAttributeValue("value4user1", user1.getAttributes().get("attr3"));
@ -1580,11 +1564,7 @@ public class UserTest extends AbstractAdminTest {
updateUser(realm.users().get(user1Id), user1); updateUser(realm.users().get(user1Id), user1);
user1 = realm.users().get(user1Id).toRepresentation(); user1 = realm.users().get(user1Id).toRepresentation();
assertNotNull(user1.getAttributes()); 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 // empty attributes should remove attributes
user1.setAttributes(Collections.emptyMap()); user1.setAttributes(Collections.emptyMap());
@ -1602,22 +1582,14 @@ public class UserTest extends AbstractAdminTest {
realm.users().get(user1Id).update(user1); realm.users().get(user1Id).update(user1);
user1 = realm.users().get(user1Id).toRepresentation(); 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"); user1.getAttributes().remove("foo");
realm.users().get(user1Id).update(user1); realm.users().get(user1Id).update(user1);
user1 = realm.users().get(user1Id).toRepresentation(); 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 @Test
public void updateUserWithReadOnlyAttributes() { public void updateUserWithReadOnlyAttributes() {
@ -1669,12 +1641,8 @@ public class UserTest extends AbstractAdminTest {
user1 = realm.users().get(user1Id).toRepresentation(); user1 = realm.users().get(user1Id).toRepresentation();
assertEquals("foo", user1.getAttributes().get("usercertificate").get(0)); assertEquals("foo", user1.getAttributes().get("usercertificate").get(0));
assertEquals("bar", user1.getAttributes().get("saml.persistent.name.id.for.foo").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 @Test
public void testImportUserWithNullAttribute() { public void testImportUserWithNullAttribute() {

View file

@ -18,6 +18,8 @@
package org.keycloak.testsuite.federation.ldap; 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.assertTrue;
import static org.junit.Assert.fail; import static org.junit.Assert.fail;
import static org.keycloak.testsuite.forms.VerifyProfileTest.disableDynamicUserProfile; import static org.keycloak.testsuite.forms.VerifyProfileTest.disableDynamicUserProfile;
@ -71,14 +73,16 @@ public class LDAPAdminRestApiWithUserProfileTest extends LDAPAdminRestApiTest {
UserResource user = testRealm().users().get(newUserId); UserResource user = testRealm().users().get(newUserId);
UserRepresentation userRep = user.toRepresentation(); UserRepresentation userRep = user.toRepresentation();
assertNull(userRep.getAttributes());
assertTrue(userRep.getAttributes().containsKey(LDAPConstants.LDAP_ID));
assertTrue(userRep.getAttributes().get(LDAPConstants.LDAP_ID).isEmpty());
userRep.singleAttribute(LDAPConstants.LDAP_ID, ""); userRep.singleAttribute(LDAPConstants.LDAP_ID, "");
user.update(userRep); user.update(userRep);
userRep = testRealm().users().get(newUserId).toRepresentation();
assertNull(userRep.getAttributes());
userRep.singleAttribute(LDAPConstants.LDAP_ID, null); userRep.singleAttribute(LDAPConstants.LDAP_ID, null);
user.update(userRep); user.update(userRep);
userRep = testRealm().users().get(newUserId).toRepresentation();
assertNull(userRep.getAttributes());
try { try {
userRep.singleAttribute(LDAPConstants.LDAP_ID, "should-fail"); userRep.singleAttribute(LDAPConstants.LDAP_ID, "should-fail");

View file

@ -55,6 +55,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.LDAPConstants; import org.keycloak.models.LDAPConstants;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.AbstractUserRepresentation;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; 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 // create a user with attribute foo value 123 allowed by the profile now but disallowed later
UPConfig config = parseDefaultConfig(); UPConfig config = parseDefaultConfig();
config.addOrReplaceAttribute(new UPAttribute("foo", new UPAttributePermissions(Set.of(), Set.of(ROLE_ADMIN)))); 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(); RealmResource realmRes = testRealm();
realmRes.users().userProfile().update(config); realmRes.users().userProfile().update(config);
@ -142,6 +144,18 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile.validate(); 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<String, Object> 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 // it should fail if foo can be modified
getTestingClient().server(TEST_REALM_NAME).run(session -> { getTestingClient().server(TEST_REALM_NAME).run(session -> {
RealmModel realm = session.getContext().getRealm(); RealmModel realm = session.getContext().getRealm();
@ -174,7 +188,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
// once created, profile attributes can not be changed // once created, profile attributes can not be changed
assertTrue(profile.getAttributes().contains(UserModel.USERNAME)); assertTrue(profile.getAttributes().contains(UserModel.USERNAME));
assertNull(profile.getAttributes().getFirstValue(UserModel.USERNAME)); assertNull(profile.getAttributes().getFirst(UserModel.USERNAME));
} }
@Test @Test
@ -413,11 +427,11 @@ public class UserProfileTest extends AbstractUserProfileTest {
assertTrue(ve.isAttributeOnError("address")); assertTrue(ve.isAttributeOnError("address"));
} }
assertNotNull(attributes.getFirstValue(UserModel.USERNAME)); assertNotNull(attributes.getFirst(UserModel.USERNAME));
assertNotNull(attributes.getFirstValue(UserModel.EMAIL)); assertNotNull(attributes.getFirst(UserModel.EMAIL));
assertNotNull(attributes.getFirstValue(UserModel.FIRST_NAME)); assertNotNull(attributes.getFirst(UserModel.FIRST_NAME));
assertNotNull(attributes.getFirstValue(UserModel.LAST_NAME)); assertNotNull(attributes.getFirst(UserModel.LAST_NAME));
assertNull(attributes.getFirstValue("address")); assertNull(attributes.getFirst("address"));
user.setAttribute("address", Arrays.asList("fixed-address")); user.setAttribute("address", Arrays.asList("fixed-address"));
@ -426,7 +440,7 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile.validate(); profile.validate();
assertNotNull(attributes.getFirstValue("address")); assertNotNull(attributes.getFirst("address"));
} }
@Test @Test
@ -1516,20 +1530,20 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile = provider.create(UserProfileContext.USER_API, user); profile = provider.create(UserProfileContext.USER_API, user);
Attributes userAttributes = profile.getAttributes(); Attributes userAttributes = profile.getAttributes();
assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL)); assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL));
assertEquals("Test Value", userAttributes.getFirstValue("test-attribute")); assertEquals("Test Value", userAttributes.getFirst("test-attribute"));
assertEquals("changed", userAttributes.getFirstValue("foo")); assertEquals("changed", userAttributes.getFirst("foo"));
attributes.remove("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 = provider.create(UserProfileContext.USER_API, attributes, user);
profile.update(true); profile.update(true);
profile = provider.create(UserProfileContext.USER_API, user); profile = provider.create(UserProfileContext.USER_API, user);
userAttributes = profile.getAttributes(); userAttributes = profile.getAttributes();
// remove attribute if not set // remove attribute if not set
assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL)); assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL));
assertEquals("Test Value", userAttributes.getFirstValue("test-attribute")); assertEquals("Test Value", userAttributes.getFirst("test-attribute"));
assertNull(userAttributes.getFirstValue("foo")); assertNull(userAttributes.getFirst("foo"));
config.addOrReplaceAttribute(new UPAttribute("test-attribute", new UPAttributePermissions(Set.of(), Set.of(ROLE_USER)))); config.addOrReplaceAttribute(new UPAttribute("test-attribute", new UPAttributePermissions(Set.of(), Set.of(ROLE_USER))));
provider.setConfiguration(JsonSerialization.writeValueAsString(config)); provider.setConfiguration(JsonSerialization.writeValueAsString(config));
@ -1539,8 +1553,8 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile = provider.create(UserProfileContext.USER_API, user); profile = provider.create(UserProfileContext.USER_API, user);
userAttributes = profile.getAttributes(); userAttributes = profile.getAttributes();
// do not remove test-attribute because admin does not have write permissions // do not remove test-attribute because admin does not have write permissions
assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL)); assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL));
assertEquals("Test Value", userAttributes.getFirstValue("test-attribute")); assertEquals("Test Value", userAttributes.getFirst("test-attribute"));
config.addOrReplaceAttribute(new UPAttribute("test-attribute", new UPAttributePermissions(Set.of(), Set.of(ROLE_USER, ROLE_ADMIN)))); config.addOrReplaceAttribute(new UPAttribute("test-attribute", new UPAttributePermissions(Set.of(), Set.of(ROLE_USER, ROLE_ADMIN))));
provider.setConfiguration(JsonSerialization.writeValueAsString(config)); provider.setConfiguration(JsonSerialization.writeValueAsString(config));
@ -1550,8 +1564,8 @@ public class UserProfileTest extends AbstractUserProfileTest {
profile = provider.create(UserProfileContext.USER_API, user); profile = provider.create(UserProfileContext.USER_API, user);
userAttributes = profile.getAttributes(); userAttributes = profile.getAttributes();
// removes the test-attribute attribute because now admin has write permission // removes the test-attribute attribute because now admin has write permission
assertEquals("new-email@test.com", userAttributes.getFirstValue(UserModel.EMAIL)); assertEquals("new-email@test.com", userAttributes.getFirst(UserModel.EMAIL));
assertNull(userAttributes.getFirstValue("test-attribute")); assertNull(userAttributes.getFirst("test-attribute"));
} }
@Test @Test
@ -1594,11 +1608,11 @@ public class UserProfileTest extends AbstractUserProfileTest {
} }
private static void assertRemoveEmptyRootAttribute(Map<String, List<String>> attributes, UserModel user, Attributes upAttributes) { private static void assertRemoveEmptyRootAttribute(Map<String, List<String>> attributes, UserModel user, Attributes upAttributes) {
assertNull(upAttributes.getFirstValue(UserModel.LAST_NAME)); assertNull(upAttributes.getFirst(UserModel.LAST_NAME));
assertNull(user.getLastName()); assertNull(user.getLastName());
assertNull(upAttributes.getFirstValue(UserModel.EMAIL)); assertNull(upAttributes.getFirst(UserModel.EMAIL));
assertNull(user.getEmail()); 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 @Test
@ -1693,6 +1707,77 @@ public class UserProfileTest extends AbstractUserProfileTest {
assertFalse(profile.getAttributes().isReadOnly("foo")); 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<String, String> 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 @Test
public void testAttributeNormalization() { public void testAttributeNormalization() {
getTestingClient().server(TEST_REALM_NAME).run((RunOnServer) UserProfileTest::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"); attributes.put(UserModel.EMAIL, "TesT@TesT.org");
UserProfile profile = provider.create(UserProfileContext.USER_API, attributes); UserProfile profile = provider.create(UserProfileContext.USER_API, attributes);
Attributes profileAttributes = profile.getAttributes(); Attributes profileAttributes = profile.getAttributes();
assertEquals(attributes.get(UserModel.USERNAME).toLowerCase(), profileAttributes.getFirstValue(UserModel.USERNAME)); assertEquals(attributes.get(UserModel.USERNAME).toLowerCase(), profileAttributes.getFirst(UserModel.USERNAME));
assertEquals(attributes.get(UserModel.EMAIL).toLowerCase(), profileAttributes.getFirstValue(UserModel.EMAIL)); assertEquals(attributes.get(UserModel.EMAIL).toLowerCase(), profileAttributes.getFirst(UserModel.EMAIL));
} }
} }