Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement CredentialQueryResolver #168

Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public Response queryPresentation(JsonObject query, @HeaderParam(AUTHORIZATION)
var credentials = queryResolver.query(presentationQuery, issuerScopes).orElseThrow(f -> new NotAuthorizedException(f.getFailureDetail()));

// package the credentials in a VP and sign
var presentationResponse = presentationGenerator.createPresentation(credentials, presentationQuery.getPresentationDefinition())
var presentationResponse = presentationGenerator.createPresentation(credentials.toList(), presentationQuery.getPresentationDefinition())
.orElseThrow(failure -> new EdcException("Error creating VerifiablePresentation: %s".formatted(failure.getFailureDetail())));
return Response.ok()
.entity(presentationResponse)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.eclipse.edc.identityhub.spi.model.PresentationSubmission;
import org.eclipse.edc.identityhub.spi.model.presentationdefinition.PresentationDefinition;
import org.eclipse.edc.identityhub.spi.resolution.CredentialQueryResolver;
import org.eclipse.edc.identityhub.spi.resolution.QueryResult;
import org.eclipse.edc.identityhub.spi.verification.AccessTokenVerifier;
import org.eclipse.edc.junit.annotations.ApiTest;
import org.eclipse.edc.spi.EdcException;
Expand All @@ -41,13 +42,15 @@
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;

import static jakarta.json.Json.createObjectBuilder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.eclipse.edc.identityhub.junit.testfixtures.VerifiableCredentialTestUtil.buildSignedJwt;
import static org.eclipse.edc.identityhub.junit.testfixtures.VerifiableCredentialTestUtil.generateEcKey;
import static org.eclipse.edc.identityhub.spi.model.PresentationQuery.PRESENTATION_QUERY_TYPE_PROPERTY;
import static org.eclipse.edc.identityhub.spi.resolution.QueryResult.success;
import static org.eclipse.edc.validator.spi.ValidationResult.failure;
import static org.eclipse.edc.validator.spi.ValidationResult.success;
import static org.eclipse.edc.validator.spi.Violation.violation;
Expand Down Expand Up @@ -134,7 +137,7 @@ void query_queryResolutionFails_shouldReturn403() {
var presentationQueryBuilder = createPresentationQueryBuilder().build();
when(typeTransformerRegistry.transform(isA(JsonObject.class), eq(PresentationQuery.class))).thenReturn(Result.success(presentationQueryBuilder));
when(accessTokenVerifier.verify(anyString())).thenReturn(Result.success(List.of("test-scope1")));
when(queryResolver.query(any(), eq(List.of("test-scope1")))).thenReturn(Result.failure("test-failure"));
when(queryResolver.query(any(), eq(List.of("test-scope1")))).thenReturn(QueryResult.unauthorized("test-failure"));

assertThatThrownBy(() -> controller().queryPresentation(createObjectBuilder().build(), generateJwt()))
.isInstanceOf(NotAuthorizedException.class)
Expand All @@ -148,7 +151,7 @@ void query_presentationGenerationFails_shouldReturn500() {
var presentationQueryBuilder = createPresentationQueryBuilder().build();
when(typeTransformerRegistry.transform(isA(JsonObject.class), eq(PresentationQuery.class))).thenReturn(Result.success(presentationQueryBuilder));
when(accessTokenVerifier.verify(anyString())).thenReturn(Result.success(List.of("test-scope1")));
when(queryResolver.query(any(), eq(List.of("test-scope1")))).thenReturn(Result.success(List.of()));
when(queryResolver.query(any(), eq(List.of("test-scope1")))).thenReturn(success(Stream.empty()));

when(generator.createPresentation(anyList(), any())).thenReturn(Result.failure("test-failure"));

Expand All @@ -163,7 +166,7 @@ void query_success() {
var presentationQueryBuilder = createPresentationQueryBuilder().build();
when(typeTransformerRegistry.transform(isA(JsonObject.class), eq(PresentationQuery.class))).thenReturn(Result.success(presentationQueryBuilder));
when(accessTokenVerifier.verify(anyString())).thenReturn(Result.success(List.of("test-scope1")));
when(queryResolver.query(any(), eq(List.of("test-scope1")))).thenReturn(Result.success(List.of()));
when(queryResolver.query(any(), eq(List.of("test-scope1")))).thenReturn(success(Stream.empty()));

var pres = new PresentationResponse(generateJwt(), new PresentationSubmission("id", "def-id", List.of(new InputDescriptorMapping("id", "ldp_vp", "$.verifiableCredentials[0]"))));
when(generator.createPresentation(anyList(), any())).thenReturn(Result.success(pres));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@

package org.eclipse.edc.identityhub;

import org.eclipse.edc.identityhub.defaults.EdcScopeToCriterionTransformer;
import org.eclipse.edc.identityhub.defaults.InMemoryCredentialStore;
import org.eclipse.edc.identityhub.spi.ScopeToCriterionTransformer;
import org.eclipse.edc.identityhub.spi.generator.PresentationGenerator;
import org.eclipse.edc.identityhub.spi.resolution.CredentialQueryResolver;
import org.eclipse.edc.identityhub.spi.store.CredentialStore;
import org.eclipse.edc.runtime.metamodel.annotation.Extension;
import org.eclipse.edc.runtime.metamodel.annotation.Provider;
Expand All @@ -31,15 +32,17 @@ public CredentialStore createInMemStore() {

}

@Provider(isDefault = true)
public CredentialQueryResolver createCredentialResolver(ServiceExtensionContext context) {
context.getMonitor().warning(" #### Creating a default NOOP CredentialQueryResolver, that will always return 'null'!");
return (query, issuerScopes) -> null;
}

@Provider(isDefault = true)
public PresentationGenerator createPresentationGenerator(ServiceExtensionContext context) {
context.getMonitor().warning(" #### Creating a default NOOP PresentationGenerator, that will always return 'null'!");
return (credentials, presentationDefinition) -> null;
}

@Provider(isDefault = true)
public ScopeToCriterionTransformer createScopeTransformer(ServiceExtensionContext context) {
context.getMonitor().warning("Using the default EdcScopeToCriterionTransformer. This is not intended for production use and should be replaced " +
"with a specialized implementation for your dataspace!");
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved
return new EdcScopeToCriterionTransformer();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
import org.eclipse.edc.iam.did.spi.key.PublicKeyWrapper;
import org.eclipse.edc.iam.did.spi.resolution.DidResolverRegistry;
import org.eclipse.edc.iam.identitytrust.validation.SelfIssuedIdTokenValidator;
import org.eclipse.edc.identityhub.spi.ScopeToCriterionTransformer;
import org.eclipse.edc.identityhub.spi.resolution.CredentialQueryResolver;
import org.eclipse.edc.identityhub.spi.store.CredentialStore;
import org.eclipse.edc.identityhub.spi.verification.AccessTokenVerifier;
import org.eclipse.edc.identityhub.token.verification.AccessTokenVerifierImpl;
import org.eclipse.edc.identitytrust.validation.JwtValidator;
Expand Down Expand Up @@ -61,6 +64,12 @@
@Inject
private JsonLd jsonLd;

@Inject
private CredentialStore credentialStore;

@Inject
private ScopeToCriterionTransformer transformer;

@Override
public void initialize(ServiceExtensionContext context) {
// Setup API
Expand Down Expand Up @@ -88,6 +97,11 @@
return jwtVerifier;
}

@Provider
public CredentialQueryResolver createCredentialQueryResolver(ServiceExtensionContext context) {
Fixed Show fixed Hide fixed
return new CredentialQueryResolverImpl(credentialStore, transformer);
}

private String getOwnDid(ServiceExtensionContext context) {
return context.getConfig().getString(OWN_DID_PROPERTY);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
*
* 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:
* Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation
*
*/

package org.eclipse.edc.identityhub.core;

import org.eclipse.edc.identityhub.spi.ScopeToCriterionTransformer;
import org.eclipse.edc.identityhub.spi.model.PresentationQuery;
import org.eclipse.edc.identityhub.spi.resolution.CredentialQueryResolver;
import org.eclipse.edc.identityhub.spi.resolution.QueryResult;
import org.eclipse.edc.identityhub.spi.store.CredentialStore;
import org.eclipse.edc.identityhub.spi.store.model.VerifiableCredentialResource;
import org.eclipse.edc.spi.query.Criterion;
import org.eclipse.edc.spi.query.QuerySpec;
import org.eclipse.edc.spi.result.AbstractResult;
import org.eclipse.edc.spi.result.Result;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

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


public class CredentialQueryResolverImpl implements CredentialQueryResolver {


private final CredentialStore credentialStore;

private final ScopeToCriterionTransformer scopeTransformer;

public CredentialQueryResolverImpl(CredentialStore credentialStore, ScopeToCriterionTransformer scopeTransformer) {
this.credentialStore = credentialStore;
this.scopeTransformer = scopeTransformer;
}

@Override
public QueryResult query(PresentationQuery query, List<String> issuerScopes) {
if (query.getPresentationDefinition() != null) {
throw new UnsupportedOperationException("Querying with a DIF Presentation Exchange definition is not yet supported.");
}
if (query.getScopes().isEmpty()) {
return QueryResult.noScopeFound("Invalid query: must contain at least one scope.");
}

// check that all prover scopes are valid
var proverScopeResult = parseScopes(query.getScopes());
if (proverScopeResult.failed()) return QueryResult.invalidScope(proverScopeResult.getFailureMessages());
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved

// check that all issuer scopes are valid
var issuerScopeResult = parseScopes(issuerScopes);
if (issuerScopeResult.failed()) return QueryResult.invalidScope(issuerScopeResult.getFailureMessages());
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved

// query storage for requested credentials
var queryspec = convertToQuerySpec(proverScopeResult.getContent());
var res = credentialStore.query(queryspec);
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved
if (res.failed()) {
return QueryResult.storageFailure(res.getFailureMessages());
}

// the credentials requested by the other party
var requestedCredentials = res.getContent().toList();

// check that prover scope is not wider than issuer scope
var issuerQuery = convertToQuerySpec(issuerScopeResult.getContent());
var predicate = issuerQuery.getFilterExpression().stream()
.map(c -> credentialsPredicate(c.getOperandRight().toString()))
.reduce(Predicate::or)
.orElse(x -> false);

// now narrow down the requested credentials to only contain allowed creds
var allowedCredentials = requestedCredentials.stream().filter(predicate).toList();

var isValidQuery = validateResults(new ArrayList<>(requestedCredentials), new ArrayList<>(allowedCredentials));
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved

return isValidQuery ?
QueryResult.success(requestedCredentials.stream().map(VerifiableCredentialResource::getVerifiableCredential))
: QueryResult.unauthorized("Invalid query: requested Credentials outside of scope.");
}

/**
* Returns a predicate that filters {@link VerifiableCredentialResource} objects based on the provided type by
* inspecting the {@code types} property of the {@link org.eclipse.edc.identitytrust.model.VerifiableCredential} that is
* encapsulated in the resource.
*
* @param type The type to filter by.
* @return A predicate that filters {@link VerifiableCredentialResource} objects based on the provided type.
*/
private Predicate<VerifiableCredentialResource> credentialsPredicate(String type) {
return resource -> {
var cred = resource.getVerifiableCredential();
return cred != null && cred.credential() != null && cred.credential().getTypes().contains(type);
};
}

private Result<List<Criterion>> parseScopes(List<String> query) {
var transformResult = query.stream()
.map(scopeTransformer::transform)
.toList();

if (transformResult.stream().anyMatch(AbstractResult::failed)) {
return failure(transformResult.stream().flatMap(r -> r.getFailureMessages().stream()).toList());
}

return success(transformResult.stream().map(AbstractResult::getContent).toList());
}

/**
* Checks whether the list of requested credentials is valid. Validity is determined by whether the list of requested credentials
* contains elements that are not in the list of allowed credentials. The list of allowed credentials may contain more elements, but not less.
* Every element, that is in the list of requested credentials must be found in the list of allowed credentials.
*
* @param requestedCredentials The list of requested credentials.
* @param allowedCredentials The list of allowed credentials.
* @return true if the list of requested credentials contains only elements that can be found in the list of allowed credentials, false otherwise.
*/
private boolean validateResults(List<VerifiableCredentialResource> requestedCredentials, List<VerifiableCredentialResource> allowedCredentials) {
if (requestedCredentials == allowedCredentials) {
return true;
}
if (requestedCredentials.size() > allowedCredentials.size()) {
return false;
}

requestedCredentials.removeAll(allowedCredentials);
return requestedCredentials.isEmpty();
}

private QuerySpec convertToQuerySpec(List<Criterion> criteria) {
return QuerySpec.Builder.newInstance()
.filter(criteria)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
*
* 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:
* Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation
*
*/

package org.eclipse.edc.identityhub.defaults;

import org.eclipse.edc.identityhub.spi.ScopeToCriterionTransformer;
import org.eclipse.edc.spi.query.Criterion;
import org.eclipse.edc.spi.result.Result;

import java.util.List;

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

/**
* Implementation of the {@link ScopeToCriterionTransformer} interface that converts a scope string to a {@link Criterion} object.
* This is a default/example implementation, that assumes scope strings adhere to the following format:
* <pre>
* org.eclipse.edc.vc.type:SomeCredential:[read|all|*]
* </pre>
* This scope string will get translated into a {@link Criterion} like:
* <pre>
* verifiableCredential.credential.types like SomeCredential
* </pre>
*
* <em>This MUST be adapted to the needs and requirements of the dataspace!</em>
* <em>Do NOT use this in production code!</em>
*/
public class EdcScopeToCriterionTransformer implements ScopeToCriterionTransformer {
public static final String TYPE_OPERAND = "verifiableCredential.credential.types";
public static final String ALIAS_LITERAL = "org.eclipse.edc.vc.type";
public static final String LIKE_OPERATOR = "like";
private static final String SCOPE_SEPARATOR = ":";
private final List<String> allowedOperations = List.of("read", "*", "all");

@Override
public Result<Criterion> transform(String scope) {
var tokens = parseScope(scope);
if (tokens.failed()) {
return failure("Scope string cannot be converted: %s".formatted(tokens.getFailureDetail()));
}
var credentialType = tokens.getContent()[1];
return success(new Criterion(TYPE_OPERAND, LIKE_OPERATOR, credentialType));
}

private Result<String[]> parseScope(String scope) {
if (scope == null) return failure("Scope was null");
paullatzelsperger marked this conversation as resolved.
Show resolved Hide resolved

var tokens = scope.split(SCOPE_SEPARATOR);
if (tokens.length != 3) {
return failure("Scope string has invalid format.");
}
if (!ALIAS_LITERAL.equalsIgnoreCase(tokens[0])) {
return failure("Scope alias MUST be %s but was %s".formatted(ALIAS_LITERAL, tokens[0]));
}
if (!allowedOperations.contains(tokens[2])) {
return failure("Invalid scope operation: " + tokens[2]);
}

return success(tokens);
}
}
Loading
Loading