diff --git a/certify-core/src/main/java/io/mosip/certify/core/constants/ErrorConstants.java b/certify-core/src/main/java/io/mosip/certify/core/constants/ErrorConstants.java index eea912459..5c58f0212 100644 --- a/certify-core/src/main/java/io/mosip/certify/core/constants/ErrorConstants.java +++ b/certify-core/src/main/java/io/mosip/certify/core/constants/ErrorConstants.java @@ -31,4 +31,8 @@ public class ErrorConstants { public static final String INVALID_CERTIFICATE = "invalid_certificate"; public static final String VERIFICATION_METHOD_GENERATION_FAILED = "verification_method_generation_failed"; public static final String MISSING_APPLICATION_OR_REFERENCE_ID = "missing_application_or_reference_id"; + public static final String STATUS_LIST_NOT_FOUND = "status_list_not_found_for_the_given_id"; + public static final String STATUS_RETRIEVAL_ERROR = "error_parsing_status_list_credential_document"; + public static final String INDEX_OUT_OF_BOUNDS = "requested_index_is_out_of_bounds_for_status_list_capacity"; + public static final String INVALID_FRAGMENT = "invalid_fragment_format_must_be_a_valid_number"; } diff --git a/certify-service/pom.xml b/certify-service/pom.xml index 5a4ffb7eb..f16714cfa 100644 --- a/certify-service/pom.xml +++ b/certify-service/pom.xml @@ -153,6 +153,25 @@ hypersistence-utils-hibernate-63 3.9.9 + + com.jayway.jsonpath + json-path + + + com.fasterxml.jackson.core + jackson-databind + + + net.javacrumbs.shedlock + shedlock-spring + 6.8.0 + + + net.javacrumbs.shedlock + shedlock-provider-jdbc-template + 6.8.0 + + diff --git a/certify-service/src/main/java/io/mosip/certify/config/BatchJobConfig.java b/certify-service/src/main/java/io/mosip/certify/config/BatchJobConfig.java new file mode 100644 index 000000000..1d8bc7e2e --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/config/BatchJobConfig.java @@ -0,0 +1,38 @@ +package io.mosip.certify.config; + +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +import javax.sql.DataSource; +import java.util.concurrent.Executors; + +/** + * Configuration for batch job scheduling + */ +@Configuration +@EnableScheduling +@EnableSchedulerLock(defaultLockAtMostFor = "10m") +public class BatchJobConfig implements SchedulingConfigurer { + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + taskRegistrar.setScheduler(Executors.newScheduledThreadPool(2)); + } + + @Bean + public LockProvider lockProvider(DataSource dataSource) { + return new JdbcTemplateLockProvider( + JdbcTemplateLockProvider.Configuration.builder() + .withJdbcTemplate(new JdbcTemplate(dataSource)) + .usingDbTime() + .build() + ); + } +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/config/IndexedAttributesConfig.java b/certify-service/src/main/java/io/mosip/certify/config/IndexedAttributesConfig.java new file mode 100644 index 000000000..3aba40e3f --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/config/IndexedAttributesConfig.java @@ -0,0 +1,21 @@ +package io.mosip.certify.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +@ConfigurationProperties(prefix = "mosip.certify") +@Getter +@Setter +public class IndexedAttributesConfig { + /** + * Holds the mappings from a desired attribute name (key) to the + * JSONPath expression (value) used to extract it from the source data. + */ + private Map indexedMappings = new HashMap<>(); +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/controller/StatusListCredentialController.java b/certify-service/src/main/java/io/mosip/certify/controller/StatusListCredentialController.java new file mode 100644 index 000000000..2f89b7719 --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/controller/StatusListCredentialController.java @@ -0,0 +1,57 @@ +/*More actions + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package io.mosip.certify.controller; + +import io.mosip.certify.core.dto.VCError; +import io.mosip.certify.core.exception.CertifyException; +import io.mosip.certify.services.StatusListCredentialService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.MessageSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +import java.util.Locale; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/status-list") +public class StatusListCredentialController { + + @Autowired + private StatusListCredentialService statusListCredentialService; + + @Autowired + MessageSource messageSource; + + /** + * Get Status List Credential by ID with optional fragment support + * Handles URLs like: /{id} or /{id}#{fragment} + * + * @param id The status list credential ID + // * @param fragment Optional fragment identifier (for specific index references) + * @return Status List VC JSON document + * @throws CertifyException + */ + @GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE) + public String getStatusListById(@PathVariable("id") String id) throws CertifyException { + + log.info("Retrieving status list credential with ID: {}", id); + return statusListCredentialService.getStatusListCredential(id); + } + + @ResponseBody + @ExceptionHandler(CertifyException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public VCError statusListNotFoundExceptionHandler(CertifyException ex) { + VCError vcError = new VCError(); + vcError.setError(ex.getErrorCode()); + vcError.setError_description(messageSource.getMessage(ex.getErrorCode(), null, ex.getErrorCode(), Locale.getDefault())); + return vcError; + } +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/entity/CredentialStatusTransaction.java b/certify-service/src/main/java/io/mosip/certify/entity/CredentialStatusTransaction.java new file mode 100644 index 000000000..6b4d4c913 --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/entity/CredentialStatusTransaction.java @@ -0,0 +1,52 @@ +package io.mosip.certify.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "credential_status_transaction") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CredentialStatusTransaction { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "transaction_log_id") + private Long transactionLogId; + + @Column(name = "credential_id", length = 255, nullable = false) + private String credentialId; + + @Column(name = "status_purpose", length = 100) + private String statusPurpose; + + @Column(name = "status_value") + private Boolean statusValue; + + @Column(name = "status_list_credential_id", length = 255) + private String statusListCredentialId; + + @Column(name = "status_list_index") + private Long statusListIndex; + + @Column(name = "cr_dtimes", nullable = false, updatable = false) + private LocalDateTime createdDtimes; + + @Column(name = "upd_dtimes") + private LocalDateTime updatedDtimes; + + @PrePersist + protected void onCreate() { + createdDtimes = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedDtimes = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/entity/Ledger.java b/certify-service/src/main/java/io/mosip/certify/entity/Ledger.java new file mode 100644 index 000000000..d1422df12 --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/entity/Ledger.java @@ -0,0 +1,58 @@ +package io.mosip.certify.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Ledger { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "credential_id", length = 255, nullable = false, unique = true) + private String credentialId; + + @Column(name = "issuer_id", length = 255, nullable = false) + private String issuerId; + + @Column(name = "issue_date", nullable = false) + private OffsetDateTime issueDate; + + @Column(name = "expiration_date") + private OffsetDateTime expirationDate; + + @Column(name = "credential_type", length = 100, nullable = false) + private String credentialType; + + @Column(name = "indexed_attributes") + @JdbcTypeCode(SqlTypes.JSON) + private Map indexedAttributes; + + @Column(name = "credential_status_details", nullable = false) + @JdbcTypeCode(SqlTypes.JSON) + private List> credentialStatusDetails; + + @Column(name = "cr_dtimes", nullable = false, updatable = false) + private LocalDateTime createdDtimes; + + @PrePersist + protected void onCreate() { + createdDtimes = LocalDateTime.now(); + if (credentialStatusDetails == null) { + credentialStatusDetails = List.of(); + } + } +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/entity/StatusListAvailableIndices.java b/certify-service/src/main/java/io/mosip/certify/entity/StatusListAvailableIndices.java new file mode 100644 index 000000000..47c9ef266 --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/entity/StatusListAvailableIndices.java @@ -0,0 +1,63 @@ +package io.mosip.certify.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "status_list_available_indices", + uniqueConstraints = { + @UniqueConstraint(name = "uq_list_id_and_index", + columnNames = {"status_list_credential_id", "list_index"}) + }, + indexes = { + @Index(name = "idx_sla_available_indices", + columnList = "status_list_credential_id, is_assigned, list_index") + } +) +@Data +@NoArgsConstructor +@AllArgsConstructor +public class StatusListAvailableIndices { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "status_list_credential_id", length = 255, nullable = false) + private String statusListCredentialId; + + @Column(name = "list_index", nullable = false) + private Long listIndex; + + @Column(name = "is_assigned", nullable = false) + private Boolean isAssigned = false; + + @Column(name = "cr_dtimes", nullable = false, updatable = false) + private LocalDateTime createdDtimes; + + @Column(name = "upd_dtimes") + private LocalDateTime updatedDtimes; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "status_list_credential_id", + foreignKey = @ForeignKey(name = "fk_status_list_credential"), + insertable = false, updatable = false) + private StatusListCredential statusListCredential; + + @PrePersist + protected void onCreate() { + createdDtimes = LocalDateTime.now(); + if (isAssigned == null) { + isAssigned = false; + } + } + + @PreUpdate + protected void onUpdate() { + updatedDtimes = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/entity/StatusListCredential.java b/certify-service/src/main/java/io/mosip/certify/entity/StatusListCredential.java new file mode 100644 index 000000000..7be083bf2 --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/entity/StatusListCredential.java @@ -0,0 +1,53 @@ +package io.mosip.certify.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "status_list_credential", indexes = { + @Index(name = "idx_slc_status_purpose", columnList = "status_purpose") +}) +@Data +@NoArgsConstructor +@AllArgsConstructor +public class StatusListCredential { + + @Id + @Column() + private String id; + + @Column(name = "vc_document", columnDefinition = "TEXT", nullable = false) + private String vcDocument; + + + @Column(name = "credential_type", length = 100, nullable = false) + private String credentialType; + + @Column(name = "status_purpose", length = 100) + private String statusPurpose; + + @Column(name = "capacity") + private Long capacity; + + @Column(name = "credential_status") + @Enumerated(EnumType.STRING) + @JdbcType(PostgreSQLEnumJdbcType.class) + private CredentialStatus credentialStatus; + + @Column(name = "cr_dtimes", nullable = false, updatable = false) + private LocalDateTime createdDtimes; + + @Column(name = "upd_dtimes") + private LocalDateTime updatedDtimes; + + public enum CredentialStatus { + AVAILABLE, + FULL; + } +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/exception/RevocationException.java b/certify-service/src/main/java/io/mosip/certify/exception/RevocationException.java new file mode 100644 index 000000000..7bce0f034 --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/exception/RevocationException.java @@ -0,0 +1,7 @@ +package io.mosip.certify.exception; + +public class RevocationException extends RuntimeException { + public RevocationException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/exception/StatusListException.java b/certify-service/src/main/java/io/mosip/certify/exception/StatusListException.java new file mode 100644 index 000000000..3c5f6ac8d --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/exception/StatusListException.java @@ -0,0 +1,18 @@ +package io.mosip.certify.exception; + +/** + * Exception class for Bitstring Status List operations + */ +public class StatusListException extends Exception { + + private final String errorCode; + + public StatusListException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/repository/CredentialStatusTransactionRepository.java b/certify-service/src/main/java/io/mosip/certify/repository/CredentialStatusTransactionRepository.java new file mode 100644 index 000000000..272e7b56d --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/repository/CredentialStatusTransactionRepository.java @@ -0,0 +1,38 @@ +package io.mosip.certify.repository; + +import io.mosip.certify.entity.CredentialStatusTransaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface CredentialStatusTransactionRepository extends JpaRepository { + + /** + * Find all transactions created since the given timestamp, ordered by creation time + * Limited by the specified batch size + */ + @Query("SELECT t FROM CredentialStatusTransaction t WHERE t.createdDtimes > :since ORDER BY t.createdDtimes ASC") + List findTransactionsSince(@Param("since") LocalDateTime since, Pageable pageable); + + /** + * Convenience method for batch processing + */ + default List findTransactionsSince(LocalDateTime since, int batchSize) { + return findTransactionsSince(since, PageRequest.of(0, batchSize)); + } + + /** + * Find the latest status transaction for each credential in a specific status list + * This helps to get the current state of all credentials in a status list + */ + @Query("SELECT t FROM CredentialStatusTransaction t WHERE t.statusListCredentialId = :statusListId ORDER BY t.credentialId, t.createdDtimes DESC") + List findLatestStatusByStatusListId(@Param("statusListId") String statusListId); +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/repository/LedgerRepository.java b/certify-service/src/main/java/io/mosip/certify/repository/LedgerRepository.java new file mode 100644 index 000000000..51850f59b --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/repository/LedgerRepository.java @@ -0,0 +1,54 @@ +package io.mosip.certify.repository; + +import io.mosip.certify.entity.Ledger; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface LedgerRepository extends JpaRepository { + + + /** + * Search by indexed attributes using JSONB containment + * This will find records where indexed_attributes contains all the key-value pairs in the search criteria + */ + @Query(value = "SELECT * FROM ledger WHERE indexed_attributes @> CAST(:searchJson AS jsonb)", + nativeQuery = true) + List findByIndexedAttributesContaining(@Param("searchJson") String searchJson); + + /** + * Search by specific key-value pair in indexed attributes + */ + @Query(value = "SELECT * FROM ledger WHERE indexed_attributes ->> :key = :value", + nativeQuery = true) + List findByIndexedAttributeKeyValue(@Param("key") String key, @Param("value") String value); + + /** + * Find all active (non-expired) credentials + */ + @Query("SELECT l FROM Ledger l WHERE l.expirationDate IS NULL OR l.expirationDate > :currentDate") + List findActiveCredentials(@Param("currentDate") OffsetDateTime currentDate); + + /** + * Complex search combining multiple criteria + */ + @Query(value = """ + SELECT * FROM ledger l + WHERE (:issuerId IS NULL OR l.issuer_id = :issuerId) + AND (:credentialType IS NULL OR l.credential_type = :credentialType) + AND (:searchJson IS NULL OR l.indexed_attributes @> CAST(:searchJson AS jsonb)) + """, nativeQuery = true) + List searchWithCriteria( + @Param("issuerId") String issuerId, + @Param("credentialType") String credentialType, + @Param("searchJson") String searchJson + ); +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/repository/StatusListAvailableIndicesRepository.java b/certify-service/src/main/java/io/mosip/certify/repository/StatusListAvailableIndicesRepository.java new file mode 100644 index 000000000..68346a757 --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/repository/StatusListAvailableIndicesRepository.java @@ -0,0 +1,21 @@ +package io.mosip.certify.repository; + +import io.mosip.certify.entity.StatusListAvailableIndices; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface StatusListAvailableIndicesRepository extends JpaRepository { + + /** + * Count assigned indices for a specific status list + */ + long countByStatusListCredentialIdAndIsAssignedTrue(String statusListCredentialId); +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/repository/StatusListCredentialRepository.java b/certify-service/src/main/java/io/mosip/certify/repository/StatusListCredentialRepository.java new file mode 100644 index 000000000..b07f770f7 --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/repository/StatusListCredentialRepository.java @@ -0,0 +1,38 @@ +package io.mosip.certify.repository; + +import io.mosip.certify.entity.StatusListCredential; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface StatusListCredentialRepository extends JpaRepository { + + /** + * Find a suitable status list credential that is available (not full) and matches the given purpose + * + * @param statusPurpose The purpose of the status list (e.g., "revocation", "suspension") + * @return An optional containing the first available status list credential, or empty if none found + */ + @Query("SELECT s FROM StatusListCredential s WHERE s.statusPurpose = :statusPurpose " + + "AND s.credentialStatus = :status " + + "ORDER BY s.createdDtimes DESC") + Optional findSuitableStatusList(@Param("statusPurpose") String statusPurpose, StatusListCredential.CredentialStatus status); + + /** + * Find capacity of status list by ID + */ + @Query("SELECT s.capacity FROM StatusListCredential s WHERE s.id = :id") + Optional findCapacityById(@Param("id") String id); + + /** + * Find the maximum updated timestamp from all status list credentials + */ + @Query("SELECT MAX(s.updatedDtimes) FROM StatusListCredential s WHERE s.updatedDtimes IS NOT NULL") + Optional findMaxUpdatedTime(); +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/services/CertifyIssuanceServiceImpl.java b/certify-service/src/main/java/io/mosip/certify/services/CertifyIssuanceServiceImpl.java index 0d4fe1efc..eeacb5d2d 100644 --- a/certify-service/src/main/java/io/mosip/certify/services/CertifyIssuanceServiceImpl.java +++ b/certify-service/src/main/java/io/mosip/certify/services/CertifyIssuanceServiceImpl.java @@ -6,15 +6,25 @@ package io.mosip.certify.services; import java.text.ParseException; +import java.time.OffsetDateTime; import java.util.*; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.Option; import com.nimbusds.jwt.SignedJWT; import io.mosip.certify.api.util.AuditHelper; +import io.mosip.certify.config.IndexedAttributesConfig; import io.mosip.certify.core.dto.*; import io.mosip.certify.core.spi.CredentialConfigurationService; +import io.mosip.certify.entity.Ledger; +import io.mosip.certify.entity.StatusListCredential; +import io.mosip.certify.repository.LedgerRepository; +import io.mosip.certify.repository.StatusListCredentialRepository; import io.mosip.certify.utils.VCIssuanceUtil; +import jakarta.transaction.Transactional; import org.apache.commons.lang3.StringUtils; import org.json.JSONObject; import org.springframework.beans.factory.annotation.Autowired; @@ -119,6 +129,27 @@ public class CertifyIssuanceServiceImpl implements VCIssuanceService { @Value("${mosip.certify.identifier}") private String certifyIssuer; + @Autowired + private StatusListCredentialService statusListCredentialService; + + @Autowired + private StatusListCredentialRepository statusListCredentialRepository; + + @Autowired + private LedgerRepository ledgerRepository; + + @Autowired + private IndexedAttributesConfig indexedAttributesConfig; + + @Value("${mosip.certify.statuslist.enabled:true}") + private boolean statusListEnabled; + + @Value("${mosip.certify.statuslist.default-purpose:revocation}") + private String defaultStatusPurpose; + + @Value("${mosip.certify.domain.url}") + private String domainUrl; + @Override public CredentialResponse getCredential(CredentialRequest credentialRequest) { // 1. Credential Request validation @@ -232,6 +263,9 @@ private VCResult getVerifiableCredential(CredentialRequest credentialRequest, String templateName = CredentialUtils.getTemplateName(vcRequestDto); templateParams.put(Constants.TEMPLATE_NAME, templateName); templateParams.put(Constants.ISSUER_URI, issuerURI); + if (statusListEnabled) { + addCredentialStatus(jsonObject); + } if (!StringUtils.isEmpty(renderTemplateId)) { templateParams.put(Constants.RENDERING_TEMPLATE_ID, renderTemplateId); } @@ -276,4 +310,206 @@ private VCResult getVerifiableCredential(CredentialRequest credentialRequest, throw new CertifyException(ErrorConstants.UNSUPPORTED_VC_FORMAT); } } + + @Transactional + private void addCredentialStatus(JSONObject jsonObject) { + try { + log.info("Adding credential status forstatus list integration"); + + // Find or create a suitable status list + StatusListCredential statusList = statusListCredentialService.findOrCreateStatusList(defaultStatusPurpose); + + // Assign next available index using database approach + long assignedIndex = statusListCredentialService.findNextAvailableIndex(statusList.getId()); + + // If the current list is full, create a new one + if(assignedIndex == -1) { + log.info("Current status list is full, creating a new one"); + statusList = statusListCredentialService.generateStatusListCredential(defaultStatusPurpose); + assignedIndex = statusListCredentialService.findNextAvailableIndex(statusList.getId()); + + if(assignedIndex == -1) { + log.error("Failed to get available index even from new status list"); + throw new CertifyException("STATUS_LIST_INDEX_UNAVAILABLE"); + } + } + Map indexedAttributes = extractIndexedAttributes(jsonObject); + + // Create credential status object for VC + JSONObject credentialStatus = new JSONObject(); + String statusId = domainUrl + "/v1/certify/status-list/" + statusList.getId(); + credentialStatus.put("id", statusId + "#" + assignedIndex); + credentialStatus.put("type", "BitstringStatusListEntry"); + credentialStatus.put("statusPurpose", defaultStatusPurpose); + credentialStatus.put("statusListIndex", String.valueOf(assignedIndex)); + credentialStatus.put("statusListCredential", statusId); + + // Add credential status to the VC data + jsonObject.put("credentialStatus", credentialStatus); + + // Extract credential details for ledger storage + String credentialType = extractCredentialType(jsonObject); + + // Prepare status details for ledger + Map statusDetails = new HashMap<>(); + statusDetails.put("status_purpose", defaultStatusPurpose); + statusDetails.put("status_value", false); // Initially not revoked + statusDetails.put("status_list_credential_id", statusList.getId()); + statusDetails.put("status_list_index", assignedIndex); + statusDetails.put("cr_dtimes", System.currentTimeMillis()); + + // Store in ledger + storeLedgerEntry(issuerURI, credentialType, statusDetails, indexedAttributes); + + log.info("Successfully added credential status with index {} in status list {} and stored in ledger", assignedIndex, statusList.getId()); + + } catch (Exception e) { + log.error("Error adding credential status", e); + throw new CertifyException("CREDENTIAL_STATUS_ASSIGNMENT_FAILED"); + } + } + + private static String extractCredentialType(JSONObject jsonObject) { + try { + if(jsonObject.has("type")) { + Object typeObj = jsonObject.get("type"); + if(typeObj instanceof org.json.JSONArray) { + org.json.JSONArray typeArray = (org.json.JSONArray) typeObj; + List types = new ArrayList<>(); + + // Extract all types from the array + for(int i = 0; i < typeArray.length(); i++) { + String type = typeArray.getString(i); + if(type != null && !type.trim().isEmpty()) { + types.add(type.trim()); + } + } + + if(!types.isEmpty()) { + // Sort the types and join with comma + Collections.sort(types); + return String.join(",", types); + } + } else { + // Single type as string + String singleType = typeObj.toString().trim(); + if(!singleType.isEmpty()) { + return singleType; + } + } + } + return "VerifiableCredential"; + } catch (Exception e) { + log.warn("Error extracting credential type, using default", e); + return "VerifiableCredential"; + } + } + + // Enhanced version with better complex field support + public Map extractIndexedAttributes(JSONObject jsonObject) { + Configuration jsonPathConfig = Configuration.defaultConfiguration().addOptions(Option.SUPPRESS_EXCEPTIONS); + Map indexedAttributes = new HashMap<>(); + + if(jsonObject == null) { + return indexedAttributes; + } + + Map indexedMappings = indexedAttributesConfig.getIndexedMappings(); + if(indexedMappings.isEmpty()) { + log.info("No indexed mappings configured, returning empty attributes"); + return indexedAttributes; + } + log.info("Indexed Mapping Found: {}", indexedMappings); + + try { + // Convert credential subject to JSON string forJsonPath processing + String sourceJsonString = jsonObject.toString(); + for(Map.Entry entry : indexedMappings.entrySet()) { + String targetKey = entry.getKey(); + String pathsConfig = entry.getValue(); + + // Support multiple paths separated by pipe (|) forfallback + String[] paths = pathsConfig.split("\\|"); + Object extractedValue = null; + + for(String jsonPath : paths) { + jsonPath = jsonPath.trim(); + try { + // Use JsonPath to read the value from the source JSON + extractedValue = JsonPath.using(jsonPathConfig) + .parse(sourceJsonString) + .read(jsonPath); + } catch (Exception e) { + log.warn("Error extracting value forpath '{}' and key '{}': {}", + jsonPath, targetKey, e.getMessage()); + } + } + // Handle different types of extracted values + if(extractedValue != null) { + Object processedValue = processExtractedIndexedAttributes(extractedValue); + indexedAttributes.put(targetKey, processedValue); + log.info("Added processed value '{}' to indexed attributes under key '{}'", + processedValue, targetKey); + } else { + log.info("No value extracted forkey '{}'; skipping indexing.", targetKey); + } + } + } catch (Exception e) { + log.error("Error processing credential subject forindexed attributes: {}", e.getMessage(), e); + } + return indexedAttributes; + } + + /** + * Process extracted values to handle complex types appropriately + */ + private Object processExtractedIndexedAttributes(Object extractedValue) { + if(extractedValue == null) { + return null; + } + + if(extractedValue instanceof List) { + List list = (List) extractedValue; + if(list.isEmpty()) { + return null; + } + if(list.size() == 1) { + return list.get(0); + } + return extractedValue; // Keep as array + } + else if(extractedValue instanceof Map) { + return extractedValue; + } + else if(extractedValue instanceof String) { + String stringValue = (String) extractedValue; + return stringValue.trim().isEmpty() ? null : stringValue; + } + + return extractedValue; + } + + @Transactional + public void storeLedgerEntry(String issuerId, String credentialType, Map statusDetails, Map indexedAttributes) { + try { + Ledger ledger = new Ledger(); + String credentialId = UUID.randomUUID().toString(); + ledger.setCredentialId(credentialId); + ledger.setIssuerId(issuerId); + ledger.setIssueDate(OffsetDateTime.now()); + ledger.setCredentialType(credentialType); + ledger.setIndexedAttributes(indexedAttributes); + + // Store status details as array + List> statusDetailsList = new ArrayList<>(); + statusDetailsList.add(statusDetails); + ledger.setCredentialStatusDetails(statusDetailsList); + + ledgerRepository.save(ledger); + log.info("Ledger entry stored forcredential: {}", credentialId); + } catch (Exception e) { + log.error("Error storing ledger entry", e); + throw new RuntimeException("Failed to store ledger entry", e); + } + } } diff --git a/certify-service/src/main/java/io/mosip/certify/services/DatabaseStatusListIndexProvider.java b/certify-service/src/main/java/io/mosip/certify/services/DatabaseStatusListIndexProvider.java new file mode 100644 index 000000000..3715166f4 --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/services/DatabaseStatusListIndexProvider.java @@ -0,0 +1,140 @@ +package io.mosip.certify.services; + +import io.mosip.certify.core.exception.CertifyException; +import io.mosip.certify.entity.StatusListAvailableIndices; +import io.mosip.certify.entity.StatusListCredential; +import io.mosip.certify.repository.StatusListAvailableIndicesRepository; +import io.mosip.certify.repository.StatusListCredentialRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.math.BigInteger; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +public class DatabaseStatusListIndexProvider implements StatusListIndexProvider { + + @Autowired + private StatusListAvailableIndicesRepository statusListAvailableIndicesRepository; + + @Autowired + private StatusListCredentialRepository statusListCredentialRepository; + + @Autowired + private EntityManager entityManager; + + @Value("${mosip.certify.statuslist.usable-capacity:50}") + private int usableCapacity; + + @Override + public String getProviderName() { + return "DatabaseRandomAvailableIndexProvider"; + } + + @Override + @Transactional + public Optional acquireIndex(String listId, Map options) { + log.info("Attempting to acquire index for status list: {}", listId); + + try { + // 1. Get status list and its capacity + Optional statusListOpt = statusListCredentialRepository.findById(listId); + if (statusListOpt.isEmpty()) { + log.error("Status list not found: {}", listId); + return Optional.empty(); + } + + StatusListCredential statusList = statusListOpt.get(); + long physicalCapacity = statusList.getCapacity(); + + // 2. Calculate effective threshold based on usable capacity + long effectiveThresholdCount = (long) Math.floor(physicalCapacity * (usableCapacity / 100.0)); + + // 3. Check current assigned count + long currentAssignedCount = statusListAvailableIndicesRepository + .countByStatusListCredentialIdAndIsAssignedTrue(listId); + + if (currentAssignedCount >= effectiveThresholdCount) { + log.warn("Status list {} has reached usable capacity limit ({}/{})", + listId, currentAssignedCount, effectiveThresholdCount); + + // Mark status list as full + statusList.setCredentialStatus(StatusListCredential.CredentialStatus.FULL); + statusListCredentialRepository.save(statusList); + + return Optional.empty(); + } + + // 4. Attempt to atomically claim an index using native query + Long claimedIndex = atomicallyClaimIndex(listId); + + if (claimedIndex != null) { + log.info("Successfully claimed index {} for status list: {}", claimedIndex, listId); + return Optional.of(claimedIndex); + } else { + log.warn("Failed to claim any available index for status list: {}", listId); + return Optional.empty(); + } + + } catch (Exception e) { + log.error("Error acquiring index for status list: {}", listId, e); + return Optional.empty(); + } + } + + /** + * Atomically claim an available index using database skip lock mechanism + */ + private Long atomicallyClaimIndex(String listId) { + try { + String sql = """ + WITH available_slot AS ( + SELECT list_index + FROM status_list_available_indices + WHERE status_list_credential_id = :listId + AND is_assigned = false + ORDER BY RANDOM() + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + UPDATE status_list_available_indices sla + SET is_assigned = true, + upd_dtimes = NOW() + FROM available_slot avs + WHERE sla.status_list_credential_id = :listId + AND sla.list_index = avs.list_index + AND sla.is_assigned = false + RETURNING sla.list_index + """; + + Query query = entityManager.createNativeQuery(sql); + query.setParameter("listId", listId); + + Object result = query.getSingleResult(); + + if (result != null) { + if (result instanceof BigInteger) { + return ((BigInteger) result).longValue(); + } else if (result instanceof Long) { + return (Long) result; + } else if (result instanceof Integer) { + return ((Integer) result).longValue(); + } + } + + return null; + + } catch (Exception e) { + log.debug("No available index found or error in atomic claim for list: {}", listId); + return null; + } + } +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/services/StatusListCredentialService.java b/certify-service/src/main/java/io/mosip/certify/services/StatusListCredentialService.java new file mode 100644 index 000000000..b92a40d5b --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/services/StatusListCredentialService.java @@ -0,0 +1,350 @@ +package io.mosip.certify.services; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.mosip.certify.api.dto.VCResult; +import io.mosip.certify.core.constants.Constants; +import io.mosip.certify.core.constants.ErrorConstants; +import io.mosip.certify.core.exception.CertifyException; +import io.mosip.certify.entity.StatusListCredential; +import io.mosip.certify.repository.StatusListAvailableIndicesRepository; +import io.mosip.certify.repository.StatusListCredentialRepository; +import io.mosip.certify.utils.BitStringStatusListUtils; +import io.mosip.certify.vcformatters.VCFormatter; +import io.mosip.certify.vcsigners.VCSigner; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.*; + +/** + * Service class for managing Status List Credentials + * Responsible for generating, retrieving, and managing status list VCs + */ +@Slf4j +@Service +public class StatusListCredentialService { + + @Autowired + private StatusListCredentialRepository statusListCredentialRepository; + + @Autowired + private VCFormatter vcFormatter; + + @Autowired + private VCSigner vcSigner; + + @Autowired + private DatabaseStatusListIndexProvider indexProvider; + + @PersistenceContext + private EntityManager entityManager; + + @Value("${mosip.certify.data-provider-plugin.issuer.vc-sign-algo:Ed25519Signature2020}") + private String vcSignAlgorithm; + + @Value("${mosip.certify.data-provider-plugin.issuer-uri}") + private String issuerId; + + @Value("${mosip.certify.domain.url}") + private String domainUrl; + + @Value("#{${mosip.certify.statuslist.default-capacity:16}}") // value in kb + private long defaultCapacity; + + public String getStatusListCredential(String id) throws CertifyException { + log.info("Processing status list credential request for ID: {}", id); + + try { + // Find the status list credential by ID + Optional statusListOpt = findStatusListById(id); + + if (statusListOpt.isEmpty()) { + log.warn("Status list credential not found for ID: {}", id); + throw new CertifyException(ErrorConstants.STATUS_LIST_NOT_FOUND); + } + + StatusListCredential statusList = statusListOpt.get(); + + // Parse the VC document + JSONObject vcDocument; + try { + vcDocument = new JSONObject(statusList.getVcDocument()); + } catch (Exception e) { + log.error("Error parsing VC document for status list ID: {}", id, e); + throw new CertifyException(ErrorConstants.STATUS_RETRIEVAL_ERROR); + } + + log.info("Successfully retrieved status list credential for ID: {}", id); + + // Convert JSONObject to Map for consistent return type + return vcDocument.toString(); + + } catch (Exception e) { + log.error("Unexpected error retrieving status list credential with ID: {}", id, e); + throw new CertifyException(ErrorConstants.STATUS_RETRIEVAL_ERROR); + } + } + + /** + * Find status list credential by ID + * + * @param id the ID of the status list credential + * @return Optional containing StatusListCredential if found + */ + public Optional findStatusListById(String id) { + log.info("Finding status list credential by ID: {}", id); + + try { + return statusListCredentialRepository.findById(id); + } catch (Exception e) { + log.error("Error finding status list credential by ID: {}", id, e); + return Optional.empty(); + } + } + + /** + * Find a suitable status list for the given purpose + * + * @param statusPurpose the purpose of the status list (e.g., "revocation", "suspension") + * @return Optional containing StatusListCredential if found + */ + public Optional findSuitableStatusList(String statusPurpose, StatusListCredential.CredentialStatus status) { + return statusListCredentialRepository.findSuitableStatusList(statusPurpose, status); + } + + /** + * Generate a new status list credential for the specified purpose + * + * @param statusPurpose the purpose of the status list (e.g., "revocation", "suspension") + * @return the generated StatusListCredential + */ + @Transactional + public StatusListCredential generateStatusListCredential(String statusPurpose) { + log.info("Generating new status list credential with purpose: {}", statusPurpose); + + try { + // Generate unique ID for status list + String id = UUID.randomUUID().toString(); + String statusListId = domainUrl + "/status-list/" + id; + + // Create the template data for the status list VC + JSONObject statusListData = new JSONObject(); + + JSONArray contextList = new JSONArray(); + contextList.put("https://www.w3.org/ns/credentials/v2"); + statusListData.put("@context", contextList); + + JSONArray typeList = new JSONArray(); + typeList.put("VerifiableCredential"); + typeList.put("BitstringStatusListCredential"); + statusListData.put("type", typeList); + + statusListData.put("id", statusListId); + statusListData.put("issuer", issuerId); + statusListData.put("validFrom", new Date().toInstant().toString()); + + JSONObject credentialSubject = new JSONObject(); + credentialSubject.put("id", statusListId); + credentialSubject.put("type", "BitstringStatusList"); + credentialSubject.put("statusPurpose", statusPurpose); + + // Create empty encoded list (all 0s) + String encodedList = BitStringStatusListUtils.createEmptyEncodedList(defaultCapacity); + credentialSubject.put("encodedList", encodedList); + + statusListData.put("credentialSubject", credentialSubject); + + log.debug("Created status list VC structure: {}", statusListData.toString(2)); + + // Sign the status list credential + Map signerSettings = new HashMap<>(); + signerSettings.put(Constants.APPLICATION_ID, CertifyIssuanceServiceImpl.keyChooser.get(vcSignAlgorithm).getFirst()); + signerSettings.put(Constants.REFERENCE_ID, CertifyIssuanceServiceImpl.keyChooser.get(vcSignAlgorithm).getLast()); + + // Attach signature to the VC + VCResult vcResult = vcSigner.attachSignature(statusListData.toString(), signerSettings); + + if (vcResult.getCredential() == null) { + log.error("Failed to generate status list VC - vcResult.getCredential() returned null"); + throw new CertifyException("VC_ISSUANCE_FAILED"); + } + + // Convert to byte array for storage + byte[] vcDocument; + try { + vcDocument = vcResult.getCredential().toString().getBytes(StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("Error converting VC to byte array", e); + throw new CertifyException("VC_SERIALIZATION_FAILED"); + } + + String vcDocS = vcResult.getCredential().toString(); + log.info("Signed VC document: {}", vcDocS); + + // Create and save the status list credential entity + StatusListCredential statusListCredential = new StatusListCredential(); + statusListCredential.setId(id); + statusListCredential.setVcDocument(vcDocS); + statusListCredential.setCredentialType("BitstringStatusListCredential"); + statusListCredential.setStatusPurpose(statusPurpose); + statusListCredential.setCapacity(defaultCapacity); + statusListCredential.setCredentialStatus(StatusListCredential.CredentialStatus.AVAILABLE); + statusListCredential.setCreatedDtimes(LocalDateTime.now()); + + // Save to database + StatusListCredential savedCredential = statusListCredentialRepository.saveAndFlush(statusListCredential); + log.info("Saved StatusListCredential: ID={}, CreatedDtimes={}", savedCredential.getId(), savedCredential.getCreatedDtimes()); + initializeAvailableIndices(savedCredential); + + return savedCredential; + + } catch (JSONException e) { + log.error("JSON error while generating status list credential", e); + throw new CertifyException("STATUS_LIST_JSON_ERROR"); + } catch (Exception e) { + e.printStackTrace(); + log.error("Error generating status list credential", e); + throw new CertifyException("STATUS_LIST_GENERATION_FAILED"); + } + } + + /** + * Initialize available indices for a newly created status list using Database Query Approach + * + * @param statusListCredential the status list credential + */ + @Transactional + public void initializeAvailableIndices(StatusListCredential statusListCredential) { + log.info("Initializing available indices for status list: {}", statusListCredential.getId()); + + try { + + Query checkQuery = entityManager.createNativeQuery("SELECT COUNT(*) FROM status_list_credential WHERE id = ?"); + checkQuery.setParameter(1, statusListCredential.getId()); + Object count = checkQuery.getSingleResult(); + log.info("StatusListCredential with ID {} exists in DB: {}", statusListCredential.getId(), count); + + String insertSql = """ + INSERT INTO status_list_available_indices + (status_list_credential_id, list_index, is_assigned, cr_dtimes) + SELECT ?, generate_series(0, ? - 1), false, NOW() + """; + + try { + Query nativeQuery = entityManager.createNativeQuery(insertSql); + nativeQuery.setParameter(1, statusListCredential.getId()); + nativeQuery.setParameter(2, statusListCredential.getCapacity()); + + int rowsInserted = nativeQuery.executeUpdate(); + + log.info("Successfully initialized {} available indices for status list: {}", rowsInserted, statusListCredential.getId()); + + } catch (Exception e) { + if (entityManager.getTransaction().isActive()) { + entityManager.getTransaction().rollback(); + } + throw e; + } finally { + entityManager.close(); + } + + } catch (Exception e) { + log.error("Error initializing available indices for status list: {}", statusListCredential.getId(), e); + throw new CertifyException("STATUS_LIST_INDEX_INITIALIZATION_FAILED"); + } + } + + /** + * Find or create a suitable status list for the given purpose + * If no suitable status list exists, a new one will be created + * + * @param statusPurpose the purpose of the status list + * @return StatusListCredential that can be used for the given purpose + */ + @Transactional + public StatusListCredential findOrCreateStatusList(String statusPurpose) { + log.info("Finding or creating status list for purpose: {}", statusPurpose); + + // Try to find an existing suitable status list + Optional existingStatusList = findSuitableStatusList(statusPurpose, StatusListCredential.CredentialStatus.AVAILABLE); + + if (existingStatusList.isPresent()) { + log.info("suitable status list found, returning the existing one"); + return existingStatusList.get(); + } + + // No suitable status list found, generate a new one + log.info("No suitable status list found, generating a new one"); + StatusListCredential statusListCredential = generateStatusListCredential(statusPurpose); + return statusListCredential; + } + + /** + * Find next available index in the status list using the configured index provider + * + * @param statusListId the ID of the status list + * @return the next available index, or -1 if the list is full + */ + public long findNextAvailableIndex(String statusListId) { + Optional availableIndex = indexProvider.acquireIndex(statusListId, Map.of()); + return availableIndex.orElse(-1L); + } + + // Add this method to StatusListCredentialService.java + + /** + * Re-sign a status list credential with updated content + * + * @param vcDocumentJson The updated VC document as JSON string + * @return The re-signed VC document as JSON string + */ + @Transactional + public String resignStatusListCredential(String vcDocumentJson) { + log.info("Re-signing status list credential"); + + try { + // Prepare signer settings + Map signerSettings = new HashMap<>(); + signerSettings.put(Constants.APPLICATION_ID, CertifyIssuanceServiceImpl.keyChooser.get(vcSignAlgorithm).getFirst()); + signerSettings.put(Constants.REFERENCE_ID, CertifyIssuanceServiceImpl.keyChooser.get(vcSignAlgorithm).getLast()); + + // Remove existing proof if present before re-signing + JSONObject vcDocument = new JSONObject(vcDocumentJson); + if (vcDocument.has("proof")) { + vcDocument.remove("proof"); + } + + // Update validFrom timestamp to current time + vcDocument.put("validFrom", new Date().toInstant().toString()); + + // Sign the updated VC + VCResult vcResult = vcSigner.attachSignature(vcDocument.toString(), signerSettings); + + if (vcResult.getCredential() == null) { + log.error("Failed to re-sign status list VC - vcResult.getCredential() returned null"); + throw new CertifyException("VC_RESIGNATION_FAILED"); + } + + String resignedVcDocument = vcResult.getCredential().toString(); + log.debug("Successfully re-signed status list credential"); + + return resignedVcDocument; + + } catch (Exception e) { + log.error("Error re-signing status list credential", e); + throw new CertifyException("VC_RESIGNATION_FAILED"); + } + } +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/services/StatusListIndexProvider.java b/certify-service/src/main/java/io/mosip/certify/services/StatusListIndexProvider.java new file mode 100644 index 000000000..340b5d622 --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/services/StatusListIndexProvider.java @@ -0,0 +1,32 @@ +package io.mosip.certify.services; + +import java.util.Map; +import java.util.Optional; + +/** + * Interface for providing status list index assignment strategies + */ +public interface StatusListIndexProvider { + + /** + * @return A descriptive name or identifier for this index provider strategy + * (e.g., "DatabaseRandomAvailableIndexProvider", "RedisSequentialIndexProvider"). + */ + String getProviderName(); + + /** + * Attempts to acquire an available index from the specified status list. + *

+ * The implementing class is responsible for ensuring the returned index is unique + * for the given listId at the time of acquisition and that it respects + * the list's capacity and any applicable usage policies (like usableCapacity). + * + * @param listId The unique identifier of the status list from which to acquire an index. + * @param options A map of optional parameters that might influence the index acquisition. + * This allows for flexibility in implementations. Examples: + * - "preferredIndex": (Long) a hint for a desired index, if supported. + * - "purpose": (String) context for why the index is needed, could influence choice. + */ + Optional acquireIndex(String listId, Map options); + +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/services/StatusListUpdateBatchJob.java b/certify-service/src/main/java/io/mosip/certify/services/StatusListUpdateBatchJob.java new file mode 100644 index 000000000..d07acf5ad --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/services/StatusListUpdateBatchJob.java @@ -0,0 +1,294 @@ +package io.mosip.certify.services; + +import io.mosip.certify.core.exception.CertifyException; +import io.mosip.certify.entity.CredentialStatusTransaction; +import io.mosip.certify.entity.StatusListCredential; +import io.mosip.certify.repository.CredentialStatusTransactionRepository; +import io.mosip.certify.repository.StatusListCredentialRepository; +import io.mosip.certify.utils.BitStringStatusListUtils; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.core.LockAssert; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Batch job service for updating Status List Credentials + * Runs hourly to process new credential status transactions and update status lists + */ +@Slf4j +@Service +public class StatusListUpdateBatchJob { + + @Autowired + private CredentialStatusTransactionRepository transactionRepository; + + @Autowired + private StatusListCredentialRepository statusListRepository; + + @Autowired + private StatusListCredentialService statusListCredentialService; + + @Value("${mosip.certify.batch.status-list-update.enabled:true}") + private boolean batchJobEnabled; + + @Value("${mosip.certify.batch.status-list-update.batch-size:1000}") + private int batchSize; + + @Value("${mosip.certify.batch.status-list-update.since-time:1970-01-01T00:00:00}") + private String sinceTime; + + // Track last processed timestamp to avoid reprocessing + private LocalDateTime lastProcessedTime = null; + + /** + * Scheduled method that runs every hour to update status lists + * Uses @Scheduled with fixedRate to run every hour (3600000 ms) + */ + @Scheduled(cron = "${mosip.certify.batch.status-list-update.cron-expression:0 0 * * * *}") + @SchedulerLock( + name = "updateStatusLists", + lockAtMostFor = "${mosip.certify.batch.status-list-update.lock-at-most-for:50m}", + lockAtLeastFor = "${mosip.certify.batch.status-list-update.lock-at-least-for:5m}" + ) + @Transactional + public void updateStatusLists() { + LockAssert.assertLocked(); + if (!batchJobEnabled) { + log.info("Status list update batch job is disabled"); + return; + } + + log.info("Starting status list update batch job"); + + try { + // Determine the starting timestamp for processing + LocalDateTime startTime = determineStartTime(); + log.info("Processing transactions since: {}", startTime); + + // Fetch new transactions + List newTransactions = fetchNewTransactions(startTime); + + if (newTransactions.isEmpty()) { + log.info("No new transactions found since {}", startTime); + return; + } + + log.info("Found {} new transactions to process", newTransactions.size()); + + // Group transactions by status list credential ID + Map> transactionsByStatusList = groupTransactionsByStatusList(newTransactions); + + // Update each affected status list + int updatedLists = 0; + for (Map.Entry> entry : transactionsByStatusList.entrySet()) { + String statusListId = entry.getKey(); + List transactions = entry.getValue(); + + try { + updateStatusList(statusListId, transactions); + updatedLists++; + log.info("Successfully updated status list: {}", statusListId); + } catch (Exception e) { + log.error("Failed to update status list: {}", statusListId, e); + // Continue processing other status lists even if one fails + } + } + + // Update last processed time + lastProcessedTime = newTransactions.stream() + .map(CredentialStatusTransaction::getCreatedDtimes) + .max(LocalDateTime::compareTo) + .orElse(LocalDateTime.now()); + + log.info("Status list update batch job completed successfully. Updated {} status lists", updatedLists); + + } catch (Exception e) { + log.error("Error in status list update batch job", e); + throw new CertifyException("BATCH_JOB_EXECUTION_FAILED"); + } + } + + /** + * Determine the starting timestamp for processing transactions + */ + private LocalDateTime determineStartTime() { + if (lastProcessedTime != null) { + return lastProcessedTime; + } + + // First run - get the latest update time from existing status lists + Optional lastKnownUpdate = statusListRepository.findMaxUpdatedTime(); + + if (lastKnownUpdate.isPresent()) { + log.info("Using last known status list update time: {}", lastKnownUpdate.get()); + return lastKnownUpdate.get(); + } + + // No previous updates found, using configured since time + try { + LocalDateTime defaultStart = LocalDateTime.parse(sinceTime); + log.info("No previous update time found, using configured default start time: {}", defaultStart); + return defaultStart; + } catch (DateTimeParseException e) { + // Fallback: safe default to 24 hours ago if parsing fails + LocalDateTime fallbackStart = LocalDateTime.now().minusHours(24); + log.warn("Failed to parse configured since-time '{}'. Falling back to 24 hours ago: {}", sinceTime, fallbackStart); + return fallbackStart; + } + } + + /** + * Fetch new transactions since the given timestamp + */ + private List fetchNewTransactions(LocalDateTime since) { + try { + return transactionRepository.findTransactionsSince(since, batchSize); + } catch (Exception e) { + log.error("Error fetching new transactions since {}", since, e); + throw new CertifyException("TRANSACTION_FETCH_FAILED"); + } + } + + /** + * Group transactions by their status list credential ID + */ + private Map> groupTransactionsByStatusList(List transactions) { + + return transactions.stream() + .filter(t -> t.getStatusListCredentialId() != null) + .collect(Collectors.groupingBy(CredentialStatusTransaction::getStatusListCredentialId)); + } + + /** + * Update a specific status list with the given transactions + */ + @Transactional + public void updateStatusList(String statusListId, List transactions) { + log.info("Updating status list {} with {} transactions", statusListId, transactions.size()); + + try { + // Fetch the current status list credential + Optional optionalStatusList = statusListRepository.findById(statusListId); + + if (optionalStatusList.isEmpty()) { + log.error("Status list credential not found: {}", statusListId); + throw new CertifyException("STATUS_LIST_NOT_FOUND"); + } + + StatusListCredential statusListCredential = optionalStatusList.get(); + + // Get current status data for this status list + Map currentStatuses = getCurrentStatusData(statusListId); + + // Apply transaction updates to the status data + Map updatedStatuses = applyTransactionUpdates(currentStatuses, transactions); + + // Generate new encoded list + String newEncodedList = BitStringStatusListUtils.generateEncodedList(updatedStatuses, statusListCredential.getCapacity()); + + // Update the status list credential with new encoded list + updateStatusListCredential(statusListCredential, newEncodedList); + + log.info("Successfully updated status list credential: {}", statusListId); + + } catch (Exception e) { + log.error("Error updating status list: {}", statusListId, e); + throw new CertifyException("STATUS_LIST_UPDATE_FAILED"); + } + } + + /** + * Get current status data for a specific status list from transactions + */ + private Map getCurrentStatusData(String statusListId) { + // Get the latest status for each index in this status list + List latestTransactions = + transactionRepository.findLatestStatusByStatusListId(statusListId); + + Map statusMap = new HashMap<>(); + for (CredentialStatusTransaction transaction : latestTransactions) { + if (transaction.getStatusListIndex() != null) { + statusMap.put(transaction.getStatusListIndex(), transaction.getStatusValue()); + } + } + + return statusMap; + } + + /** + * Apply transaction updates to the current status data + */ + private Map applyTransactionUpdates( + Map currentStatuses, + List transactions) { + + Map updatedStatuses = new HashMap<>(currentStatuses); + + // Sort transactions by timestamp to apply them in chronological order + transactions.sort(Comparator.comparing(CredentialStatusTransaction::getCreatedDtimes)); + + for (CredentialStatusTransaction transaction : transactions) { + if (transaction.getStatusListIndex() != null) { + updatedStatuses.put(transaction.getStatusListIndex(), transaction.getStatusValue()); + log.info("Applied transaction: index={}, value={}, timestamp={}", + transaction.getStatusListIndex(), + transaction.getStatusValue(), + transaction.getCreatedDtimes()); + } + } + + return updatedStatuses; + } + + /** + * Update the status list credential with the new encoded list + */ + @Transactional + public void updateStatusListCredential(StatusListCredential statusListCredential, String newEncodedList) { + try { + log.info("Starting update of StatusListCredential with ID: {}", statusListCredential.getId()); + + // Parse the current VC document + JSONObject vcDocument = new JSONObject(statusListCredential.getVcDocument()); + log.info("Parsed VC document for StatusListCredential ID: {}", statusListCredential.getId()); + + // Update the encodedList in the credential subject + JSONObject credentialSubject = vcDocument.getJSONObject("credentialSubject"); + credentialSubject.put("encodedList", newEncodedList); + log.info("Updated encodedList for StatusListCredential ID: {}", newEncodedList); + + // Update timestamps + String newValidFrom = new Date().toInstant().toString(); + vcDocument.put("validFrom", newValidFrom); + log.info("Set new validFrom timestamp: {} for StatusListCredential ID: {}", newValidFrom, statusListCredential.getId()); + + // Re-sign the status list credential + String updatedVcDocument = statusListCredentialService.resignStatusListCredential(vcDocument.toString()); + log.info("Re-signed VC document for StatusListCredential ID: {}", statusListCredential.getId()); + + // Update the database record + statusListCredential.setVcDocument(updatedVcDocument); + statusListCredential.setUpdatedDtimes(LocalDateTime.now()); + statusListRepository.save(statusListCredential); + + log.info("Successfully updated and saved StatusListCredential ID: {}", statusListCredential.getId()); + + } catch (Exception e) { + log.error("Error updating StatusListCredential ID: {}", statusListCredential.getId(), e); + throw new CertifyException("STATUS_LIST_CREDENTIAL_UPDATE_FAILED"); + } + } +} + +//H4sIAAAAAAAA_-3OMQ0AAAgDsEnAv1o87CEhrYImAHTmOgAAAAAAAAAAAAAAAAAAwCML9WKdBQBAAAA +//H4sIAAAAAAAA_-3OMQkAAAgAMCMY2ehiBRF8tgSLAGAnvwMAAAAAAAAAAAAAwI36DgAAAMBoH-LL9QBAAAA \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/utils/BitStringStatusListUtils.java b/certify-service/src/main/java/io/mosip/certify/utils/BitStringStatusListUtils.java new file mode 100644 index 000000000..743200f01 --- /dev/null +++ b/certify-service/src/main/java/io/mosip/certify/utils/BitStringStatusListUtils.java @@ -0,0 +1,138 @@ +package io.mosip.certify.utils; + +import io.mosip.certify.core.exception.CertifyException; +import lombok.extern.slf4j.Slf4j; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.Map; +import java.util.zip.GZIPOutputStream; + +/** + * Utility class to handle bit string operations for status lists + * This utility provides static methods for manipulating encoded status lists + */ +@Slf4j +public final class BitStringStatusListUtils { + + // Private constructor to prevent instantiation + private BitStringStatusListUtils() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + /** + * Generate encoded list from a map of index-status pairs + * + * @param statusMap Map containing index -> status mappings + * @param capacity Total capacity of the status list + * @return Base64URL encoded compressed bitstring + */ + public static String generateEncodedList(Map statusMap, long capacity) { + log.info("Generating encoded list from status map with {} entries for capacity {}", + statusMap.size(), capacity); + + try { + // Create bitstring array initialized to false (0) + boolean[] bitstring = new boolean[(int) capacity]; + + // Set the appropriate bits based on the status map + for (Map.Entry entry : statusMap.entrySet()) { + long index = entry.getKey(); + boolean status = entry.getValue(); + + if (index >= 0 && index < capacity) { + bitstring[(int) index] = status; + } else { + log.warn("Index {} is out of bounds for capacity {}", index, capacity); + } + } + + // Convert bitstring to byte array + byte[] byteArray = convertBitstringToByteArray(bitstring); + + // Compress the byte array + byte[] compressedBytes = compressByteArray(byteArray); + + // Encode to base64url + String encodedList = Base64.getUrlEncoder().withoutPadding().encodeToString(compressedBytes); + + log.info("Generated encoded list of length {} from {} status entries", + encodedList.length(), statusMap.size()); + + return encodedList; + + } catch (Exception e) { + log.error("Error generating encoded list from status map", e); + throw new CertifyException("ENCODED_LIST_GENERATION_FAILED"); + } + } + + /** + * Creates an empty encoded list (all bits set to 0) according to W3C Bitstring Status List v1.0 + * + * @param capacity the number of bits in the list + * @return Multibase-encoded base64url (with no padding) string representing the GZIP-compressed bit array + * @throws RuntimeException if compression fails + */ + public static String createEmptyEncodedList(long capacity) { + log.debug("Creating empty encoded list with capacity {}", capacity); + + // Ensure minimum size of 16KB (131,072 bits) as per specification + long actualCapacity = Math.max(capacity, 131072L); + + int numBytes = (int) Math.ceil(actualCapacity / 8.0); + byte[] emptyBitstring = new byte[numBytes]; + + try { + // GZIP compress the bitstring as required by the specification + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (GZIPOutputStream gzipOut = new GZIPOutputStream(baos)) { + gzipOut.write(emptyBitstring); + } + byte[] compressedBitstring = baos.toByteArray(); + + // Multibase-encode using base64url (with no padding) as required by specification + String base64urlEncoded = Base64.getUrlEncoder().withoutPadding() + .encodeToString(compressedBitstring); + + return "u" + base64urlEncoded; + + } catch (IOException e) { + throw new RuntimeException("Failed to compress bitstring", e); + } + } + + /** + * Convert bitstring boolean array to byte array + * Each byte contains 8 bits + */ + private static byte[] convertBitstringToByteArray(boolean[] bitstring) { + int byteLength = (bitstring.length + 7) / 8; // Round up to nearest byte + byte[] byteArray = new byte[byteLength]; + + for (int i = 0; i < bitstring.length; i++) { + if (bitstring[i]) { + int byteIndex = i / 8; + int bitIndex = i % 8; + byteArray[byteIndex] |= (1 << (7 - bitIndex)); // Set bit (MSB first) + } + } + + return byteArray; + } + + /** + * Compress byte array using GZIP compression + */ + private static byte[] compressByteArray(byte[] input) throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + GZIPOutputStream gzipOut = new GZIPOutputStream(baos)) { + + gzipOut.write(input); + gzipOut.finish(); + + return baos.toByteArray(); + } + } +} \ No newline at end of file diff --git a/certify-service/src/main/java/io/mosip/certify/vcformatters/VelocityTemplatingEngineImpl.java b/certify-service/src/main/java/io/mosip/certify/vcformatters/VelocityTemplatingEngineImpl.java index 1546fd66a..3e256d348 100644 --- a/certify-service/src/main/java/io/mosip/certify/vcformatters/VelocityTemplatingEngineImpl.java +++ b/certify-service/src/main/java/io/mosip/certify/vcformatters/VelocityTemplatingEngineImpl.java @@ -262,7 +262,10 @@ protected static Map jsonify(Map valueMap) { } else if (value instanceof String){ // entities which need to be quoted finalTemplate.put(key, JSONObject.wrap(value)); - } else { + } else if( value instanceof Map) { + finalTemplate.put(key,JSONObject.wrap(value)); + } + else { // no conversion needed finalTemplate.put(key, value); } diff --git a/certify-service/src/main/resources/application-local.properties b/certify-service/src/main/resources/application-local.properties index eaace08df..8c1d830ee 100644 --- a/certify-service/src/main/resources/application-local.properties +++ b/certify-service/src/main/resources/application-local.properties @@ -7,10 +7,10 @@ mosip.certify.security.auth.get-urls={} mosip.certify.security.ignore-csrf-urls=**/actuator/**,/favicon.ico,**/error,\ **/swagger-ui/**,**/v3/api-docs/**,\ - **/issuance/**,**/system-info/**,**/credentials/** + **/issuance/**,**/system-info/**,**/credentials/**,**/status-list/** mosip.certify.security.ignore-auth-urls=/actuator/**,**/error,**/swagger-ui/**,\ - **/v3/api-docs/**, **/issuance/**,/system-info/**,/rendering-template/**,/credentials/** + **/v3/api-docs/**, **/issuance/**,/system-info/**,/rendering-template/**,/credentials/**,**/status-list/** ## ------------------------------------------ Discovery openid-configuration ------------------------------------------- @@ -20,7 +20,7 @@ mosip.certify.authorization.url=http://localhost:8088 mosip.certify.discovery.issuer-id=${mosip.certify.domain.url}${server.servlet.path} mosip.certify.data-provider-plugin.issuer.vc-sign-algo=Ed25519Signature2020 mosip.certify.plugin-mode=DataProvider -mosip.certify.data-provider-plugin.data-integrity.crypto-suite=ecdsa-rdfc-2019 +#mosip.certify.data-provider-plugin.data-integrity.crypto-suite=ecdsa-rdfc-2019 ##--------------change this later--------------------------------- mosip.certify.supported.jwt-proof-alg={'RS256','PS256','ES256'} @@ -354,7 +354,8 @@ spring.datasource.url=jdbc:postgresql://localhost:5432/inji_certify?currentSchem spring.datasource.username=postgres spring.datasource.password=postgres -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true diff --git a/db_scripts/inji_certify/ddl/certify-credential_status_transaction.sql b/db_scripts/inji_certify/ddl/certify-credential_status_transaction.sql new file mode 100644 index 000000000..fdffc9b30 --- /dev/null +++ b/db_scripts/inji_certify/ddl/certify-credential_status_transaction.sql @@ -0,0 +1,57 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. +-- ------------------------------------------------------------------------------------------------- +-- Database Name: inji_certify +-- Table Name : credential_status_transaction +-- Purpose : Credential Status Transaction Table +-- +-- +-- Modified Date Modified By Comments / Remarks +-- ------------------------------------------------------------------------------------------ +-- ------------------------------------------------------------------------------------------ + +-- Create credential_status_transaction table +CREATE TABLE IF NOT EXISTS credential_status_transaction ( + transaction_log_id SERIAL PRIMARY KEY, -- Unique ID for this transaction log entry + credential_id VARCHAR(255) NOT NULL, -- The ID of the credential this transaction pertains to (should exist in ledger.credential_id) + status_purpose VARCHAR(100), -- The purpose of this status update + status_value boolean, -- The status value (true/false) + status_list_credential_id VARCHAR(255), -- The ID of the status list credential involved, if any + status_list_index BIGINT, -- The index on the status list, if any + cr_dtimes TIMESTAMP NOT NULL DEFAULT NOW(), -- Creation timestamp + upd_dtimes TIMESTAMP, -- Update timestamp + + -- Foreign key constraint to ledger table + CONSTRAINT fk_credential_status_transaction_ledger + FOREIGN KEY(credential_id) + REFERENCES ledger(credential_id) + ON DELETE CASCADE + ON UPDATE CASCADE, + + -- Foreign key constraint to status_list_credential table + CONSTRAINT fk_credential_status_transaction_status_list + FOREIGN KEY(status_list_credential_id) + REFERENCES status_list_credential(id) + ON DELETE SET NULL + ON UPDATE CASCADE +); + +-- Add comments for documentation +COMMENT ON TABLE credential_status_transaction IS 'Transaction log for credential status changes and updates.'; +COMMENT ON COLUMN credential_status_transaction.transaction_log_id IS 'Serial primary key for the transaction log entry.'; +COMMENT ON COLUMN credential_status_transaction.credential_id IS 'The ID of the credential this transaction pertains to (references ledger.credential_id).'; +COMMENT ON COLUMN credential_status_transaction.status_purpose IS 'The purpose of this status update (e.g., revocation, suspension).'; +COMMENT ON COLUMN credential_status_transaction.status_value IS 'The status value (true for revoked/suspended, false for active).'; +COMMENT ON COLUMN credential_status_transaction.status_list_credential_id IS 'The ID of the status list credential involved, if any.'; +COMMENT ON COLUMN credential_status_transaction.status_list_index IS 'The index on the status list, if any.'; +COMMENT ON COLUMN credential_status_transaction.cr_dtimes IS 'Timestamp when this transaction was created.'; +COMMENT ON COLUMN credential_status_transaction.upd_dtimes IS 'Timestamp when this transaction was last updated.'; + +-- Create indexes for credential_status_transaction +CREATE INDEX IF NOT EXISTS idx_cst_credential_id ON credential_status_transaction(credential_id); +CREATE INDEX IF NOT EXISTS idx_cst_status_purpose ON credential_status_transaction(status_purpose); +CREATE INDEX IF NOT EXISTS idx_cst_status_list_credential_id ON credential_status_transaction(status_list_credential_id); +CREATE INDEX IF NOT EXISTS idx_cst_status_list_index ON credential_status_transaction(status_list_index); +CREATE INDEX IF NOT EXISTS idx_cst_cr_dtimes ON credential_status_transaction(cr_dtimes); +CREATE INDEX IF NOT EXISTS idx_cst_status_value ON credential_status_transaction(status_value); \ No newline at end of file diff --git a/db_scripts/inji_certify/ddl/certify-ledger.sql b/db_scripts/inji_certify/ddl/certify-ledger.sql new file mode 100644 index 000000000..3bce1240a --- /dev/null +++ b/db_scripts/inji_certify/ddl/certify-ledger.sql @@ -0,0 +1,50 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. +-- ------------------------------------------------------------------------------------------------- +-- Database Name: inji_certify +-- Table Name : ledger +-- Purpose : Ledger to store status list credential entries +-- +-- +-- Modified Date Modified By Comments / Remarks +-- ------------------------------------------------------------------------------------------ +-- ------------------------------------------------------------------------------------------ +-- Create ledger table (insert only table, data once added will not be updated) +CREATE TABLE ledger ( + id SERIAL PRIMARY KEY, -- Auto-incrementing serial primary key + credential_id VARCHAR(255) NOT NULL, -- Unique ID of the Verifiable Credential WHOSE STATUS IS BEING TRACKED + issuer_id VARCHAR(255) NOT NULL, -- Issuer of the TRACKED credential + issue_date TIMESTAMPTZ NOT NULL, -- Issuance date of the TRACKED credential + expiration_date TIMESTAMPTZ, -- Expiration date of the TRACKED credential, if any + credential_type VARCHAR(100) NOT NULL, -- Type of the TRACKED credential (e.g., 'VerifiableId') + indexed_attributes JSONB, -- Optional searchable attributes from the TRACKED credential + credential_status_details JSONB NOT NULL DEFAULT '[]'::jsonb, -- Stores a list of status objects for this credential, defaults to an empty array. + cr_dtimes TIMESTAMP NOT NULL DEFAULT NOW(), -- Creation timestamp of this ledger entry for the tracked credential + + -- Constraints + CONSTRAINT uq_ledger_tracked_credential_id UNIQUE (credential_id), -- Ensure tracked credential_id is unique + CONSTRAINT ensure_credential_status_details_is_array CHECK (jsonb_typeof(credential_status_details) = 'array') -- Ensure it's always a JSON array +); + +-- Add comments for documentation +COMMENT ON TABLE ledger IS 'Stores intrinsic information about tracked Verifiable Credentials and their status history.'; +COMMENT ON COLUMN ledger.id IS 'Serial primary key for the ledger table.'; +COMMENT ON COLUMN ledger.credential_id IS 'Unique identifier of the Verifiable Credential whose status is being tracked. Must be unique across the table.'; +COMMENT ON COLUMN ledger.issuer_id IS 'Identifier of the issuer of the tracked credential.'; +COMMENT ON COLUMN ledger.issue_date IS 'Issuance date of the tracked credential.'; +COMMENT ON COLUMN ledger.expiration_date IS 'Expiration date of the tracked credential, if applicable.'; +COMMENT ON COLUMN ledger.credential_type IS 'The type(s) of the tracked credential (e.g., VerifiableId, ProofOfEnrollment).'; +COMMENT ON COLUMN ledger.indexed_attributes IS 'Stores specific attributes extracted from the tracked credential for optimized searching.'; +COMMENT ON COLUMN ledger.credential_status_details IS 'An array of status objects, guaranteed to be a JSON array (list). Defaults to an empty list []. Each object can contain: status_purpose, status_value (boolean), status_list_credential_id, status_list_index, cr_dtimes, upd_dtimes.'; +COMMENT ON COLUMN ledger.cr_dtimes IS 'Timestamp of when this ledger record for the tracked credential was created.'; + +-- Create indexes for ledger +CREATE INDEX IF NOT EXISTS idx_ledger_credential_id ON ledger(credential_id); +CREATE INDEX IF NOT EXISTS idx_ledger_issuer_id ON ledger(issuer_id); +CREATE INDEX IF NOT EXISTS idx_ledger_credential_type ON ledger(credential_type); +CREATE INDEX IF NOT EXISTS idx_ledger_issue_date ON ledger(issue_date); +CREATE INDEX IF NOT EXISTS idx_ledger_expiration_date ON ledger(expiration_date); +CREATE INDEX IF NOT EXISTS idx_ledger_cr_dtimes ON ledger(cr_dtimes); +CREATE INDEX IF NOT EXISTS idx_gin_ledger_indexed_attrs ON ledger USING GIN (indexed_attributes); +CREATE INDEX IF NOT EXISTS idx_gin_ledger_status_details ON ledger USING GIN (credential_status_details); \ No newline at end of file diff --git a/db_scripts/inji_certify/ddl/certify-status_list_available_indices.sql b/db_scripts/inji_certify/ddl/certify-status_list_available_indices.sql new file mode 100644 index 000000000..756f6bac3 --- /dev/null +++ b/db_scripts/inji_certify/ddl/certify-status_list_available_indices.sql @@ -0,0 +1,53 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. +-- ------------------------------------------------------------------------------------------------- +-- Database Name: inji_certify +-- Table Name : status_list_available_indices +-- Purpose : status_list_available_indices to store status list available indices +-- +-- +-- Modified Date Modified By Comments / Remarks +-- ------------------------------------------------------------------------------------------ +-- ------------------------------------------------------------------------------------------ +-- Create status_list_available_indices table +CREATE TABLE status_list_available_indices ( + id SERIAL PRIMARY KEY, -- Serial primary key + status_list_credential_id VARCHAR(255) NOT NULL, -- References status_list_credential.id + list_index BIGINT NOT NULL, -- The numerical index within the status list + is_assigned BOOLEAN NOT NULL DEFAULT FALSE, -- Flag indicating if this index has been assigned + cr_dtimes TIMESTAMP NOT NULL DEFAULT NOW(), -- Creation timestamp + upd_dtimes TIMESTAMP, -- Update timestamp + + -- Foreign key constraint + CONSTRAINT fk_status_list_credential + FOREIGN KEY(status_list_credential_id) + REFERENCES status_list_credential(id) + ON DELETE CASCADE -- If a status list credential is deleted, its available index entries are also deleted. + ON UPDATE CASCADE, -- If the ID of a status list credential changes, update it here too. + + -- Unique constraint to ensure each index within a list is represented only once + CONSTRAINT uq_list_id_and_index + UNIQUE (status_list_credential_id, list_index) +); + +-- Add comments for documentation +COMMENT ON TABLE status_list_available_indices IS 'Helper table to manage and assign available indices from status list credentials.'; +COMMENT ON COLUMN status_list_available_indices.id IS 'Serial primary key for the available index entry.'; +COMMENT ON COLUMN status_list_available_indices.status_list_credential_id IS 'Identifier of the status list credential this index belongs to (FK to status_list_credential.id).'; +COMMENT ON COLUMN status_list_available_indices.list_index IS 'The numerical index (e.g., 0 to N-1) within the specified status list.'; +COMMENT ON COLUMN status_list_available_indices.is_assigned IS 'Flag indicating if this specific index has been assigned (TRUE) or is available (FALSE).'; +COMMENT ON COLUMN status_list_available_indices.cr_dtimes IS 'Timestamp when this index entry record was created (typically when the parent status list was populated).'; +COMMENT ON COLUMN status_list_available_indices.upd_dtimes IS 'Timestamp when this index entry record was last updated (e.g., when is_assigned changed).'; + +-- Create indexes for status_list_available_indices +-- Partial index specifically for finding available slots +CREATE INDEX IF NOT EXISTS idx_sla_available_indices + ON status_list_available_indices (status_list_credential_id, is_assigned, list_index) + WHERE is_assigned = FALSE; + +-- Additional indexes for performance +CREATE INDEX IF NOT EXISTS idx_sla_status_list_credential_id ON status_list_available_indices(status_list_credential_id); +CREATE INDEX IF NOT EXISTS idx_sla_is_assigned ON status_list_available_indices(is_assigned); +CREATE INDEX IF NOT EXISTS idx_sla_list_index ON status_list_available_indices(list_index); +CREATE INDEX IF NOT EXISTS idx_sla_cr_dtimes ON status_list_available_indices(cr_dtimes); \ No newline at end of file diff --git a/db_scripts/inji_certify/ddl/certify-status_list_credential.sql b/db_scripts/inji_certify/ddl/certify-status_list_credential.sql new file mode 100644 index 000000000..70533c267 --- /dev/null +++ b/db_scripts/inji_certify/ddl/certify-status_list_credential.sql @@ -0,0 +1,41 @@ +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. +-- ------------------------------------------------------------------------------------------------- +-- Database Name: inji_certify +-- Table Name : status_list_credential +-- Purpose : status_list_credential to store status list credential +-- +-- +-- Modified Date Modified By Comments / Remarks +-- ------------------------------------------------------------------------------------------ +-- ------------------------------------------------------------------------------------------ +-- Create ENUM type for credential status +CREATE TYPE credential_status_enum AS ENUM ('available', 'full'); + +-- Create status_list_credential table +CREATE TABLE status_list_credential ( + id VARCHAR(255) PRIMARY KEY, -- The unique ID (URL/DID/URN) extracted from the VC's 'id' field. + vc_document bytea NOT NULL, -- Stores the entire Verifiable Credential JSON document. + credential_type VARCHAR(100) NOT NULL, -- Type of the status list (e.g., 'StatusList2021Credential') + status_purpose VARCHAR(100), -- Intended purpose of this list within the system (e.g., 'revocation', 'suspension', 'general'). NULLABLE. + capacity BIGINT, --- length of status list + credential_status credential_status_enum, -- Use the created ENUM type here + cr_dtimes timestamp NOT NULL default now(), + upd_dtimes timestamp -- When this VC record was last updated in the system +); + +-- Add comments for documentation +COMMENT ON TABLE status_list_credential IS 'Stores full Status List Verifiable Credentials, including their type and intended purpose within the system.'; +COMMENT ON COLUMN status_list_credential.id IS 'Unique identifier (URL/DID/URN) of the Status List VC (extracted from vc_document.id). Primary Key.'; +COMMENT ON COLUMN status_list_credential.vc_document IS 'The complete JSON document of the Status List Verifiable Credential.'; +COMMENT ON COLUMN status_list_credential.credential_type IS 'The type of the Status List credential, often found in vc_document.type (e.g., StatusList2021Credential).'; +COMMENT ON COLUMN status_list_credential.status_purpose IS 'The intended purpose assigned to this entire Status List within the system (e.g., revocation, suspension, general). This may be based on convention or system policy, distinct from the credentialStatus.statusPurpose used by individual credentials.'; +COMMENT ON COLUMN status_list_credential.cr_dtimes IS 'Timestamp when this Status List VC was first added/fetched into the local system.'; +COMMENT ON COLUMN status_list_credential.upd_dtimes IS 'Timestamp when this Status List VC record was last updated.'; + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_slc_status_purpose ON status_list_credential(status_purpose); +CREATE INDEX IF NOT EXISTS idx_slc_credential_type ON status_list_credential(credential_type); +CREATE INDEX IF NOT EXISTS idx_slc_credential_status ON status_list_credential(credential_status); +CREATE INDEX IF NOT EXISTS idx_slc_cr_dtimes ON status_list_credential(cr_dtimes); \ No newline at end of file diff --git a/db_scripts/inji_certify/ddl/combined.sql b/db_scripts/inji_certify/ddl/combined.sql deleted file mode 100644 index b34deb4e8..000000000 --- a/db_scripts/inji_certify/ddl/combined.sql +++ /dev/null @@ -1,223 +0,0 @@ -DROP TABLE IF EXISTS key_alias CASCADE CONSTRAINTS; -CREATE TABLE IF NOT EXISTS key_alias( - id character varying(36) NOT NULL, - app_id character varying(36) NOT NULL, - ref_id character varying(128), - key_gen_dtimes timestamp, - key_expire_dtimes timestamp, - status_code character varying(36), - lang_code character varying(3), - cr_by character varying(256) NOT NULL, - cr_dtimes timestamp NOT NULL, - upd_by character varying(256), - upd_dtimes timestamp, - is_deleted boolean DEFAULT FALSE, - del_dtimes timestamp, - cert_thumbprint character varying(100), - uni_ident character varying(50), - CONSTRAINT pk_keymals_id PRIMARY KEY (id), - CONSTRAINT uni_ident_const UNIQUE (uni_ident) -); -DROP TABLE IF EXISTS key_policy_def CASCADE CONSTRAINTS; -CREATE TABLE IF NOT EXISTS key_policy_def( - app_id character varying(36) NOT NULL, - key_validity_duration smallint, - is_active boolean NOT NULL, - pre_expire_days smallint, - access_allowed character varying(1024), - cr_by character varying(256) NOT NULL, - cr_dtimes timestamp NOT NULL, - upd_by character varying(256), - upd_dtimes timestamp, - is_deleted boolean DEFAULT FALSE, - del_dtimes timestamp, - CONSTRAINT pk_keypdef_id PRIMARY KEY (app_id) -); -DROP TABLE IF EXISTS key_store CASCADE CONSTRAINTS; -CREATE TABLE IF NOT EXISTS key_store( - id character varying(36) NOT NULL, - master_key character varying(36) NOT NULL, - private_key character varying(2500) NOT NULL, - certificate_data character varying NOT NULL, - cr_by character varying(256) NOT NULL, - cr_dtimes timestamp NOT NULL, - upd_by character varying(256), - upd_dtimes timestamp, - is_deleted boolean DEFAULT FALSE, - del_dtimes timestamp, - CONSTRAINT pk_keystr_id PRIMARY KEY (id) -); -DROP TABLE IF EXISTS rendering_template CASCADE CONSTRAINTS; -CREATE TABLE IF NOT EXISTS rendering_template ( - id UUID NOT NULL, - template VARCHAR NOT NULL, - cr_dtimes timestamp NOT NULL, - upd_dtimes timestamp, - CONSTRAINT pk_svgtmp_id PRIMARY KEY (id) -); - -CREATE TABLE credential_config ( - credential_config_key_id VARCHAR(255) NOT NULL UNIQUE, - config_id VARCHAR(255), - status VARCHAR(255), - vc_template VARCHAR, - doctype VARCHAR, - vct VARCHAR, - context VARCHAR NOT NULL, - credential_type VARCHAR NOT NULL, - credential_format VARCHAR(255) NOT NULL, - did_url VARCHAR NOT NULL, - key_manager_app_id VARCHAR(36) NOT NULL, - key_manager_ref_id VARCHAR(128), - signature_algo VARCHAR(36), - sd_claim VARCHAR, - display JSONB NOT NULL, - display_order TEXT[] NOT NULL, - scope VARCHAR(255) NOT NULL, - cryptographic_binding_methods_supported TEXT[] NOT NULL, - credential_signing_alg_values_supported TEXT[] NOT NULL, - proof_types_supported JSONB NOT NULL, - credential_subject JSONB, - claims JSONB, - plugin_configurations JSONB, - cr_dtimes TIMESTAMP NOT NULL, - upd_dtimes TIMESTAMP, - CONSTRAINT pk_config_id PRIMARY KEY (context, credential_type, credential_format) -); - - -INSERT INTO key_policy_def(APP_ID,KEY_VALIDITY_DURATION,PRE_EXPIRE_DAYS,ACCESS_ALLOWED,IS_ACTIVE,CR_BY,CR_DTIMES) VALUES('ROOT', 2920, 1125, 'NA', true, 'mosipadmin', now()); -INSERT INTO key_policy_def(APP_ID,KEY_VALIDITY_DURATION,PRE_EXPIRE_DAYS,ACCESS_ALLOWED,IS_ACTIVE,CR_BY,CR_DTIMES) VALUES('CERTIFY_SERVICE', 1095, 60, 'NA', true, 'mosipadmin', now()); -INSERT INTO key_policy_def(APP_ID,KEY_VALIDITY_DURATION,PRE_EXPIRE_DAYS,ACCESS_ALLOWED,IS_ACTIVE,CR_BY,CR_DTIMES) VALUES('CERTIFY_PARTNER', 1095, 60, 'NA', true, 'mosipadmin', now()); -INSERT INTO key_policy_def(APP_ID,KEY_VALIDITY_DURATION,PRE_EXPIRE_DAYS,ACCESS_ALLOWED,IS_ACTIVE,CR_BY,CR_DTIMES) VALUES('CERTIFY_VC_SIGN_RSA', 1095, 60, 'NA', true, 'mosipadmin', now()); -INSERT INTO key_policy_def(APP_ID,KEY_VALIDITY_DURATION,PRE_EXPIRE_DAYS,ACCESS_ALLOWED,IS_ACTIVE,CR_BY,CR_DTIMES) VALUES('CERTIFY_VC_SIGN_ED25519', 1095, 60, 'NA', true, 'mosipadmin', now()); -INSERT INTO key_policy_def(APP_ID,KEY_VALIDITY_DURATION,PRE_EXPIRE_DAYS,ACCESS_ALLOWED,IS_ACTIVE,CR_BY,CR_DTIMES) VALUES('BASE', 1095, 60, 'NA', true, 'mosipadmin', now()); -INSERT INTO key_policy_def(APP_ID,KEY_VALIDITY_DURATION,PRE_EXPIRE_DAYS,ACCESS_ALLOWED,IS_ACTIVE,CR_BY,CR_DTIMES) VALUES('CERTIFY_VC_SIGN_EC_K1', 1095, 60, 'NA', true, 'mosipadmin', now()); -INSERT INTO key_policy_def(APP_ID,KEY_VALIDITY_DURATION,PRE_EXPIRE_DAYS,ACCESS_ALLOWED,IS_ACTIVE,CR_BY,CR_DTIMES) VALUES('CERTIFY_VC_SIGN_EC_R1', 1095, 60, 'NA', true, 'mosipadmin', now()); - - - -INSERT INTO template_data (context, credential_type, template, credential_format, key_manager_app_id, key_manager_ref_id, did_url, cr_dtimes, upd_dtimes) VALUES ('https://www.w3.org/2018/credentials/v1', 'FarmerCredential,VerifiableCredential', '{ - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://piyush7034.github.io/my-files/farmer.json", - "https://w3id.org/security/suites/ed25519-2020/v1" - ], - "issuer": "${_issuer}", - "type": [ - "VerifiableCredential", - "FarmerCredential" - ], - "issuanceDate": "${validFrom}", - "expirationDate": "${validUntil}", - "credentialSubject": { - "id": "${id}", - "fullName": "${fullName}", - "mobileNumber": "${mobileNumber}", - "dateOfBirth": "${dateOfBirth}", - "gender": "${gender}", - "state": "${state}", - "district": "${district}", - "villageOrTown": "${villageOrTown}", - "postalCode": "${postalCode}", - "landArea": "${landArea}", - "landOwnershipType": "${landOwnershipType}", - "primaryCropType": "${primaryCropType}", - "secondaryCropType": "${secondaryCropType}", - "face": "${face}", - "farmerID": "${farmerID}" - } -} -', 'ldp_vc', 'CERTIFY_VC_SIGN_ED25519','ED25519_SIGN','did:web:vharsh.github.io:DID:harsh#key-0', '2024-10-24 12:32:38.065994', NULL); - -INSERT INTO template_data(context, credential_type, template, credential_format, key_manager_app_id, key_manager_ref_id, did_url, cr_dtimes, upd_dtimes) VALUES ('https://www.w3.org/ns/credentials/v2', 'FarmerCredential,VerifiableCredential', '{ - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://piyush7034.github.io/my-files/farmer.json", - "https://w3id.org/security/suites/ed25519-2020/v1" - ], - "issuer": "${_issuer}", - "type": [ - "VerifiableCredential", - "FarmerCredential" - ], - "validFrom": "${validFrom}", - "validUntil": "${validUntil}", - "credentialSubject": { - "id": "${id}", - "fullName": "${fullName}", - "mobileNumber": "${mobileNumber}", - "dateOfBirth": "${dateOfBirth}", - "gender": "${gender}", - "state": "${state}", - "district": "${district}", - "villageOrTown": "${villageOrTown}", - "postalCode": "${postalCode}", - "landArea": "${landArea}", - "landOwnershipType": "${landOwnershipType}", - "primaryCropType": "${primaryCropType}", - "secondaryCropType": "${secondaryCropType}", - "face": "${face}", - "farmerID": "${farmerID}" - } -}', 'ldp_vc', 'CERTIFY_VC_SIGN_ED25519','ED25519_SIGN', 'did:web:vharsh.github.io:DID:harsh', '2024-10-24 12:32:38.065994', NULL); - - -INSERT INTO template_data (context, credential_type, template, credential_format, key_manager_app_id, key_manager_ref_id, did_url, cr_dtimes, upd_dtimes) VALUES ('https://www.w3.org/2018/credentials/v1', 'FarmerCredential,VerifiableCredential', '{ - "iss": "${_issuer}", - "iat": ${_iat}, - "nbf": ${_nbf}, - "exp": ${_exp}, - "vc": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiableCredential", "AadhaarCredential"], - "credentialSubject": { - "id": "${id}", - "fullName": "${fullName}", - "mobileNumber": "${mobileNumber}", - "dateOfBirth": "${dateOfBirth}", - "gender": "${gender}", - "state": "${state}", - "district": "${district}", - "villageOrTown": "${villageOrTown}", - "postalCode": "${postalCode}", - "landArea": "${landArea}", - "landOwnershipType": "${landOwnershipType}", - "primaryCropType": "${primaryCropType}", - "secondaryCropType": "${secondaryCropType}", - "face": "${face}", - "farmerID": "${farmerID}" - } - } - } -', 'vc+sd-jwt', 'CERTIFY_VC_SIGN_EC_R1','EC_SECP256R1_SIGN','did:web:vharsh.github.io:DID:harsh#key-0', '2024-10-24 12:32:38.065994', NULL); - - -INSERT INTO template_data (context, credential_type, template, credential_format, key_manager_app_id, key_manager_ref_id, did_url, cr_dtimes, upd_dtimes) VALUES ('https://www.w3.org/2018/credentials/v1', 'FarmerCredential,VerifiableCredential', '{ - "iss": "${_issuer}", - "iat": ${_iat}, - "nbf": ${_nbf}, - "exp": ${_exp}, - "vc": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiableCredential", "AadhaarCredential"], - "credentialSubject": { - "id": "${id}", - "fullName": "${fullName}", - "mobileNumber": "${mobileNumber}", - "dateOfBirth": "${dateOfBirth}", - "gender": "${gender}", - "state": "${state}", - "district": "${district}", - "villageOrTown": "${villageOrTown}", - "postalCode": "${postalCode}", - "landArea": "${landArea}", - "landOwnershipType": "${landOwnershipType}", - "primaryCropType": "${primaryCropType}", - "secondaryCropType": "${secondaryCropType}", - "face": "${face}", - "farmerID": "${farmerID}" - } - } - } -', 'dc+sd-jwt', 'CERTIFY_VC_SIGN_EC_K1','EC_SECP256K1_SIGN','did:web:vharsh.github.io:DID:harsh#key-0', '2024-10-24 12:32:38.065994', NULL); diff --git a/db_scripts/inji_certify/combined.sql b/db_scripts/local-setup/combined.sql similarity index 100% rename from db_scripts/inji_certify/combined.sql rename to db_scripts/local-setup/combined.sql diff --git a/db_upgrade_script/mosip_certify/sql/0.11.0_to_0.12.0_rollback.sql b/db_upgrade_script/mosip_certify/sql/0.11.0_to_0.12.0_rollback.sql index 629dbc67a..bb7680424 100644 --- a/db_upgrade_script/mosip_certify/sql/0.11.0_to_0.12.0_rollback.sql +++ b/db_upgrade_script/mosip_certify/sql/0.11.0_to_0.12.0_rollback.sql @@ -68,3 +68,50 @@ COMMENT ON COLUMN credential_template.credential_type IS 'Credential Type: Crede COMMENT ON COLUMN credential_template.template IS 'Template Content: Velocity Template to generate the VC'; COMMENT ON COLUMN credential_template.cr_dtimes IS 'Date when the template was inserted in table.'; COMMENT ON COLUMN credential_template.upd_dtimes IS 'Date when the template was last updated in table.'; + + +-- Indexes for credential_status_transaction +DROP INDEX IF EXISTS certify.idx_cst_credential_id; +DROP INDEX IF EXISTS certify.idx_cst_status_purpose; +DROP INDEX IF EXISTS certify.idx_cst_status_list_credential_id; +DROP INDEX IF EXISTS certify.idx_cst_status_list_index; +DROP INDEX IF EXISTS certify.idx_cst_cr_dtimes; +DROP INDEX IF EXISTS certify.idx_cst_status_value; + +-- Indexes for status_list_available_indices +DROP INDEX IF EXISTS certify.idx_sla_available_indices; +DROP INDEX IF EXISTS certify.idx_sla_status_list_credential_id; +DROP INDEX IF EXISTS certify.idx_sla_is_assigned; +DROP INDEX IF EXISTS certify.idx_sla_list_index; +DROP INDEX IF EXISTS certify.idx_sla_cr_dtimes; + +-- Indexes for ledger +DROP INDEX IF EXISTS certify.idx_ledger_credential_id; +DROP INDEX IF EXISTS certify.idx_ledger_issuer_id; +DROP INDEX IF EXISTS certify.idx_ledger_credential_type; +DROP INDEX IF EXISTS certify.idx_ledger_issue_date; +DROP INDEX IF EXISTS certify.idx_ledger_expiration_date; +DROP INDEX IF EXISTS certify.idx_ledger_cr_dtimes; +DROP INDEX IF EXISTS certify.idx_gin_ledger_indexed_attrs; +DROP INDEX IF EXISTS certify.idx_gin_ledger_status_details; + +-- Indexes for status_list_credential +DROP INDEX IF EXISTS certify.idx_slc_status_purpose; +DROP INDEX IF EXISTS certify.idx_slc_credential_type; +DROP INDEX IF EXISTS certify.idx_slc_credential_status; +DROP INDEX IF EXISTS certify.idx_slc_cr_dtimes; + + +-- ========= Step 2: Drop the newly created tables ========= +-- The order is important to respect foreign key constraints. +-- We drop tables with foreign keys first, then the tables they reference. + +DROP TABLE IF EXISTS certify.credential_status_transaction; +DROP TABLE IF EXISTS certify.status_list_available_indices; +DROP TABLE IF EXISTS certify.ledger; +DROP TABLE IF EXISTS certify.status_list_credential; + + +-- ========= Step 3: Drop the custom ENUM type ========= +-- This can only be done after all tables using the type have been dropped. +DROP TYPE IF EXISTS certify.credential_status_enum; \ No newline at end of file diff --git a/db_upgrade_script/mosip_certify/sql/0.11.0_to_0.12.0_upgrade.sql b/db_upgrade_script/mosip_certify/sql/0.11.0_to_0.12.0_upgrade.sql index 4584b6919..6be819923 100644 --- a/db_upgrade_script/mosip_certify/sql/0.11.0_to_0.12.0_upgrade.sql +++ b/db_upgrade_script/mosip_certify/sql/0.11.0_to_0.12.0_upgrade.sql @@ -83,3 +83,158 @@ COMMENT ON COLUMN credential_config.claims IS 'Claims: JSON object containing su COMMENT ON COLUMN credential_config.plugin_configurations IS 'Plugin Configurations: Array of JSON objects for plugin configurations.'; COMMENT ON COLUMN credential_config.cr_dtimes IS 'Created DateTime: Date and time when the config was inserted in table.'; COMMENT ON COLUMN credential_config.upd_dtimes IS 'Updated DateTime: Date and time when the config was last updated in table.'; + +-- Create ENUM type for credential status +CREATE TYPE credential_status_enum AS ENUM ('available', 'full'); + +-- Create status_list_credential table +CREATE TABLE status_list_credential ( + id VARCHAR(255) PRIMARY KEY, -- The unique ID (URL/DID/URN) extracted from the VC's 'id' field. + vc_document bytea NOT NULL, -- Stores the entire Verifiable Credential JSON document. + credential_type VARCHAR(100) NOT NULL, -- Type of the status list (e.g., 'StatusList2021Credential') + status_purpose VARCHAR(100), -- Intended purpose of this list within the system (e.g., 'revocation', 'suspension', 'general'). NULLABLE. + capacity BIGINT, --- length of status list + credential_status credential_status_enum, -- Use the created ENUM type here + cr_dtimes timestamp NOT NULL default now(), + upd_dtimes timestamp -- When this VC record was last updated in the system +); + +-- Add comments for documentation +COMMENT ON TABLE status_list_credential IS 'Stores full Status List Verifiable Credentials, including their type and intended purpose within the system.'; +COMMENT ON COLUMN status_list_credential.id IS 'Unique identifier (URL/DID/URN) of the Status List VC (extracted from vc_document.id). Primary Key.'; +COMMENT ON COLUMN status_list_credential.vc_document IS 'The complete JSON document of the Status List Verifiable Credential.'; +COMMENT ON COLUMN status_list_credential.credential_type IS 'The type of the Status List credential, often found in vc_document.type (e.g., StatusList2021Credential).'; +COMMENT ON COLUMN status_list_credential.status_purpose IS 'The intended purpose assigned to this entire Status List within the system (e.g., revocation, suspension, general). This may be based on convention or system policy, distinct from the credentialStatus.statusPurpose used by individual credentials.'; +COMMENT ON COLUMN status_list_credential.cr_dtimes IS 'Timestamp when this Status List VC was first added/fetched into the local system.'; +COMMENT ON COLUMN status_list_credential.upd_dtimes IS 'Timestamp when this Status List VC record was last updated.'; + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_slc_status_purpose ON status_list_credential(status_purpose); +CREATE INDEX IF NOT EXISTS idx_slc_credential_type ON status_list_credential(credential_type); +CREATE INDEX IF NOT EXISTS idx_slc_credential_status ON status_list_credential(credential_status); +CREATE INDEX IF NOT EXISTS idx_slc_cr_dtimes ON status_list_credential(cr_dtimes); + +CREATE TABLE IF NOT EXISTS credential_status_transaction ( + transaction_log_id SERIAL PRIMARY KEY, -- Unique ID for this transaction log entry + credential_id VARCHAR(255) NOT NULL, -- The ID of the credential this transaction pertains to (should exist in ledger.credential_id) + status_purpose VARCHAR(100), -- The purpose of this status update + status_value boolean, -- The status value (true/false) + status_list_credential_id VARCHAR(255), -- The ID of the status list credential involved, if any + status_list_index BIGINT, -- The index on the status list, if any + cr_dtimes TIMESTAMP NOT NULL DEFAULT NOW(), -- Creation timestamp + upd_dtimes TIMESTAMP, -- Update timestamp + + -- Foreign key constraint to ledger table + CONSTRAINT fk_credential_status_transaction_ledger + FOREIGN KEY(credential_id) + REFERENCES ledger(credential_id) + ON DELETE CASCADE + ON UPDATE CASCADE, + + -- Foreign key constraint to status_list_credential table + CONSTRAINT fk_credential_status_transaction_status_list + FOREIGN KEY(status_list_credential_id) + REFERENCES status_list_credential(id) + ON DELETE SET NULL + ON UPDATE CASCADE +); + +-- Add comments for documentation +COMMENT ON TABLE credential_status_transaction IS 'Transaction log for credential status changes and updates.'; +COMMENT ON COLUMN credential_status_transaction.transaction_log_id IS 'Serial primary key for the transaction log entry.'; +COMMENT ON COLUMN credential_status_transaction.credential_id IS 'The ID of the credential this transaction pertains to (references ledger.credential_id).'; +COMMENT ON COLUMN credential_status_transaction.status_purpose IS 'The purpose of this status update (e.g., revocation, suspension).'; +COMMENT ON COLUMN credential_status_transaction.status_value IS 'The status value (true for revoked/suspended, false for active).'; +COMMENT ON COLUMN credential_status_transaction.status_list_credential_id IS 'The ID of the status list credential involved, if any.'; +COMMENT ON COLUMN credential_status_transaction.status_list_index IS 'The index on the status list, if any.'; +COMMENT ON COLUMN credential_status_transaction.cr_dtimes IS 'Timestamp when this transaction was created.'; +COMMENT ON COLUMN credential_status_transaction.upd_dtimes IS 'Timestamp when this transaction was last updated.'; + +-- Create indexes for credential_status_transaction +CREATE INDEX IF NOT EXISTS idx_cst_credential_id ON credential_status_transaction(credential_id); +CREATE INDEX IF NOT EXISTS idx_cst_status_purpose ON credential_status_transaction(status_purpose); +CREATE INDEX IF NOT EXISTS idx_cst_status_list_credential_id ON credential_status_transaction(status_list_credential_id); +CREATE INDEX IF NOT EXISTS idx_cst_status_list_index ON credential_status_transaction(status_list_index); +CREATE INDEX IF NOT EXISTS idx_cst_cr_dtimes ON credential_status_transaction(cr_dtimes); +CREATE INDEX IF NOT EXISTS idx_cst_status_value ON credential_status_transaction(status_value); + +CREATE TABLE status_list_available_indices ( + id SERIAL PRIMARY KEY, -- Serial primary key + status_list_credential_id VARCHAR(255) NOT NULL, -- References status_list_credential.id + list_index BIGINT NOT NULL, -- The numerical index within the status list + is_assigned BOOLEAN NOT NULL DEFAULT FALSE, -- Flag indicating if this index has been assigned + cr_dtimes TIMESTAMP NOT NULL DEFAULT NOW(), -- Creation timestamp + upd_dtimes TIMESTAMP, -- Update timestamp + + -- Foreign key constraint + CONSTRAINT fk_status_list_credential + FOREIGN KEY(status_list_credential_id) + REFERENCES status_list_credential(id) + ON DELETE CASCADE -- If a status list credential is deleted, its available index entries are also deleted. + ON UPDATE CASCADE, -- If the ID of a status list credential changes, update it here too. + + -- Unique constraint to ensure each index within a list is represented only once + CONSTRAINT uq_list_id_and_index + UNIQUE (status_list_credential_id, list_index) +); + +-- Add comments for documentation +COMMENT ON TABLE status_list_available_indices IS 'Helper table to manage and assign available indices from status list credentials.'; +COMMENT ON COLUMN status_list_available_indices.id IS 'Serial primary key for the available index entry.'; +COMMENT ON COLUMN status_list_available_indices.status_list_credential_id IS 'Identifier of the status list credential this index belongs to (FK to status_list_credential.id).'; +COMMENT ON COLUMN status_list_available_indices.list_index IS 'The numerical index (e.g., 0 to N-1) within the specified status list.'; +COMMENT ON COLUMN status_list_available_indices.is_assigned IS 'Flag indicating if this specific index has been assigned (TRUE) or is available (FALSE).'; +COMMENT ON COLUMN status_list_available_indices.cr_dtimes IS 'Timestamp when this index entry record was created (typically when the parent status list was populated).'; +COMMENT ON COLUMN status_list_available_indices.upd_dtimes IS 'Timestamp when this index entry record was last updated (e.g., when is_assigned changed).'; + +-- Create indexes for status_list_available_indices +-- Partial index specifically for finding available slots +CREATE INDEX IF NOT EXISTS idx_sla_available_indices + ON status_list_available_indices (status_list_credential_id, is_assigned, list_index) + WHERE is_assigned = FALSE; + +-- Additional indexes for performance +CREATE INDEX IF NOT EXISTS idx_sla_status_list_credential_id ON status_list_available_indices(status_list_credential_id); +CREATE INDEX IF NOT EXISTS idx_sla_is_assigned ON status_list_available_indices(is_assigned); +CREATE INDEX IF NOT EXISTS idx_sla_list_index ON status_list_available_indices(list_index); +CREATE INDEX IF NOT EXISTS idx_sla_cr_dtimes ON status_list_available_indices(cr_dtimes); + + +CREATE TABLE ledger ( + id SERIAL PRIMARY KEY, -- Auto-incrementing serial primary key + credential_id VARCHAR(255) NOT NULL, -- Unique ID of the Verifiable Credential WHOSE STATUS IS BEING TRACKED + issuer_id VARCHAR(255) NOT NULL, -- Issuer of the TRACKED credential + issue_date TIMESTAMPTZ NOT NULL, -- Issuance date of the TRACKED credential + expiration_date TIMESTAMPTZ, -- Expiration date of the TRACKED credential, if any + credential_type VARCHAR(100) NOT NULL, -- Type of the TRACKED credential (e.g., 'VerifiableId') + indexed_attributes JSONB, -- Optional searchable attributes from the TRACKED credential + credential_status_details JSONB NOT NULL DEFAULT '[]'::jsonb, -- Stores a list of status objects for this credential, defaults to an empty array. + cr_dtimes TIMESTAMP NOT NULL DEFAULT NOW(), -- Creation timestamp of this ledger entry for the tracked credential + + -- Constraints + CONSTRAINT uq_ledger_tracked_credential_id UNIQUE (credential_id), -- Ensure tracked credential_id is unique + CONSTRAINT ensure_credential_status_details_is_array CHECK (jsonb_typeof(credential_status_details) = 'array') -- Ensure it's always a JSON array +); + +-- Add comments for documentation +COMMENT ON TABLE ledger IS 'Stores intrinsic information about tracked Verifiable Credentials and their status history.'; +COMMENT ON COLUMN ledger.id IS 'Serial primary key for the ledger table.'; +COMMENT ON COLUMN ledger.credential_id IS 'Unique identifier of the Verifiable Credential whose status is being tracked. Must be unique across the table.'; +COMMENT ON COLUMN ledger.issuer_id IS 'Identifier of the issuer of the tracked credential.'; +COMMENT ON COLUMN ledger.issue_date IS 'Issuance date of the tracked credential.'; +COMMENT ON COLUMN ledger.expiration_date IS 'Expiration date of the tracked credential, if applicable.'; +COMMENT ON COLUMN ledger.credential_type IS 'The type(s) of the tracked credential (e.g., VerifiableId, ProofOfEnrollment).'; +COMMENT ON COLUMN ledger.indexed_attributes IS 'Stores specific attributes extracted from the tracked credential for optimized searching.'; +COMMENT ON COLUMN ledger.credential_status_details IS 'An array of status objects, guaranteed to be a JSON array (list). Defaults to an empty list []. Each object can contain: status_purpose, status_value (boolean), status_list_credential_id, status_list_index, cr_dtimes, upd_dtimes.'; +COMMENT ON COLUMN ledger.cr_dtimes IS 'Timestamp of when this ledger record for the tracked credential was created.'; + +-- Create indexes for ledger +CREATE INDEX IF NOT EXISTS idx_ledger_credential_id ON ledger(credential_id); +CREATE INDEX IF NOT EXISTS idx_ledger_issuer_id ON ledger(issuer_id); +CREATE INDEX IF NOT EXISTS idx_ledger_credential_type ON ledger(credential_type); +CREATE INDEX IF NOT EXISTS idx_ledger_issue_date ON ledger(issue_date); +CREATE INDEX IF NOT EXISTS idx_ledger_expiration_date ON ledger(expiration_date); +CREATE INDEX IF NOT EXISTS idx_ledger_cr_dtimes ON ledger(cr_dtimes); +CREATE INDEX IF NOT EXISTS idx_gin_ledger_indexed_attrs ON ledger USING GIN (indexed_attributes); +CREATE INDEX IF NOT EXISTS idx_gin_ledger_status_details ON ledger USING GIN (credential_status_details); +