ApplicationController.java
package access.api;
import access.exception.NotFoundException;
import access.exception.UserRestrictionException;
import access.manage.ConnectionProviderConverter;
import access.manage.Manage;
import access.manage.ManageData;
import access.model.Application;
import access.model.ApplicationMembership;
import access.model.ApplicationStatus;
import access.model.Authority;
import access.model.Connection;
import access.model.ConnectionStatus;
import access.model.EntityType;
import access.model.ImportEntityRequest;
import access.model.MigrateApplicationRequest;
import access.model.Organization;
import access.model.OrganizationMembership;
import access.model.User;
import access.repository.ApplicationMembershipRepository;
import access.repository.ApplicationRepository;
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.security.SecurityRequirement;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.hibernate.Hibernate;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.validation.annotation.Validated;
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.RestController;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
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.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import static access.SwaggerOpenIdConfig.API_TOKENS_SCHEME_NAME;
import static access.SwaggerOpenIdConfig.OPEN_ID_SCHEME_NAME;
import static access.api.Results.deleteResult;
import static access.manage.Manage.INSTITUTION_GUID;
import static access.manage.ManageData.*;
@RestController
@RequestMapping(value = {"/api/v1/applications"}, produces = MediaType.APPLICATION_JSON_VALUE)
@Transactional
@SecurityRequirement(name = OPEN_ID_SCHEME_NAME, scopes = {"openid"})
@SecurityRequirement(name = API_TOKENS_SCHEME_NAME)
@SuppressWarnings("unchecked")
public class ApplicationController implements UserAccessRights {
private static final Log LOG = LogFactory.getLog(ApplicationController.class);
private final ApplicationRepository applicationRepository;
private final ApplicationMembershipRepository applicationMembershipRepository;
private final ConnectionRepository connectionRepository;
private final Manage manage;
private final UserRepository userRepository;
private final S3Storage s3Storage;
private final ConnectionProviderConverter connectionProviderConverter;
private final OrganizationRepository organizationRepository;
private final Map<String, Object> arpInfo;
public ApplicationController(ApplicationRepository applicationRepository,
ApplicationMembershipRepository applicationMembershipRepository,
ConnectionRepository connectionRepository,
Manage manage,
UserRepository userRepository,
S3Storage s3Storage,
ConnectionProviderConverter connectionProviderConverter,
OrganizationRepository organizationRepository,
ObjectMapper objectMapper) throws IOException {
this.applicationRepository = applicationRepository;
this.applicationMembershipRepository = applicationMembershipRepository;
this.connectionRepository = connectionRepository;
this.manage = manage;
this.userRepository = userRepository;
this.s3Storage = s3Storage;
this.connectionProviderConverter = connectionProviderConverter;
this.organizationRepository = organizationRepository;
this.arpInfo = objectMapper.readValue(new ClassPathResource("/metadata/ARP.json").getInputStream(), new TypeReference<>() {
});
}
@GetMapping("/all")
public ResponseEntity<List<Map<String, Object>>> all(User user) {
LOG.debug("/allLightByOrganization");
confirmSuperUser(user);
List<Map<String, Object>> applications = applicationRepository.findAllLight();
return ResponseEntity.ok(applications);
}
@GetMapping("/all/light/{organizationId}")
public ResponseEntity<List<Map<String, Object>>> allLightByOrganization(@PathVariable Long organizationId, User user) {
LOG.debug("/allLightByOrganization");
confirmSuperUser(user);
List<Map<String, Object>> applications = applicationRepository.findAllLightByOrganization(organizationId);
return ResponseEntity.ok(applications);
}
@GetMapping({"/{applicationId}"})
@SuppressWarnings("unchecked")
public ResponseEntity<Application> find(User user, @PathVariable("applicationId") Long applicationId) {
LOG.debug("/find application for " + user.getEmail());
Application application = applicationRepository.findDetailsById(applicationId)
.orElseThrow(() -> new NotFoundException("Application not found"));
user = reinitializeUser(user, userRepository);
confirmApplicationWriteAccess(user, application);
AtomicReference<Map<String, Object>> latestChangedProvider = new AtomicReference<>();
AtomicReference<Instant> latestRevision = new AtomicReference<>();
application.getConnections().stream()
.filter(connection -> StringUtils.hasText(connection.getManageIdentifier()))
.forEach(connection -> {
Map<String, Object> provider = manage.providerByConnection(connection);
if (connection.mergeMetaData(provider, false)) {
Map<String, Object> revision = (Map<String, Object>) provider.get("revision");
Instant revisionCreated = Instant.parse(revision.get("created").toString());
if (latestRevision.get() == null || revisionCreated.isAfter(latestRevision.get())) {
latestRevision.set(revisionCreated);
latestChangedProvider.set(provider);
}
connectionRepository.save(connection);
}
if (connection.getStatus().equals(ConnectionStatus.PROD_READY)) {
connection.convertChangeRequests(manage.getChangeRequests(connection));
}
});
Map<String, Object> provider = latestChangedProvider.get();
if (provider != null) {
//Take the last updated provider and merge / save the application metaData
HashMap<String, Object> updatedMetaData = connectionProviderConverter.convertProviderToApplicationMetaData(provider);
application.setMetaData(updatedMetaData);
Map<String, Object> metaDataFields = getMetaDataFields(getData(provider));
application.setLogoUrl((String) metaDataFields.getOrDefault("logo:0:url", application.getLogoUrl()));
application.setName((String) metaDataFields.getOrDefault("coin:application_name", application.getName()));
applicationRepository.save(application);
}
ManageData.removeSecrets(application);
return ResponseEntity.ok(application);
}
@PostMapping({"", "/"})
public ResponseEntity<Application> create(User user, @Validated @RequestBody Application application) {
LOG.debug("/create application by " + user.getEmail());
Organization organization = application.getOrganization();
confirmOrganizationMembership(user, organization, Authority.MEMBER);
application.setCreatedAt(Instant.now());
application.setCreatedBy(user.getName());
application.setOwner(user);
Application applicationSaved = applicationRepository.save(application);
Optional<OrganizationMembership> optionalOrganizationMembership = user.getOrganizationMemberships().stream()
.filter(organizationMembership -> organizationMembership.getOrganization().getId().equals(organization.getId()))
.findFirst();
//Super User's may not have organization memberships
optionalOrganizationMembership.ifPresent(organizationMembership -> {
ApplicationMembership applicationMembership =
new ApplicationMembership(applicationSaved, organizationMembership);
applicationMembershipRepository.save(applicationMembership);
});
return ResponseEntity.status(HttpStatus.CREATED).body(applicationSaved);
}
@PutMapping({"", "/"})
public ResponseEntity<Application> update(User user, @Validated @RequestBody Application applicationData) {
LOG.debug("/update application by " + user.getEmail());
Application application = applicationRepository.findById(applicationData.getId())
.orElseThrow(() -> new NotFoundException("Application not found"));
user = this.reinitializeUser(user, userRepository);
confirmApplicationWriteAccess(user, application);
// Only admins can sign contracts
if (!application.isSignedContract() && applicationData.isSignedContract() &&
!user.isSuperUser() &&
getOrganizationMembership(user, application.getOrganization(), Authority.ADMIN).isEmpty()) {
throw new UserRestrictionException(
String.format("User %s is not allowed to sign contract for application %s",
user.getEmail(), application.getName()));
}
//If the metadata has changed, we must propagate this to manage
boolean metaDataHasChanged = !application.getMetaData().equals(applicationData.getMetaData());
//However, we first need to merge the data; otherwise the outdated application metadata is used
application.merge(applicationData);
//Upload base64 encoded image to s3 storage if the logo has changed
String logoUrl = application.getLogoUrl();
if (StringUtils.hasText(logoUrl) && !logoUrl.startsWith("http")) {
String url = s3Storage.uploadFile(logoUrl);
application.setLogoUrl(url);
metaDataHasChanged = true;
}
if (metaDataHasChanged) {
application.getConnections()
.stream()
.filter(connection -> StringUtils.hasText(connection.getManageIdentifier()))
.forEach(connection -> {
Map<String, Object> provider = manage.saveProvider(connection);
connection.updateRemoteManageData(provider);
connectionRepository.save(connection);
});
} else {
Hibernate.initialize(application.getConnections());
}
applicationRepository.save(application);
return ResponseEntity.status(HttpStatus.CREATED).body(application);
}
@DeleteMapping({"", "/{applicationId}"})
public ResponseEntity<Map<String, Object>> delete(User user, @PathVariable("applicationId") Long applicationId) {
LOG.debug("/delete application by " + user.getEmail());
Application application = applicationRepository.findById(applicationId)
.orElseThrow(() -> new NotFoundException("Application not found"));
Organization organization = application.getOrganization();
user = this.reinitializeUser(user, userRepository);
confirmApplicationDeleteAccess(user, application);
//To prevent org.hibernate.TransientObjectException: persistent instance references an unsaved transient
organization.removeApplication(application);
user.getOrganizationMemberships().forEach(organizationMembership -> {
List<ApplicationMembership> applicationMemberships = organizationMembership.getApplicationMemberships()
.stream()
.filter(applicationMembership -> applicationMembership.getApplication().getId().equals(applicationId)).toList();
organizationMembership.removeApplicationMemberships(applicationMemberships);
});
application.getConnections()
.stream()
.filter(connection -> StringUtils.hasText(connection.getManageIdentifier()))
.forEach(connection -> manage.deleteProvider(connection));
applicationRepository.deleteById(application.getId());
return deleteResult();
}
@GetMapping("/identity-providers-allowed-connections/{applicationId}")
public ResponseEntity<List<Map<String, Object>>> identityProvidersByAllowedConnections(User user,
@PathVariable Long applicationId) {
LOG.debug("/identityProvidersByAllowedConnections by: " + user.getEmail());
Application application = applicationRepository.findById(applicationId)
.orElseThrow(() -> new NotFoundException("Application not found"));
user = reinitializeUser(user, userRepository);
confirmApplicationDeleteAccess(user, application);
List<Connection> connections = new ArrayList<>(application.getConnections());
List<Map<String, Object>> identityProviders = manage.identityProvidersByAllowedConnections(connections);
return ResponseEntity.ok(identityProviders);
}
@PutMapping({"/migrate"})
@Transactional
public ResponseEntity<Map<String, Object>> migrate(User user, @Validated @RequestBody MigrateApplicationRequest migrateApplicationRequest) {
LOG.debug("/migrate application by " + user.getEmail());
confirmSuperUser(user);
Application application = applicationRepository.findById(migrateApplicationRequest.getApplicationId())
.orElseThrow(() -> new NotFoundException("Application not found"));
Organization newOrganization = organizationRepository.findById(migrateApplicationRequest.getNewOrganizationId())
.orElseThrow(() -> new NotFoundException("Organization not found"));
application.setOrganization(newOrganization);
applicationRepository.save(application);
AtomicReference<String> institutionGuid = new AtomicReference<>();
if (StringUtils.hasText(newOrganization.getManageIdentifier())) {
Map<String, Object> identityProvider = manage.providerByManageIdentifier(EntityType.saml20_idp, newOrganization.getManageIdentifier());
Map<String, Object> metaDataFields = getMetaDataFields(getData(identityProvider));
institutionGuid.set((String) metaDataFields.get(INSTITUTION_GUID));
}
application.getConnections()
.stream()
.filter(connection -> !isEmpty(connection.getManageIdentifier()))
.forEach(connection -> {
Map<String, Object> provider = manage.providerByConnection(connection);
Map<String, Object> metaDataFields = getMetaDataFields(getData(provider));
metaDataFields.put("OrganizationName:en", newOrganization.getName());
metaDataFields.put("OrganizationName:nl", newOrganization.getName());
//The connections need to be linked to the new identityProvider - if the new Organization is a known IdP in Manage
if (StringUtils.hasText(institutionGuid.get())) {
metaDataFields.put(INSTITUTION_GUID, institutionGuid.get());
} else {
metaDataFields.remove(INSTITUTION_GUID);
}
Map<String, Object> updatedProvider = manage.updateProvider(provider);
Integer version = (Integer) updatedProvider.get("version");
connection.setManageVersion(version);
connectionRepository.save(connection);
});
return Results.okResult();
}
@PostMapping({"/import"})
public ResponseEntity<Map<String, Object>> importEntity(User user,
@Validated @RequestBody ImportEntityRequest importEntityRequest) {
LOG.debug("/import entity by " + user.getEmail());
confirmSuperUser(user);
Organization organization = organizationRepository.findById(importEntityRequest.getOrganizationId())
.orElseThrow(() -> new NotFoundException("Organization not found"));
Map<String, Object> serviceProvider = importEntityRequest.getServiceProvider();
Application application = importEntityRequest.getApplicationId() != null ?
applicationRepository.findById(importEntityRequest.getApplicationId())
.orElseThrow(() -> new NotFoundException("Application not found")) :
createApplicationFromProvider(user, organization, serviceProvider);
Map<String, Object> data = getData(serviceProvider);
Map<String, Object> metaDataFields = getMetaDataFields(data);
//As this is an import, we need to deduce the profile / motivation
Map<String, Object> arp = (Map<String, Object>) data.get("arp");
String profile = arpProfileName(arp);
arp.put("profile", profile);
serviceProvider = manage.updateProvider(serviceProvider);
Connection connection = new Connection(
(String) metaDataFields.get("name:en"),
application,
new HashMap<>(),// We will fill the metadata later
EntityType.valueOf((String) serviceProvider.get("type"))
);
connection.setSecretSet(true);
connection.setStatus(ConnectionStatus.PENDING_PROD);
connection.updateRemoteManageData(serviceProvider);
connection.mergeMetaData(serviceProvider, true);
Connection savedConnection = connectionRepository.save(connection);
Map<String, Object> body = Map.of(
"status", HttpStatus.OK.value(),
"connectionId", savedConnection.getId(),
"applicationId", application.getId()
);
return ResponseEntity.status(HttpStatus.OK).body(body);
}
private Application createApplicationFromProvider(User user, Organization organization, Map<String, Object> serviceProvider) {
Map<String, Object> data = getData(serviceProvider);
Map<String, Object> metaDataFields = getMetaDataFields(data);
HashMap<String, Object> metaData = this.connectionProviderConverter.convertProviderToApplicationMetaData(serviceProvider);
String name = (String) metaDataFields.getOrDefault("coin:application_name", (String) metaDataFields.get("name:en"));
Application application = new Application(
name,
organization,
"System",
new HashMap<>()
);
application.setMetaData(metaData);
String logoUrl = (String) metaDataFields.get("logo:0:url");
application.setLogoUrl(logoUrl);
application.setCreatedBy(user.getName());
application.setStatus(ApplicationStatus.COMPLETE);
application.setSignedContract(true);
return applicationRepository.save(application);
}
private String arpProfileName(Map<String, Object> arp) {
Set<String> attributeNames = ((Map<String, Object>) arp.get("attributes")).keySet();
List<Map<String, Object>> attributes = (List<Map<String, Object>>) this.arpInfo.get("attributes");
Map<String, String> attributesMap = attributes.stream().collect(Collectors.toMap(
attr -> (String) attr.get("name"), attr -> (String) attr.get("urn")
));
List<Map<String, Object>> profiles = (List<Map<String, Object>>) this.arpInfo.get("profiles");
return profiles.stream().filter(profile -> {
List<String> profileAttributes = (List<String>) profile.get("attributes");
List<String> optionalAttributes = (List<String>) profile.getOrDefault("optionalAttributes", List.of());
List<String> allAttributes = new ArrayList<>(profileAttributes);
allAttributes.addAll(optionalAttributes);
Set<String> urns = allAttributes.stream()
.map(attr -> attributesMap.get(attr))
.filter(Objects::nonNull)
.collect(Collectors.toSet());
return urns.containsAll(attributeNames);
}).findFirst()
.map(profile -> (String) profile.get("name"))
.orElse("personalized");
}
}