Skip to content

Commit

Permalink
feat: implement CredentialQueryResolver (#168)
Browse files Browse the repository at this point in the history
* add possibility to supply PublicKey as config value

* added QueryResolverImpl + Test

* add storageresult class

* made scope-to-criterion conversion pluggable

* cleanup

* javadoc

* DEPENDENCIES

* Update core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java

Co-authored-by: Jim Marino <[email protected]>

* pr remars

---------

Co-authored-by: Jim Marino <[email protected]>
  • Loading branch information
paullatzelsperger and jimmarino authored Nov 9, 2023
1 parent d47d56d commit 9f0a2f8
Show file tree
Hide file tree
Showing 15 changed files with 674 additions and 26 deletions.
7 changes: 0 additions & 7 deletions DEPENDENCIES
Original file line number Diff line number Diff line change
Expand Up @@ -314,19 +314,12 @@ maven/mavencentral/org.jetbrains/annotations/13.0, Apache-2.0, approved, clearly
maven/mavencentral/org.jetbrains/annotations/17.0.0, Apache-2.0, approved, clearlydefined
maven/mavencentral/org.jetbrains/annotations/24.0.1, Apache-2.0, approved, #7417
maven/mavencentral/org.junit-pioneer/junit-pioneer/2.1.0, EPL-2.0, approved, #10550
maven/mavencentral/org.junit.jupiter/junit-jupiter-api/5.10.0, EPL-2.0, approved, #9714
maven/mavencentral/org.junit.jupiter/junit-jupiter-api/5.10.1, EPL-2.0, approved, #9714
maven/mavencentral/org.junit.jupiter/junit-jupiter-engine/5.10.0, EPL-2.0, approved, #9711
maven/mavencentral/org.junit.jupiter/junit-jupiter-engine/5.10.1, EPL-2.0, approved, #9711
maven/mavencentral/org.junit.jupiter/junit-jupiter-params/5.10.0, EPL-2.0, approved, #9708
maven/mavencentral/org.junit.jupiter/junit-jupiter-params/5.10.1, EPL-2.0, approved, #9708
maven/mavencentral/org.junit.platform/junit-platform-commons/1.10.0, EPL-2.0, approved, #9715
maven/mavencentral/org.junit.platform/junit-platform-commons/1.10.1, EPL-2.0, approved, #9715
maven/mavencentral/org.junit.platform/junit-platform-engine/1.10.0, EPL-2.0, approved, #9709
maven/mavencentral/org.junit.platform/junit-platform-engine/1.10.1, EPL-2.0, approved, #9709
maven/mavencentral/org.junit.platform/junit-platform-launcher/1.10.0, EPL-2.0, approved, #9704
maven/mavencentral/org.junit.platform/junit-platform-launcher/1.10.1, EPL-2.0, approved, #9704
maven/mavencentral/org.junit/junit-bom/5.10.0, EPL-2.0, approved, #9844
maven/mavencentral/org.junit/junit-bom/5.10.1, EPL-2.0, approved, #9844
maven/mavencentral/org.junit/junit-bom/5.9.2, EPL-2.0, approved, #4711
maven/mavencentral/org.jvnet.mimepull/mimepull/1.9.15, CDDL-1.1 OR GPL-2.0-only WITH Classpath-exception-2.0, approved, CQ21484
Expand Down
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");
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 @@ public class CoreServicesExtension implements ServiceExtension {
@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 @@ public JwtVerifier getJwtVerifier() {
return jwtVerifier;
}

@Provider
public CredentialQueryResolver createCredentialQueryResolver() {
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,130 @@
/*
* 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.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());
}

// check that all issuer scopes are valid
var issuerScopeResult = parseScopes(issuerScopes);
if (issuerScopeResult.failed()) {
return QueryResult.invalidScope(issuerScopeResult.getFailureMessages());
}

// query storage for requested credentials
var queryspec = convertToQuerySpec(proverScopeResult.getContent());
var credentialResult = credentialStore.query(queryspec);
if (credentialResult.failed()) {
return QueryResult.storageFailure(credentialResult.getFailureMessages());
}

// the credentials requested by the other party
var requestedCredentials = credentialResult.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 credentials
var isValidQuery = requestedCredentials.stream().filter(predicate).count() == requestedCredentials.size();

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);
};
}

/**
* Parses a list of scope strings, converts them to {@link Criterion} objects, and returns a {@link Result} containing
* the list of converted criteria. If any scope string fails to be converted, a failure result is returned.
*
* @param scopes The list of scope strings to parse and convert.
* @return A {@link Result} containing the list of converted {@link Criterion} objects.
*/
private Result<List<Criterion>> parseScopes(List<String> scopes) {
var transformResult = scopes.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());
}

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");

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

0 comments on commit 9f0a2f8

Please sign in to comment.