diff --git a/DEPENDENCIES b/DEPENDENCIES index 9831c8f62..63f6f220b 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -50,7 +50,6 @@ maven/mavencentral/com.github.java-json-tools/json-schema-core/1.2.14, Apache-2. maven/mavencentral/com.github.java-json-tools/json-schema-validator/2.2.14, Apache-2.0 OR LGPL-3.0-or-later, approved, CQ20779 maven/mavencentral/com.github.java-json-tools/msg-simple/1.2, Apache-2.0 OR LGPL-3.0-or-later, approved, #2720 maven/mavencentral/com.github.java-json-tools/uri-template/0.10, Apache-2.0 OR LGPL-3.0-only, approved, #2723 -maven/mavencentral/com.github.stephenc.jcip/jcip-annotations/1.0-1, Apache-2.0, approved, CQ21949 maven/mavencentral/com.google.code.findbugs/jsr305/2.0.1, BSD-3-Clause AND CC-BY-2.5 AND LGPL-2.1+, approved, CQ13390 maven/mavencentral/com.google.code.findbugs/jsr305/3.0.2, Apache-2.0, approved, #20 maven/mavencentral/com.google.code.gson/gson/2.10.1, Apache-2.0, approved, #6159 @@ -76,9 +75,9 @@ maven/mavencentral/com.jcraft/jzlib/1.1.3, BSD-2-Clause, approved, CQ6218 maven/mavencentral/com.lmax/disruptor/3.4.4, Apache-2.0, approved, clearlydefined maven/mavencentral/com.networknt/json-schema-validator/1.0.76, Apache-2.0, approved, CQ22638 maven/mavencentral/com.nimbusds/nimbus-jose-jwt/9.28, Apache-2.0, approved, clearlydefined -maven/mavencentral/com.nimbusds/nimbus-jose-jwt/9.37.3, Apache-2.0, approved, #11701 -maven/mavencentral/com.nimbusds/nimbus-jose-jwt/9.38, , restricted, clearlydefined -maven/mavencentral/com.puppycrawl.tools/checkstyle/10.16.0, , restricted, clearlydefined +maven/mavencentral/com.nimbusds/nimbus-jose-jwt/9.38, Apache-2.0, approved, clearlydefined +maven/mavencentral/com.nimbusds/nimbus-jose-jwt/9.39, Apache-2.0, approved, clearlydefined +maven/mavencentral/com.puppycrawl.tools/checkstyle/10.16.0, LGPL-2.1-or-later AND (Apache-2.0 AND LGPL-2.1-or-later) AND Apache-2.0, approved, #14689 maven/mavencentral/com.samskivert/jmustache/1.15, BSD-2-Clause, approved, clearlydefined maven/mavencentral/com.squareup.okhttp3/okhttp-dnsoverhttps/4.12.0, Apache-2.0, approved, #11159 maven/mavencentral/com.squareup.okhttp3/okhttp/4.12.0, Apache-2.0, approved, #11156 diff --git a/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java b/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java index 79826befb..ed6477cdb 100644 --- a/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java +++ b/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java @@ -92,7 +92,7 @@ public ScopeToCriterionTransformer createScopeTransformer(ServiceExtensionContex return new EdcScopeToCriterionTransformer(); } - @Provider + @Provider(isDefault = true) public RevocationListService createRevocationListService(ServiceExtensionContext context) { if (revocationService == null) { var validity = context.getConfig().getLong(REVOCATION_CACHE_VALIDITY, DEFAULT_REVOCATION_CACHE_VALIDITY_MILLIS); diff --git a/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/core/CoreServicesExtension.java b/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/core/CoreServicesExtension.java index 24ac7727d..e03bebee5 100644 --- a/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/core/CoreServicesExtension.java +++ b/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/core/CoreServicesExtension.java @@ -18,6 +18,7 @@ import org.eclipse.edc.iam.identitytrust.spi.verification.SignatureSuiteRegistry; import org.eclipse.edc.iam.verifiablecredentials.spi.RevocationListService; import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialFormat; +import org.eclipse.edc.identithub.verifiablecredential.CredentialStatusCheckServiceImpl; import org.eclipse.edc.identithub.verifiablepresentation.PresentationCreatorRegistryImpl; import org.eclipse.edc.identithub.verifiablepresentation.VerifiablePresentationServiceImpl; import org.eclipse.edc.identithub.verifiablepresentation.generators.JwtPresentationGenerator; @@ -28,6 +29,7 @@ import org.eclipse.edc.identityhub.spi.keypair.KeyPairService; import org.eclipse.edc.identityhub.spi.model.IdentityHubConstants; import org.eclipse.edc.identityhub.spi.store.CredentialStore; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.CredentialStatusCheckService; import org.eclipse.edc.identityhub.spi.verifiablecredentials.generator.PresentationCreatorRegistry; import org.eclipse.edc.identityhub.spi.verifiablecredentials.generator.VerifiablePresentationService; import org.eclipse.edc.identityhub.spi.verifiablecredentials.resolution.CredentialQueryResolver; @@ -131,7 +133,6 @@ public void initialize(ServiceExtensionContext context) { // Setup API cacheContextDocuments(getClass().getClassLoader()); suiteRegistry.register(IdentityHubConstants.JWS_2020_SIGNATURE_SUITE, new Jws2020SignatureSuite(JacksonJsonLd.createObjectMapper())); - } @Provider @@ -139,7 +140,6 @@ public AccessTokenVerifier createAccessTokenVerifier(ServiceExtensionContext con return new AccessTokenVerifierImpl(tokenValidationService, createPublicKey(context), tokenValidationRulesRegistry, context.getMonitor(), publicKeyResolver); } - @Provider public CredentialQueryResolver createCredentialQueryResolver(ServiceExtensionContext context) { return new CredentialQueryResolverImpl(credentialStore, transformer, revocationService, context.getMonitor().withPrefix("Credential Query")); @@ -165,6 +165,11 @@ public VerifiablePresentationService presentationGenerator(ServiceExtensionConte } + @Provider + public CredentialStatusCheckService createStatusCheckService() { + return new CredentialStatusCheckServiceImpl(revocationService, clock); + } + private String getOwnDid(ServiceExtensionContext context) { return context.getConfig().getString(OWN_DID_PROPERTY); } diff --git a/core/lib/verifiable-presentation-lib/build.gradle.kts b/core/lib/verifiable-presentation-lib/build.gradle.kts index 53b3996c7..a3f093c7c 100644 --- a/core/lib/verifiable-presentation-lib/build.gradle.kts +++ b/core/lib/verifiable-presentation-lib/build.gradle.kts @@ -13,9 +13,9 @@ dependencies { implementation(libs.edc.common.crypto) // for the CryptoConverter implementation(libs.edc.lib.jws2020) implementation(libs.edc.vc.ldp) + implementation(libs.edc.verifiablecredentials) testImplementation(libs.edc.junit) testImplementation(libs.edc.lib.jsonld) - testImplementation(testFixtures(project(":spi:identity-hub-spi"))) testImplementation(testFixtures(libs.edc.vc.jwt)) // JWT generator } diff --git a/core/lib/verifiable-presentation-lib/src/main/java/org/eclipse/edc/identithub/verifiablecredential/CredentialStatusCheckServiceImpl.java b/core/lib/verifiable-presentation-lib/src/main/java/org/eclipse/edc/identithub/verifiablecredential/CredentialStatusCheckServiceImpl.java new file mode 100644 index 000000000..5e221302e --- /dev/null +++ b/core/lib/verifiable-presentation-lib/src/main/java/org/eclipse/edc/identithub/verifiablecredential/CredentialStatusCheckServiceImpl.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2024 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identithub.verifiablecredential; + +import org.eclipse.edc.iam.verifiablecredentials.spi.RevocationListService; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.CredentialStatusCheckService; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VerifiableCredentialResource; +import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.result.Result; +import org.jetbrains.annotations.Nullable; + +import java.time.Clock; + +import static org.eclipse.edc.spi.result.Result.failure; +import static org.eclipse.edc.spi.result.Result.success; + + +public class CredentialStatusCheckServiceImpl implements CredentialStatusCheckService { + private static final String SUSPENSION = "suspension"; + private static final String REVOCATION = "revocation"; + private final RevocationListService revocationListService; + private final Clock clock; + + + public CredentialStatusCheckServiceImpl(RevocationListService revocationListService, Clock clock) { + this.revocationListService = revocationListService; + this.clock = clock; + } + + @Override + public Result checkStatus(VerifiableCredentialResource resource) { + + if (isExpired(resource)) { + return success(VcStatus.EXPIRED); + } + VcStatus targetStatus; + if (isNotYetValid(resource)) { + targetStatus = VcStatus.NOT_YET_VALID; + } else { + targetStatus = VcStatus.ISSUED; + } + + try { + if (isRevoked(resource)) { + return success(VcStatus.REVOKED); //irreversible, cannot be overwritten + } + if (isSuspended(resource)) { + targetStatus = VcStatus.SUSPENDED; + } + + } catch (EdcException ex) { + return failure(ex.getMessage()); + } + return success(targetStatus); + } + + // returns true if the expiration date is not null and is before NOW + private boolean isExpired(VerifiableCredentialResource resource) { + var cred = resource.getVerifiableCredential().credential(); + + if (cred == null) { + return false; + } + + var now = clock.instant(); + return cred.getExpirationDate() != null && cred.getExpirationDate().isBefore(now); + } + + // returns true if the issuance date is after NOW + private boolean isNotYetValid(VerifiableCredentialResource resource) { + var cred = resource.getVerifiableCredential().credential(); + if (cred == null) { + return false; + } + + var now = clock.instant(); + // issuance date can not be null, due to builder validation + return cred.getIssuanceDate().isAfter(now); + } + + // returns true if the revocation service returns "suspension" + private boolean isSuspended(VerifiableCredentialResource resource) { + return SUSPENSION.equalsIgnoreCase(fetchRevocationStatus(resource)); + } + + // returns true if the revocation service returns "revocation" + private boolean isRevoked(VerifiableCredentialResource resource) { + return REVOCATION.equalsIgnoreCase(fetchRevocationStatus(resource)); + } + + @Nullable + private String fetchRevocationStatus(VerifiableCredentialResource resource) { + var cred = resource.getVerifiableCredential().credential(); + if (cred == null) { + return null; + } + return revocationListService.getStatusPurpose(cred) + .orElseThrow(f -> new EdcException(f.getFailureDetail())); + } +} diff --git a/core/lib/verifiable-presentation-lib/src/test/java/org/eclipse/edc/identithub/verifiablecredential/CredentialStatusCheckServiceImplTest.java b/core/lib/verifiable-presentation-lib/src/test/java/org/eclipse/edc/identithub/verifiablecredential/CredentialStatusCheckServiceImplTest.java new file mode 100644 index 000000000..8e943fa2b --- /dev/null +++ b/core/lib/verifiable-presentation-lib/src/test/java/org/eclipse/edc/identithub/verifiablecredential/CredentialStatusCheckServiceImplTest.java @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2024 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identithub.verifiablecredential; + +import org.eclipse.edc.iam.verifiablecredentials.spi.RevocationListService; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialFormat; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialSubject; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.Issuer; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredentialContainer; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VerifiableCredentialResource; +import org.eclipse.edc.spi.result.Result; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.UUID; + +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class CredentialStatusCheckServiceImplTest { + + private final RevocationListService revocationListService = mock(); + private final Clock clock = Clock.systemUTC(); + private final CredentialStatusCheckServiceImpl service = new CredentialStatusCheckServiceImpl(revocationListService, clock); + + @BeforeEach + void setUp() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.success(null)); + } + + @Test + void checkStatus_whenExpired() { + var credential = createVerifiableCredential() + .expirationDate(Instant.now(clock).minus(10, ChronoUnit.MINUTES)) + .build(); + assertThat(service.checkStatus(createCredentialBuilder(credential).build())) + .isSucceeded() + .isEqualTo(VcStatus.EXPIRED); + } + + @Test + void checkStatus_notYetValid_becomesValid() { + var now = Instant.now(); + var tenSecondsAgo = now.minus(10, ChronoUnit.SECONDS); + + var credential = createVerifiableCredential() + .issuanceDate(tenSecondsAgo) + .build(); + + var result = service.checkStatus(createCredentialBuilder(credential) + .state(VcStatus.NOT_YET_VALID) + .build()); + assertThat(result).isSucceeded() + .isEqualTo(VcStatus.ISSUED); + } + + @Test + void checkStatus_suspended_becomesSuspended() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.success("suspension")); + var credential = createVerifiableCredential() + .build(); + + var result = service.checkStatus(createCredentialBuilder(credential) + .state(VcStatus.SUSPENDED) + .build()); + assertThat(result).isSucceeded() + .isEqualTo(VcStatus.SUSPENDED); + } + + @Test + void checkStatus_suspended_becomesNotSuspended() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.success(null)); + var credential = createVerifiableCredential() + .build(); + + var result = service.checkStatus(createCredentialBuilder(credential) + .state(VcStatus.SUSPENDED) + .build()); + assertThat(result).isSucceeded() + .isEqualTo(VcStatus.ISSUED); + } + + @Test + void checkStatus_whenRevoked() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.success("revocation")); + var credential = createCredentialBuilder(createVerifiableCredential().build()).build(); + + assertThat(service.checkStatus(credential)) + .isSucceeded() + .isEqualTo(VcStatus.REVOKED); + } + + @Test + void checkStatus_whenSuspended() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.success("suspension")); + var credential = createCredentialBuilder(createVerifiableCredential().build()).build(); + + assertThat(service.checkStatus(credential)) + .isSucceeded() + .isEqualTo(VcStatus.SUSPENDED); + } + + @Test + void checkStatus_whenUnknownRevocationStatus() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.success("foo-status")); + var credential = createCredentialBuilder(createVerifiableCredential().build()).build(); + + assertThat(service.checkStatus(credential)) + .isSucceeded() + .isEqualTo(VcStatus.ISSUED); + } + + @Test + void checkStatus_whenMultipleRules() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.success("revocation")); + var credential = createVerifiableCredential() + .expirationDate(Instant.now(clock).minus(10, ChronoUnit.MINUTES)) + .build(); + assertThat(service.checkStatus(createCredentialBuilder(credential).build())) + .isSucceeded() + .isEqualTo(VcStatus.EXPIRED); + } + + @Test + void checkStatus_revocationCheckThrows() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.failure("failed")); + var credential = createCredentialBuilder(createVerifiableCredential().build()).build(); + + assertThat(service.checkStatus(credential)) + .isFailed() + .detail().isEqualTo("failed"); + } + + @Test + void checkStatus_suspended_becomesExpired() { + var credential = createVerifiableCredential() + .expirationDate(Instant.now(clock).minus(10, ChronoUnit.MINUTES)) + .build(); + assertThat(service.checkStatus(createCredentialBuilder(credential).state(VcStatus.SUSPENDED).build())) + .isSucceeded() + .isEqualTo(VcStatus.EXPIRED); + } + + @Test + void checkStatus_notYetValid_becomesSuspended() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.success("suspension")); + var now = Instant.now(); + var tenSecondsAgo = now.minus(10, ChronoUnit.SECONDS); + + var credential = createVerifiableCredential() + .issuanceDate(tenSecondsAgo) + .build(); + + var result = service.checkStatus(createCredentialBuilder(credential) + .state(VcStatus.NOT_YET_VALID) + .build()); + assertThat(result).isSucceeded() + .isEqualTo(VcStatus.SUSPENDED); + } + + @Test + void checkStatus_notYetValid_becomesNotYetValid() { + var now = Instant.now(); + var inTenSeconds = now.plus(10, ChronoUnit.SECONDS); + + var credential = createVerifiableCredential() + .issuanceDate(inTenSeconds) + .build(); + + var result = service.checkStatus(createCredentialBuilder(credential) + .state(VcStatus.NOT_YET_VALID) + .build()); + assertThat(result).isSucceeded() + .isEqualTo(VcStatus.NOT_YET_VALID); + } + + @Test + void checkStatus_notYetValid_becomesExpired() { + var now = Instant.now(); + var tenSecondsAgo = now.minus(10, ChronoUnit.SECONDS); + var fiveSecondsAgo = now.minus(5, ChronoUnit.SECONDS); + + var credential = createVerifiableCredential() + .issuanceDate(tenSecondsAgo) + .expirationDate(fiveSecondsAgo) + .build(); + + var result = service.checkStatus(createCredentialBuilder(credential) + .state(VcStatus.NOT_YET_VALID) + .build()); + assertThat(result).isSucceeded() + .isEqualTo(VcStatus.EXPIRED); + } + + + @Test + void checkStatus_suspended_becomesRevoked() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.success("revocation")); + var credential = createVerifiableCredential() + .build(); + + assertThat(service.checkStatus(createCredentialBuilder(credential).state(VcStatus.SUSPENDED).build())) + .isSucceeded() + .isEqualTo(VcStatus.REVOKED); + } + + @Test + void checkStatus_expired_becomesRevoked() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.success("revocation")); + var credential = createVerifiableCredential() + .expirationDate(Instant.now(clock).minus(10, ChronoUnit.MINUTES)) + .build(); + + assertThat(service.checkStatus(createCredentialBuilder(credential).state(VcStatus.ISSUED).build())) + .isSucceeded() + .isEqualTo(VcStatus.EXPIRED); + verifyNoInteractions(revocationListService); + } + + @Test + void checkStatus_expired_becomesSuspended() { + when(revocationListService.getStatusPurpose(any())).thenReturn(Result.success("suspension")); + var credential = createVerifiableCredential() + .expirationDate(Instant.now(clock).minus(10, ChronoUnit.MINUTES)) + .build(); + + assertThat(service.checkStatus(createCredentialBuilder(credential).state(VcStatus.ISSUED).build())) + .isSucceeded() + .isEqualTo(VcStatus.EXPIRED); + verifyNoInteractions(revocationListService); + } + + private VerifiableCredentialResource.Builder createCredentialBuilder(VerifiableCredential credential) { + + return VerifiableCredentialResource.Builder.newInstance() + .issuerId("test-issuer") + .holderId("test-holder") + .state(VcStatus.ISSUED) + .participantId("participant-id") + .credential(new VerifiableCredentialContainer("raw-vc-content", CredentialFormat.JSON_LD, credential)) + .id(UUID.randomUUID().toString()); + } + + private VerifiableCredential.Builder createVerifiableCredential() { + return VerifiableCredential.Builder.newInstance() + .credentialSubject(CredentialSubject.Builder.newInstance().id("test-subject").claim("test-key", "test-val").build()) + .issuanceDate(Instant.now(clock).minus(10, ChronoUnit.DAYS)) + .type("VerifiableCredential") + .issuer(new Issuer("test-issuer", Map.of())) + .id("did:web:test-credential"); + } +} \ No newline at end of file diff --git a/extensions/common/credential-watchdog/build.gradle.kts b/extensions/common/credential-watchdog/build.gradle.kts new file mode 100644 index 000000000..d4d77f1d3 --- /dev/null +++ b/extensions/common/credential-watchdog/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +plugins { + `java-library` + `maven-publish` +} + +dependencies { + + api(project(":spi:verifiable-credential-spi")) + api(project(":spi:identity-hub-store-spi")) + implementation(libs.edc.spi.transaction) + + testImplementation(libs.edc.junit) +} diff --git a/extensions/common/credential-watchdog/src/main/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdog.java b/extensions/common/credential-watchdog/src/main/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdog.java new file mode 100644 index 000000000..4ef3d7b90 --- /dev/null +++ b/extensions/common/credential-watchdog/src/main/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdog.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2024 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.common.credentialwatchdog; + +import org.eclipse.edc.identityhub.spi.store.CredentialStore; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.CredentialStatusCheckService; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.transaction.spi.TransactionContext; + +import java.util.Collections; +import java.util.List; + +import static org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus.ISSUED; +import static org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus.NOT_YET_VALID; +import static org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus.SUSPENDED; + +/** + * This is a runnable task that is intended to be executed periodically to fetch all non-expired, non-revoked credentials from storage, check for their status, + * and update their status. Every execution (fetch-all - check-each - update-each) will run in a transaction. + *

+ * Note that this will materialize all credentials into memory at once, as the general assumption is that typically, wallets don't + * store an enormous amount of credentials. To mitigate this, the watchdog only considers credentials in states {@link VcStatus#ISSUED}, + * {@link VcStatus#SUSPENDED} and {@link VcStatus#NOT_YET_VALID}, c.f. {@link CredentialWatchdog#ALLOWED_STATES}. + * + *

+ * Note also, that a credentials status will only be updated if it did in fact change, to avoid unnecessary database interactions. + */ +public class CredentialWatchdog implements Runnable { + //todo: add more states once we have to check issuance status + public static final List ALLOWED_STATES = List.of(ISSUED.code(), NOT_YET_VALID.code(), SUSPENDED.code()); + private final CredentialStore credentialStore; + private final CredentialStatusCheckService credentialStatusCheckService; + private final Monitor monitor; + private final TransactionContext transactionContext; + + public CredentialWatchdog(CredentialStore credentialStore, CredentialStatusCheckService credentialStatusCheckService, Monitor monitor, TransactionContext transactionContext) { + this.credentialStore = credentialStore; + this.credentialStatusCheckService = credentialStatusCheckService; + this.monitor = monitor; + this.transactionContext = transactionContext; + } + + @Override + public void run() { + transactionContext.execute(() -> { + var allCredentials = credentialStore.query(allExcludingExpiredAndRevoked()) + .onFailure(f -> monitor.warning("Failed to fetch credentials from database: %s".formatted(f.getFailureDetail()))) + .orElse(f -> Collections.emptyList()); + + monitor.debug("checking %d credentials".formatted(allCredentials.size())); + + allCredentials.forEach(credential -> { + var newStatus = credentialStatusCheckService.checkStatus(credential) + .orElse(f -> { + monitor.warning("Error determining status for credential '%s': %s. Will move to the ERROR state.".formatted(credential.getId(), f.getFailureDetail())); + return VcStatus.ERROR; + }); + var changed = credential.getState() != newStatus.code(); + if (changed) { + credential.setCredentialStatus(newStatus); + credentialStore.update(credential); + } + }); + }); + } + + private QuerySpec allExcludingExpiredAndRevoked() { + return QuerySpec.Builder.newInstance() + .filter(new Criterion("state", "in", ALLOWED_STATES)) + .build(); + } +} diff --git a/extensions/common/credential-watchdog/src/main/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdogExtension.java b/extensions/common/credential-watchdog/src/main/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdogExtension.java new file mode 100644 index 000000000..382bb6697 --- /dev/null +++ b/extensions/common/credential-watchdog/src/main/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdogExtension.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2024 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.common.credentialwatchdog; + +import org.eclipse.edc.identityhub.spi.store.CredentialStore; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.CredentialStatusCheckService; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.runtime.metamodel.annotation.Setting; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.system.ExecutorInstrumentation; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.transaction.spi.TransactionContext; + +import java.security.SecureRandom; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.eclipse.edc.identityhub.common.credentialwatchdog.CredentialWatchdogExtension.NAME; + +@Extension(value = NAME) +public class CredentialWatchdogExtension implements ServiceExtension { + public static final String NAME = "VerifiableCredential Watchdog Extension"; + + public static final int DEFAULT_WATCHDOG_PERIOD = 60; + @Setting(value = "Period (in seconds) at which the Watchdog thread checks all stored credentials for their status. Configuring a number <=0 disables the Watchdog.", + type = "integer", min = 0, defaultValue = DEFAULT_WATCHDOG_PERIOD + "") + public static final String WATCHDOG_PERIOD_PROPERTY = "edc.iam.credential.status.check.period"; + + public static final int DEFAULT_WATCHDOG_INITIAL_DELAY = 5; + @Setting(value = "Initial delay (in seconds) before the Watchdog thread begins its work.", + type = "integer", min = 0, defaultValue = "random number [1.." + DEFAULT_WATCHDOG_INITIAL_DELAY + "]") + public static final String WATCHDOG_DELAY_PROPERTY = "edc.iam.credential.status.check.delay"; + public static final String CREDENTIAL_WATCHDOG = "CredentialWatchdog"; + private final SecureRandom random = new SecureRandom(); + @Inject + private ExecutorInstrumentation executorInstrumentation; + @Inject + private CredentialStatusCheckService credentialStatusCheckService; + @Inject + private CredentialStore credentialStore; + @Inject + private TransactionContext transactionContext; + private ScheduledExecutorService scheduledExecutorService; + private Integer watchdogPeriod; + private Monitor monitor; + private int initialDelay; + + @Override + public String name() { + return NAME; + } + + @Override + public void initialize(ServiceExtensionContext context) { + watchdogPeriod = context.getSetting(WATCHDOG_PERIOD_PROPERTY, DEFAULT_WATCHDOG_PERIOD); + monitor = context.getMonitor().withPrefix(CREDENTIAL_WATCHDOG); + + if (watchdogPeriod <= 0) { + monitor.debug(() -> "Config property '%s' was <= 0 (%d). The Credential Watchdog is disabled.".formatted(WATCHDOG_PERIOD_PROPERTY, watchdogPeriod)); + } else { + initialDelay = context.getSetting(WATCHDOG_DELAY_PROPERTY, randomDelay()); + monitor.debug(() -> "Credential watchdog will run with a delay of %d seconds, at an interval of %d seconds".formatted(initialDelay, watchdogPeriod)); + scheduledExecutorService = executorInstrumentation.instrument(Executors.newSingleThreadScheduledExecutor(), CREDENTIAL_WATCHDOG); + } + } + + @Override + public void start() { + if (scheduledExecutorService != null && !scheduledExecutorService.isShutdown()) { + monitor.debug(() -> "Starting credential watchdog in %d seconds, every %d seconds".formatted(initialDelay, watchdogPeriod)); + scheduledExecutorService.scheduleAtFixedRate(new CredentialWatchdog(credentialStore, credentialStatusCheckService, monitor, transactionContext), initialDelay, watchdogPeriod, TimeUnit.SECONDS); + } + } + + @Override + public void shutdown() { + if (scheduledExecutorService != null) { + scheduledExecutorService.shutdownNow(); + } + } + + private Integer randomDelay() { + return random.nextInt(1, DEFAULT_WATCHDOG_INITIAL_DELAY); + } +} diff --git a/extensions/common/credential-watchdog/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/extensions/common/credential-watchdog/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 000000000..317352375 --- /dev/null +++ b/extensions/common/credential-watchdog/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,15 @@ +# +# Copyright (c) 2023 Metaform Systems, Inc. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# +# Contributors: +# Metaform Systems, Inc. - initial API and implementation +# +# + +org.eclipse.edc.identityhub.common.credentialwatchdog.CredentialWatchdogExtension \ No newline at end of file diff --git a/extensions/common/credential-watchdog/src/test/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdogExtensionTest.java b/extensions/common/credential-watchdog/src/test/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdogExtensionTest.java new file mode 100644 index 000000000..d0f082434 --- /dev/null +++ b/extensions/common/credential-watchdog/src/test/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdogExtensionTest.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2024 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.common.credentialwatchdog; + + +import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.system.ExecutorInstrumentation; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentMatchers; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static org.eclipse.edc.identityhub.common.credentialwatchdog.CredentialWatchdogExtension.CREDENTIAL_WATCHDOG; +import static org.eclipse.edc.identityhub.common.credentialwatchdog.CredentialWatchdogExtension.WATCHDOG_DELAY_PROPERTY; +import static org.eclipse.edc.identityhub.common.credentialwatchdog.CredentialWatchdogExtension.WATCHDOG_PERIOD_PROPERTY; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(DependencyInjectionExtension.class) +class CredentialWatchdogExtensionTest { + + + private final ExecutorInstrumentation executorInstrumentationMock = mock(); + private Monitor monitor; + + @BeforeEach + void setUp(ServiceExtensionContext context) { + monitor = mock(Monitor.class); + when(monitor.withPrefix(any())).thenReturn(monitor); + context.registerService(Monitor.class, monitor); + context.registerService(ExecutorInstrumentation.class, executorInstrumentationMock); + } + + @DisplayName("Disable watchdog on negative or zero second period") + @ParameterizedTest(name = "Disable on delay of {0} seconds") + @ValueSource(ints = { 0, -1, -100 }) + void initialize_whenNegativePeriod_shouldDisable(int period, CredentialWatchdogExtension extension, ServiceExtensionContext context) { + when(context.getSetting(eq(WATCHDOG_PERIOD_PROPERTY), anyInt())).thenReturn(period); + extension.initialize(context); + + verifyNoInteractions(executorInstrumentationMock); + verify(monitor).debug(ArgumentMatchers.>argThat(stringSupplier -> stringSupplier.get().contains("Credential Watchdog is disabled"))); + } + + @DisplayName("Verify random delay [1..5] if no initial delay is configured") + @Test + void initialize_whenNoDelay_shouldUseRandomBetweenZeroAndFive(CredentialWatchdogExtension extension, ServiceExtensionContext context) { + extension.initialize(context); + + verify(monitor).debug(ArgumentMatchers.>argThat(stringSupplier -> + stringSupplier.get().matches(".* delay of ([1-5]) seconds, at an interval of 60 seconds"))); + verify(executorInstrumentationMock).instrument(any(), eq(CREDENTIAL_WATCHDOG)); + } + + @DisplayName("Verify a configured delay is used") + @Test + void initialize_whenDelay_shouldUseConfiguredDelay(CredentialWatchdogExtension extension, ServiceExtensionContext context) { + when(context.getSetting(eq(WATCHDOG_DELAY_PROPERTY), anyInt())).thenReturn(42); + + extension.initialize(context); + verify(monitor).debug(ArgumentMatchers.>argThat(stringSupplier -> + stringSupplier.get().endsWith("delay of 42 seconds, at an interval of 60 seconds"))); + verify(executorInstrumentationMock).instrument(any(), eq(CREDENTIAL_WATCHDOG)); + } + + @DisplayName("Verify the watchdog is not start if a <=0 period is configured") + @Test + void start_whenWatchdogDisabled_shouldNotStart(CredentialWatchdogExtension extension, ServiceExtensionContext context) { + when(context.getSetting(eq(WATCHDOG_PERIOD_PROPERTY), anyInt())).thenReturn(-10); + + extension.initialize(context); + extension.start(); + verifyNoInteractions(executorInstrumentationMock); + verify(monitor, never()).debug(ArgumentMatchers.>argThat(stringSupplier -> + stringSupplier.get().startsWith("Starting credential watchdog"))); + } + + @DisplayName("Verify watchdog starts when properly configured, at the expected timing intervals") + @Test + void start_shouldStartWatchdog(CredentialWatchdogExtension extension, ServiceExtensionContext context) { + when(context.getSetting(eq(WATCHDOG_PERIOD_PROPERTY), anyInt())).thenReturn(1); + when(context.getSetting(eq(WATCHDOG_DELAY_PROPERTY), anyInt())).thenReturn(1); + var executorMock = mock(ScheduledExecutorService.class); + when(executorInstrumentationMock.instrument(any(), eq(CREDENTIAL_WATCHDOG))) + .thenReturn(executorMock); + when(executorMock.isShutdown()).thenReturn(false); + extension.initialize(context); + extension.start(); + + verify(executorMock).scheduleAtFixedRate(isA(CredentialWatchdog.class), eq(1L), eq(1L), eq(TimeUnit.SECONDS)); + verify(monitor).debug(ArgumentMatchers.>argThat(stringSupplier -> + stringSupplier.get().startsWith("Starting credential watchdog"))); + } + + @DisplayName("Verify shutting down the extension is a NOOP if the watchdog is not started") + @Test + void shutdown_whenNotRunning_shouldNoop(CredentialWatchdogExtension extension, ServiceExtensionContext context) { + when(context.getSetting(eq(WATCHDOG_PERIOD_PROPERTY), anyInt())).thenReturn(-1); // executor will not be initialized + when(context.getSetting(eq(WATCHDOG_DELAY_PROPERTY), anyInt())).thenReturn(1); + var executorMock = mock(ScheduledExecutorService.class); + + extension.initialize(context); + + extension.shutdown(); + + verify(executorInstrumentationMock, never()).instrument(any(), eq(CREDENTIAL_WATCHDOG)); + } + + @DisplayName("Verify shutting down the extension stops the watchdog thread") + @Test + void shutdown_whenRunning_shouldStop(CredentialWatchdogExtension extension, ServiceExtensionContext context) { + when(context.getSetting(eq(WATCHDOG_PERIOD_PROPERTY), anyInt())).thenReturn(1); + when(context.getSetting(eq(WATCHDOG_DELAY_PROPERTY), anyInt())).thenReturn(1); + var executorMock = mock(ScheduledExecutorService.class); + + when(executorInstrumentationMock.instrument(any(), eq(CREDENTIAL_WATCHDOG))).thenReturn(executorMock); + when(executorMock.isShutdown()).thenReturn(false); + extension.initialize(context); + + extension.start(); + extension.shutdown(); + + verify(executorMock).scheduleAtFixedRate(isA(CredentialWatchdog.class), eq(1L), eq(1L), eq(TimeUnit.SECONDS)); + verify(executorMock).shutdownNow(); + } +} \ No newline at end of file diff --git a/extensions/common/credential-watchdog/src/test/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdogTest.java b/extensions/common/credential-watchdog/src/test/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdogTest.java new file mode 100644 index 000000000..6505618da --- /dev/null +++ b/extensions/common/credential-watchdog/src/test/java/org/eclipse/edc/identityhub/common/credentialwatchdog/CredentialWatchdogTest.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2024 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.common.credentialwatchdog; + +import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialFormat; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialSubject; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.Issuer; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredentialContainer; +import org.eclipse.edc.identityhub.spi.store.CredentialStore; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.CredentialStatusCheckService; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VerifiableCredentialResource; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.result.StoreResult; +import org.eclipse.edc.transaction.spi.NoopTransactionContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.eclipse.edc.identityhub.common.credentialwatchdog.CredentialWatchdog.ALLOWED_STATES; +import static org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus.ISSUED; +import static org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus.REVOKED; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class CredentialWatchdogTest { + + private final CredentialStore credentialStore = mock(); + private final CredentialStatusCheckService credentialStatusCheckService = mock(); + private final CredentialWatchdog watchdog = new CredentialWatchdog(credentialStore, credentialStatusCheckService, mock(), new NoopTransactionContext()); + + @BeforeEach + void setUp() { + when(credentialStatusCheckService.checkStatus(any())).thenReturn(Result.success(VcStatus.ISSUED)); + } + + @Test + void run_whenNonRequiresUpdate() { + when(credentialStore.query(any())) + .thenReturn(StoreResult.success(List.of(createCredentialBuilder().build(), createCredentialBuilder().build()))); + + watchdog.run(); + + // verify the store was queried with the proper filter expressions + verify(credentialStore).query(argThat(querySpec -> + querySpec.getFilterExpression().size() == 1 && + querySpec.getFilterExpression().get(0).toString().equals("state in " + ALLOWED_STATES))); + } + + @Test + void run_whenNoCredentials() { + when(credentialStore.query(any())).thenReturn(StoreResult.success(Collections.emptyList())); + + watchdog.run(); + + verifyNoInteractions(credentialStatusCheckService); + verify(credentialStore, never()).update(any()); + } + + @Test + void run_whenRequiresUpdate() { + var cred1 = createCredentialBuilder().build(); + var cred2 = createCredentialBuilder().build(); + + when(credentialStore.query(any())) + .thenReturn(StoreResult.success(List.of(cred1, cred2))); + when(credentialStatusCheckService.checkStatus(any())) + .thenReturn(Result.success(REVOKED)) + .thenReturn(Result.success(ISSUED)); + + watchdog.run(); + + verify(credentialStore).query(any()); + verify(credentialStore).update(argThat(vcr -> vcr.getId().equals(cred1.getId()))); + verifyNoMoreInteractions(credentialStore); + verify(credentialStatusCheckService, times(2)).checkStatus(any()); + verifyNoMoreInteractions(credentialStatusCheckService); + } + + + @Test + void run_whenCheckServiceFails_shouldTransitionError() { + when(credentialStore.query(any())) + .thenReturn(StoreResult.success(List.of(createCredentialBuilder().build(), createCredentialBuilder().build()))); + + when(credentialStatusCheckService.checkStatus(any())) + .thenReturn(Result.failure("test failure")) + .thenReturn(Result.success(ISSUED)); + watchdog.run(); + + verify(credentialStore).query(any()); + verify(credentialStore).update(argThat(vcr -> vcr.getStateAsEnum() == VcStatus.ERROR)); + verifyNoMoreInteractions(credentialStore); + verify(credentialStatusCheckService, times(2)).checkStatus(any()); + } + + private VerifiableCredentialResource.Builder createCredentialBuilder() { + + return VerifiableCredentialResource.Builder.newInstance() + .issuerId("test-issuer") + .holderId("test-holder") + .state(VcStatus.ISSUED) + .participantId("participant-id") + .credential(new VerifiableCredentialContainer("raw-vc-content", CredentialFormat.JSON_LD, createVerifiableCredential().build())) + .id(UUID.randomUUID().toString()); + } + + private VerifiableCredential.Builder createVerifiableCredential() { + return VerifiableCredential.Builder.newInstance() + .credentialSubject(CredentialSubject.Builder.newInstance().id("test-subject").claim("test-key", "test-val").build()) + .issuanceDate(Instant.now().minus(10, ChronoUnit.DAYS)) + .type("VerifiableCredential") + .issuer(new Issuer("test-issuer", Map.of())) + .id("did:web:test-credential"); + } + +} \ No newline at end of file diff --git a/launcher/build.gradle.kts b/launcher/build.gradle.kts index e96a5fd3f..d381ac31e 100644 --- a/launcher/build.gradle.kts +++ b/launcher/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { runtimeOnly(project(":core:identity-hub-participants")) runtimeOnly(project(":core:identity-hub-keypairs")) runtimeOnly(project(":extensions:did:local-did-publisher")) + runtimeOnly(project(":extensions:common:credential-watchdog")) runtimeOnly(project(":extensions:api:management-api:did-api")) runtimeOnly(project(":extensions:api:management-api:participant-context-api")) runtimeOnly(project(":extensions:api:management-api:verifiable-credentials-api")) diff --git a/settings.gradle.kts b/settings.gradle.kts index c956cbaa8..1e7b92b09 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -49,6 +49,7 @@ include(":extensions:store:sql:identity-hub-credentials-store-sql") include(":extensions:store:sql:identity-hub-participantcontext-store-sql") include(":extensions:store:sql:identity-hub-keypair-store-sql") include(":extensions:did:local-did-publisher") +include(":extensions:common:credential-watchdog") // management APIs include(":extensions:api:management-api:validators") diff --git a/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/CredentialStatusCheckService.java b/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/CredentialStatusCheckService.java new file mode 100644 index 000000000..5f793dc53 --- /dev/null +++ b/spi/verifiable-credential-spi/src/main/java/org/eclipse/edc/identityhub/spi/verifiablecredentials/CredentialStatusCheckService.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.spi.verifiablecredentials; + +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus; +import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VerifiableCredentialResource; +import org.eclipse.edc.spi.result.Result; + +/** + * Checks if a {@link VerifiableCredentialResource} is revoked, expired, not-yet-valid or suspended. Once a credential is {@link VcStatus#EXPIRED}, + * it can never transition to another status. + * {@link VcStatus#EXPIRED} and {@link VcStatus#REVOKED} are non-reversible (terminal) states. Once reversible + * states ({@link VcStatus#NOT_YET_VALID} and {@link VcStatus#SUSPENDED}) are cleared, the default state {@link VcStatus#ISSUED} is assumed. + */ +@FunctionalInterface +public interface CredentialStatusCheckService { + /** + * Checks the current status of a {@link VerifiableCredentialResource}. Note that the status returned by this method + * is not an indicator of a state transition, so client code should check for a change. + * + * @param resource The resource to check. {@link VerifiableCredentialResource#getVerifiableCredential()} cannot be null. + * @return A successful result with the new status, or a failure if a check (e.g. a remote call) failed. + */ + Result checkStatus(VerifiableCredentialResource resource); +}