PublicController.java

package access.api;

import access.config.Config;
import access.manage.Manage;
import access.model.EntityType;
import lombok.SneakyThrows;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import static access.api.Results.forbiddenResult;
import static access.manage.ManageData.getData;
import static access.manage.ManageData.getMetaDataFields;
import static access.manage.ManageData.removeSecretsFromProvider;

@RestController
@RequestMapping(value = {"/api/v1/public"}, produces = MediaType.APPLICATION_JSON_VALUE)
@SuppressWarnings("unchecked")
public class PublicController {

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

    private final Manage manage;
    private final Config config;

    @SneakyThrows
    public PublicController(Manage manage, Config config) {
        this.manage = manage;
        this.config = config;
    }

    @GetMapping("/service-providers")
    public ResponseEntity<List<Map<String, Object>>> serviceProviders(Authentication authentication,
                                                                      @RequestParam(value = "manageIdentifier",
                                                                          required = false) String manageIdentifier) {
        LOG.debug("/serviceProviders");
        List<Map<String, Object>> providers = manage.serviceProvidersLight();
        Set<String> allowedEntities = allowedEntities(authentication, manageIdentifier);
        providers.removeIf(provider -> removeNonPublicProvider(provider, allowedEntities));
        //RemoteManage's search query already restricts fields, but LocalManage.serviceProvidersLight() (used
        //whenever manage.enabled=false, the shipped application.yml default) returns full, unfiltered records -
        //never rely solely on the upstream query shape for an unauthenticated endpoint
        List<Map<String, Object>> sanitizedProviders = providers.stream()
                .map(provider -> removeSecretsFromProvider(provider))
                .toList();
        return ResponseEntity.ok(sanitizedProviders);
    }

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

    @GetMapping("/service-provider-detail/{type}/{identifier}")
    public ResponseEntity<Map<String, Object>> serviceProviderDetail(
        Authentication authentication,
        @PathVariable("type") EntityType entityType,
        @PathVariable("identifier") String identifier) {

        LOG.debug("/service-provider-detail");
        if (!List.of(EntityType.oidc10_rp, EntityType.saml20_sp).contains(entityType)) {
            return forbiddenResult();
        }

        Map<String, Object> provider = manage
            .providerByManageIdentifier(entityType, identifier);
        Set<String> allowedEntities = allowedEntities(authentication, null);
        if (removeNonPublicProvider(provider, allowedEntities)) {
            return forbiddenResult();
        }
        //Defensive copy first - the returned provider may be a cached / shared instance (e.g. LocalManage)
        provider = removeSecretsFromProvider(provider);
        getMetaDataFields(getData(provider)).keySet()
            .removeIf(key -> key.startsWith("contacts:"));

        return ResponseEntity.ok(provider);
    }

    private Set<String> allowedEntities(Authentication authentication, String manageIdentifier) {
        if (authentication == null) {
            return Set.of();
        }
        DefaultOidcUser user = (DefaultOidcUser) authentication.getPrincipal();
        String schacHomeOrganization = (String) user.getClaims().get("schac_home_organization");
        if (config.getExternalSchacHomeOrganizations().contains(schacHomeOrganization)) {
            return Set.of();
        }
        //We need the identity provider to see which providers are connected and are therefore visible
        Map<String, Object> identityProvider;
        if (StringUtils.hasText(manageIdentifier)) {
            identityProvider = manage.providerByManageIdentifier(EntityType.saml20_idp, manageIdentifier);
        } else {
            String surfCrmId = (String) user.getClaims().get("surf-crm-id");
            List<Map<String, Object>> identityProviders = manage.identityProvidersByInstitutionalGUID(surfCrmId);
            String authenticatingAuthority = (String) user.getClaims().get("authenticating_authority");
            identityProvider = identityProviders.stream()
                .filter(idp -> authenticatingAuthority.equals(getData(idp).get("entityid")))
                .findFirst()
                .orElseGet(identityProviders::getFirst);
        }
        return ((List<Map<String, String>>) getData(identityProvider)
            .getOrDefault("allowedEntities", List.of()))
            .stream()
            .map(allowedEntity -> allowedEntity.get("name"))
            .collect(Collectors.toSet());
    }

    private boolean removeNonPublicProvider(Map<String, Object> provider, Set<String> allowedEntities) {
        Map<String, Object> data = getData(provider);
        Map<String, Object> metaDataFields = getMetaDataFields(data);
        boolean hidden = (boolean) metaDataFields.getOrDefault("coin:ss:hidden", false);
        boolean idpVisibleOnly = (boolean) metaDataFields.getOrDefault("coin:ss:idp_visible_only", false);
        return hidden || (idpVisibleOnly && !allowedEntities.contains((String) data.get("entityid")));
    }
}