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: Credential create/update API #434

Merged
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
2 changes: 1 addition & 1 deletion DEPENDENCIES
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
maven/mavencentral/com.apicatalog/carbon-did/0.3.0, Apache-2.0, approved, clearlydefined

Check warning on line 1 in DEPENDENCIES

View workflow job for this annotation

GitHub Actions / Dependency-Check / Dash-Verify-Licenses

Restricted Dependencies found

Some dependencies are marked 'restricted' - please review them
maven/mavencentral/com.apicatalog/copper-multibase/0.5.0, Apache-2.0, approved, #14501
maven/mavencentral/com.apicatalog/copper-multicodec/0.1.1, Apache-2.0, approved, #14500
maven/mavencentral/com.apicatalog/iron-verifiable-credentials/0.14.0, Apache-2.0, approved, clearlydefined
Expand Down Expand Up @@ -370,7 +370,7 @@
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
Loading