Connection.java

package access.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Transient;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.Type;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;

import static access.manage.ManageData.*;

@Entity(name = "connections")
@NoArgsConstructor
@Getter
@Setter
@SuppressWarnings("unchecked")
public class Connection implements NameHolder {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    @NotNull
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "application_id")
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    private Application application;

    @Type(JsonType.class)
    @Column(name = "meta_data", columnDefinition = "jsonb")
    private HashMap<String, Object> metaData = new HashMap<>();

    @Enumerated(EnumType.STRING)
    @Column
    @NotNull
    private EntityType protocol = EntityType.oidc10_rp;

    @Enumerated(EnumType.STRING)
    @Column
    @NotNull
    private State state = State.testaccepted;

    @Enumerated(EnumType.STRING)
    @Column
    @NotNull
    private ConnectionStatus status = ConnectionStatus.OPEN;

    @Column(name = "secret_set")
    private boolean secretSet;

    @Column(name = "manage_identifier")
    private String manageIdentifier;

    @Column(name = "sections_complete")
    private int sectionsComplete;

    @Column(name = "manage_version")
    private Integer manageVersion;

    @Column(name = "manage_eid")
    private Integer manageEid;

    @Column(name = "created_at")
    private Instant createdAt;

    @Column(name = "updated_at")
    private Instant updatedAt;

    @Transient
    private List<Map<String, Object>> changeRequests = new ArrayList<>();

    public Connection(String name, Application application, Map<String, Object> metaData, EntityType protocol) {
        this.name = name;
        this.application = application;
        this.metaData = new HashMap<>(metaData);
        this.protocol = protocol;
        this.createdAt = Instant.now();
        this.updatedAt = Instant.now();
        this.manageVersion = 0;
    }

    @JsonIgnore
    public boolean isValid() {
        if (!StringUtils.hasText(name)) {
            return false;
        }
        String entityID = (String) metaData.get("entityID");
        if (!StringUtils.hasText(entityID) && EntityType.saml20_sp.equals(this.protocol)) {
            return false;
        }
        if (this.protocol.equals(EntityType.oidc10_rp) &&
                (CollectionUtils.isEmpty((Collection<?>) metaData.get("redirectUrls")) ||
                        CollectionUtils.isEmpty((Collection<?>) metaData.get("grantTypes")))) {
            return false;
        }
        if (this.protocol.equals(EntityType.saml20_sp) &&
                CollectionUtils.isEmpty((Collection<?>) metaData.get("acsLocations"))) {
            return false;
        }
        return true;
    }

    public void merge(Connection connectionData) {
        this.name = connectionData.name;
        this.metaData = connectionData.metaData;
        this.protocol = connectionData.protocol;
        this.status = connectionData.status;
        this.sectionsComplete = connectionData.sectionsComplete;
    }


    @JsonIgnore
    public boolean mergeMetaData(Map<String, Object> provider, boolean force) {
        // For new Connections
        boolean changed = true;
        Integer newManageVersion = (Integer) provider.get("version");
        if (newManageVersion.equals(this.manageVersion) && !force) {
            //Two-way synchronization and optimistic locking
            return false;
        }
        this.manageIdentifier = (String) provider.get("id");
        this.manageVersion = newManageVersion;
        this.metaData = new HashMap<>();

        Map<String, Object> data = getData(provider);
        this.manageEid = (Integer) data.get("eid");

        String entityID = (String) data.get("entityid");
        this.metaData.put("entityID", entityID);
        this.metaData.put("allowedall", data.get("allowedall"));
        List<Map<String, String>> allowedEntities = (List<Map<String, String>>) data.getOrDefault("allowedEntities", List.of());
        List<String> allowedEntitiesMapped = allowedEntities.stream().map(m -> m.get("name")).toList();
        this.metaData.put("allowedEntities", allowedEntitiesMapped);
        Map<String, Object> arp = (Map<String, Object>) data.get("arp");
        this.metaData.put("arp", arp);

        Map<String, Object> metaDataFields = getMetaDataFields(data);
        this.name = (String) metaDataFields.getOrDefault("name:en", this.name);
        this.metaData.put("name", name);
        this.metaData.put("pkce", metaDataFields.get("isPublicClient"));
        this.metaData.put("grantTypes", metaDataFields.get("grants"));
        this.metaData.put("refreshTokenValidity", metaDataFields.get("refreshTokenValidity"));
        this.metaData.put("claimsInIdToken", metaDataFields.get("oidc:claims_in_id_token"));
        this.metaData.put("secret", metaDataFields.get("secret"));
        this.metaData.put("redirectUrls", metaDataFields.get("redirectUrls"));
        List<String> acsLocations = new ArrayList<>();
        IntStream.of(0, 1, 2, 3, 4, 5).forEach(index -> {
            if (metaDataFields.containsKey("AssertionConsumerService:" + index + ":Location")) {
                acsLocations.add((String) metaDataFields.get("AssertionConsumerService:" + index + ":Location"));
            }
        });
        this.metaData.put("acsLocations", acsLocations);
        this.metaData.put("logoUrl", metaDataFields.get("logo:0:url"));
        boolean ssHidden = (boolean) metaDataFields.getOrDefault("coin:ss:hidden", false);
        boolean idpVisibleOnly = (boolean) metaDataFields.getOrDefault("coin:ss:idp_visible_only", false);
        String visibility = ssHidden ? Visibility.visible_to_none.name() : idpVisibleOnly ?
                Visibility.visible_to_idp_only.name() : Visibility.visible_to_all.name();
        this.metaData.put("visibility", visibility);
        this.metaData.put("loginUrl", metaDataFields.get("coin:login_url"));
        String connectOption = (String) metaDataFields
                .getOrDefault("coin:dashboard_connect_option", ConnectOptions.connect_with_interaction.name());
        this.metaData.put("connectOption", connectOption);
        /*
         * Business logic. If a status for a production connection is pending production and the state has changed
         * to prodaccepted, then we set the status to production ready
         */
        this.state = State.valueOf((String) data.getOrDefault("state", "testaccepted"));
        if (ConnectionStatus.PENDING_PROD.equals(this.status) &&
                this.state.equals(State.prodaccepted)) {
            this.status = ConnectionStatus.PROD_READY;
        }
        return changed;
    }

    @JsonIgnore
    public void updateRemoteManageData(Map<String, Object> provider) {
        this.manageIdentifier = (String) provider.get("id");
        this.manageVersion = (Integer) provider.get("version");
        Map<String, Object> data = (Map<String, Object>) provider.get("data");
        this.manageEid = (Integer) data.get("eid");
        this.state = State.valueOf((String) data.getOrDefault("state", "testaccepted"));
    }

    @JsonIgnore
    public boolean changeRequestRequired() {
        return this.state.equals(State.prodaccepted) && StringUtils.hasText(this.manageIdentifier);
    }

    @PreUpdate
    public void onPreUpdate() {
        this.updatedAt = Instant.now();
    }

    @JsonProperty(access = JsonProperty.Access.READ_ONLY)
    public List<Map<String, Object>> getChangeRequests() {
        return this.changeRequests;
    }

    public void convertChangeRequests(List<Map<String, Object>> changeRequests) {
        //We need to convert the changeRequests to the same data as the metaData of a Connection
        this.changeRequests = changeRequests.stream()
                .map(changeRequest -> {
                    Map<String, Object> pathUpdates = (Map<String, Object>) changeRequest.get("pathUpdates");
                    Map<String, Object> provider = new HashMap<>();
                    //To prevent NullPointerException
                    provider.put("version", -1);

                    Map<String, Object> data = new HashMap<>();
                    provider.put("data", data);

                    Map<String, Object> metaDataFields = new HashMap<>();
                    data.put("metaDataFields", metaDataFields);

                    pathUpdates.forEach((key, value) -> {
                        if (key.startsWith("metaDataFields")) {
                            //See src/test/resources/manage/change_request_large.json
                            key = key.substring("metaDataFields.".length());
                            metaDataFields.put(key, value);
                        } else {
                            //For pathUpdates to the ARP
                            data.put(key, value);
                        }
                    });
                    Connection connection = new Connection();
                    connection.mergeMetaData(provider, true);
                    Map<String, Object> connectionMetaData = connection.getMetaData();
                    //Now clean up all keys where the value is empty
                    connectionMetaData.entrySet().removeIf(entry -> isEmpty(entry.getValue()));
                    //Bugfix for lazy initialization of boolean values
                    Map.of(
                                    "visibility", List.of("coin:ss:idp_visible_only", "coin:ss:hidden"),
                                    "pkce", List.of("isPublicClient"),
                                    "oidc:claims_in_id_token", List.of("claimsInIdToken"),
                                    "connectOption", List.of("coin:dashboard_connect_option"))
                            .forEach((connectionKey, manageValues) -> {
                                if (manageValues.stream().noneMatch(val -> metaDataFields.containsKey(val))) {
                                    connectionMetaData.remove(connectionKey);
                                }
                            });
                    //copy some of the auditData for display purposes and revoke functionality
                    List.of("id", "metaDataId", "type", "auditData", "created", "ticketKey")
                            .forEach(attr -> connectionMetaData.put(attr, changeRequest.get(attr)));
                    return connectionMetaData;
                })
                .toList();
    }

}