IdentityIndexerService.java

/*
 * UVerify Backend
 * Copyright (C) 2025 Fabian Bormann
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU Affero General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU Affero General Public License for more details.
 *
 *  You should have received a copy of the GNU Affero General Public License
 *  along with this program. If not, see <http://www.gnu.org/licenses/>.
 */

package io.uverify.backend.service;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.uverify.backend.entity.UVerifyCertificateEntity;
import io.uverify.backend.entity.UVerifyCredentialEntity;
import io.uverify.backend.repository.CredentialRepository;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@Slf4j
@Service
public class IdentityIndexerService {

    private static final String TYPE_AUTH = "AUTH";
    private static final String TYPE_REVOKE = "REVOKE";
    private static final String IDENTITY_AUTH_TEMPLATE_ID = "IdentityAuth";

    private static final String CACHE_NAME = "vlei-verifier";

    private final CredentialRepository credentialRepository;
    private final ObjectMapper objectMapper;
    private final RestTemplate restTemplate;
    private final CacheManager cacheManager;

    @Value("${credential.vlei-verifier-url:}")
    private String vleiVerifierUrl;

    public IdentityIndexerService(CredentialRepository credentialRepository,
                                  ObjectMapper objectMapper,
                                  CacheManager cacheManager,
                                  @Qualifier("vleiVerifierRestTemplate") RestTemplate restTemplate) {
        this.credentialRepository = credentialRepository;
        this.objectMapper = objectMapper;
        this.cacheManager = cacheManager;
        this.restTemplate = restTemplate;
    }

    @Async
    public void processNewCertificates(List<UVerifyCertificateEntity> certs) {
        for (UVerifyCertificateEntity cert : certs) {
            try {
                processOneCertificate(cert);
            } catch (Exception e) {
                log.warn("Failed to index credential for cert hash={}: {}", cert.getHash(), e.getMessage());
            }
        }
    }

    @Transactional
    public void deleteCredentialsAfterSlot(long slot) {
        credentialRepository.deleteAllAfterSlot(slot);
    }

    public boolean isVleiVerifierConfigured() {
        return vleiVerifierUrl != null && !vleiVerifierUrl.isBlank();
    }

    private void processOneCertificate(UVerifyCertificateEntity cert) throws Exception {
        String extra = cert.getExtra();
        if (extra == null || extra.isBlank()) {
            return;
        }
        Map<String, Object> fields = objectMapper.readValue(extra, new TypeReference<>() {
        });

        if (!IDENTITY_AUTH_TEMPLATE_ID.equals(fields.get("uverify_template_id"))) {
            return;
        }

        String type = (String) fields.get("t");
        if (type == null) {
            return;
        }

        if (TYPE_AUTH.equals(type)) {
            handleAuthCert(cert, fields);
        } else if (TYPE_REVOKE.equals(type)) {
            handleRevokeCert(fields);
        }
    }

    private void handleAuthCert(UVerifyCertificateEntity cert, Map<String, Object> fields) {
        String credentialType = (String) fields.get("ct");
        String aid = (String) fields.get("i");
        String schema = (String) fields.get("s");
        String oobi = (String) fields.get("o");

        if (credentialType == null || credentialType.isBlank()) {
            log.warn("AUTH cert hash={} missing ct field, skipping", cert.getHash());
            return;
        }

        // Direct call (bypasses proxy/cache) — we want a live result at indexing time.
        boolean verified = checkVleiVerifier(aid);
        // Pre-populate the cache so the first GET does not make a second live call.
        Cache cache = cacheManager.getCache(CACHE_NAME);
        if (cache != null && aid != null) {
            cache.put(aid, verified);
        }

        Optional<UVerifyCredentialEntity> existing = credentialRepository.findByAuthCertHash(cert.getHash());
        UVerifyCredentialEntity entity;
        entity = existing.orElseGet(() -> UVerifyCredentialEntity.builder()
                .authCertHash(cert.getHash())
                .paymentCredential(cert.getPaymentCredential())
                .credentialType(credentialType)
                .keriAid(aid)
                .keriSchema(schema)
                .keriOobi(oobi)
                .txHash(cert.getTransactionId())
                .slot(cert.getSlot())
                .build());
        entity.setKeriVerified(verified);
        entity.setLastVerifiedAt(Instant.now());
        credentialRepository.save(entity);

        log.info("Indexed credential type={} for wallet={}, keriVerified={}", credentialType,
                cert.getPaymentCredential(), verified);
    }

    private void handleRevokeCert(Map<String, Object> fields) {
        String targetHash = (String) fields.get("th");
        if (targetHash == null || targetHash.isBlank()) {
            log.warn("REVOKE cert missing th field, skipping");
            return;
        }
        credentialRepository.findByAuthCertHash(targetHash).ifPresentOrElse(
                entity -> {
                    entity.setRevoked(true);
                    credentialRepository.save(entity);
                    // Evict so the next GET re-checks the vLEI Verifier live.
                    Cache cache = cacheManager.getCache(CACHE_NAME);
                    if (cache != null && entity.getKeriAid() != null) {
                        cache.evict(entity.getKeriAid());
                    }
                    log.info("Revoked credential authHash={}", targetHash);
                },
                () -> log.warn("REVOKE target authHash={} not found in credential table", targetHash));
    }

    @Cacheable(value = "vlei-verifier", key = "#aid", condition = "#aid != null && !#aid.isEmpty()")
    public boolean checkVleiVerifier(String aid) {
        if (vleiVerifierUrl == null || vleiVerifierUrl.isBlank() || aid == null || aid.isBlank()) {
            log.debug("vLEI Verifier URL not configured, skipping verification");
            return false;
        }
        try {
            var response = restTemplate.getForEntity(vleiVerifierUrl + "/authorizations/" + aid, Map.class);
            return response.getStatusCode().is2xxSuccessful();
        } catch (Exception e) {
            log.warn("vLEI Verifier check failed for aid={}: {}", aid, e.getMessage());
            return false;
        }
    }
}