ConnectionController.java
package access.api;
import access.exception.InvalidInputException;
import access.exception.NotFoundException;
import access.jira.JiraClient;
import access.jira.JiraIssue;
import access.manage.ChangeRequest;
import access.manage.ConnectionProviderConverter;
import access.manage.ListMerger;
import access.manage.Manage;
import access.manage.PathUpdateType;
import access.manage.RequestType;
import access.model.Application;
import access.model.Authority;
import access.model.Connection;
import access.model.ConnectionStatus;
import access.model.EntityType;
import access.model.Organization;
import access.model.User;
import access.repository.ApplicationRepository;
import access.repository.ConnectionRepository;
import access.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.SneakyThrows;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.passay.data.EnglishCharacterData;
import org.passay.generate.PasswordGenerator;
import org.passay.rule.CharacterRule;
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.CollectionUtils;
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.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
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.ManageData.*;
@RestController
@RequestMapping(value = {"/api/v1/connections"}, produces = MediaType.APPLICATION_JSON_VALUE)
@Transactional
@SecurityRequirement(name = OPEN_ID_SCHEME_NAME, scopes = {"openid"})
@SecurityRequirement(name = API_TOKENS_SCHEME_NAME)
public class ConnectionController implements UserAccessRights {
public static final int SECRET_LENGTH = 36;
private static final Log LOG = LogFactory.getLog(ConnectionController.class);
private final ConnectionRepository connectionRepository;
private final ApplicationRepository applicationRepository;
private final UserRepository userRepository;
private final Manage manage;
private final JiraClient jiraClient;
private final List<CharacterRule> passwordGeneratorRules = initPasswordGeneratorRules();
private final PasswordGenerator passwordGenerator = new PasswordGenerator(SECRET_LENGTH, passwordGeneratorRules);
private final ConnectionProviderConverter connectionProviderConverter;
private final ObjectMapper objectMapper;
public ConnectionController(ConnectionRepository connectionRepository,
ApplicationRepository applicationRepository,
UserRepository userRepository,
Manage manage,
JiraClient jiraClient,
ConnectionProviderConverter connectionProviderConverter,
ObjectMapper objectMapper) {
this.connectionRepository = connectionRepository;
this.applicationRepository = applicationRepository;
this.userRepository = userRepository;
this.manage = manage;
this.jiraClient = jiraClient;
this.connectionProviderConverter = connectionProviderConverter;
this.objectMapper = objectMapper;
}
private List<CharacterRule> initPasswordGeneratorRules() {
return List.of(
new CharacterRule(EnglishCharacterData.LowerCase, 8),
new CharacterRule(EnglishCharacterData.UpperCase, 8),
new CharacterRule(EnglishCharacterData.Digit, 8));
}
@GetMapping({"/{connectionId}"})
public ResponseEntity<Connection> find(User user, @PathVariable("connectionId") Long connectionId) {
LOG.debug("/find connection for " + user.getEmail());
Connection connection = connectionRepository.findById(connectionId)
.orElseThrow(() -> new NotFoundException("Connection not found"));
Application application = connection.getApplication();
user = reinitializeUser(user, userRepository);
confirmApplicationWriteAccess(user, application, Authority.GUEST);
if (StringUtils.hasText(connection.getManageIdentifier())) {
Map<String, Object> provider = manage.providerByConnection(connection);
if (connection.mergeMetaData(provider, false)) {
connectionRepository.save(connection);
}
if (connection.getStatus().equals(ConnectionStatus.PROD_READY)) {
connection.convertChangeRequests(manage.getChangeRequests(connection));
}
}
return ResponseEntity.ok(connection);
}
@GetMapping({"/{manageType}/{manageIdentifier}"})
public ResponseEntity<?> findByManage(User user,
@PathVariable("manageType") EntityType entityType,
@PathVariable String manageIdentifier) {
LOG.debug("/find findByManage for " + user.getEmail());
return connectionRepository.findByProtocolAndManageIdentifier(entityType, manageIdentifier)
.map(connection -> {
Application application = connection.getApplication();
User userFromDB = reinitializeUser(user, userRepository);
confirmApplicationWriteAccess(userFromDB, application, Authority.GUEST);
Organization organization = application.getOrganization();
return ResponseEntity.ok(Map.of(
"connection", connection,
"application", application,
"organisation", organization
));
})
.orElse(ResponseEntity.notFound().build());
}
@PostMapping({"", "/"})
public ResponseEntity<Connection> create(User user, @Validated @RequestBody Connection connection) {
LOG.debug("/create connection by " + user.getEmail());
if (!connection.isValid()) {
throw new InvalidInputException("Connection is not valid");
}
Long applicationID = connection.getApplication().getId();
Application application = applicationRepository.findById(applicationID)
.orElseThrow(() -> new NotFoundException("Application not found"));
user = this.reinitializeUser(user, userRepository);
confirmApplicationWriteAccess(user, application);
connection.setCreatedAt(Instant.now());
connection.setApplication(application);
connection = saveConnection(connection);
return ResponseEntity.status(HttpStatus.CREATED).body(connection);
}
@PutMapping({"", "/"})
public ResponseEntity<Connection> update(User user, @Validated @RequestBody Connection connectionData) {
LOG.debug("/update connection by " + user.getEmail());
Connection connection = doUpdateConnection(user, connectionData);
return ResponseEntity.status(HttpStatus.CREATED).body(connection);
}
@PutMapping("/update-request-production-status")
public ResponseEntity<Map<String, Object>> updateWithProductionReadyRequest(User user, @Validated @RequestBody Connection connectionData) {
LOG.debug("/update connection by " + user.getEmail());
Connection connection = doUpdateConnection(user, connectionData);
String jiraKey = this.doRequestProductionStatus(user, connection);
Map<String, Object> body = Map.of("connection", connection, "jiraKey", jiraKey);
return ResponseEntity.status(HttpStatus.CREATED).body(body);
}
private Connection doUpdateConnection(User user, Connection connectionData) {
if (!connectionData.isValid()) {
throw new InvalidInputException("Connection is not valid");
}
Connection connection = findConnectionForAuthorizedUser(user, connectionData.getId());
connection.merge(connectionData);
if (connection.changeRequestRequired()) {
//Not allowed to sync the connection to Manage. Create or update outstanding ChangeRequest
connection = this.productionReadyChangeRequests(connection, user);
connection.convertChangeRequests(manage.getChangeRequests(connection));
} else {
connection = saveConnection(connection);
}
return connection;
}
@SneakyThrows
@GetMapping(value = "/change-requests/{connectionId}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<Map<String, Object>>> changeRequests(User user, @PathVariable("connectionId") Long connectionId) {
Connection connection = findConnectionForAuthorizedUser(user, connectionId);
List<Map<String, Object>> changeRequests = manage.getChangeRequests(connection);
return ResponseEntity.ok(changeRequests);
}
@PutMapping(value = "/reset-secret/{connectionId}", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, String> secret(User user, @PathVariable("connectionId") Long connectionId) {
Connection connection = findConnectionForAuthorizedUser(user, connectionId);
String secret = passwordGenerator.generate().toString();
connection.getMetaData().put("secret", secret);
connection.setSecretSet(false);
saveConnection(connection);
return Collections.singletonMap("secret", secret);
}
@PutMapping(value = "/request-production-status/{connectionId}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> requestProductionStatus(User user,
@PathVariable("connectionId") Long connectionId) {
Connection connection = findConnectionForAuthorizedUser(user, connectionId);
String jiraKey = doRequestProductionStatus(user, connection);
return ResponseEntity.status(HttpStatus.CREATED).body(
Map.of("status", HttpStatus.CREATED.value(), "jiraKey", jiraKey));
}
private String doRequestProductionStatus(User user, Connection connection) {
String changeRequestURL = manage.changeRequestURL(connection);
Map<String, Object> provider = manage.providerByConnection(connection);
String entityId = (String) ((Map) provider.get("data")).get("entityid");
String lineSeparator = System.lineSeparator();
String summary = String.format("Production status requested by %s for %s.",
user.getName(), connection.getName());
String jiraKey = jiraClient.create(new JiraIssue(
entityId,
null,// There is no identity provider for requesting production status
String.format("%s A change request in manage has been created to merge this user request. See:%s%s",
summary,
lineSeparator,
changeRequestURL),
summary,
connection.getProtocol(),
user.getEmail()
));
Map<String, Object> auditData = Map.of("user", user.getEmail(),
"notes", String.format("Production status requested by %s for %s. See Jira %s",
user.getName(), connection.getName(), jiraKey));
ChangeRequest changeRequest = new ChangeRequest(
connection.getManageIdentifier(),
connection.getProtocol(),
Map.of("state", "prodaccepted"),
false,
PathUpdateType.ADDITION,
RequestType.ProductionStatusRequest);
changeRequest.setTicketKey(jiraKey);
changeRequest.setAuditData(auditData);
Map<String, Object> changeRequestResponse = manage.createChangeRequest(changeRequest);
LOG.debug("Change request response from manage: " + changeRequestResponse);
connection.setStatus(ConnectionStatus.PENDING_PROD);
saveConnection(connection);
return jiraKey;
}
@DeleteMapping({"", "/{connectionId}"})
public ResponseEntity<Map<String, Object>> delete(User user, @PathVariable("connectionId") Long connectionId) {
LOG.debug("/delete connection by " + user.getEmail());
Connection connection = findConnectionForAuthorizedUser(user, connectionId);
//To prevent org.hibernate.TransientObjectException: persistent instance references an unsaved transient
Application application = connection.getApplication();
application.removeConnection(connection);
if (StringUtils.hasText(connection.getManageIdentifier())) {
manage.deleteProvider(connection);
}
connectionRepository.deleteConnectionById(connectionId);
return deleteResult();
}
@GetMapping("/identity-providers-allowed-connections/{connectionId}")
public ResponseEntity<List<Map<String, Object>>> identityProvidersByAllowedConnections(User user,
@PathVariable("connectionId") Long connectionId) {
LOG.debug("/identityProvidersByAllowedConnections by: " + user.getEmail());
Connection connection = connectionRepository.findById(connectionId)
.orElseThrow(() -> new NotFoundException("Connection not found"));
user = reinitializeUser(user, userRepository);
confirmApplicationWriteAccess(user, connection.getApplication(), Authority.GUEST);
List<Map<String, Object>> identityProviders = manage.identityProvidersByAllowedConnections(List.of(connection));
return ResponseEntity.ok(identityProviders);
}
private boolean isNewChangeRequestDuplicate(List<Map<String, Object>> existingChangeRequests,
Optional<ChangeRequest> newChangeRequest) {
if (isEmpty(existingChangeRequests)) {
return false;
}
return newChangeRequest
.map(changeRequest ->
existingChangeRequests.stream().anyMatch(changeRequestMap -> changeRequest.matches(changeRequestMap)))
.orElse(false);
}
@SuppressWarnings("unchecked")
private Connection productionReadyChangeRequests(Connection connection, User user) {
String changeRequestURL = manage.changeRequestURL(connection);
Map<String, Object> provider = manage.providerByConnection(connection);
connection.updateRemoteManageData(provider);
List<Map<String, Object>> existingChangeRequests = manage.getChangeRequests(connection);
Optional<ChangeRequest> changeRequestOptional = connectionProviderConverter.deduceChangeRequests(connection, provider);
boolean isDuplicate = isNewChangeRequestDuplicate(existingChangeRequests, changeRequestOptional);
if (changeRequestOptional.isPresent() && !isDuplicate) {
if (existingChangeRequests.isEmpty()) {
//No existing change requests, proceed as normal and create a ticket
String entityId = (String) ((Map) provider.get("data")).get("entityid");
String summary = String.format("Data change requested by %s for %s with entityID %s",
user.getName(),
connection.getName(),
entityId);
String jiraKey = jiraClient.create(new JiraIssue(
entityId,
null,// There is no identity provider for change requests
String.format("%s A change request in manage has been created to merge this user request. See:%s%s",
summary,
System.lineSeparator(),
changeRequestURL),
summary,
connection.getProtocol(),
user.getEmail()
));
Map<String, Object> auditData = Map.of("user", user.getEmail(),
"notes", String.format("Data change requested by %s for %s. See Jira %s",
user.getName(),
connection.getName(),
jiraKey));
ChangeRequest changeRequest = changeRequestOptional.get();
changeRequest.setTicketKey(jiraKey);
changeRequest.setAuditData(auditData);
manage.createChangeRequest(changeRequest);
} else {
//Now we need to ensure that previous change requests, with the same pathUpdate and value a List, does not overwrite changes
//And therefore we don't create a new change request, but update the existing one
Map<String, Object> existingChangeRequest = existingChangeRequests.getFirst();
ChangeRequest newChangeRequest = changeRequestOptional.get();
Map<String, Object> existingPathUpdates = (Map<String, Object>) existingChangeRequest.get("pathUpdates");
Map<String, Object> newPathUpdates = newChangeRequest.getPathUpdates();
newPathUpdates.forEach((key, value) -> {
if (key.equals("arp") && existingPathUpdates.containsKey(key)) {
//three way merge on the attributes and profile, motivation from the latest change
Map<String, Object> attributes = (Map<String, Object>) ((Map<String, Object>) value).getOrDefault("attributes", Map.of());
List<String> attibuteNames = attributes.keySet().stream().toList();
Map<String, Object> arpPath = (Map<String, Object>) existingPathUpdates.getOrDefault("arp", Map.of());
Map<String, Object> pathAttributes = (Map<String, Object>) arpPath.getOrDefault("attributes", Map.of());
List<String> pathValues = pathAttributes.keySet().stream().toList();
Map<String, Object> baseArp = (Map<String, Object>) getData(provider).getOrDefault("arp", Map.of());
Map<String, Object> baseAttributes = (Map<String, Object>) baseArp.getOrDefault("attributes", Map.of());
List<String> baseValues = baseAttributes.keySet().stream().toList();
List<String> newValues = ListMerger.threeWayMerge(baseValues, pathValues, attibuteNames);
//Now we need to construct a new attributes Map with all the values from the three attributes Map
Map<String, Object> newAttributes = newValues.stream().collect(Collectors.toMap(
attrName -> attrName,
attrName -> attributes.getOrDefault(attrName, pathAttributes.getOrDefault(attrName, baseAttributes.get(attrName)))));
arpPath.put("attributes", newAttributes);
} else if (value instanceof List && existingPathUpdates.containsKey(key)) {
//three way merge
List<String> pathUpdateValue = (List<String>) existingPathUpdates.get(key);
List<String> base = (List<String>) getMetaDataFields(getData(provider)).getOrDefault(key.substring(key.indexOf(".") + 1), List.of());
List<String> newValues = ListMerger.threeWayMerge(base, pathUpdateValue, (List<String>) value);
existingPathUpdates.put(key, newValues);
} else {
//simply override
existingPathUpdates.put(key, value);
}
});
ChangeRequest changeRequest = objectMapper.convertValue(existingChangeRequest, ChangeRequest.class);
manage.updateChangeRequest(changeRequest);
}
}
//Now the tricky bit, we must fetch the changeRequest after they are created and return the data based on the provider
connection.mergeMetaData(provider, true);
connection = connectionRepository.save(connection);
connection.convertChangeRequests(manage.getChangeRequests(connection));
return connection;
}
@SuppressWarnings("unchecked")
private Connection saveConnection(Connection connection) {
//Put / Post to Manage only if the status is not OPEN
if (!connection.getStatus().equals(ConnectionStatus.OPEN)) {
boolean isPrivateRelyingParty = connection.getProtocol().equals(EntityType.oidc10_rp) &&
connection.getMetaData().getOrDefault("pkce", false) == Boolean.FALSE;
boolean secretNotSet = !connection.isSecretSet();
if (isPrivateRelyingParty && secretNotSet) {
//generate secret if it is not already set, but store the raw-text variant, because Manage encodes it
String secret = (String) connection.getMetaData().getOrDefault("secret", passwordGenerator.generate().toString());
connection.getMetaData().put("secret", secret);
connection.setSecretSet(true);
}
//Now sync the Connection to Manage.
Map<String, Object> provider = manage.saveProvider(connection);
connection.updateRemoteManageData(provider);
if (isPrivateRelyingParty && secretNotSet) {
//We must store the encrypted secret, otherwise manage will keep encrypting it again and again
Map<String, Object> data = getData(provider);
Map<String, Object> metaDataFields = getMetaDataFields(data);
String secretFromManage = (String) metaDataFields.get("secret");
if (StringUtils.hasText(secretFromManage) && secretFromManage.length() != SECRET_LENGTH) {
String originalSecret = (String) connection.getMetaData().get("secret");
connection.getMetaData().put("secret", secretFromManage);
//To display in the client
if (originalSecret.length() == SECRET_LENGTH) {
connection.getMetaData().put("originalSecret", originalSecret);
}
}
}
List<Map<String, Object>> contactPersons = (List<Map<String, Object>>) connection.getMetaData().get("contactPersons");
if (!CollectionUtils.isEmpty(contactPersons)) {
Application application = connection.getApplication();
application.getMetaData().put("contactPersons", contactPersons);
applicationRepository.save(application);
//No need to store redundant data
connection.getMetaData().remove("contactPersons");
}
}
return connectionRepository.save(connection);
}
private Connection findConnectionForAuthorizedUser(User user, Long connectionId) {
Connection connection = connectionRepository.findById(connectionId)
.orElseThrow(() -> new NotFoundException("Connection not found"));
Application application = connection.getApplication();
user = this.reinitializeUser(user, userRepository);
confirmApplicationWriteAccess(user, application);
return connection;
}
}