MapClientScopeAdapter.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.models.map.clientscope;

import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.jboss.logging.Logger;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.map.client.MapProtocolMapperEntity;
import org.keycloak.models.map.client.MapProtocolMapperUtils;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.RoleUtils;

public class MapClientScopeAdapter extends AbstractClientScopeModel<MapClientScopeEntity> implements ClientScopeModel {

    private static final Logger LOG = Logger.getLogger(MapClientScopeAdapter.class);
    private final MapProtocolMapperUtils pmUtils;

    public MapClientScopeAdapter(KeycloakSession session, RealmModel realm, MapClientScopeEntity entity) {
        super(session, realm, entity);
        pmUtils = MapProtocolMapperUtils.instanceFor(safeGetProtocol());
    }

    @Override
    public String getId() {
        return entity.getId();
    }

    @Override
    public String getName() {
        return entity.getName();
    }

    @Override
    public void setName(String name) {
        entity.setName(KeycloakModelUtils.convertClientScopeName(name));
    }

    @Override
    public String getDescription() {
        return entity.getDescription();
    }

    @Override
    public void setDescription(String description) {
        entity.setDescription(description);
    }

    @Override
    public String getProtocol() {
        return entity.getProtocol();
    }

    @Override
    public void setProtocol(String protocol) {
        entity.setProtocol(protocol);
    }

    @Override
    public RealmModel getRealm() {
        return realm;
    }

    @Override
    public void setAttribute(String name, String value) {
//  TODO: https://github.com/keycloak/keycloak/issues/9741
//        boolean valueUndefined = value == null || "".equals(value.trim());
//
//        if (valueUndefined) {
//            removeAttribute(name);
//            return;
//        }

        entity.setAttribute(name, Collections.singletonList(value));
    }

    @Override
    public void removeAttribute(String name) {
        entity.removeAttribute(name);
    }

    @Override
    public String getAttribute(String name) {
        List<String> attribute = entity.getAttribute(name);
        if (attribute == null || attribute.isEmpty()) return null;
        return attribute.get(0);
    }

    @Override
    public Map<String, String> getAttributes() {
        final Map<String, List<String>> attributes = entity.getAttributes();
        final Map<String, List<String>> a = attributes == null ? Collections.emptyMap() : attributes;
        return a.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,
            entry -> {
                if (entry.getValue().isEmpty()) {
                    return null;
                } else if (entry.getValue().size() > 1) {
                    // This could be caused by an inconsistency in the storage, a programming error,
                    // or a downgrade from a future version of Keycloak that already supports multi-valued attributes.
                    // The caller will not see the other values, and when this entity is later updated, the additional values will be lost.
                    LOG.warnf("ClientScope '%s' realm '%s' has attribute '%s' with %d values, retrieving only the first", getName(), getRealm().getName(), entry.getKey(),
                            entry.getValue().size());
                }
                return entry.getValue().get(0);
            })
        );
    }

    /*************** Protocol mappers ****************/

    private String safeGetProtocol() {
        return entity.getProtocol() == null ? "openid-connect" : entity.getProtocol();
    }

    @Override
    public Stream<ProtocolMapperModel> getProtocolMappersStream() {
        final Set<MapProtocolMapperEntity> protocolMappers = entity.getProtocolMappers();
        return protocolMappers == null ? Stream.empty() : protocolMappers.stream().distinct().map(pmUtils::toModel);
    }

    @Override
    public ProtocolMapperModel addProtocolMapper(ProtocolMapperModel model) {
        if (model == null) {
            return null;
        }

        MapProtocolMapperEntity pm = MapProtocolMapperUtils.fromModel(model);
        if (pm.getId() == null) {
            String id = KeycloakModelUtils.generateId();
            pm.setId(id);
        }
        if (model.getConfig() == null) {
            pm.setConfig(new HashMap<>());
        }

        entity.addProtocolMapper(pm);
        return pmUtils.toModel(pm);
    }

    @Override
    public void removeProtocolMapper(ProtocolMapperModel mapping) {
        final String id = mapping == null ? null : mapping.getId();
        if (id != null) {
            entity.removeProtocolMapper(id);
        }
    }

    @Override
    public void updateProtocolMapper(ProtocolMapperModel mapping) {
        final String id = mapping == null ? null : mapping.getId();
        if (id != null) {
            entity.getProtocolMapper(id).ifPresent((pmEntity) -> {
                entity.removeProtocolMapper(id);
                addProtocolMapper(mapping);
            });
        }
    }

    @Override
    public ProtocolMapperModel getProtocolMapperById(String id) {
        return entity.getProtocolMapper(id).map(pmUtils::toModel).orElse(null);
    }

    @Override
    public ProtocolMapperModel getProtocolMapperByName(String protocol, String name) {
        final Set<MapProtocolMapperEntity> protocolMappers = entity.getProtocolMappers();
        if (! Objects.equals(protocol, safeGetProtocol())) {
            return null;
        }
        return protocolMappers == null ? null : protocolMappers.stream()
          .filter(pm -> Objects.equals(pm.getName(), name))
          .map(pmUtils::toModel)
          .findAny()
          .orElse(null);
    }

    /*************** Scopes mappings ****************/

    @Override
    public Stream<RoleModel> getScopeMappingsStream() {
        final Collection<String> scopeMappings = this.entity.getScopeMappings();
        return scopeMappings == null ? Stream.empty() : scopeMappings.stream()
                .map(realm::getRoleById)
                .filter(Objects::nonNull);
    }

    @Override
    public Stream<RoleModel> getRealmScopeMappingsStream() {
        return getScopeMappingsStream().filter(r -> RoleUtils.isRealmRole(r, realm));
    }

    @Override
    public void addScopeMapping(RoleModel role) {
        final String id = role == null ? null : role.getId();
        if (id != null) {
            this.entity.addScopeMapping(id);
        }
    }

    @Override
    public void deleteScopeMapping(RoleModel role) {
        final String id = role == null ? null : role.getId();
        if (id != null) {
            this.entity.removeScopeMapping(id);
        }
    }

    @Override
    public boolean hasScope(RoleModel role) {
        return RoleUtils.hasRole(getScopeMappingsStream(), role);
    }

    @Override
    public String toString() {
        return String.format("%s@%08x", getId(), System.identityHashCode(this));
    }
}