Skip to content

Commit

Permalink
feat: Credential create/update API (#434)
Browse files Browse the repository at this point in the history
  • Loading branch information
bscholtes1A authored Aug 27, 2024
1 parent a5cbf52 commit 02d5584
Show file tree
Hide file tree
Showing 18 changed files with 1,108 additions and 167 deletions.
2 changes: 1 addition & 1 deletion DEPENDENCIES
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ maven/mavencentral/org.ow2.asm/asm-commons/9.7, BSD-3-Clause, approved, #14075
maven/mavencentral/org.ow2.asm/asm-tree/9.7, BSD-3-Clause, approved, #14073
maven/mavencentral/org.ow2.asm/asm/9.1, BSD-3-Clause, approved, CQ23029
maven/mavencentral/org.ow2.asm/asm/9.7, BSD-3-Clause, approved, #14076
maven/mavencentral/org.postgresql/postgresql/42.7.3, BSD-2-Clause AND Apache-2.0, approved, #11681
maven/mavencentral/org.postgresql/postgresql/42.7.4, BSD-2-Clause AND Apache-2.0, approved, #11681
maven/mavencentral/org.reflections/reflections/0.10.2, Apache-2.0 AND WTFPL, approved, clearlydefined
maven/mavencentral/org.rnorth.duct-tape/duct-tape/1.0.8, MIT, approved, clearlydefined
maven/mavencentral/org.slf4j/slf4j-api/1.7.22, MIT, approved, CQ11943
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* Copyright (c) 2024 Amadeus IT Group.
*
* 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:
* Amadeus IT Group - initial API and implementation
*
*/

package org.eclipse.edc.identityhub.tests;

import io.restassured.http.Header;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialFormat;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredentialContainer;
import org.eclipse.edc.identityhub.spi.participantcontext.ParticipantContextService;
import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VerifiableCredentialManifest;
import org.eclipse.edc.identityhub.tests.fixtures.IdentityHubEndToEndExtension;
import org.eclipse.edc.identityhub.tests.fixtures.IdentityHubEndToEndTestContext;
import org.eclipse.edc.junit.annotations.EndToEndTest;
import org.eclipse.edc.junit.annotations.PostgresqlIntegrationTest;
import org.eclipse.edc.spi.EdcException;
import org.eclipse.edc.spi.query.QuerySpec;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.util.Arrays;
import java.util.Base64;
import java.util.UUID;

import static io.restassured.http.ContentType.JSON;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.notNullValue;

public class VerifiableCredentialApiEndToEndTest {

abstract static class Tests {

@AfterEach
void tearDown(ParticipantContextService store) {
// purge all users
store.query(QuerySpec.max()).getContent()
.forEach(pc -> store.deleteParticipantContext(pc.getParticipantId()).getContent());
}

@Test
void findById(IdentityHubEndToEndTestContext context) {
var superUserKey = context.createSuperUser();
var user = "user1";
var token = context.createParticipant(user);

var credential = context.createCredential();
var resourceId = context.storeCredential(credential, user);

assertThat(Arrays.asList(token, superUserKey))
.allSatisfy(t -> context.getIdentityApiEndpoint().baseRequest()
.contentType(JSON)
.header(new Header("x-api-key", t))
.get("/v1alpha/participants/%s/credentials/%s".formatted(toBase64(user), resourceId))
.then()
.log().ifValidationFails()
.statusCode(200)
.body(notNullValue()));
}

@Test
void create(IdentityHubEndToEndTestContext context) {
var superUserKey = context.createSuperUser();
var user = "user1";
var token = context.createParticipant(user);

assertThat(Arrays.asList(token, superUserKey))
.allSatisfy(t -> {
var vc = context.createCredential();
var resourceId = UUID.randomUUID().toString();
var manifest = createManifest(user, vc).id(resourceId).build();
context.getIdentityApiEndpoint().baseRequest()
.contentType(JSON)
.header(new Header("x-api-key", t))
.body(manifest)
.post("/v1alpha/participants/%s/credentials".formatted(toBase64(user)))
.then()
.log().ifValidationFails()
.statusCode(204)
.body(notNullValue());

var resource = context.getCredential(resourceId).orElseThrow(() -> new EdcException("Failed to credential with id %s".formatted(resourceId)));
assertThat(resource.getVerifiableCredential().credential()).usingRecursiveComparison().isEqualTo(vc);
});
}

@Test
void update(IdentityHubEndToEndTestContext context) {
var superUserKey = context.createSuperUser();
var user = "user1";
var token = context.createParticipant(user);

assertThat(Arrays.asList(token, superUserKey))
.allSatisfy(t -> {
var credential1 = context.createCredential();
var credential2 = context.createCredential();
var resourceId1 = context.storeCredential(credential1, user);
var manifest = createManifest(user, credential2).id(resourceId1).build();
context.getIdentityApiEndpoint().baseRequest()
.contentType(JSON)
.header(new Header("x-api-key", t))
.body(manifest)
.put("/v1alpha/participants/%s/credentials".formatted(toBase64(user)))
.then()
.log().ifValidationFails()
.statusCode(204)
.body(notNullValue());

var resource = context.getCredential(resourceId1).orElseThrow(() -> new EdcException("Failed to retrieve credential with id %s".formatted(resourceId1)));
assertThat(resource.getVerifiableCredential().credential()).usingRecursiveComparison().isEqualTo(credential2);
});
}

@Test
void delete(IdentityHubEndToEndTestContext context) {
var superUserKey = context.createSuperUser();
var user = "user1";
var token = context.createParticipant(user);

assertThat(Arrays.asList(token, superUserKey))
.allSatisfy(t -> {
var credential = context.createCredential();
var resourceId = context.storeCredential(credential, user);
context.getIdentityApiEndpoint().baseRequest()
.contentType(JSON)
.header(new Header("x-api-key", t))
.delete("/v1alpha/participants/%s/credentials/%s".formatted(toBase64(user), resourceId))
.then()
.log().ifValidationFails()
.statusCode(204)
.body(notNullValue());

var resource = context.getCredential(resourceId);
assertThat(resource.isEmpty()).isTrue();
});
}

private String toBase64(String s) {
return Base64.getUrlEncoder().encodeToString(s.getBytes());
}

private VerifiableCredentialManifest.Builder createManifest(String participantId, VerifiableCredential vc) {
return VerifiableCredentialManifest.Builder.newInstance()
.verifiableCredentialContainer(new VerifiableCredentialContainer("rawVc", CredentialFormat.JWT, vc))
.participantId(participantId);
}

}

@Nested
@EndToEndTest
@ExtendWith(IdentityHubEndToEndExtension.InMemory.class)
class InMemory extends Tests {
}

@Nested
@PostgresqlIntegrationTest
@ExtendWith(IdentityHubEndToEndExtension.Postgres.class)
class Postgres extends Tests {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
import com.nimbusds.jose.jwk.Curve;
import org.eclipse.edc.iam.did.spi.document.DidDocument;
import org.eclipse.edc.iam.did.spi.document.Service;
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.identithub.spi.did.DidDocumentService;
import org.eclipse.edc.identityhub.participantcontext.ApiTokenGenerator;
import org.eclipse.edc.identityhub.spi.authentication.ServicePrincipal;
Expand All @@ -27,17 +32,22 @@
import org.eclipse.edc.identityhub.spi.participantcontext.model.ParticipantContext;
import org.eclipse.edc.identityhub.spi.participantcontext.model.ParticipantManifest;
import org.eclipse.edc.identityhub.spi.participantcontext.model.ParticipantResource;
import org.eclipse.edc.identityhub.spi.store.CredentialStore;
import org.eclipse.edc.identityhub.spi.store.KeyPairResourceStore;
import org.eclipse.edc.identityhub.spi.store.ParticipantContextStore;
import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus;
import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VerifiableCredentialResource;
import org.eclipse.edc.junit.extensions.EmbeddedRuntime;
import org.eclipse.edc.spi.EdcException;
import org.eclipse.edc.spi.query.Criterion;
import org.eclipse.edc.spi.query.QuerySpec;
import org.eclipse.edc.spi.security.Vault;

import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;

/**
Expand All @@ -63,6 +73,28 @@ public String createParticipant(String participantId) {
return createParticipant(participantId, List.of());
}

public VerifiableCredential createCredential() {
return VerifiableCredential.Builder.newInstance()
.id(UUID.randomUUID().toString())
.type("test-type")
.issuanceDate(Instant.now())
.issuer(new Issuer("did:web:issuer"))
.credentialSubject(CredentialSubject.Builder.newInstance().id("id").claim("foo", "bar").build())
.build();
}

public String storeCredential(VerifiableCredential credential, String participantId) {
var resource = VerifiableCredentialResource.Builder.newInstance()
.id(UUID.randomUUID().toString())
.state(VcStatus.ISSUED)
.participantId(participantId)
.holderId("holderId")
.issuerId("issuerId")
.credential(new VerifiableCredentialContainer("rawVc", CredentialFormat.JWT, credential))
.build();
runtime.getService(CredentialStore.class).create(resource).orElseThrow(f -> new EdcException(f.getFailureDetail()));
return resource.getId();
}

public String createParticipant(String participantId, List<String> roles) {
var manifest = ParticipantManifest.Builder.newInstance()
Expand Down Expand Up @@ -161,4 +193,11 @@ public ParticipantContext getParticipant(String participantId) {
.orElseThrow(f -> new EdcException(f.getFailureDetail()));
}

public Optional<VerifiableCredentialResource> getCredential(String credentialId) {
return runtime.getService(CredentialStore.class)
.query(QuerySpec.Builder.newInstance().filter(new Criterion("id", "=", credentialId)).build())
.orElseThrow(f -> new EdcException(f.getFailureDetail()))
.stream().findFirst();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{
"version": "1.0.0-alpha",
"urlPath": "/v1alpha",
"lastUpdated": "2024-08-22T09:20:00Z",
"lastUpdated": "2024-08-27T11:00:00Z",
"maturity": null
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import jakarta.ws.rs.core.SecurityContext;
import org.eclipse.edc.identityhub.spi.keypair.model.KeyPairResource;
import org.eclipse.edc.identityhub.spi.participantcontext.model.KeyDescriptor;
import org.eclipse.edc.identityhub.spi.participantcontext.model.ParticipantContext;
import org.eclipse.edc.web.spi.ApiErrorDetail;

import java.util.Collection;
Expand All @@ -44,7 +43,7 @@ public interface KeyPairResourceApi {
},
responses = {
@ApiResponse(responseCode = "200", description = "The KeyPairResource.",
content = @Content(schema = @Schema(implementation = ParticipantContext.class))),
content = @Content(schema = @Schema(implementation = KeyPairResource.class))),
@ApiResponse(responseCode = "400", description = "Request body was malformed, or the request could not be processed",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")),
@ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.",
Expand All @@ -59,7 +58,7 @@ public interface KeyPairResourceApi {
operationId = "queryKeyPairByParticipantId",
responses = {
@ApiResponse(responseCode = "200", description = "The KeyPairResource.",
content = @Content(schema = @Schema(implementation = ParticipantContext.class))),
content = @Content(array = @ArraySchema(schema = @Schema(implementation = KeyPairResource.class)))),
@ApiResponse(responseCode = "400", description = "Request body was malformed, or the request could not be processed",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")),
@ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.",
Expand All @@ -75,8 +74,7 @@ public interface KeyPairResourceApi {
requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = KeyDescriptor.class), mediaType = "application/json")),
parameters = @Parameter(name = "makeDefault", description = "Make the new key pair the default key pair"),
responses = {
@ApiResponse(responseCode = "200", description = "The KeyPairResource was successfully created and linked to the participant.",
content = @Content(schema = @Schema(implementation = ParticipantContext.class))),
@ApiResponse(responseCode = "200", description = "The KeyPairResource was successfully created and linked to the participant."),
@ApiResponse(responseCode = "400", description = "Request body was malformed, or the request could not be processed",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")),
@ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.",
Expand All @@ -94,8 +92,7 @@ public interface KeyPairResourceApi {
@Parameter(name = "participantId", description = "Base64-Url encode Participant Context ID", required = true, in = ParameterIn.PATH)
},
responses = {
@ApiResponse(responseCode = "200", description = "The KeyPairResource.",
content = @Content(schema = @Schema(implementation = ParticipantContext.class))),
@ApiResponse(responseCode = "200", description = "The KeyPairResource."),
@ApiResponse(responseCode = "400", description = "Request body was malformed, or the request could not be processed.",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")),
@ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.",
Expand All @@ -114,8 +111,7 @@ public interface KeyPairResourceApi {
@Parameter(name = "participantId", description = "Base64-Url encode Participant Context ID", required = true, in = ParameterIn.PATH)
},
responses = {
@ApiResponse(responseCode = "200", description = "The KeyPairResource was successfully rotated and linked to the participant.",
content = @Content(schema = @Schema(implementation = ParticipantContext.class))),
@ApiResponse(responseCode = "200", description = "The KeyPairResource was successfully rotated and linked to the participant."),
@ApiResponse(responseCode = "400", description = "Request body was malformed, or the request could not be processed",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")),
@ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.",
Expand All @@ -133,8 +129,7 @@ public interface KeyPairResourceApi {
},
requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = KeyDescriptor.class), mediaType = "application/json")),
responses = {
@ApiResponse(responseCode = "200", description = "The KeyPairResource was successfully rotated and linked to the participant.",
content = @Content(schema = @Schema(implementation = ParticipantContext.class))),
@ApiResponse(responseCode = "200", description = "The KeyPairResource was successfully rotated and linked to the participant."),
@ApiResponse(responseCode = "400", description = "Request body was malformed, or the request could not be processed",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")),
@ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.",
Expand Down
1 change: 1 addition & 0 deletions extensions/api/identity-api/validators/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ dependencies {
api(libs.edc.spi.core)
api(project(":spi:identity-hub-spi"))
api(project(":spi:did-spi"))
api(project(":spi:verifiable-credential-spi"))
implementation(libs.edc.lib.util)

testImplementation(libs.edc.junit)
Expand Down
Loading

0 comments on commit 02d5584

Please sign in to comment.