Skip to content

Commit

Permalink
feat: add getStatusPurpose method (#4172)
Browse files Browse the repository at this point in the history
* improve credential status service (wip)

* javadoc

* adde nullable method

* pr remarks

* DEPENDENCIES
  • Loading branch information
paullatzelsperger committed May 13, 2024
1 parent 4b5c103 commit f3992e2
Show file tree
Hide file tree
Showing 5 changed files with 121 additions and 12 deletions.
6 changes: 3 additions & 3 deletions DEPENDENCIES
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ 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.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.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
Expand Down Expand Up @@ -125,7 +125,7 @@ maven/mavencentral/io.netty/netty-tcnative-classes/2.0.56.Final, Apache-2.0, app
maven/mavencentral/io.netty/netty-transport-native-unix-common/4.1.86.Final, Apache-2.0 AND BSD-3-Clause AND MIT, approved, CQ20926
maven/mavencentral/io.netty/netty-transport/4.1.86.Final, Apache-2.0 AND BSD-3-Clause AND MIT, approved, CQ20926
maven/mavencentral/io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations/1.32.0, Apache-2.0, approved, #11684
maven/mavencentral/io.opentelemetry.proto/opentelemetry-proto/1.3.1-alpha, , restricted, clearlydefined
maven/mavencentral/io.opentelemetry.proto/opentelemetry-proto/1.3.1-alpha, None, restricted, #14688
maven/mavencentral/io.opentelemetry/opentelemetry-api/1.32.0, Apache-2.0, approved, #11682
maven/mavencentral/io.opentelemetry/opentelemetry-context/1.32.0, Apache-2.0, approved, #11683
maven/mavencentral/io.prometheus/simpleclient/0.16.0, Apache-2.0, approved, clearlydefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,16 @@
import org.eclipse.edc.iam.verifiablecredentials.spi.model.statuslist.BitString;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.statuslist.StatusList2021Credential;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.statuslist.StatusListStatus;
import org.eclipse.edc.spi.result.AbstractResult;
import org.eclipse.edc.spi.result.Result;
import org.eclipse.edc.util.collection.Cache;

import java.io.IOException;
import java.net.URI;
import java.util.Map;
import java.util.stream.Collectors;

import static org.eclipse.edc.spi.result.Result.success;

/**
* Service to check if a particular {@link VerifiableCredential} is "valid", where "validity" is defined as not revoked and not suspended.
Expand All @@ -44,7 +48,7 @@ public StatusList2021RevocationService(ObjectMapper objectMapper, long cacheVali
this.objectMapper = objectMapper.copy()
.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) // technically, credential subjects and credential status can be objects AND Arrays
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); // let's make sure this is disabled, because the "@context" would cause problems
cache = new Cache<>(this::updateCredential, cacheValidity);
cache = new Cache<>(this::downloadStatusListCredential, cacheValidity);
}

@Override
Expand All @@ -55,7 +59,43 @@ public Result<Void> checkValidity(VerifiableCredential credential) {
.orElse(Result.failure("Could not check the validity of the credential with ID '%s'".formatted(credential.getId())));
}

@Override
public Result<String> getStatusPurpose(VerifiableCredential credential) {
if (credential.getCredentialStatus().isEmpty()) {
return success(null);
}
var res = credential.getCredentialStatus().stream()
.map(StatusListStatus::parse)
.map(this::getStatusInternal)
.collect(Collectors.groupingBy(AbstractResult::succeeded)); //partition by succeeded/failed

if (res.containsKey(false)) {
return Result.failure(res.get(false).stream().map(AbstractResult::getFailureDetail).toList());
}

var list = res.get(true).stream()
.filter(r -> r.getContent() != null)
.map(AbstractResult::getContent).toList();

// get(0) is OK, because there should only be 1 credentialStatus
return list.isEmpty() ? success(null) : success(list.get(0));

}

private Result<Void> checkStatus(StatusListStatus status) {
var index = status.getStatusListIndex();
return getStatusInternal(status)
.compose(purpose -> purpose != null ?
Result.failure("Credential status is '%s', status at index %d is '1'".formatted(purpose, index)) :
success());
}

/**
* Obtains the status purpose for a particular credentialStatus entry if it is set, otherwise returns a successful result with a {@code null} content.
* So, a successful result with a non-null content indicates, that the respective credentialStatus is set.
*/
private Result<String> getStatusInternal(StatusListStatus status) {
var index = status.getStatusListIndex();
var slCredUrl = status.getStatusListCredential();
var credential = cache.get(slCredUrl);
var slCred = StatusList2021Credential.parse(credential);
Expand All @@ -74,15 +114,14 @@ private Result<Void> checkStatus(StatusListStatus status) {
}
var bitString = bitStringResult.getContent();

var index = status.getStatusListIndex();
// check that the value at index in the bitset is "1"
if (bitString.get(index)) {
return Result.failure("Credential status is '%s', status at index %d is '1'".formatted(purpose, index));
return success(purpose);
}
return Result.success();
return success(null);
}

private VerifiableCredential updateCredential(String credentialUrl) {
private VerifiableCredential downloadStatusListCredential(String credentialUrl) {
try {
return objectMapper.readValue(URI.create(credentialUrl).toURL(), VerifiableCredential.class);
} catch (IOException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ void tearDown() {
}

@Test
void checkRevocation_shenSubjectIsArray() {
void checkRevocation_whenSubjectIsArray() {
clientAndServer.reset();
clientAndServer.when(request().withMethod("GET").withPath("/credentials/status/3"))
.respond(HttpResponse.response().withStatusCode(200).withBody(TestData.STATUS_LIST_CREDENTIAL_SUBJECT_IS_ARRAY));
Expand All @@ -66,7 +66,6 @@ void checkRevocation_shenSubjectIsArray() {
assertThat(revocationService.checkValidity(credential)).isSucceeded();
}


@Test
void checkRevocation_whenNotCached_valid() {
var credential = TestFunctions.createCredentialBuilder().credentialStatus(new CredentialStatus("test-id", "StatusList2021",
Expand Down Expand Up @@ -110,4 +109,58 @@ void checkRevocation_whenCached_valid() {
assertThat(revocationService.checkValidity(credential)).isSucceeded();
clientAndServer.verify(request(), VerificationTimes.exactly(1));
}

@Test
void getStatusPurposes_whenSingleCredentialStatusRevoked() {
clientAndServer.reset();
clientAndServer.when(request().withMethod("GET").withPath("/credentials/status/3"))
.respond(HttpResponse.response().withStatusCode(200).withBody(TestData.STATUS_LIST_CREDENTIAL_SINGLE_SUBJECT));
var credential = TestFunctions.createCredentialBuilder().credentialStatus(new CredentialStatus("test-id", "StatusList2021",
Map.of(STATUS_LIST_PURPOSE, "revocation",
STATUS_LIST_INDEX, REVOKED_INDEX,
STATUS_LIST_CREDENTIAL, "http://localhost:%d/credentials/status/3".formatted(clientAndServer.getPort()))))
.build();
assertThat(revocationService.getStatusPurpose(credential)).isSucceeded()
.isEqualTo("revocation");
}

@Test
void getStatusPurposes_whenMultipleCredentialStatusRevoked() {
clientAndServer.reset();
clientAndServer.when(request().withMethod("GET").withPath("/credentials/status/3"))
.respond(HttpResponse.response().withStatusCode(200).withBody(TestData.STATUS_LIST_CREDENTIAL_SUBJECT_IS_ARRAY));
var credential = TestFunctions.createCredentialBuilder().credentialStatus(new CredentialStatus("test-id", "StatusList2021",
Map.of(STATUS_LIST_PURPOSE, "revocation",
STATUS_LIST_INDEX, REVOKED_INDEX,
STATUS_LIST_CREDENTIAL, "http://localhost:%d/credentials/status/3".formatted(clientAndServer.getPort()))))
.build();
assertThat(revocationService.getStatusPurpose(credential)).isSucceeded()
.isEqualTo("revocation");
}

@Test
void getStatusPurpose_whenCredentialStatusNotActive() {
clientAndServer.reset();
clientAndServer.when(request().withMethod("GET").withPath("/credentials/status/3"))
.respond(HttpResponse.response().withStatusCode(200).withBody(TestData.STATUS_LIST_CREDENTIAL_SINGLE_SUBJECT));
var credential = TestFunctions.createCredentialBuilder().credentialStatus(new CredentialStatus("test-id", "StatusList2021",
Map.of(STATUS_LIST_PURPOSE, "revocation",
STATUS_LIST_INDEX, NOT_REVOKED_INDEX,
STATUS_LIST_CREDENTIAL, "http://localhost:%d/credentials/status/3".formatted(clientAndServer.getPort()))))
.build();
assertThat(revocationService.getStatusPurpose(credential)).isSucceeded()
.isNull();
}

@Test
void getStatusPurpose_whenNoCredentialStatus() {
clientAndServer.reset();
clientAndServer.when(request().withMethod("GET").withPath("/credentials/status/3"))
.respond(HttpResponse.response().withStatusCode(200).withBody(TestData.STATUS_LIST_CREDENTIAL_SINGLE_SUBJECT));
var credential = TestFunctions.createCredentialBuilder().build();
assertThat(revocationService.getStatusPurpose(credential))
.isNotNull()
.isSucceeded();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public static Result<Void> success() {
return new Result<>(null, null);
}

public static <T> Result<T> success(@NotNull T content) {
public static <T> Result<T> success(T content) {
return new Result<>(content, null);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,24 @@
* A credential is regarded as "valid" if its {@code statusPurpose} field matches the status list credential and if
* the value at the index indicated by {@code statusListIndex} is "1".
*/
@FunctionalInterface
public interface RevocationListService {
/**
* Check the "validity" of a credential, where validity is understood as not-revoked and not-suspended. Credentials that don't have
* a {@code credentialStatus} object are deemed valid. If the {@code credentialStatus} object is invalid, the credential is deemed invalid.
*/
Result<Void> checkValidity(VerifiableCredential credential);

/**
* Determines the status of a credential. If a {@code credentialStatus} object exists, the service will determin the "status purpose". It can be:
* <ul>
* <li>null: {@code credentialStatus} object not present, or status purpose is present but the status credential's encoded bitstring resolves a "0" at the status index.
* i.e. the credential is "not revoked" and "not suspended". </li>
* <li>suspended: credential is temporarily deactivated, i.e. the status credential's encoded bitstring resolves a "1" at the status index</li>
* <li>revoked: credential is permanently deactivated, i.e. the status credential's encoded bitstring resolves a "1" at the status index</li>
* </ul>
*
* @param credential The credential to inspect.
* @return either the status purpose, if the status is active, or null, if not active or not present. returns a failure if the status check failed, or the {@code credentialStatus} object is invalid.
*/
Result<String> getStatusPurpose(VerifiableCredential credential);
}

0 comments on commit f3992e2

Please sign in to comment.