RemoteManage.java

package access.manage;

import access.exception.NotFoundException;
import access.exception.UserRestrictionException;
import access.model.Connection;
import access.model.EntityType;
import access.model.Organization;
import access.model.State;
import access.model.User;
import access.remote.RestTemplateFactory;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;

import static access.manage.ManageData.getData;
import static access.manage.ManageData.getMetaDataFields;

@SuppressWarnings("unchecked")
public class RemoteManage implements Manage {

    private static final Log LOG = LogFactory.getLog(RemoteManage.class);
    private static final ParameterizedTypeReference<Map<String, Object>> PARAMETERIZED_TYPE_REFERENCE = new ParameterizedTypeReference<>() {
    };

    private final RestTemplate restTemplate;
    private final String url;
    private final Map<String, Object> queries;
    private final ConnectionProviderConverter converter;

    public RemoteManage(ManageAuthorization authorization,
                        ConnectionProviderConverter converter,
                        ObjectMapper objectMapper) throws IOException {
        this.url = authorization.url();
        this.converter = converter;
        this.queries = objectMapper.readValue(new ClassPathResource("/manage/query_templates.json").getInputStream(), new TypeReference<>() {
        });
        ResponseErrorHandler resilientErrorHandler = new ResilientErrorHandler(objectMapper);
        this.restTemplate = RestTemplateFactory.buildRestTemplate(resilientErrorHandler, authorization.user(), authorization.password(), null);
    }

    @Override
    public List<Map<String, Object>> providers(EntityType... entityTypes) {
        LOG.debug("Providers for entityTypes: " + List.of(entityTypes));
        return Stream.of(entityTypes).map(entityType -> this.getRemoteMetaData(entityType.name(), false))
                .flatMap(List::stream)
                .toList();
    }

    @Override
    public Map<String, Object> providerByConnection(Connection connection) {
        String manageIdentifier = connection.getManageIdentifier();
        EntityType protocol = connection.getProtocol();

        LOG.debug("providerById: " + protocol);

        return providerDetails(protocol, manageIdentifier);
    }

    private Map<String, Object> providerDetails(EntityType protocol, String manageIdentifier) {
        String queryUrl = String.format("%s/manage/api/internal/metadata/%s/%s", url, protocol.name(), manageIdentifier);
        ResponseEntity<Map> responseEntity = restTemplate.getForEntity(queryUrl, Map.class);
        if (responseEntity.getStatusCode().equals(HttpStatus.OK)) {
            return sanitizeProvider(responseEntity.getBody());
        }
        return responseEntity.getBody();
    }

    public Map<String, Object> providerByManageIdentifier(EntityType entityType, String manageIdentifier) {
        LOG.debug("providerById: " + entityType);

        return providerDetails(entityType, manageIdentifier);
    }

    @SneakyThrows
    @Override
    public Map<String, Object> saveIdentityProvider(Organization organization) {
        Map<String, Object> provider = providerByManageIdentifier(EntityType.saml20_idp, organization.getManageIdentifier());
        Map<String, Object> metaDataFields = getMetaDataFields(getData(provider));
        Map<String, Object> metaDataOrganization = organization.getMetaData();
        converter.convertContactPersons(metaDataOrganization, metaDataFields);
        String keyWords = String.join(" ", ((List<String>) metaDataOrganization.getOrDefault("keyWords", List.of())));
        metaDataFields.put("keywords:0:nl", keyWords);
        metaDataFields.put("keywords:0:en", keyWords);

        return internalSaveProvider(provider);
    }

    @Override
    public Map<String, Object>  saveIdentityProvider(Map<String, Object> identityProvider) {
        return internalSaveProvider(identityProvider);
    }

    @SneakyThrows
    @Override
    public Map<String, Object> saveProvider(Connection connection) {
        Map<String, Object> remoteProvider = StringUtils.hasText(connection.getManageIdentifier()) ?
                providerByConnection(connection) :
                baseStructureProvider();
        //We must ensure that no data is overridden that was altered in Manage. Especially additional metadata and
        //Attribute Release Policies that are not available in Access
        //We can't update everything if the connection is production ready, only the application data
        Map<String, Object> provider = converter.convert(connection, remoteProvider, connection.changeRequestRequired());
        HttpMethod httpMethod = StringUtils.hasText(connection.getManageIdentifier()) ? HttpMethod.PUT : HttpMethod.POST;
        ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(String.format("%s/manage/api/internal/metadata", url),
                httpMethod, new HttpEntity<>(provider), PARAMETERIZED_TYPE_REFERENCE);
        return checkNoChangeResponse(responseEntity, provider);
    }

    @Override
    public Map<String, Object> updateProvider(Map<String, Object> provider) {
        return internalSaveProvider(provider);
    }

    private Map<String, Object> internalSaveProvider(Map<String, Object> provider) {
        ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(String.format("%s/manage/api/internal/metadata", url),
                HttpMethod.PUT, new HttpEntity<>(provider), PARAMETERIZED_TYPE_REFERENCE);
        return checkNoChangeResponse(responseEntity, provider);
    }

    @Override
    public void deleteProvider(Connection connection) {
        String deleteUrl = String.format("%s/manage/api/internal/metadata/%s/%s",
                url,
                connection.getProtocol(),
                connection.getManageIdentifier());
        restTemplate.exchange(URI.create(deleteUrl), HttpMethod.DELETE, null, Void.class);
    }

    @Override
    public void rejectChangeRequest(ChangeRequest changeRequest) {
        String rejectUrl = String.format("%s/manage/api/internal/change-requests/reject", url);
        restTemplate.put(URI.create(rejectUrl), changeRequest);
    }

    @Override
    public List<Map<String, Object>> uniqueEntityId(EntityType entityType, String entityID) {
        String queryUrl = String.format("%s/manage/api/internal/uniqueEntityId/%s", url, entityType.name());
        return restTemplate.postForEntity(queryUrl, Map.of("entityid", entityID), List.class).getBody();
    }

    @Override
    public Map<String, Object> createChangeRequest(ChangeRequest changeRequest) {
        return doSaveChangeRequest(changeRequest, HttpMethod.POST);
    }

    @Override
    public Map<String, Object> updateChangeRequest(ChangeRequest changeRequest) {
        return doSaveChangeRequest(changeRequest, HttpMethod.PUT);
    }

    private Map<String, Object> doSaveChangeRequest(ChangeRequest changeRequest, HttpMethod method) {
        String changeRequestUrl = String.format("%s/manage/api/internal/change-requests", url);
        HttpEntity<ChangeRequest> requestEntity = new HttpEntity<>(changeRequest);
        ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(changeRequestUrl, method, requestEntity,
                PARAMETERIZED_TYPE_REFERENCE);
        return responseEntity.getBody();
    }

    @Override
    public List<Map<String, Object>> getChangeRequests(Connection connection) {
        String changeRequestUrl = String.format("%s/manage/api/internal/change-requests/%s/%s",
                url,
                connection.getProtocol().name(),
                connection.getManageIdentifier());
        return restTemplate.getForEntity(changeRequestUrl, List.class).getBody();
    }

    @Override
    public List<Map<String, Object>> getChangeRequestsIdentityProvider(Map<String, Object> identityProvider) {
        String changeRequestUrl = String.format("%s/manage/api/internal/change-requests/%s/%s",
                url,
                EntityType.saml20_idp.name(),
                identityProvider.get("id"));
        return restTemplate.getForEntity(changeRequestUrl, List.class).getBody();
    }

    @Override
    public String changeRequestURL(Connection connection) {
        return String.format("%s/metadata/%s/%s/requests",
                url, connection.getProtocol().name(), connection.getManageIdentifier());
    }

    @Override
    public String changeRequestURLConnectionRequest(EntityType entityType, String manageIdentifier) {
        return String.format("%s/metadata/%s/%s/requests",
                url, entityType.name(), manageIdentifier);
    }

    /**
     * Equivalent of
     * curl -H 'Content-Type: application/json' -u user:password  -X POST -d \
     * '{"entityid":"http://mock-idp","ALL_ATTRIBUTES":true}' \
     * 'https://manage.test.surfconext.nl/manage/api/internal/search/saml20_idp' | jq .
     */
    @Override
    public Map<String, Object> identityProviderByEntityID(String entityID) {
        LOG.debug("identityProvidersByEntityID for : " + entityID);
        Map<String, Object> baseQuery = getBaseQuery(true);
        baseQuery.put("entityid", entityID);

        String searchUrl = String.format("%s/manage/api/internal/search/%s",
                url,
                EntityType.saml20_idp.name());
        List<Map<String, Object>> identityProviders = restTemplate.postForObject(
                searchUrl,
                baseQuery, List.class);
        if (identityProviders.isEmpty()) {
            throw new NotFoundException("No identityProviders found for entityID: " + entityID);
        }
        return sanitizeProvider(identityProviders.getFirst());
    }

    @Override
    public List<Map<String, Object>> serviceProvidersByEntityID(List<String> entityIdentifiers) {
        LOG.debug("serviceProvidersByEntityID for : " + entityIdentifiers);

        Map<String, Object> baseQuery = getBaseQuery(false);
        baseQuery.put("entityid", entityIdentifiers);
        return Stream.of(EntityType.oidc10_rp, EntityType.saml20_sp)
                .flatMap(entityType -> {
                    String searchUrl = String.format("%s/manage/api/internal/search/%s",
                            url,
                            entityType.name());
                    List<Map<String, Object>> providers = restTemplate.postForObject(
                            searchUrl,
                            baseQuery,
                            List.class);
                    return providers.stream();
                }).toList();
    }

    @Override
    public List<Map<String, Object>> identityProvidersByInstitutionalGUID(String organisationGUID) {
        LOG.debug("identityProviderByInstitutionalGUID for : " + organisationGUID);

        Map<String, Object> baseQuery = getBaseQuery(true);
        baseQuery.put("metaDataFields.coin:institution_guid", organisationGUID);

        String searchUrl = String.format("%s/manage/api/internal/search/%s",
                url,
                EntityType.saml20_idp.name());
        return restTemplate.postForObject(searchUrl, baseQuery, List.class);
    }

    @Override
    public List<Map<String, Object>> identityProvidersLight() {
        LOG.debug("identityProvidersLight");

        Map<String, Object> baseQuery = getBaseQuery(false);
        baseQuery.put("state", "prodaccepted");
        ((List) baseQuery.get("REQUESTED_ATTRIBUTES")).add("metaDataFields.coin:institution_type");

        String searchUrl = String.format("%s/manage/api/internal/search/%s",
                url,
                EntityType.saml20_idp.name());
        return restTemplate.postForObject(searchUrl, baseQuery, List.class);
    }

    @Override
    public List<Map<String, Object>> serviceProvidersLight() {
        LOG.debug("serviceProvidersLight");

        Map<String, Object> baseQuery = getBaseQuery(false);
        baseQuery.put("state", "prodaccepted");
        List requestedAttributes = (List) baseQuery.get("REQUESTED_ATTRIBUTES");
        requestedAttributes.add("metaDataFields.coin:interfed_source");
        requestedAttributes.add("metaDataFields.coin:ss:hidden");
        requestedAttributes.add("metaDataFields.coin:ss:idp_visible_only");
        requestedAttributes.add("metaDataFields.application_tags");


        String spUrl = String.format("%s/manage/api/internal/search/%s", url, EntityType.saml20_sp.name());
        List<Map<String, Object>> serviceProviders = restTemplate.postForObject(spUrl, baseQuery, List.class);

        String rpUrl = String.format("%s/manage/api/internal/search/%s", url, EntityType.oidc10_rp.name());
        List<Map<String, Object>> relyingParties = restTemplate.postForObject(rpUrl, baseQuery, List.class);

        serviceProviders.addAll(relyingParties);
        return serviceProviders;
    }

    @Override
    public Map<String, Integer> stats() {
        LOG.debug("stats");

        String statsUrl = String.format("%s/manage/api/internal/stats", url);
        return restTemplate.getForEntity(statsUrl, Map.class).getBody();
    }

    @Override
    public List<Map<String, Object>> identityProvidersByAllowedConnections(List<Connection> connections) {
        List<Map<String, String>> body = connections.stream()
                .filter(connection -> StringUtils.hasText(connection.getManageIdentifier()) &&
                        connection.getState().equals(State.prodaccepted))
                .map(connection -> Map.of(
                        "id", connection.getManageIdentifier(),
                        "type", connection.getProtocol().name()))
                .toList();
        if (body.isEmpty()) {
            //No use to actually go to Manage
            return List.of();
        }
        String deleteConsequencesUrl = String.format("%s/manage/api/internal/delete-consequences", url);
        return restTemplate.postForEntity(URI.create(deleteConsequencesUrl), body, List.class).getBody();
    }

    @Override
    public List<Map<String, Object>> policiesByServiceProvider(String identityProviderEntityId,
                                                               String serviceProviderEntityId) {
        // Build query using immutable Maps to ensure proper JSON serialization and prevent injection
        Map<String, Object> identityProviderOrCondition = Map.of(
                "data.identityProviderIds.name", identityProviderEntityId
        );
        Map<String, Object> identityProviderExistsFalseCondition = Map.of(
                "data.identityProviderIds", Map.of("$exists", false)
        );
        Map<String, Object> identityProviderSizeZeroCondition = Map.of(
                "data.identityProviderIds", Map.of("$size", 0)
        );
        Map<String, Object> query = Map.of(
                "data.serviceProviderIds.name", serviceProviderEntityId,
                "$or", List.of(
                        identityProviderSizeZeroCondition,
                        identityProviderExistsFalseCondition,
                        identityProviderOrCondition
                )
        );
        String policyUrl = String.format("%s/manage/api/internal/rawSearch/%s", url, EntityType.policy);
        return restTemplate.postForEntity(policyUrl, query, List.class).getBody();
    }

    @Override
    public List<Map<String, Object>> policiesByServiceProviders(List<String> serviceProviderEntityIds) {
        if (serviceProviderEntityIds.isEmpty()) {
            return List.of();
        }
        Map<String, Object> query = Map.of(
                "data.serviceProviderIds.name", Map.of("$in", serviceProviderEntityIds)
        );
        String policyUrl = String.format("%s/manage/api/internal/rawSearch/%s", url, EntityType.policy);
        return restTemplate.postForEntity(policyUrl, query, List.class).getBody();
    }

    @Override
    public List<Map<String, Object>> policiesByIdentityProvider(String identityProviderEntityId) {
        Map<String, Object> query = Map.of(
                "data.identityProviderIds.name", identityProviderEntityId
        );
        String policyUrl = String.format("%s/manage/api/internal/rawSearch/%s", url, EntityType.policy);
        return restTemplate.postForEntity(policyUrl, query, List.class).getBody();
    }

    @Override
    public Map<String, Object> createPolicy(Map<String, Object> policy) {
        String policyUrl = String.format("%s/manage/api/internal/metadata", url);
        return restTemplate.postForEntity(policyUrl, policy, Map.class).getBody();
    }

    @Override
    public Map<String, Object> updatePolicy(Map<String, Object> policy) {
        String policyUrl = String.format("%s/manage/api/internal/metadata", url);
        ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(policyUrl, HttpMethod.PUT, new HttpEntity<>(policy), PARAMETERIZED_TYPE_REFERENCE);
        return checkNoChangeResponse(responseEntity, policy);
    }

    @Override
    public List<Map<String, Object>> uniquePolicyName(Map<String, Object> properties) {
        String policyUrl = String.format("%s/manage/api/internal/uniquePolicyName/policy", url);
        return restTemplate.exchange(policyUrl, HttpMethod.POST, new HttpEntity<>(properties), List.class).getBody();
    }

    @Override
    public List<Map<String, Object>> allowedAttributes() {
        String attributesUrl = String.format("%s/manage/api/internal/protected/allowed-attributes", url);
        return restTemplate.getForObject(attributesUrl, List.class);
    }

    @Override
    public void deletePolicy(Map<String, Object> policy) {
        String policyUrl = String.format("%s/manage/api/internal/metadata/%s/%s",
                url, EntityType.policy.name(), policy.get("id"));
        restTemplate.delete(policyUrl);
    }

    @Override
    public Map<String, List<Map<String, Object>>> autoCompleteEntities(EntityType type, String query) {
        String autocompleteUrl = String.format("%s/manage/api/internal/autocomplete/%s?query=%s",
                url,
                type.name(),
                URLEncoder.encode(query, Charset.defaultCharset()));
        return restTemplate.getForObject(autocompleteUrl, Map.class);
    }

    @Override
    public void connectWithoutInteraction(Map<String, Object> identityProvider, Map<String, Object> serviceProvider, User user) {
        String connectUrl = String.format("%s/manage/api/internal/connectWithoutInteraction", url);
        Map<String, String> bodyMap = new HashMap<>();
        bodyMap.put("idpId", (String) getData(identityProvider).get("entityid"));
        bodyMap.put("spId", (String) getData(serviceProvider).get("entityid"));
        bodyMap.put("spType", (String) serviceProvider.get("type"));
        bodyMap.put("user", user.getName());
        bodyMap.put("userUrn", user.getSub());
        //Fire and forget. An exception will be thrown by the restTemplate if the return is not 20X
        restTemplate.put(connectUrl, bodyMap);
    }

    private List<Map<String, Object>> getRemoteMetaData(String type, boolean allAttributes) {
        Map<String, Object> baseQuery = getBaseQuery(allAttributes);
        String searchUrl = String.format("%s/manage/api/internal/search/%s", url, type);
        return restTemplate.postForObject(searchUrl, baseQuery, List.class);
    }

    private Map<String, Object> getBaseQuery(boolean allAttributes) {
        HashMap<String, Object> baseQuery = new HashMap<>((Map<String, Object>) this.queries.get("base_query"));
        if (allAttributes) {
            baseQuery.remove("REQUESTED_ATTRIBUTES");
            baseQuery.put("ALL_ATTRIBUTES", true);
        } else {
            baseQuery.put("REQUESTED_ATTRIBUTES", baseQuery.get("REQUESTED_ATTRIBUTES"));
        }
        return baseQuery;
    }

    private Map<String, Object> checkNoChangeResponse(ResponseEntity<Map<String, Object>> responseEntity, Map<String, Object> provider) {
        Map<String, Object> body = responseEntity.getBody();
        if (body == null || ResilientErrorHandler.ignoreError(body)) {
            //See ResilientErrorHandler#handleError. Any no-data-changed error is thrown there
            return provider;
        }
        return body;
    }

}