ManageController.java

package access.api;

import access.config.Config;
import access.exception.InvalidInputException;
import access.exception.NotFoundException;
import access.exception.UserRestrictionException;
import access.jira.JiraClient;
import access.manage.Assurance;
import access.manage.ChangeRequest;
import access.manage.Consent;
import access.manage.MFAType;
import access.manage.Manage;
import access.manage.StepUpType;
import access.manage.MetaData;
import access.manage.MetaDataFeedParser;
import access.manage.PolicyAccessRights;
import access.manage.PolicyDefinition;
import access.model.Authority;
import access.model.Connection;
import access.model.EntityType;
import access.model.Organization;
import access.model.User;
import access.repository.ConnectionRepository;
import access.repository.OrganizationRepository;
import access.repository.UserRepository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Parameter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
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 static access.api.Results.createResult;
import static access.manage.ManageData.getData;

@RestController
@RequestMapping(value = {"/api/v1/manage"}, produces = MediaType.APPLICATION_JSON_VALUE)
@EnableConfigurationProperties(Config.class)
@SuppressWarnings("unchecked")
public class ManageController implements UserAccessRights, PolicyAccessRights {

    private static final Log LOG = LogFactory.getLog(ManageController.class);

    private final MetaDataFeedParser metaDataFeedParser = new MetaDataFeedParser();
    private final Manage manage;
    private final ObjectMapper objectMapper;
    private final Map<String, Object> arpInfo;
    private final List<Map<String, Object>> privacyInfo;
    private final OrganizationRepository organizationRepository;
    private final JiraClient jiraClient;
    private final UserRepository userRepository;
    private final ConnectionRepository connectionRepository;
    private final Config config;

    public ManageController(Manage manage,
                            ObjectMapper objectMapper,
                            OrganizationRepository organizationRepository,
                            JiraClient jiraClient,
                            UserRepository userRepository,
                            ConnectionRepository connectionRepository,
                            Config config) throws IOException {
        this.manage = manage;
        this.objectMapper = objectMapper;
        this.arpInfo = objectMapper.readValue(new ClassPathResource("/metadata/ARP.json").getInputStream(), new TypeReference<>() {
        });
        this.privacyInfo = objectMapper.readValue(new ClassPathResource("/metadata/Privacy.json").getInputStream(), new TypeReference<>() {
        });
        this.organizationRepository = organizationRepository;
        this.jiraClient = jiraClient;
        this.userRepository = userRepository;
        this.connectionRepository = connectionRepository;
        this.config = config;
    }

    @GetMapping("/arp")
    public ResponseEntity<Map<String, Object>> arp() {
        LOG.debug("/arp");

        return ResponseEntity.ok(this.arpInfo);
    }

    @GetMapping("/privacy")
    public ResponseEntity<List<Map<String, Object>>> privacy() {
        LOG.debug("/privacy");

        return ResponseEntity.ok(this.privacyInfo);
    }


    @PostMapping("/parse")
    public ResponseEntity<List<MetaData>> parse(@RequestBody Map<String, String> requestBody) throws URISyntaxException, MalformedURLException {
        LOG.debug("/parse");

        List<EntityDescriptor> entityDescriptors;
        Resource resource;
        if (requestBody.containsKey("url")) {
            URL url = new URI(requestBody.get("url")).toURL();
            String protocol = url.getProtocol().toLowerCase();
            if (!List.of("http", "https").contains(protocol)) {
                throw new InvalidInputException("Not allowed protocol: " + protocol);
            }
            resource = new UrlResource(url);
        } else {
            String xml = requestBody.get("xml");
            resource = new ByteArrayResource(xml.getBytes(Charset.defaultCharset()));
        }
        entityDescriptors = metaDataFeedParser.importXML(resource);
        return ResponseEntity.ok(entityDescriptors.stream().map(MetaData::new).toList());
    }


    @GetMapping("/identity-providers")
    public ResponseEntity<List<Map<String, Object>>> identityProviders() {
        LOG.debug("/identityProviders");

        List<Map<String, Object>> providers = manage.providers(EntityType.saml20_idp);
        return ResponseEntity.ok(providers);
    }

    @Transactional(readOnly = true)
    @GetMapping("/allowed-service-providers/{organizationId}")
    public ResponseEntity<List<Map<String, Object>>> serviceProviders(User user, @PathVariable Long organizationId) {
        LOG.debug("/serviceProviders for user: " + user.getEmail());

        Organization organization = organizationRepository.getReferenceById(organizationId);

        user = reinitializeUser(user, userRepository);
        confirmOrganizationMembership(user, organization, Authority.ADMIN);

        boolean isIdentityProvider = StringUtils.hasText(organization.getManageIdentifier());
        List<Map<String, Object>> serviceProviders = List.of();
        if (isIdentityProvider) {
            Map<String, Object> identityProvider = manage.providerByManageIdentifier(
                    EntityType.saml20_idp, organization.getManageIdentifier());
            Map<String, Object> data = getData(identityProvider);
            boolean allowedall = (boolean) data.getOrDefault("allowedall", false);
            if (allowedall) {
                serviceProviders = manage.serviceProvidersLight();
            } else {
                List<Map<String, String>> allowedEntities = (List<Map<String, String>>) data.get("allowedEntities");
                List<String> names = allowedEntities.stream().map(allowedEntity -> allowedEntity.get("name")).toList();
                serviceProviders = manage.serviceProvidersByEntityID(names);
            }
        }
        return ResponseEntity.ok(serviceProviders);
    }

    @PostMapping("/policies/by-service-providers")
    public ResponseEntity<List<String>> policiesByServiceProviders(
            @Parameter(hidden = true) User user,
            @RequestBody List<String> serviceProviderEntityIds) {
        LOG.debug("/policiesByServiceProviders for " + serviceProviderEntityIds);
        List<Map<String, Object>> policies = manage.policiesByServiceProviders(serviceProviderEntityIds);
        List<String> policyNames = policies.stream().map(policy -> (String) getData(policy).get("name")).toList();
        return ResponseEntity.ok(policyNames);
    }

    @GetMapping("/identity-provider/policies")
    @Transactional(readOnly = true)
    @SuppressWarnings("unchecked")
    public ResponseEntity<List<Map<String, Object>>> identityProviderPolicies(@Parameter(hidden = true) User user,
                                                                              @RequestParam("organizationId") Long organizationId) {
        LOG.debug("/identityProviderPolicies for " + user.getEmail());

        Organization organization = organizationRepository.getReferenceById(organizationId);

        confirmInstitutionAdmin(user, organization);

        Map<String, Object> provider = this.manage.providerByManageIdentifier(EntityType.saml20_idp, organization.getManageIdentifier());

        List<Map<String, Object>> policies = this.manage.policiesByIdentityProvider((String) getData(provider).get("entityid"));
        return ResponseEntity.ok(policies);
    }


    @GetMapping("/policies")
    @Transactional(readOnly = true)
    @SuppressWarnings("unchecked")
    public ResponseEntity<List<Map<String, Object>>> policies(@Parameter(hidden = true) User user,
                                                              @RequestParam("entityId") String entityId,
                                                              @RequestParam("organizationId") Long organizationId) {
        LOG.debug("/policies for " + entityId + " for " + user.getEmail());
        Organization organization = organizationRepository.getReferenceById(organizationId);
        confirmInstitutionAdmin(user, organization);
        //we need to ensure the application is connected to the IdP of the user - realtime
        if (!user.isSuperUser()) {
            Map<String, Object> data = getIdentityProvider(user);
            boolean noneMatch = ((List<Map<String, String>>) data.getOrDefault("allowedEntities", List.of()))
                    .stream()
                    .noneMatch(allowedEntity -> allowedEntity.get("name").equals(entityId));

            if (noneMatch) {
                throw new UserRestrictionException(String.format("User %s is not allowed to request policies for %s",
                        user.getEmail(), entityId));
            }
        }
        List<Map<String, Object>> policies = this.manage
                .policiesByServiceProvider(user.getAuthenticatingAuthority(), entityId);
        return ResponseEntity.ok(policies);
    }


    @PostMapping("/policies")
    @Transactional(readOnly = true)
    public ResponseEntity<Map<String, Object>> createPolicy(User user,
                                                            @RequestParam("organizationId") Long organizationId,
                                                            @RequestBody Map<String, Object> policy) {
        LOG.debug("/createPolicy for " + policy + " for " + user.getEmail());

        Organization organization = organizationRepository.getReferenceById(organizationId);

        policyAccessAllowed(user, policy, organization);
        return ResponseEntity.ok(manage.createPolicy(policy));
    }


    @PutMapping("/policies")
    @Transactional(readOnly = true)
    public ResponseEntity<Map<String, Object>> updatePolicy(User user,
                                                            @RequestParam("organizationId") Long organizationId,
                                                            @RequestBody Map<String, Object> policy) {
        LOG.debug("/updatePolicy for " + policy + " for " + user.getEmail());

        Organization organization = organizationRepository.getReferenceById(organizationId);

        policyAccessAllowed(user, policy, organization);
        return ResponseEntity.ok(manage.updatePolicy(policy));
    }

    @PutMapping("/update/consent")
    public ResponseEntity<Map<String, Object>> updateMetaDataConsent(User user,
                                                                     @RequestBody Consent consent) {
        LOG.debug("/updateMetaDataConsent for " + consent + " for " + user.getEmail());

        Organization organization = organizationRepository.findByManageIdentifier(consent.identityProviderId())
                .orElseThrow(() -> new NotFoundException("Organization not found"));

        User userFromDB = reinitializeUser(user, userRepository);
        confirmOrganizationMembership(userFromDB, organization, Authority.ADMIN);

        Map<String, Object> identityProvider = manage.providerByManageIdentifier(EntityType.saml20_idp, consent.identityProviderId());
        Map<String, Object> data = getData(identityProvider);
        //Ensure the application is connected to this IdP
        List<Map<String, String>> allowedEntities = (List<Map<String, String>>) data.get("allowedEntities");
        if (allowedEntities.stream()
                .noneMatch(entity -> consent.name().equals(entity.get("name")))) {
            throw new UserRestrictionException(String.format("%s is not allowed to uppdate consent for %s because this application is not connected to %s",
                    user.getEmail(),
                    consent.name(),
                    consent.identityProviderId()
            ));
        }

        List<Map<String, String>> disableConsent = (List<Map<String, String>>) data.computeIfAbsent("disableConsent", key -> new ArrayList<>());
        Optional<Map<String, String>> optionalCurrentConsent = disableConsent.stream()
                .filter(entry -> consent.name().equals(entry.get("name")))
                .findFirst();
        optionalCurrentConsent.ifPresentOrElse(
                currentConsent -> consent.updateManageMap(currentConsent),
                () -> disableConsent.add(consent.toManageMap()));
        String revisionNote = this.revisionNote("Consent", user, identityProvider, consent.name());
        data.put("revisionnote", revisionNote);
        manage.saveIdentityProvider(identityProvider);

        return createResult();
    }

    @PutMapping("/update/assurance")
    public ResponseEntity<Map<String, Object>> updateMetaDataAssurance(User user,
                                                                       @RequestBody Assurance assurance) {
        LOG.debug("/updateMetaDataAssurance for " + assurance + " for " + user.getEmail());

        Organization organization = organizationRepository.findByManageIdentifier(assurance.identityProviderId())
                .orElseThrow(() -> new NotFoundException("Organization not found"));

        int loaLevel = user.getLoaLevel();
        User userFromDB = reinitializeUser(user, userRepository);
        confirmOrganizationMembership(userFromDB, organization, Authority.ADMIN);

        if (assurance.stepupEntity().level() != null) {
            StepUpType stepUpType = StepUpType.fromLevel(config.getAcrValues(), assurance.stepupEntity().level());
            if (loaLevel < stepUpType.getRequiredLoaLevel()) {
                throw new UserRestrictionException(String.format(
                        "User %s has loaLevel %d but stepUpType %s requires loaLevel %d",
                        user.getEmail(), loaLevel,
                        stepUpType, stepUpType.getRequiredLoaLevel()));
            }
        }

        if (assurance.mfaEntity().level() != null) {
            MFAType mfaType = MFAType.fromValue(assurance.mfaEntity().level());
            if (loaLevel < mfaType.getRequiredLoaLevel()) {
                throw new UserRestrictionException(String.format(
                        "User %s has loaLevel %d but mfaType %s requires loaLevel %d",
                        user.getEmail(), loaLevel,
                        mfaType, mfaType.getRequiredLoaLevel()));
            }
        }

        Map<String, Object> identityProvider = manage.providerByManageIdentifier(EntityType.saml20_idp, assurance.identityProviderId());
        Map<String, Object> data = getData(identityProvider);

        List<Map<String, String>> mfaEntities = (List<Map<String, String>>) data.computeIfAbsent("mfaEntities", key -> new ArrayList<>());
        if (assurance.mfaEntity().level() == null) {
            mfaEntities.removeIf(e -> assurance.mfaEntity().name().equals(e.get("name")));
        } else {
            Optional<Map<String, String>> existingMfa = mfaEntities.stream()
                    .filter(e -> assurance.mfaEntity().name().equals(e.get("name")))
                    .findFirst();
            existingMfa.ifPresentOrElse(
                    e -> e.put("level", assurance.mfaEntity().level()),
                    () -> {
                        Map<String, String> entry = new HashMap<>();
                        entry.put("name", assurance.mfaEntity().name());
                        entry.put("level", assurance.mfaEntity().level());
                        mfaEntities.add(entry);
                    });
        }

        List<Map<String, String>> stepupEntities = (List<Map<String, String>>) data.computeIfAbsent("stepupEntities", key -> new ArrayList<>());
        if (assurance.stepupEntity().level() == null) {
            stepupEntities.removeIf(e -> assurance.stepupEntity().name().equals(e.get("name")));
        } else {
            Optional<Map<String, String>> existingStepup = stepupEntities.stream()
                    .filter(e -> assurance.stepupEntity().name().equals(e.get("name")))
                    .findFirst();
            existingStepup.ifPresentOrElse(
                    e -> e.put("level", assurance.stepupEntity().level()),
                    () -> {
                        Map<String, String> entry = new HashMap<>();
                        entry.put("name", assurance.stepupEntity().name());
                        entry.put("level", assurance.stepupEntity().level());
                        stepupEntities.add(entry);
                    });
        }
        String revisionNote = this.revisionNote("Assurance", user, identityProvider, assurance.stepupEntity().name());
        data.put("revisionnote", revisionNote);
        manage.saveIdentityProvider(identityProvider);
        return createResult();
    }

    @DeleteMapping("/policies/{policyId}")
    @Transactional(readOnly = true)
    public ResponseEntity<Void> deletePolicy(User user,
                                             @RequestParam("organizationId") Long organizationId,
                                             @PathVariable String policyId) {
        Map<String, Object> policy = manage.providerByManageIdentifier(EntityType.policy, policyId);

        Organization organization = organizationRepository.getReferenceById(organizationId);

        LOG.debug("/deletePolicy for " + policy + " for " + user.getEmail());

        policyAccessAllowed(user, policy, organization);
        manage.deletePolicy(policy);
        return ResponseEntity.noContent().build();
    }


    @PostMapping("/unique-entity-id")
    public ResponseEntity<List<Map<String, Object>>> providersByEntityId(@RequestBody Map<String, String> data) {
        LOG.debug("/unique-entity-id for " + data);

        String entityID = data.get("entityID");
        //It does not matter which entityType we use, all services will be queried
        List<Map<String, Object>> providers = manage.uniqueEntityId(EntityType.saml20_sp, entityID);
        return ResponseEntity.ok(providers);
    }


    @PostMapping("/unique-policy-name")
    public ResponseEntity<List<Map<String, Object>>> uniquePolicyName(@RequestBody Map<String, Object> properties) {
        LOG.debug("/unique-entity-id for " + properties);

        List<Map<String, Object>> policies = manage.uniquePolicyName(properties);
        return ResponseEntity.ok(policies);
    }

    @GetMapping("/autocomplete/{type}")
    public List<Map<String, Object>> autoCompleteEntities(@PathVariable EntityType type,
                                                          @RequestParam("query") String query) {
        Map<String, List<Map<String, Object>>> entities = manage.autoCompleteEntities(type, query);
        //We concat the suggestions and alternatives
        List<Map<String, Object>> suggestions = entities.getOrDefault("suggestions", new ArrayList<>());
        List<Map<String, Object>> alternatives = entities.getOrDefault("alternatives", new ArrayList<>());
        suggestions.addAll(alternatives);
        return suggestions;
    }


    @GetMapping("/allowed-attributes")
    public ResponseEntity<List<Map<String, Object>>> allowedAttributes() {
        LOG.debug("/allowedAttributes");

        return ResponseEntity.ok(manage.allowedAttributes());
    }


    @PutMapping("/reject-change-request")
    public ResponseEntity<Map<String, Object>> rejectChangeRequest(User user, @RequestBody ChangeRequest changeRequest) {
        LOG.debug("/reject-change-request " + changeRequest + " by " + user.getEmail());

        String metaDataId = changeRequest.getMetaDataId();
        Connection connection = connectionRepository.findByProtocolAndManageIdentifier(EntityType.valueOf(changeRequest.getType()), metaDataId)
                .orElseThrow(() -> new NotFoundException("No connection found for " + metaDataId));
        User userFromDB = reinitializeUser(user, userRepository);
        confirmApplicationWriteAccess(userFromDB, connection.getApplication());

        //change request has non guessable identifier
        manage.rejectChangeRequest(changeRequest);

        jiraClient.comment(changeRequest.getTicketKey(), "Ticket can be closed by request of the requestor");

        return Results.okResult();
    }

    private void policyAccessAllowed(User user, Map<String, Object> policy, Organization organization) {
        confirmInstitutionAdmin(user, organization);
        //We don't want to use PolicyDefinition as @RequestBody, because the template from Manage is leading
        PolicyDefinition policyDefinition = this.objectMapper.convertValue(policy.get("data"), PolicyDefinition.class);
        confirmPolicyAccess(user, policyDefinition, manage, organization);
    }

    private Map<String, Object> getIdentityProvider(User user) {
        //We can't use any cache as this method is called right after automatic connection allowed
        Map<String, Object> identityProvider = manage.identityProviderByEntityID(user.getAuthenticatingAuthority());
        return getData(identityProvider);
    }

    private String revisionNote(String section, User user, Map<String, Object> identityProvider, String applicationEntityId) {
        return String.format("Update in %s settings by %s from %s for %s.",
                section,
                user.getName(),
                getData(identityProvider).get("entityid"),
                applicationEntityId);
    }

}