Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public class EdcScopeToCriterionTransformer implements ScopeToCriterionTransform
// this has to include the "@" for Postgres queries to work because they operate on JSON
public static final String CONTEXT_OPERAND = "verifiableCredential.credential.@context";
public static final String ALIAS_LITERAL = "org.eclipse.dspace.dcp.vc.type";
public static final String ID_OPERAND = "verifiableCredential.credential.id";
public static final String ALIAS_LITERAL_ID = "org.eclipse.dspace.dcp.vc.id";
public static final String EQUALS_OPERATOR = "=";
public static final String CONTAINS_OPERATOR = "contains";
private static final String SCOPE_SEPARATOR = ":";
private final List<String> allowedOperations = List.of("read", "*", "all");
Expand All @@ -56,6 +59,15 @@ public EdcScopeToCriterionTransformer(DiscriminatorMappingRegistry discriminator

@Override
public Result<List<Criterion>> transformScope(String scope) {
if (scope == null) {
return failure("Scope was null");
}

var idAliasPrefix = ALIAS_LITERAL_ID + SCOPE_SEPARATOR;
if (scope.regionMatches(true, 0, idAliasPrefix, 0, idAliasPrefix.length())) {
return convertIdAlias(scope.substring(idAliasPrefix.length()));
}

var tokens = tokenize(scope);
if (tokens.failed()) {
return failure("Scope string cannot be converted: %s".formatted(tokens.getFailureDetail()));
Expand Down Expand Up @@ -93,6 +105,18 @@ protected Result<String[]> tokenize(String scope) {
return success(tokens);
}

/**
* Converts the {@code org.eclipse.dspace.dcp.vc.id} alias, which grants read access to one credential by id. Unlike
* the type alias it carries no operation part, and everything after the first separator is the id, so ids that
* themselves contain separators (URNs, for instance) survive intact.
*/
private Result<List<Criterion>> convertIdAlias(String credentialId) {
if (credentialId.isBlank()) {
return failure("Scope string cannot be converted: no credential ID given after the '%s' alias".formatted(ALIAS_LITERAL_ID));
}
return success(List.of(new Criterion(ID_OPERAND, EQUALS_OPERATOR, credentialId)));
}

private Result<List<Criterion>> convertDiscriminator(String discriminator) {
if (discriminator == null) {
return failure("discriminator was null");
Expand Down
3 changes: 3 additions & 0 deletions core/common-core/src/main/resources/dcp.v1.0.jsonld
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
"credentialType": {
"@id": "dcp:credentialType"
},
"credentialSchema": {
"@id": "dcp:credentialSchema"
},
"offerReason": {
"@id": "dcp:offerReason",
"@type": "xsd:string"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

package org.eclipse.edc.identityhub.defaults;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
Expand Down Expand Up @@ -61,4 +62,37 @@ void transform_withAlias() {
void transform_invalidScope(String scope) {
assertThat(transformer.transformScope(scope)).isFailed();
}

// CS-PRES-11: the org.eclipse.dspace.dcp.vc.id alias MUST be supported and selects one credential by its id
@Test
@DisplayName("CS-PRES-11: the vc.id alias resolves to an equality criterion on the credential id")
void transform_idAlias() {
assertThat(transformer.transformScope("org.eclipse.dspace.dcp.vc.id:8247b87d-8d72-47e1-8128-9ce47e3d829d"))
.isSucceeded()
.satisfies(criteria -> {
assertThat(criteria).hasSize(1);
assertThat(criteria.get(0).getOperandLeft()).isEqualTo("verifiableCredential.credential.id");
assertThat(criteria.get(0).getOperator()).isEqualTo("=");
assertThat(criteria.get(0).getOperandRight()).isEqualTo("8247b87d-8d72-47e1-8128-9ce47e3d829d");
});
}

// the alias carries no operation part, so everything after the first separator belongs to the id
@Test
@DisplayName("CS-PRES-11: an id containing separators is not truncated")
void transform_idAlias_idWithSeparators() {
assertThat(transformer.transformScope("org.eclipse.dspace.dcp.vc.id:urn:uuid:8247b87d-8d72-47e1-8128-9ce47e3d829d"))
.isSucceeded()
.satisfies(criteria -> assertThat(criteria.get(0).getOperandRight()).isEqualTo("urn:uuid:8247b87d-8d72-47e1-8128-9ce47e3d829d"));
}

@ParameterizedTest
@ValueSource(strings = {
"org.eclipse.dspace.dcp.vc.id:",
"org.eclipse.dspace.dcp.vc.id: ",
})
@DisplayName("CS-PRES-11: the vc.id alias without an id is rejected")
void transform_idAlias_withoutId(String scope) {
assertThat(transformer.transformScope(scope)).isFailed();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import org.eclipse.edc.transaction.spi.TransactionContext;
import org.eclipse.edc.transform.spi.TypeTransformerRegistry;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.time.Instant;
Expand Down Expand Up @@ -96,19 +97,83 @@ public ServiceResult<Void> write(String holderPid, String holderDid, String issu
});
}

private ServiceResult<Void> writeCredentials(HolderCredentialRequest holderRequest, String holderPid, String holderDid, String issuerPid, String issuerDid,
Collection<CredentialWriteRequest> writeRequests, String participantContextId) {
@Override
public ServiceResult<Void> reject(String holderPid, String issuerPid, String issuerDid, @Nullable String rejectionReason, String participantContextId) {
return transactionContext.execute(() -> {

var holderRequestResult = holderCredentialRequestStore.findByIdAndLease(holderPid);
if (holderRequestResult.failed()) {
return from(holderRequestResult).mapEmpty();
}

var holderRequest = holderRequestResult.getContent();

var result = rejectRequest(holderRequest, holderPid, issuerPid, issuerDid, rejectionReason, participantContextId);
if (result.failed()) {
holderCredentialRequestStore.breakLease(holderRequest);
}
return result;
});
}

private ServiceResult<Void> rejectRequest(HolderCredentialRequest holderRequest, String holderPid, String issuerPid, String issuerDid,
@Nullable String rejectionReason, String participantContextId) {
var origin = checkOrigin(holderRequest, holderPid, issuerDid, participantContextId);
if (origin.failed()) {
return origin;
}

// once the Issuer's process ID is known it is fixed for this request, so a rejection reporting a different one
// belongs to a different issuance and must not fail this request
var knownIssuerPid = holderRequest.getIssuerPid();
if (knownIssuerPid != null && !knownIssuerPid.isBlank() && !knownIssuerPid.equals(issuerPid)) {
return ServiceResult.badRequest("HolderCredentialRequest '%s' is tracked under issuerPid '%s', but the message reported '%s'"
.formatted(holderPid, knownIssuerPid, issuerPid));
}

// the credentials already arrived, so a rejection that trails a completed issuance must not undo it. It is
// acknowledged rather than rejected, because there is nothing for the Issuer to retry.
if (holderRequest.stateAsEnum() == ISSUED) {
holderCredentialRequestStore.breakLease(holderRequest);
return success();
}

if (holderRequest.stateAsEnum() != REQUESTED) {
return ServiceResult.badRequest("HolderCredentialRequest is expected to be in state '%s' but was '%s'".formatted(REQUESTED, holderRequest.stateAsString()));
}

monitor.debug("Issuer '%s' rejected credential request '%s'".formatted(issuerDid, holderPid));
holderRequest.transitionError(rejectionReason == null || rejectionReason.isBlank()
? "The Issuer rejected the credential request '%s'".formatted(issuerPid)
: "The Issuer rejected the credential request '%s': %s".formatted(issuerPid, rejectionReason));
holderCredentialRequestStore.save(holderRequest);
return success();
}

/**
* Establishes that an inbound message may act on this request at all: it must belong to the addressed participant
* context, and it must come from the Issuer the request was sent to. Asking that Issuer for the credentials is what
* makes it trusted for this request.
*/
private ServiceResult<Void> checkOrigin(HolderCredentialRequest holderRequest, String holderPid, String issuerDid, String participantContextId) {
// requests of other participant contexts are not writable here, and their existence must not be observable either
if (!holderRequest.getParticipantContextId().equals(participantContextId)) {
return ServiceResult.notFound("HolderCredentialRequest with ID '%s' does not exist".formatted(holderPid));
}

// credentials are only accepted from the Issuer the request was addressed to: having asked that Issuer for them
// is what makes it trusted for this request
if (!holderRequest.getIssuerDid().equals(issuerDid)) {
return ServiceResult.unauthorized("HolderCredentialRequest '%s' was sent to Issuer '%s', so credentials delivered by '%s' are not accepted"
.formatted(holderPid, holderRequest.getIssuerDid(), issuerDid));
}
return success();
}

private ServiceResult<Void> writeCredentials(HolderCredentialRequest holderRequest, String holderPid, String holderDid, String issuerPid, String issuerDid,
Collection<CredentialWriteRequest> writeRequests, String participantContextId) {
var origin = checkOrigin(holderRequest, holderPid, issuerDid, participantContextId);
if (origin.failed()) {
return origin;
}

if (!ALLOWED_STATES.contains(holderRequest.stateAsEnum())) {
return ServiceResult.badRequest("HolderCredentialRequest is expected to be in any of the states '%s' but was '%s'".formatted(ALLOWED_STATES, holderRequest.stateAsString()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import org.eclipse.edc.spi.result.StoreResult;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.time.Instant;
Expand All @@ -52,6 +53,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
Expand Down Expand Up @@ -354,8 +356,85 @@ void query_whenRevokedCredential_doesNotInclude() {
verify(monitor).warning(eq("Credential '%s' not valid: revoked".formatted(credential.getId())));
}

// CS-PRES-12: a mixed set of one type - valid, expired and revoked - must present only the valid credential
@Test
@DisplayName("CS-PRES-12: only the valid credential of a valid/expired/revoked set is presented")
void query_mixedValidity_returnsOnlyValid() {
var valid = createCredential("TestCredential").expirationDate(Instant.now().plus(1, ChronoUnit.DAYS)).build();
var expired = createCredential("TestCredential").expirationDate(Instant.now().minus(1, ChronoUnit.DAYS)).build();
var revoked = createCredential("TestCredential")
.credentialStatus(new CredentialStatus("status-id", "StatusList2021Entry",
Map.of("statusListCredential", "https://university.example/credentials/status/3",
"statusPurpose", "revocation",
"statusListIndex", 42)))
.build();
// only the revoked one carries a status list entry, so only it is checked against the registry
when(revocationServiceRegistry.checkValidity(revoked)).thenReturn(Result.failure("revoked"));
// built once: the resolver queries the store twice, and the two result sets are correlated by resource id
var resources = List.of(createCredentialResource(valid).build(),
createCredentialResource(expired).build(),
createCredentialResource(revoked).build());
when(storeMock.query(any())).thenAnswer(i -> success(resources));

var res = resolver.query(TEST_PARTICIPANT_CONTEXT_ID,
createPresentationQuery("org.eclipse.dspace.dcp.vc.type:TestCredential:read"),
List.of("org.eclipse.dspace.dcp.vc.type:TestCredential:read"));

assertThat(res).isSucceeded();
assertThat(res.getContent().map(c -> c.credential().getId())).containsExactly(valid.getId());
}

// CS-PRES-11: a query for the vc.id alias returns exactly the credential with that id
@Test
@DisplayName("CS-PRES-11: a vc.id scope resolves to exactly that credential")
void query_byCredentialId() {
var credential = createCredential("TestCredential").build();
var resource = createCredentialResource(credential).build();
var idScope = "org.eclipse.dspace.dcp.vc.id:" + credential.getId();
when(storeMock.query(queryingFor(credential.getId()))).thenReturn(success(List.of(resource)));

var res = resolver.query(TEST_PARTICIPANT_CONTEXT_ID, createPresentationQuery(idScope), List.of(idScope));

assertThat(res).isSucceeded();
assertThat(res.getContent()).containsExactly(resource.getVerifiableCredential());
// the credential is selected by id, not by type
verify(storeMock, atLeastOnce()).query(argThat(q -> q.getFilterExpression().stream()
.anyMatch(c -> c.getOperandLeft().equals("verifiableCredential.credential.id") && c.getOperandRight().equals(credential.getId()))));
}

// negative variant: an id nobody holds yields an empty presentation rather than an error that leaks its absence
@Test
@DisplayName("CS-PRES-11: an unknown credential id yields an empty result, not an error")
void query_byCredentialId_whenUnknown_returnsEmpty() {
var idScope = "org.eclipse.dspace.dcp.vc.id:" + UUID.randomUUID();
when(storeMock.query(any())).thenAnswer(i -> success(List.of()));

var res = resolver.query(TEST_PARTICIPANT_CONTEXT_ID, createPresentationQuery(idScope), List.of(idScope));

assertThat(res).isSucceeded();
assertThat(res.getContent()).isEmpty();
}

// scope escalation must be caught for the id alias too: an access token for one credential does not unlock another
@Test
@DisplayName("CS-PRES-11: a vc.id query for a credential the token does not authorize returns nothing")
void query_byCredentialId_whenNotAuthorized_returnsEmpty() {
var authorized = createCredential("TestCredential").build();
var other = createCredential("TestCredential").build();
when(storeMock.query(queryingFor(authorized.getId()))).thenReturn(success(List.of(createCredentialResource(authorized).build())));
when(storeMock.query(queryingFor(other.getId()))).thenReturn(success(List.of(createCredentialResource(other).build())));

var res = resolver.query(TEST_PARTICIPANT_CONTEXT_ID,
createPresentationQuery("org.eclipse.dspace.dcp.vc.id:" + other.getId()),
List.of("org.eclipse.dspace.dcp.vc.id:" + authorized.getId()));

assertThat(res).isSucceeded();
assertThat(res.getContent()).isEmpty();
}

private QuerySpec queryingFor(String slug) {
return argThat(q -> q.getFilterExpression().stream().anyMatch(c -> c.getOperandRight().toString().contains(slug)));
// null-safe: with more than one stubbed matcher, Mockito applies each to the placeholder argument of the others
return argThat(q -> q != null && q.getFilterExpression().stream().anyMatch(c -> c.getOperandRight().toString().contains(slug)));
}

private VerifiableCredentialResource.Builder createCredentialResource(VerifiableCredential cred) {
Expand Down
Loading
Loading