DefaultAttributes.java

/*
 *
 *  * Copyright 2021  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.userprofile;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;

import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.validate.ValidationContext;
import org.keycloak.validate.ValidationError;

/**
 * <p>The default implementation for {@link Attributes}. Should be reused as much as possible by the different implementations
 * of {@link UserProfileProvider}.
 *
 * <p>One of the main aspects of this implementation is to allow normalizing attributes accordingly to the profile
 * configuration and current context. As such, it provides some common normalization to common profile attributes (e.g.: username,
 * email, first and last names, dynamic read-only attributes).
 *
 * <p>This implementation is not specific to any user profile implementation.
 *
 * @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
 */
public class DefaultAttributes extends HashMap<String, List<String>> implements Attributes {

    /**
     * To reference dynamic attributes that can be configured as read-only when setting up the provider.
     * We should probably remove that once we remove the legacy provider, because this will come from the configuration.
     */
    public static final String READ_ONLY_ATTRIBUTE_KEY = "kc.read.only";

    protected final UserProfileContext context;
    protected final KeycloakSession session;
    private final Map<String, AttributeMetadata> metadataByAttribute;
    protected final UserModel user;

    public DefaultAttributes(UserProfileContext context, Map<String, ?> attributes, UserModel user,
            UserProfileMetadata profileMetadata,
            KeycloakSession session) {
        this.context = context;
        this.user = user;
        this.session = session;
        this.metadataByAttribute = configureMetadata(profileMetadata.getAttributes());
        putAll(Collections.unmodifiableMap(normalizeAttributes(attributes)));
    }

    @Override
    public boolean isReadOnly(String attributeName) {
        if (UserModel.USERNAME.equals(attributeName)) {
            if (isServiceAccountUser()) {
                return true;
            }
        }

        if (UserModel.EMAIL.equals(attributeName)) {
            if (isServiceAccountUser()) {
                return false;
            }
        }

        if (isReadOnlyFromMetadata(attributeName) || isReadOnlyInternalAttribute(attributeName)) {
            return true;
        }

        return getMetadata(attributeName) == null;
    }

    /**
     * Checks whether an attribute is marked as read only by looking at its metadata.
     *
     * @param attributeName the attribute name
     * @return @return {@code true} if the attribute is readonly. Otherwise, returns {@code false}
     */
    protected boolean isReadOnlyFromMetadata(String attributeName) {
        AttributeMetadata attributeMetadata = metadataByAttribute.get(attributeName);

        if (attributeMetadata == null) {
            return false;
        }

        return attributeMetadata.isReadOnly(createAttributeContext(attributeMetadata));
    }

    @Override
    public boolean isRequired(String name) {
        AttributeMetadata attributeMetadata = metadataByAttribute.get(name);

        if (attributeMetadata == null) {
            return false;
        }

        return attributeMetadata.isRequired(createAttributeContext(attributeMetadata));
    }

    @Override
    public boolean validate(String name, Consumer<ValidationError>... listeners) {
        Entry<String, List<String>> attribute = createAttribute(name);
        List<AttributeMetadata> metadatas = new ArrayList<>();

        metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(attribute.getKey()))
                .map(Collections::singletonList).orElse(Collections.emptyList()));
        metadatas.addAll(Optional.ofNullable(this.metadataByAttribute.get(READ_ONLY_ATTRIBUTE_KEY))
                .map(Collections::singletonList).orElse(Collections.emptyList()));

        Boolean result = null;

        for (AttributeMetadata metadata : metadatas) {
            AttributeContext attributeContext = createAttributeContext(attribute, metadata);

            for (AttributeValidatorMetadata validator : metadata.getValidators()) {
                ValidationContext vc = validator.validate(attributeContext);

                if (vc.isValid()) {
                    continue;
                }

                if (result == null) {
                    result = false;
                }

                if (listeners != null) {
                    for (ValidationError error : vc.getErrors()) {
                        for (Consumer<ValidationError> consumer : listeners) {
                            consumer.accept(error);
                        }
                    }
                }
            }
        }

        return result == null;
    }

    @Override
    public List<String> getValues(String name) {
        return getOrDefault(name, EMPTY_VALUE);
    }

    @Override
    public boolean contains(String name) {
        return containsKey(name);
    }

    @Override
    public Set<String> nameSet() {
        return keySet();
    }

    @Override
    public Map<String, List<String>> getWritable() {
        Map<String, List<String>> attributes = new HashMap<>(this);

        for (String name : nameSet()) {
            AttributeMetadata metadata = getMetadata(name);

            if (metadata == null || !metadata.canEdit(createAttributeContext(metadata))) {
                attributes.remove(name);
            }
        }

        return attributes;
    }

    @Override
    public AttributeMetadata getMetadata(String name) {
        AttributeMetadata metadata = metadataByAttribute.get(name);

        if (metadata == null) {
            return null;
        }

        return metadata.clone();
    }

    @Override
    public Map<String, List<String>> getReadable() {
        Map<String, List<String>> attributes = new HashMap<>(this);

        for (String name : nameSet()) {
            AttributeMetadata metadata = getMetadata(name);

            if (metadata == null || !metadata.canView(createAttributeContext(metadata))) {
                attributes.remove(name);
            }
        }

        return attributes;
    }

    @Override
    public Map<String, List<String>> toMap() {
        return this;
    }

    protected boolean isServiceAccountUser() {
        return user != null && user.getServiceAccountClientLink() != null;
    }

    private AttributeContext createAttributeContext(Entry<String, List<String>> attribute, AttributeMetadata metadata) {
        return new AttributeContext(context, session, attribute, user, metadata);
    }

    private AttributeContext createAttributeContext(String attributeName, AttributeMetadata metadata) {
        return new AttributeContext(context, session, createAttribute(attributeName), user, metadata);
    }

    protected AttributeContext createAttributeContext(AttributeMetadata metadata) {
        return createAttributeContext(createAttribute(metadata.getName()), metadata);
    }

    private Map<String, AttributeMetadata> configureMetadata(List<AttributeMetadata> attributes) {
        Map<String, AttributeMetadata> metadatas = new HashMap<>();

        for (AttributeMetadata metadata : attributes) {
            // checks whether the attribute is selected for the current profile
            if (metadata.isSelected(createAttributeContext(metadata))) {
                metadatas.put(metadata.getName(), metadata);
            }
        }

        return metadatas;
    }

    private SimpleImmutableEntry<String, List<String>> createAttribute(String name) {
        return new SimpleImmutableEntry<String, List<String>>(name, null) {
            @Override
            public List<String> getValue() {
                List<String> values = get(name);

                if (values == null) {
                    return EMPTY_VALUE;
                }

                return values;
            }
        };
    }

    /**
     * Normalizes the given {@code attributes} (as they were provided when creating a profile) accordingly to the
     * profile configuration and the current context.
     *
     * @param attributes the denormalized map of attributes
     *
     * @return a normalized map of attributes
     */
    private Map<String, List<String>> normalizeAttributes(Map<String, ?> attributes) {
        Map<String, List<String>> newAttributes = new HashMap<>();
        RealmModel realm = session.getContext().getRealm();

        if (attributes != null) {
            for (Map.Entry<String, ?> entry : attributes.entrySet()) {
                String key = entry.getKey();

                if (!isSupportedAttribute(key)) {
                    continue;
                }

                if (key.startsWith(Constants.USER_ATTRIBUTES_PREFIX)) {
                    key = key.substring(Constants.USER_ATTRIBUTES_PREFIX.length());
                }

                Object value = entry.getValue();
                List<String> values;

                if (value instanceof String) {
                    values = Collections.singletonList((String) value);
                } else {
                    values = (List<String>) value;
                }

                newAttributes.put(key, Collections.unmodifiableList(values));
            }
        }

        // the profile should always hold all attributes defined in the config
        for (String attributeName : metadataByAttribute.keySet()) {
            if (!isSupportedAttribute(attributeName) || newAttributes.containsKey(attributeName)) {
                continue;
            }

            List<String> values = EMPTY_VALUE;
            AttributeMetadata metadata = metadataByAttribute.get(attributeName);

            if (user != null && isIncludeAttributeIfNotProvided(metadata)) {
                values = user.getAttributes().getOrDefault(attributeName, EMPTY_VALUE);
            }

            newAttributes.put(attributeName, values);
        }

        if (user != null) {
            List<String> username = newAttributes.getOrDefault(UserModel.USERNAME, Collections.emptyList());

            if (username.isEmpty() && isReadOnly(UserModel.USERNAME)) {
                setUserName(newAttributes, Collections.singletonList(user.getUsername()));
            }
        }

        List<String> email = newAttributes.getOrDefault(UserModel.EMAIL, Collections.emptyList());

        if (!email.isEmpty() && realm.isRegistrationEmailAsUsername()) {
            List<String> lowerCaseEmailList = email.stream()
                    .filter(Objects::nonNull)
                    .map(String::toLowerCase)
                    .collect(Collectors.toList());

            setUserName(newAttributes, lowerCaseEmailList);

            if (user != null && isReadOnly(UserModel.EMAIL)) {
                newAttributes.put(UserModel.EMAIL, Collections.singletonList(user.getEmail()));
                setUserName(newAttributes, Collections.singletonList(user.getEmail()));
            }
        }

        return newAttributes;
    }

    private void setUserName(Map<String, List<String>> newAttributes, List<String> lowerCaseEmailList) {
        if (isServiceAccountUser()) {
            return;
        }
        newAttributes.put(UserModel.USERNAME, lowerCaseEmailList);
    }

    protected boolean isIncludeAttributeIfNotProvided(AttributeMetadata metadata) {
        return !metadata.canEdit(createAttributeContext(metadata));
    }

    /**
     * <p>Checks whether an attribute is support by the profile configuration and the current context.
     *
     * <p>This method can be used to avoid unexpected attributes from being added as an attribute because
     * the attribute source is a regular {@link Map} and not normalized.
     *
     * @param name the name of the attribute
     * @return
     */
    protected boolean isSupportedAttribute(String name) {
        if (READ_ONLY_ATTRIBUTE_KEY.equals(name)) {
            return false;
        }

        if (metadataByAttribute.containsKey(name)) {
            return true;
        }

        if (isServiceAccountUser()) {
            return true;
        }

        if (isReadOnlyInternalAttribute(name)) {
            return true;
        }

        // checks whether the attribute is a core attribute
        return isRootAttribute(name);
    }

    /**
     * <p>Returns whether an attribute is read only based on the provider configuration (using provider config),
     * usually related to internal attributes managed by the server.
     *
     * <p>For user-defined attributes, it should be preferable to use the user profile configuration.
     *
     * @param attributeName the attribute name
     * @return {@code true} if the attribute is readonly. Otherwise, returns {@code false}
     */
    protected boolean isReadOnlyInternalAttribute(String attributeName) {
        // read-only can be configured through the provider so we try to validate global validations
        AttributeMetadata readonlyMetadata = metadataByAttribute.get(READ_ONLY_ATTRIBUTE_KEY);

        if (readonlyMetadata == null) {
            return false;
        }

        AttributeContext attributeContext = createAttributeContext(attributeName, readonlyMetadata);

        for (AttributeValidatorMetadata validator : readonlyMetadata.getValidators()) {
            ValidationContext vc = validator.validate(attributeContext);
            if (!vc.isValid()) {
                return true;
            }
        }

        return false;
    }
}