From c27b329dc1bec4fe2a3f8b69bd31f6327d006f0f Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Tue, 19 Dec 2023 08:32:38 +0100 Subject: [PATCH] feat: add DID Management API --- .../api/PresentationApiExtension.java | 12 +- .../api/v1/ApiSchema.java | 6 +- .../api/v1/PresentationApi.java | 15 +- .../api/v1/PresentationApiController.java | 6 +- .../PresentationQueryValidator.java | 6 +- ...rg.eclipse.edc.spi.system.ServiceExtension | 2 +- .../api/v1/PresentationApiControllerTest.java | 1 + .../PresentationQueryValidatorTest.java | 1 + .../did/DidDocumentPublisherRegistryImpl.java | 14 +- .../did/DidDocumentServiceImpl.java | 152 ++++++++ .../identityhub/did/DidServicesExtension.java | 22 +- .../did/DidDocumentServiceImplTest.java | 283 ++++++++++++++ .../did/did-management-api/build.gradle.kts | 36 ++ .../DidManagementApiExtension.java | 67 ++++ .../didmanagement/v1/DidManagementApi.java | 136 +++++++ .../v1/DidManagementApiController.java | 109 ++++++ .../didmanagement/v1/DidRequestPayload.java | 21 ++ .../v1/validation/DidRequestValidator.java | 57 +++ ...rg.eclipse.edc.spi.system.ServiceExtension | 15 + .../v1/DidManagementApiControllerTest.java | 352 ++++++++++++++++++ .../did/local/LocalDidPublisher.java | 15 - .../did/local/LocalDidPublisherTest.java | 16 - launcher/build.gradle.kts | 1 + settings.gradle.kts | 1 + .../did/spi/DidDocumentPublisher.java | 20 +- .../did/spi/DidDocumentPublisherRegistry.java | 8 - .../did/spi/DidDocumentService.java | 65 +++- .../identithub/did/spi/model/DidResource.java | 6 +- 28 files changed, 1346 insertions(+), 99 deletions(-) rename core/identity-hub-api/src/main/java/org/eclipse/edc/{identityservice => identityhub}/api/PresentationApiExtension.java (88%) rename core/identity-hub-api/src/main/java/org/eclipse/edc/{identityservice => identityhub}/api/v1/ApiSchema.java (94%) rename core/identity-hub-api/src/main/java/org/eclipse/edc/{identityservice => identityhub}/api/v1/PresentationApi.java (82%) rename core/identity-hub-api/src/main/java/org/eclipse/edc/{identityservice => identityhub}/api/v1/PresentationApiController.java (95%) rename core/identity-hub-api/src/main/java/org/eclipse/edc/{identityservice => identityhub}/api/validation/PresentationQueryValidator.java (91%) create mode 100644 core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java create mode 100644 core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java create mode 100644 extensions/did/did-management-api/build.gradle.kts create mode 100644 extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/DidManagementApiExtension.java create mode 100644 extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApi.java create mode 100644 extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiController.java create mode 100644 extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidRequestPayload.java create mode 100644 extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/validation/DidRequestValidator.java create mode 100644 extensions/did/did-management-api/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension create mode 100644 extensions/did/did-management-api/src/test/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiControllerTest.java diff --git a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/PresentationApiExtension.java b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/PresentationApiExtension.java similarity index 88% rename from core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/PresentationApiExtension.java rename to core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/PresentationApiExtension.java index e1736c657..1be8f5d89 100644 --- a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/PresentationApiExtension.java +++ b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/PresentationApiExtension.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * Copyright (c) 2023 Metaform Systems, Inc. * * This program and the accompanying materials are made available under the * terms of the Apache License, Version 2.0 which is available at @@ -8,19 +8,19 @@ * SPDX-License-Identifier: Apache-2.0 * * Contributors: - * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * Metaform Systems, Inc. - initial API and implementation * */ -package org.eclipse.edc.identityservice.api; +package org.eclipse.edc.identityhub.api; import org.eclipse.edc.core.transform.transformer.to.JsonValueToGenericTypeTransformer; import org.eclipse.edc.iam.identitytrust.transform.to.JsonObjectToPresentationQueryTransformer; +import org.eclipse.edc.identityhub.api.v1.PresentationApiController; +import org.eclipse.edc.identityhub.api.validation.PresentationQueryValidator; import org.eclipse.edc.identityhub.spi.generator.VerifiablePresentationService; import org.eclipse.edc.identityhub.spi.resolution.CredentialQueryResolver; import org.eclipse.edc.identityhub.spi.verification.AccessTokenVerifier; -import org.eclipse.edc.identityservice.api.v1.PresentationApiController; -import org.eclipse.edc.identityservice.api.validation.PresentationQueryValidator; import org.eclipse.edc.identitytrust.model.credentialservice.PresentationQuery; import org.eclipse.edc.jsonld.spi.JsonLd; import org.eclipse.edc.runtime.metamodel.annotation.Extension; @@ -34,7 +34,7 @@ import org.eclipse.edc.web.jersey.jsonld.ObjectMapperProvider; import org.eclipse.edc.web.spi.WebService; -import static org.eclipse.edc.identityservice.api.PresentationApiExtension.NAME; +import static org.eclipse.edc.identityhub.api.PresentationApiExtension.NAME; import static org.eclipse.edc.spi.CoreConstants.JSON_LD; @Extension(value = NAME) diff --git a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/v1/ApiSchema.java b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/ApiSchema.java similarity index 94% rename from core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/v1/ApiSchema.java rename to core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/ApiSchema.java index 551f9fba9..3ae6b3643 100644 --- a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/v1/ApiSchema.java +++ b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/ApiSchema.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * Copyright (c) 2023 Metaform Systems, Inc. * * This program and the accompanying materials are made available under the * terms of the Apache License, Version 2.0 which is available at @@ -8,11 +8,11 @@ * SPDX-License-Identifier: Apache-2.0 * * Contributors: - * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * Metaform Systems, Inc. - initial API and implementation * */ -package org.eclipse.edc.identityservice.api.v1; +package org.eclipse.edc.identityhub.api.v1; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/v1/PresentationApi.java b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/PresentationApi.java similarity index 82% rename from core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/v1/PresentationApi.java rename to core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/PresentationApi.java index 79073ae73..03cbff974 100644 --- a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/v1/PresentationApi.java +++ b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/PresentationApi.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * Copyright (c) 2023 Metaform Systems, Inc. * * This program and the accompanying materials are made available under the * terms of the Apache License, Version 2.0 which is available at @@ -8,11 +8,11 @@ * SPDX-License-Identifier: Apache-2.0 * * Contributors: - * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * Metaform Systems, Inc. - initial API and implementation * */ -package org.eclipse.edc.identityservice.api.v1; +package org.eclipse.edc.identityhub.api.v1; import io.swagger.v3.oas.annotations.OpenAPIDefinition; @@ -29,7 +29,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.json.JsonObject; import jakarta.ws.rs.core.Response; -import org.eclipse.edc.identityservice.api.v1.ApiSchema.ApiErrorDetailSchema; import org.eclipse.edc.identitytrust.model.credentialservice.PresentationResponse; @OpenAPIDefinition( @@ -50,14 +49,14 @@ public interface PresentationApi { @ApiResponse(responseCode = "200", description = "The query was successfully processed, the response contains the VerifiablePresentation", content = @Content(schema = @Schema(implementation = PresentationResponse.class), mediaType = "application/ld+json")), @ApiResponse(responseCode = "400", description = "Request body was malformed, for example when both scope and presentationDefinition are given", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetailSchema.class)), mediaType = "application/json")), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiSchema.ApiErrorDetailSchema.class)), mediaType = "application/json")), @ApiResponse(responseCode = "401", description = "No Authorization header was given.", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetailSchema.class)), mediaType = "application/json")), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiSchema.ApiErrorDetailSchema.class)), mediaType = "application/json")), @ApiResponse(responseCode = "403", description = "The given authentication token could not be validated. This can happen, when the request body " + "calls for a broader query scope than the granted scope in the auth token", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetailSchema.class)), mediaType = "application/json")), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiSchema.ApiErrorDetailSchema.class)), mediaType = "application/json")), @ApiResponse(responseCode = "501", description = "When the request contained a presentationDefinition object, but the implementation does not support it.", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetailSchema.class)), mediaType = "application/json")) + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiSchema.ApiErrorDetailSchema.class)), mediaType = "application/json")) } ) Response queryPresentation(JsonObject query, String authHeader); diff --git a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/v1/PresentationApiController.java b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/PresentationApiController.java similarity index 95% rename from core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/v1/PresentationApiController.java rename to core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/PresentationApiController.java index 0a5a6d8c1..1a6ce92dd 100644 --- a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/v1/PresentationApiController.java +++ b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/PresentationApiController.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * Copyright (c) 2023 Metaform Systems, Inc. * * This program and the accompanying materials are made available under the * terms of the Apache License, Version 2.0 which is available at @@ -8,11 +8,11 @@ * SPDX-License-Identifier: Apache-2.0 * * Contributors: - * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * Metaform Systems, Inc. - initial API and implementation * */ -package org.eclipse.edc.identityservice.api.v1; +package org.eclipse.edc.identityhub.api.v1; import com.nimbusds.jwt.SignedJWT; import jakarta.json.JsonObject; diff --git a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/validation/PresentationQueryValidator.java b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/validation/PresentationQueryValidator.java similarity index 91% rename from core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/validation/PresentationQueryValidator.java rename to core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/validation/PresentationQueryValidator.java index 3031e0767..504a0d114 100644 --- a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityservice/api/validation/PresentationQueryValidator.java +++ b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/validation/PresentationQueryValidator.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * Copyright (c) 2023 Metaform Systems, Inc. * * This program and the accompanying materials are made available under the * terms of the Apache License, Version 2.0 which is available at @@ -8,11 +8,11 @@ * SPDX-License-Identifier: Apache-2.0 * * Contributors: - * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * Metaform Systems, Inc. - initial API and implementation * */ -package org.eclipse.edc.identityservice.api.validation; +package org.eclipse.edc.identityhub.api.validation; import jakarta.json.JsonArray; import jakarta.json.JsonObject; diff --git a/core/identity-hub-api/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/core/identity-hub-api/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension index a8c488640..6371d903b 100644 --- a/core/identity-hub-api/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension +++ b/core/identity-hub-api/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -12,4 +12,4 @@ # # -org.eclipse.edc.identityservice.api.PresentationApiExtension +org.eclipse.edc.identityhub.api.PresentationApiExtension \ No newline at end of file diff --git a/core/identity-hub-api/src/test/java/org/eclipse/edc/identityservice/api/v1/PresentationApiControllerTest.java b/core/identity-hub-api/src/test/java/org/eclipse/edc/identityservice/api/v1/PresentationApiControllerTest.java index 0734e8ee3..24c8108f2 100644 --- a/core/identity-hub-api/src/test/java/org/eclipse/edc/identityservice/api/v1/PresentationApiControllerTest.java +++ b/core/identity-hub-api/src/test/java/org/eclipse/edc/identityservice/api/v1/PresentationApiControllerTest.java @@ -16,6 +16,7 @@ import com.nimbusds.jwt.JWTClaimsSet; import jakarta.json.JsonObject; +import org.eclipse.edc.identityhub.api.v1.PresentationApiController; import org.eclipse.edc.identityhub.spi.generator.VerifiablePresentationService; import org.eclipse.edc.identityhub.spi.resolution.CredentialQueryResolver; import org.eclipse.edc.identityhub.spi.resolution.QueryResult; diff --git a/core/identity-hub-api/src/test/java/org/eclipse/edc/identityservice/api/validation/PresentationQueryValidatorTest.java b/core/identity-hub-api/src/test/java/org/eclipse/edc/identityservice/api/validation/PresentationQueryValidatorTest.java index a3753fb36..e5b965f86 100644 --- a/core/identity-hub-api/src/test/java/org/eclipse/edc/identityservice/api/validation/PresentationQueryValidatorTest.java +++ b/core/identity-hub-api/src/test/java/org/eclipse/edc/identityservice/api/validation/PresentationQueryValidatorTest.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.json.JsonArray; import jakarta.json.JsonObject; +import org.eclipse.edc.identityhub.api.validation.PresentationQueryValidator; import org.eclipse.edc.identitytrust.model.credentialservice.PresentationQuery; import org.eclipse.edc.identitytrust.model.presentationdefinition.Constraints; import org.eclipse.edc.identitytrust.model.presentationdefinition.Field; diff --git a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentPublisherRegistryImpl.java b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentPublisherRegistryImpl.java index 219ca79a3..cf43864a8 100644 --- a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentPublisherRegistryImpl.java +++ b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentPublisherRegistryImpl.java @@ -24,9 +24,10 @@ * In-mem variant of the publisher registry. */ public class DidDocumentPublisherRegistryImpl implements DidDocumentPublisherRegistry { + public static final String DID_PREFIX = "did:"; + public static final String DID_METHOD_SEPARATOR = ":"; private final Map publishers = new HashMap<>(); - @Override public void addPublisher(String didMethodName, DidDocumentPublisher publisher) { publishers.put(didMethodName, publisher); @@ -34,11 +35,12 @@ public void addPublisher(String didMethodName, DidDocumentPublisher publisher) { @Override public DidDocumentPublisher getPublisher(String did) { - return publishers.get(did); + // only need the "did" and method prefix + did = did.replace(DID_PREFIX, ""); + var endIndex = did.indexOf(DID_METHOD_SEPARATOR); + return endIndex >= 0 ? + publishers.get(DID_PREFIX + did.substring(0, endIndex)) : + null; } - @Override - public boolean canPublish(String did) { - return publishers.containsKey(did); - } } diff --git a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java new file mode 100644 index 000000000..36af911ca --- /dev/null +++ b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImpl.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2023 Metaform Systems, Inc. + * + * 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: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.did; + +import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.identithub.did.spi.DidDocumentPublisherRegistry; +import org.eclipse.edc.identithub.did.spi.DidDocumentService; +import org.eclipse.edc.identithub.did.spi.model.DidResource; +import org.eclipse.edc.identithub.did.spi.model.DidState; +import org.eclipse.edc.identithub.did.spi.store.DidResourceStore; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.transaction.spi.TransactionContext; + +import java.util.Collection; + +/** + * This is an aggregate service to manage CRUD operations of {@link DidDocument}s as well as handle their + * publishing and un-publishing. All methods are executed transactionally. + */ +public class DidDocumentServiceImpl implements DidDocumentService { + + private final TransactionContext transactionContext; + private final DidResourceStore didResourceStore; + private final DidDocumentPublisherRegistry registry; + + public DidDocumentServiceImpl(TransactionContext transactionContext, DidResourceStore didResourceStore, DidDocumentPublisherRegistry registry) { + this.transactionContext = transactionContext; + this.didResourceStore = didResourceStore; + this.registry = registry; + } + + @Override + public ServiceResult store(DidDocument document) { + return transactionContext.execute(() -> { + var res = DidResource.Builder.newInstance() + .document(document) + .did(document.getId()) + .build(); + var result = didResourceStore.save(res); + return result.succeeded() ? + ServiceResult.success() : + ServiceResult.fromFailure(result); + }); + } + + @Override + public ServiceResult publish(String did) { + return transactionContext.execute(() -> { + var existingDoc = didResourceStore.findById(did); + if (existingDoc == null) { + return ServiceResult.notFound(notFoundMessage(did)); + } + var publisher = registry.getPublisher(did); + if (publisher == null) { + return ServiceResult.badRequest(noPublisherFoundMessage(did)); + } + var publishResult = publisher.publish(did); + return publishResult.succeeded() ? + ServiceResult.success() : + ServiceResult.badRequest(publishResult.getFailureDetail()); + + }); + } + + @Override + public ServiceResult unpublish(String did) { + return transactionContext.execute(() -> { + var existingDoc = didResourceStore.findById(did); + if (existingDoc == null) { + return ServiceResult.notFound(notFoundMessage(did)); + } + var publisher = registry.getPublisher(did); + if (publisher == null) { + return ServiceResult.badRequest(noPublisherFoundMessage(did)); + } + var publishResult = publisher.unpublish(did); + return publishResult.succeeded() ? + ServiceResult.success() : + ServiceResult.badRequest(publishResult.getFailureDetail()); + + }); + } + + @Override + public ServiceResult update(DidDocument document) { + return transactionContext.execute(() -> { + // obtain existing resource from storage + var did = document.getId(); + var existing = didResourceStore.findById(did); + if (existing == null) { + return ServiceResult.notFound(notFoundMessage(did)); + } + + //update only the did document + var updatedResource = DidResource.Builder.newInstance() + .document(document) + .did(did) + .state(existing.getState()) + .createTimestamp(existing.getCreateTimestamp()) + .stateTimeStamp(existing.getStateTimestamp()) + .build(); + + var res = didResourceStore.update(updatedResource); + return res.succeeded() ? + ServiceResult.success() : + ServiceResult.fromFailure(res); + }); + } + + @Override + public ServiceResult deleteById(String did) { + return transactionContext.execute(() -> { + var existing = didResourceStore.findById(did); + if (existing == null) { + return ServiceResult.notFound(notFoundMessage(did)); + } + if (existing.getState() == DidState.PUBLISHED.code()) { + return ServiceResult.conflict("Cannot delete DID '%s' because it is already published. Un-publish first!".formatted(did)); + } + var res = didResourceStore.deleteById(did); + return res.succeeded() ? + ServiceResult.success() : + ServiceResult.fromFailure(res); + }); + } + + @Override + public ServiceResult> queryDocuments(QuerySpec query) { + return transactionContext.execute(() -> { + var res = didResourceStore.query(query); + return ServiceResult.success(res.stream().map(DidResource::getDocument).toList()); + }); + } + + @Override + public DidResource findById(String did) { + return transactionContext.execute(() -> didResourceStore.findById(did)); + } +} diff --git a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidServicesExtension.java b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidServicesExtension.java index 0f30aa2f7..c9fe89f52 100644 --- a/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidServicesExtension.java +++ b/core/identity-hub-did/src/main/java/org/eclipse/edc/identityhub/did/DidServicesExtension.java @@ -15,15 +15,25 @@ package org.eclipse.edc.identityhub.did; import org.eclipse.edc.identithub.did.spi.DidDocumentPublisherRegistry; +import org.eclipse.edc.identithub.did.spi.DidDocumentService; +import org.eclipse.edc.identithub.did.spi.store.DidResourceStore; import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; import org.eclipse.edc.runtime.metamodel.annotation.Provider; import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.transaction.spi.TransactionContext; import static org.eclipse.edc.identityhub.did.DidServicesExtension.NAME; @Extension(value = NAME) public class DidServicesExtension implements ServiceExtension { public static final String NAME = "DID Service Extension"; + @Inject + private TransactionContext transactionContext; + @Inject + private DidResourceStore didResourceStore; + + private DidDocumentPublisherRegistry didPublisherRegistry; @Override public String name() { @@ -31,7 +41,15 @@ public String name() { } @Provider - public DidDocumentPublisherRegistry createRegistry() { - return new DidDocumentPublisherRegistryImpl(); + public DidDocumentPublisherRegistry getDidPublisherRegistry() { + if (didPublisherRegistry == null) { + didPublisherRegistry = new DidDocumentPublisherRegistryImpl(); + } + return didPublisherRegistry; + } + + @Provider + public DidDocumentService createDidDocumentService() { + return new DidDocumentServiceImpl(transactionContext, didResourceStore, getDidPublisherRegistry()); } } diff --git a/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java b/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java new file mode 100644 index 000000000..4b2440aa3 --- /dev/null +++ b/core/identity-hub-did/src/test/java/org/eclipse/edc/identityhub/did/DidDocumentServiceImplTest.java @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2023 Metaform Systems, Inc. + * + * 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: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.did; + +import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.iam.did.spi.document.Service; +import org.eclipse.edc.iam.did.spi.document.VerificationMethod; +import org.eclipse.edc.identithub.did.spi.DidDocumentPublisher; +import org.eclipse.edc.identithub.did.spi.DidDocumentPublisherRegistry; +import org.eclipse.edc.identithub.did.spi.model.DidResource; +import org.eclipse.edc.identithub.did.spi.model.DidState; +import org.eclipse.edc.identithub.did.spi.store.DidResourceStore; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.result.StoreResult; +import org.eclipse.edc.transaction.spi.NoopTransactionContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class DidDocumentServiceImplTest { + private final DidResourceStore storeMock = mock(); + private final DidDocumentPublisherRegistry publisherRegistry = mock(); + private final DidDocumentPublisher publisherMock = mock(); + private DidDocumentServiceImpl service; + + @BeforeEach + void setUp() { + var trx = new NoopTransactionContext(); + when(publisherRegistry.getPublisher(startsWith("did:web:"))).thenReturn(publisherMock); + + service = new DidDocumentServiceImpl(trx, storeMock, publisherRegistry); + } + + @Test + void store() { + var doc = createDidDocument().build(); + when(storeMock.save(argThat(dr -> dr.getDocument().equals(doc)))).thenReturn(StoreResult.success()); + assertThat(service.store(doc)).isSucceeded(); + } + + @Test + void store_alreadyExists() { + var doc = createDidDocument().build(); + when(storeMock.save(argThat(dr -> dr.getDocument().equals(doc)))).thenReturn(StoreResult.alreadyExists("foo")); + assertThat(service.store(doc)).isFailed().detail().isEqualTo("foo"); + verify(storeMock).save(any()); + verifyNoInteractions(publisherMock); + } + + @Test + void publish() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + when(publisherMock.publish(did)).thenReturn(Result.success()); + + assertThat(service.publish(did)).isSucceeded(); + + verify(storeMock).findById(did); + verify(publisherMock).publish(did); + verifyNoMoreInteractions(publisherMock, storeMock); + } + + @Test + void publish_notExist() { + var did = "did:web:test-did"; + when(storeMock.findById(eq(did))).thenReturn(null); + + assertThat(service.publish(did)).isFailed() + .detail().isEqualTo(service.notFoundMessage(did)); + + verify(storeMock).findById(did); + verifyNoMoreInteractions(publisherMock, storeMock); + } + + @Test + void publish_noPublisherFound() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(publisherRegistry.getPublisher(any())).thenReturn(null); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + + assertThat(service.publish(did)).isFailed().detail() + .isEqualTo(service.noPublisherFoundMessage(did)); + + verify(storeMock).findById(did); + verify(publisherRegistry).getPublisher(did); + verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + } + + @Test + void publish_publisherReportsError() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + when(publisherMock.publish(did)).thenReturn(Result.failure("test-failure")); + + assertThat(service.publish(did)).isFailed() + .detail() + .isEqualTo("test-failure"); + + verify(storeMock).findById(did); + verify(publisherMock).publish(did); + verifyNoMoreInteractions(publisherMock, storeMock); + } + + @Test + void unpublish() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.PUBLISHED).document(doc).build()); + when(publisherMock.unpublish(did)).thenReturn(Result.success()); + + assertThat(service.unpublish(did)).isSucceeded(); + + verify(storeMock).findById(did); + verify(publisherMock).unpublish(did); + verifyNoMoreInteractions(publisherMock, storeMock); + } + + @Test + void unpublish_notExist() { + var did = "did:web:test-did"; + when(storeMock.findById(eq(did))).thenReturn(null); + + assertThat(service.unpublish(did)).isFailed() + .detail().isEqualTo(service.notFoundMessage(did)); + + verify(storeMock).findById(did); + verifyNoMoreInteractions(publisherMock, storeMock); + } + + @Test + void unpublish_noPublisherFound() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(publisherRegistry.getPublisher(any())).thenReturn(null); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.PUBLISHED).document(doc).build()); + + assertThat(service.unpublish(did)).isFailed().detail() + .isEqualTo(service.noPublisherFoundMessage(did)); + + verify(storeMock).findById(did); + verify(publisherRegistry).getPublisher(did); + verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + } + + @Test + void unpublish_publisherReportsError() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.PUBLISHED).document(doc).build()); + when(publisherMock.unpublish(did)).thenReturn(Result.failure("test-failure")); + + assertThat(service.unpublish(did)).isFailed() + .detail() + .isEqualTo("test-failure"); + + verify(storeMock).findById(did); + verify(publisherMock).unpublish(did); + verifyNoMoreInteractions(publisherMock, storeMock); + } + + @Test + void update() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.PUBLISHED).document(doc).build()); + when(storeMock.update(any())).thenReturn(StoreResult.success()); + + assertThat(service.update(doc)).isSucceeded(); + + verify(storeMock).findById(did); + verify(storeMock).update(argThat(dr -> dr.getDocument().equals(doc))); + verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + } + + @Test + void update_notExists() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(null); + when(storeMock.update(any())).thenReturn(StoreResult.success()); + + assertThat(service.update(doc)) + .isFailed() + .detail() + .isEqualTo(service.notFoundMessage(did)); + + verify(storeMock).findById(did); + verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + } + + @Test + void deleteById() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.UNPUBLISHED).document(doc).build()); + when(storeMock.deleteById(any())).thenReturn(StoreResult.success()); + + assertThat(service.deleteById(did)).isSucceeded(); + + verify(storeMock).findById(did); + verify(storeMock).deleteById(did); + verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + } + + @Test + void deleteById_alreadyPublished() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.PUBLISHED).document(doc).build()); + + assertThat(service.deleteById(did)).isFailed() + .detail() + .isEqualTo("Cannot delete DID '%s' because it is already published. Un-publish first!".formatted(did)); + + verify(storeMock).findById(did); + verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + } + + @Test + void deleteById_notExists() { + var doc = createDidDocument().build(); + var did = doc.getId(); + when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.UNPUBLISHED).document(doc).build()); + when(storeMock.deleteById(any())).thenReturn(StoreResult.notFound("test-message")); + + assertThat(service.deleteById(did)).isFailed().detail().isEqualTo("test-message"); + + verify(storeMock).findById(did); + verify(storeMock).deleteById(did); + verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + } + + @Test + void queryDocuments() { + var q = QuerySpec.max(); + var doc = createDidDocument().build(); + var res = DidResource.Builder.newInstance().did(doc.getId()).state(DidState.PUBLISHED).document(doc).build(); + when(storeMock.query(any())).thenReturn(List.of(res)); + + assertThat(service.queryDocuments(q)).isSucceeded(); + + verify(storeMock).query(eq(q)); + verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + } + + private DidDocument.Builder createDidDocument() { + return DidDocument.Builder.newInstance() + .id("did:web:testdid") + .service(List.of(new Service("test-service", "test-service", "https://test.service.com/"))) + .verificationMethod(List.of(VerificationMethod.Builder.newInstance() + .id("did:web:testdid#key-1") + .publicKeyMultibase("saflasjdflaskjdflasdkfj") + .build())); + } +} \ No newline at end of file diff --git a/extensions/did/did-management-api/build.gradle.kts b/extensions/did/did-management-api/build.gradle.kts new file mode 100644 index 000000000..6d4962f81 --- /dev/null +++ b/extensions/did/did-management-api/build.gradle.kts @@ -0,0 +1,36 @@ +/* + * 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 + * + */ + +plugins { + `java-library` + `maven-publish` + id("io.swagger.core.v3.swagger-gradle-plugin") +} + +dependencies { + api(libs.edc.spi.core) + api(project(":spi:identity-hub-spi")) + api(project(":spi:identity-hub-did-spi")) + implementation(libs.edc.spi.validator) + implementation(libs.edc.spi.web) + implementation(libs.edc.core.jerseyproviders) + implementation(libs.jakarta.rsApi) + + testImplementation(libs.edc.junit) + testImplementation(libs.edc.ext.jsonld) + testImplementation(testFixtures(libs.edc.core.jersey)) + testImplementation(testFixtures(project(":spi:identity-hub-spi"))) + testImplementation(libs.nimbus.jwt) + testImplementation(libs.restAssured) +} diff --git a/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/DidManagementApiExtension.java b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/DidManagementApiExtension.java new file mode 100644 index 000000000..21585fda6 --- /dev/null +++ b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/DidManagementApiExtension.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 Metaform Systems, Inc. + * + * 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: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.api.didmanagement; + +import org.eclipse.edc.identithub.did.spi.DidDocumentService; +import org.eclipse.edc.identityhub.api.didmanagement.v1.DidManagementApiController; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.web.spi.WebServer; +import org.eclipse.edc.web.spi.WebService; +import org.eclipse.edc.web.spi.configuration.WebServiceConfigurer; +import org.eclipse.edc.web.spi.configuration.WebServiceSettings; + +import static org.eclipse.edc.identityhub.api.didmanagement.DidManagementApiExtension.NAME; + +@Extension(value = NAME) +public class DidManagementApiExtension implements ServiceExtension { + + public static final String NAME = "DID Management API Extension"; + private static final String MGMT_CONTEXT_ALIAS = "management"; + private static final String DEFAULT_DID_PATH = "/api/management"; + private static final int DEFAULT_DID_PORT = 8182; + public static final WebServiceSettings SETTINGS = WebServiceSettings.Builder.newInstance() + .apiConfigKey("web.http." + MGMT_CONTEXT_ALIAS) + .contextAlias(MGMT_CONTEXT_ALIAS) + .defaultPath(DEFAULT_DID_PATH) + .defaultPort(DEFAULT_DID_PORT) + .useDefaultContext(false) + .name("DID Management Endpoint API") + .build(); + @Inject + private WebService webService; + @Inject + private DidDocumentService didDocumentService; + @Inject + private WebServiceConfigurer configurer; + @Inject + private WebServer webServer; + + @Override + public String name() { + return NAME; + } + + @Override + public void initialize(ServiceExtensionContext context) { + var webServiceConfiguration = configurer.configure(context, webServer, SETTINGS); + + var controller = new DidManagementApiController(didDocumentService); + webService.registerResource(webServiceConfiguration.getContextAlias(), controller); + } + +} diff --git a/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApi.java b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApi.java new file mode 100644 index 000000000..0a6730de1 --- /dev/null +++ b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApi.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2023 Metaform Systems, Inc. + * + * 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: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.api.didmanagement.v1; + + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.web.spi.ApiErrorDetail; + +import java.util.Collection; + +@OpenAPIDefinition( + info = @Info(description = "This is the Management API for DID documents", title = "DID Management API", version = "1")) +public interface DidManagementApi { + + @Tag(name = "DID Management API") + @Operation(description = "Stores a new DID document and optionally also publishes it", + requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = DidDocument.class), mediaType = "application/json")), + parameters = {@Parameter(name = "publish", description = "Indicates whether the DID should be published right after creation")}, + responses = { + @ApiResponse(responseCode = "200", description = "The DID document was successfully stored"), + @ApiResponse(responseCode = "400", description = "Request body was malformed, for example the DID document was invalid", + 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.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "409", description = "Can't create the DID document, because a document with the same ID already exists", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")) + } + ) + void createDidDocument(DidDocument document, boolean publish); + + @Tag(name = "DID Management API") + @Operation(description = "Publish an (existing) DID document. The DID is expected to exist in the database.", + requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = DidRequestPayload.class), mediaType = "application/json")), + responses = { + @ApiResponse(responseCode = "200", description = "The DID document was successfully published."), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "The DID could not be published because it does not exist.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")) + } + ) + void publishDidFromBody(DidRequestPayload didRequestPayload); + + @Tag(name = "DID Management API") + @Operation(description = "Un-Publish an (existing) DID document. The DID is expected to exist in the database.", + requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = DidRequestPayload.class), mediaType = "application/json")), + responses = { + @ApiResponse(responseCode = "200", description = "The DID document was successfully un-published."), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "400", description = "The DID could not be unpublished because the underlying VDR does not support un-publishing.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "The DID could not be un-published because it does not exist.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")) + } + ) + void unpublishDidFromBody(DidRequestPayload didRequestPayload); + + @Tag(name = "DID Management API") + @Operation(description = "Updates an (existing) DID document and re-publishes it if so desired. The DID is expected to exist in the database.", + parameters = {@Parameter(name = "republish", description = "Indicates whether the DID document should be re-published after the update.")}, + responses = { + @ApiResponse(responseCode = "200", description = "The DID document was successfully updated."), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "The DID could not be updated because it does not exist.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")) + } + ) + void updateDid(DidDocument document, boolean republish); + + @Tag(name = "DID Management API") + @Operation(description = "Delete an (existing) DID document. The DID is expected to exist in the database.", + requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = DidRequestPayload.class), mediaType = "application/json")), + responses = { + @ApiResponse(responseCode = "200", description = "The DID document was successfully deleted."), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "The DID could not be deleted because it does not exist.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "409", description = "The DID could not be deleted because it is already published. Un-publish first.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")) + } + ) + void deleteDidFromBody(DidRequestPayload request); + + @Tag(name = "DID Management API") + @Operation(description = "Query for DID documents..", + requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = QuerySpec.class), mediaType = "application/json")), + responses = { + @ApiResponse(responseCode = "200", description = "The DID document was successfully deleted.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = DidDocument.class)))), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "400", description = "The query was malformed or was not understood by the server.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + } + ) + Collection queryDid(QuerySpec querySpec); + + @Tag(name = "DID Management API") + @Operation(description = "Get state of a DID document", + requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = DidRequestPayload.class), mediaType = "application/json")), + responses = { + @ApiResponse(responseCode = "200", description = "The DID state was successfully obtained"), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "The DID does not exist.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + } + ) + String getState(DidRequestPayload request); +} diff --git a/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiController.java b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiController.java new file mode 100644 index 000000000..87335bfba --- /dev/null +++ b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiController.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 Metaform Systems, Inc. + * + * 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: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.api.didmanagement.v1; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.identithub.did.spi.DidDocumentService; +import org.eclipse.edc.identithub.did.spi.model.DidState; +import org.eclipse.edc.identityhub.api.didmanagement.v1.validation.DidRequestValidator; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.web.spi.exception.ValidationFailureException; + +import java.util.Collection; + +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static org.eclipse.edc.web.spi.exception.ServiceResultHandler.exceptionMapper; + +@Consumes(APPLICATION_JSON) +@Produces(APPLICATION_JSON) +@Path("/v1/dids") +public class DidManagementApiController implements DidManagementApi { + + private final DidDocumentService documentService; + private final DidRequestValidator requestValidator; + + public DidManagementApiController(DidDocumentService documentService) { + this.documentService = documentService; + this.requestValidator = new DidRequestValidator(); + } + + @POST + @Override + public void createDidDocument(DidDocument document, @QueryParam("publish") boolean publish) { + requestValidator.validate(document).orElseThrow(ValidationFailureException::new); + + documentService.store(document) + .compose(v -> publish ? documentService.publish(document.getId()) : ServiceResult.success()) + .orElseThrow(exceptionMapper(DidDocument.class, document.getId())); + } + + @Override + @POST + @Path("/publish") + public void publishDidFromBody(DidRequestPayload didRequestPayload) { + documentService.publish(didRequestPayload.did()) + .orElseThrow(exceptionMapper(DidDocument.class, didRequestPayload.did())); + } + + @Override + @POST + @Path("/unpublish") + public void unpublishDidFromBody(DidRequestPayload didRequestPayload) { + documentService.unpublish(didRequestPayload.did()) + .orElseThrow(exceptionMapper(DidDocument.class, didRequestPayload.did())); + } + + @PUT + @Override + public void updateDid(DidDocument document, @QueryParam("republish") boolean republish) { + requestValidator.validate(document).orElseThrow(ValidationFailureException::new); + var did = document.getId(); + documentService.update(document) + .compose(v -> republish ? documentService.publish(did) : ServiceResult.success()) + .orElseThrow(exceptionMapper(DidDocument.class, did)); + } + + @Override + @DELETE + public void deleteDidFromBody(DidRequestPayload request) { + documentService.deleteById(request.did()) + .orElseThrow(exceptionMapper(DidDocument.class, request.did())); + } + + @POST + @Path("/query") + @Override + public Collection queryDid(QuerySpec querySpec) { + return documentService.queryDocuments(querySpec) + .orElseThrow(exceptionMapper(DidDocument.class, null)); + } + + @Override + @POST + @Path("/state") + public String getState(DidRequestPayload request) { + var byId = documentService.findById(request.did()); + return byId != null ? DidState.from(byId.getState()).toString() : null; + } + +} diff --git a/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidRequestPayload.java b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidRequestPayload.java new file mode 100644 index 000000000..772f3c653 --- /dev/null +++ b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidRequestPayload.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 Metaform Systems, Inc. + * + * 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: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.api.didmanagement.v1; + +/** + * JSON container for a DID + */ +public record DidRequestPayload(String did) { +} diff --git a/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/validation/DidRequestValidator.java b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/validation/DidRequestValidator.java new file mode 100644 index 000000000..23bb434b5 --- /dev/null +++ b/extensions/did/did-management-api/src/main/java/org/eclipse/edc/identityhub/api/didmanagement/v1/validation/DidRequestValidator.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 Metaform Systems, Inc. + * + * 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: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.api.didmanagement.v1.validation; + +import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.validator.spi.ValidationResult; +import org.eclipse.edc.validator.spi.Validator; + +import java.net.URI; + +import static org.eclipse.edc.validator.spi.ValidationResult.failure; +import static org.eclipse.edc.validator.spi.Violation.violation; + +/** + * Validates that a {@link DidDocument} is valid by checking all mandatory properties. + */ +public class DidRequestValidator implements Validator { + + @Override + public ValidationResult validate(DidDocument input) { + if (input == null) { + return failure(violation("input was null", ".")); + } + + if (input.getId() == null) { + return failure(violation("ID was null", "id")); + } + + if (!isValidUri(input.getId())) { + return failure(violation("ID is not a valid URI", "id")); + } + + return ValidationResult.success(); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private boolean isValidUri(String supposedUri) { + try { + URI.create(supposedUri); + return true; + } catch (IllegalArgumentException ex) { + return false; + } + } +} diff --git a/extensions/did/did-management-api/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/extensions/did/did-management-api/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 000000000..de0a93a07 --- /dev/null +++ b/extensions/did/did-management-api/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,15 @@ +# +# 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 +# +# + +org.eclipse.edc.identityhub.api.didmanagement.DidManagementApiExtension diff --git a/extensions/did/did-management-api/src/test/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiControllerTest.java b/extensions/did/did-management-api/src/test/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiControllerTest.java new file mode 100644 index 000000000..636e9a8a3 --- /dev/null +++ b/extensions/did/did-management-api/src/test/java/org/eclipse/edc/identityhub/api/didmanagement/v1/DidManagementApiControllerTest.java @@ -0,0 +1,352 @@ +/* + * Copyright (c) 2023 Metaform Systems, Inc. + * + * 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: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.api.didmanagement.v1; + +import io.restassured.common.mapper.TypeRef; +import io.restassured.specification.RequestSpecification; +import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.iam.did.spi.document.Service; +import org.eclipse.edc.iam.did.spi.document.VerificationMethod; +import org.eclipse.edc.identithub.did.spi.DidDocumentService; +import org.eclipse.edc.junit.annotations.ApiTest; +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.web.jersey.testfixtures.RestControllerTestBase; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ApiTest +class DidManagementApiControllerTest extends RestControllerTestBase { + + public static final String TEST_DID = "did:web:host%3A1234:test-did"; + private final DidDocumentService didDocumentServiceMock = mock(); + + @Test + void create_success() { + when(didDocumentServiceMock.store(any())).thenReturn(ServiceResult.success()); + var document = createDidDocument().build(); + + baseRequest() + .with() + .body(document) + .post() + .then() + .log().ifError() + .statusCode(anyOf(equalTo(200), equalTo(204))); + } + + @Test + void create_alreadyExists_expect409() { + when(didDocumentServiceMock.store(any())).thenReturn(ServiceResult.conflict("already exists")); + var document = createDidDocument().build(); + + baseRequest() + .body(document) + .post() + .then() + .log().ifValidationFails() + .statusCode(409); + } + + @Test + void create_malformedBody_expect400() { + when(didDocumentServiceMock.store(any())).thenReturn(ServiceResult.success()); + var document = createDidDocument().id("not a uri").build(); + + baseRequest() + .body(document) + .post() + .then() + .log().ifValidationFails() + .statusCode(400); + } + + @Test + void publish_success() { + + when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.success()); + + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .post("/publish") + .then() + .log().ifValidationFails() + .statusCode(anyOf(equalTo(200), equalTo(204))); + verify(didDocumentServiceMock).publish(eq(TEST_DID)); + } + + @Test + void publish_whenNotExist_expect404() { + + when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.notFound("test-message")); + + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .post("/publish") + .then() + .log().ifValidationFails() + .statusCode(equalTo(404)); + verify(didDocumentServiceMock).publish(eq(TEST_DID)); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void publish_whenAlreadyPublished_expect200() { + + when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.success()); + + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .post("/publish") + .then() + .log().ifValidationFails() + .statusCode(anyOf(equalTo(200), equalTo(204))); + verify(didDocumentServiceMock).publish(eq(TEST_DID)); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void unpublish_success() { + + when(didDocumentServiceMock.unpublish(eq(TEST_DID))).thenReturn(ServiceResult.success()); + + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .post("/unpublish") + .then() + .log().ifValidationFails() + .statusCode(anyOf(equalTo(200), equalTo(204))); + verify(didDocumentServiceMock).unpublish(eq(TEST_DID)); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void unpublish_whenNotExist_expect404() { + when(didDocumentServiceMock.unpublish(eq(TEST_DID))).thenReturn(ServiceResult.notFound("test-message")); + + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .post("/unpublish") + .then() + .log().ifValidationFails() + .statusCode(404); + verify(didDocumentServiceMock).unpublish(eq(TEST_DID)); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void unpublish_whenNotPublished_expect200() { + // not needed - test setup is identical to publish_success + } + + @Test + void unpublish_whenAlreadyUnpublished_expect200() { + // not needed - test setup is identical to publish_success + } + + @Test + void unpublish_whenNotSupported_expect400() { + when(didDocumentServiceMock.unpublish(eq(TEST_DID))).thenReturn(ServiceResult.badRequest("test-message")); + + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .post("/unpublish") + .then() + .log().ifValidationFails() + .statusCode(400); + verify(didDocumentServiceMock).unpublish(eq(TEST_DID)); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void updateDid_success() { + var doc = createDidDocument().id(TEST_DID).build(); + when(didDocumentServiceMock.update(any())).thenReturn(ServiceResult.success()); + + baseRequest() + .body(doc) + .put() + .then() + .log().ifError() + .statusCode(204); + verify(didDocumentServiceMock).update(argThat(dd -> dd.getId().equals(TEST_DID))); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void updateDid_success_withRepublish() { + var doc = createDidDocument().id(TEST_DID).build(); + when(didDocumentServiceMock.update(any())).thenReturn(ServiceResult.success()); + when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.success()); + + baseRequest() + .body(doc) + .put("?republish=true") + .then() + .log().ifError() + .statusCode(204); + verify(didDocumentServiceMock).update(argThat(dd -> dd.getId().equals(TEST_DID))); + verify(didDocumentServiceMock).publish(eq(TEST_DID)); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void updateDid_success_withRepublishFails() { + + var doc = createDidDocument().id(TEST_DID).build(); + when(didDocumentServiceMock.update(any())).thenReturn(ServiceResult.success()); + when(didDocumentServiceMock.publish(eq(TEST_DID))).thenReturn(ServiceResult.badRequest("test-failure")); + + baseRequest() + .body(doc) + .put("?republish=true") + .then() + .log().ifError() + .statusCode(400); + verify(didDocumentServiceMock).update(argThat(dd -> dd.getId().equals(TEST_DID))); + verify(didDocumentServiceMock).publish(eq(TEST_DID)); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void updateDid_whenNotExist_expect404() { + + var doc = createDidDocument().id(TEST_DID).build(); + when(didDocumentServiceMock.update(any())).thenReturn(ServiceResult.notFound("test-failure")); + + baseRequest() + .body(doc) + .put() + .then() + .log().ifError() + .statusCode(404); + verify(didDocumentServiceMock).update(argThat(dd -> dd.getId().equals(TEST_DID))); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void deleteDid_success() { + + when(didDocumentServiceMock.deleteById(eq(TEST_DID))).thenReturn(ServiceResult.success()); + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .delete("/") + .then() + .log().ifError() + .statusCode(204); + verify(didDocumentServiceMock).deleteById(eq(TEST_DID)); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void deleteDid_whenNotExist_expect404() { + + when(didDocumentServiceMock.deleteById(eq(TEST_DID))).thenReturn(ServiceResult.notFound("test-message")); + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .delete("/") + .then() + .log().ifError() + .statusCode(404); + verify(didDocumentServiceMock).deleteById(eq(TEST_DID)); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void deleteDid_whenAlreadyPublished_expect409() { + + when(didDocumentServiceMock.deleteById(eq(TEST_DID))).thenReturn(ServiceResult.conflict("test-message")); + baseRequest() + .body(new DidRequestPayload(TEST_DID)) + .delete("/") + .then() + .log().ifError() + .statusCode(409); + verify(didDocumentServiceMock).deleteById(eq(TEST_DID)); + verifyNoMoreInteractions(didDocumentServiceMock); + } + + @Test + void query_withSimpleField() { + var resultList = List.of(createDidDocument().build()); + when(didDocumentServiceMock.queryDocuments(any())).thenReturn(ServiceResult.success(resultList)); + var q = QuerySpec.Builder.newInstance().filter(new Criterion("id", "=", "foobar")).build(); + + var docListType = new TypeRef>() { + }; + var docList = baseRequest() + .body(q) + .post("/query") + .then() + .log().ifError() + .statusCode(200) + .extract().body().as(docListType); + + assertThat(docList).isNotEmpty().hasSize(1) + .usingRecursiveFieldByFieldElementComparator() + .isEqualTo(resultList); + verify(didDocumentServiceMock).queryDocuments(eq(q)); + } + + @Test + void query_invalidQuery_expect400() { + when(didDocumentServiceMock.queryDocuments(any())).thenReturn(ServiceResult.badRequest("test-message")); + var q = QuerySpec.Builder.newInstance().build(); + baseRequest() + .body(q) + .post("/query") + .then() + .log().ifValidationFails() + .statusCode(400); + + verify(didDocumentServiceMock).queryDocuments(eq(q)); + } + + @Override + protected DidManagementApiController controller() { + return new DidManagementApiController(didDocumentServiceMock); + } + + private DidDocument.Builder createDidDocument() { + return DidDocument.Builder.newInstance() + .id("did:web:testdid") + .service(List.of(new Service("test-service", "test-service", "https://test.service.com/"))) + .verificationMethod(List.of(VerificationMethod.Builder.newInstance() + .id("did:web:testdid#key-1") + .publicKeyMultibase("saflasjdflaskjdflasdkfj") + .build())); + } + + private RequestSpecification baseRequest() { + return given() + .contentType("application/json") + .baseUri("http://localhost:" + port + "/v1/dids") + .when(); + } +} \ No newline at end of file diff --git a/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisher.java b/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisher.java index bf8c4d3ba..3bc28262f 100644 --- a/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisher.java +++ b/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisher.java @@ -14,18 +14,13 @@ package org.eclipse.edc.identityhub.publisher.did.local; -import org.eclipse.edc.identithub.did.spi.DidConstants; import org.eclipse.edc.identithub.did.spi.DidDocumentPublisher; import org.eclipse.edc.identithub.did.spi.model.DidResource; import org.eclipse.edc.identithub.did.spi.model.DidState; import org.eclipse.edc.identithub.did.spi.store.DidResourceStore; import org.eclipse.edc.spi.monitor.Monitor; -import org.eclipse.edc.spi.query.Criterion; -import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.Result; -import java.util.Collection; - import static org.eclipse.edc.identithub.did.spi.DidConstants.DID_WEB_METHOD_REGEX; import static org.eclipse.edc.spi.result.Result.failure; import static org.eclipse.edc.spi.result.Result.success; @@ -87,16 +82,6 @@ public Result unpublish(String did) { } - @Override - public Collection getPublishedDocuments() { - var q = QuerySpec.Builder.newInstance() - .filter(new Criterion("state", "=", DidState.PUBLISHED.code())) - .filter(new Criterion("did", "like", DidConstants.DID_WEB_METHOD + "%")) - .build(); - - return didResourceStore.query(q); - } - private boolean isPublished(DidResource didResource) { return didResource.getState() == DidState.PUBLISHED.code(); } diff --git a/extensions/did/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherTest.java b/extensions/did/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherTest.java index ce7809dda..caa46c042 100644 --- a/extensions/did/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherTest.java +++ b/extensions/did/local-did-publisher/src/test/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherTest.java @@ -24,7 +24,6 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import static java.util.stream.IntStream.range; import static org.assertj.core.api.Assertions.assertThat; import static org.eclipse.edc.identityhub.publisher.did.local.TestFunctions.createDidResource; import static org.mockito.ArgumentMatchers.any; @@ -170,19 +169,4 @@ void unpublish_storeFailsUpdate_returnsFailure() { verify(storeMock).update(any()); verifyNoMoreInteractions(storeMock); } - - @Test - void getPublishedDocuments() { - var list = range(0, 10) - .mapToObj(i -> createDidResource().did("did:web:" + i).state(DidState.PUBLISHED).build()) - .toList(); - - when(storeMock.query(any())).thenReturn(list); - - assertThat(publisher.getPublishedDocuments()) - .usingRecursiveFieldByFieldElementComparator() - .containsAll(list); - } - - } \ No newline at end of file diff --git a/launcher/build.gradle.kts b/launcher/build.gradle.kts index 1ee6cfd36..0002de85f 100644 --- a/launcher/build.gradle.kts +++ b/launcher/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { runtimeOnly(project(":core:identity-hub-credentials")) runtimeOnly(project(":extensions:cryptography:public-key-provider")) runtimeOnly(project(":extensions:did:local-did-publisher")) + runtimeOnly(project(":extensions:did:did-management-api")) runtimeOnly(libs.edc.identity.did.core) runtimeOnly(libs.edc.identity.did.web) runtimeOnly(libs.bundles.connector) diff --git a/settings.gradle.kts b/settings.gradle.kts index 21506a269..05ad375be 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ include(":extensions:cryptography:public-key-provider") include(":extensions:store:sql:identity-hub-did-store-sql") include(":extensions:store:sql:identity-hub-credentials-store-sql") include(":extensions:did:local-did-publisher") +include(":extensions:did:did-management-api") // other modules include(":launcher") diff --git a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisher.java b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisher.java index fa35248f9..e2cc589ba 100644 --- a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisher.java +++ b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisher.java @@ -15,12 +15,9 @@ package org.eclipse.edc.identithub.did.spi; import org.eclipse.edc.iam.did.spi.document.DidDocument; -import org.eclipse.edc.identithub.did.spi.model.DidResource; import org.eclipse.edc.runtime.metamodel.annotation.ExtensionPoint; import org.eclipse.edc.spi.result.Result; -import java.util.Collection; - /** * The DidDocumentPublisher is responsible for taking a {@link DidDocument} and making it available at a VDR (verifiable data registry). * For example, an implementation may choose to publish the DID to a CDN. @@ -38,27 +35,18 @@ public interface DidDocumentPublisher { boolean canHandle(String id); /** - * Publishes a given {@link DidDocument} to a verifiable data registry (VDR). Publishing the same DID twice is a noop. + * Publishes a given {@link DidDocument} to a verifiable data registry (VDR). * - * @param did the DID to publish. A document with that DID must exist in the database. + * @param did the DID of the document to publish * @return a {@link Result} object indicating the success or failure of the operation. */ Result publish(String did); /** - * Unpublishes a given {@link DidDocument} from a verifiable data registry (VDR). Attempting to unpublish a DID document - * that isn't published will result in an error. + * Unpublishes a given {@link DidDocument} from a verifiable data registry (VDR). * - * @param did the DID to unpublish. A document with that DID must exist in the database. + * @param did the DID of the document to un-publish * @return a {@link Result} object indicating the success or failure of the operation. */ Result unpublish(String did); - - /** - * Returns a list of all {@link DidDocument}s that are managed by this publisher, and are currently published. - * - * @return a list of documents that are currently published. - */ - //todo: not sure if needed? - Collection getPublishedDocuments(); } diff --git a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisherRegistry.java b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisherRegistry.java index 14588a8de..7a35d8ebd 100644 --- a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisherRegistry.java +++ b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentPublisherRegistry.java @@ -37,12 +37,4 @@ public interface DidDocumentPublisherRegistry { */ DidDocumentPublisher getPublisher(String did); - - /** - * Determines whether a given DID can be published by this registry. The DID must conform to the W3C DID Syntax - * - * @param did The W3C DID to examine - * @return true if a publisher is found for this DID method, false if no publisher is found, or the DID is not valid. - */ - boolean canPublish(String did); } diff --git a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentService.java b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentService.java index f988e5e11..c8ea18d65 100644 --- a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentService.java +++ b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/DidDocumentService.java @@ -15,27 +15,74 @@ package org.eclipse.edc.identithub.did.spi; import org.eclipse.edc.iam.did.spi.document.DidDocument; -import org.eclipse.edc.identithub.did.spi.model.DidState; +import org.eclipse.edc.identithub.did.spi.model.DidResource; +import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.ServiceResult; +import java.util.Collection; + /** * The {@code DidDocumentService} gives access to a {@link DidDocument} that are held in storage. */ public interface DidDocumentService { + + /** + * Stores a DID document in persistent storage. + * + * @param document the {@link DidDocument} to store + * @return a {@link ServiceResult} to indicate success or failure. + */ + ServiceResult store(DidDocument document); + /** - * Retrieves the {@link DidDocument} associated with the given DID, if it exists. + * Publishes an already existing DID document. Returns a failure if the DID document was not found or cannot be published. * - * @param did The DID for which to retrieve the DidDocument. - * @return A {@link ServiceResult} containing the DidDocument if it exists, or an error if it does not exist or cannot be retrieved. + * @param did The ID of the DID document to publish. Must exist in the database. + * @return success, or a failure indicating what went wrong. */ - ServiceResult getDidDocument(String did); + ServiceResult publish(String did); /** - * Retrieves the state of a DID resource. + * Un-publishes an already existing DID document. Returns a failure if the DID document was not found or the underlying + * VDR does not support un-publishing * - * @param did The identifier of the DID resource. - * @return A {@link ServiceResult} containing the state of the DID resource if it exists. + * @param did The ID of the DID document to un-publish. Must exist in the database. + * @return success, or a failure indicating what went wrong. */ - ServiceResult getState(String did); + ServiceResult unpublish(String did); + + /** + * Updates a given DID document if it exists, returns a failure otherwise. + * + * @param document The DID document to update + * @return success, or a failure indicating what went wrong. + */ + ServiceResult update(DidDocument document); + + /** + * Deletes a DID document if found. + * + * @param did The ID of the DID document to delete. + * @return A {@link ServiceResult} indicating success or failure. + */ + ServiceResult deleteById(String did); + + /** + * Queries the {@link DidDocument} objects based on the given query specification. + * + * @param spec The query + * @return A {@link ServiceResult} containing a collection of {@link DidDocument} objects that match the query parameters. + */ + ServiceResult> queryDocuments(QuerySpec spec); + + default String notFoundMessage(String did) { + return "A DID document with ID '%s' does not exist.".formatted(did); + } + + default String noPublisherFoundMessage(String did) { + return "No publisher was found for did '%s'".formatted(did); + } + + DidResource findById(String did); } diff --git a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/model/DidResource.java b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/model/DidResource.java index af7803be7..ac562264b 100644 --- a/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/model/DidResource.java +++ b/spi/identity-hub-did-spi/src/main/java/org/eclipse/edc/identithub/did/spi/model/DidResource.java @@ -104,12 +104,12 @@ public Builder createTimestamp(long createdAt) { public DidResource build() { Objects.requireNonNull(resource.did, "Must have an identifier"); - Objects.requireNonNull(resource.state, "Must have a state"); - if (resource.stateTimestamp <= 0) { resource.stateTimestamp = resource.clock.millis(); } - + if (resource.createTimestamp <= 0) { + resource.createTimestamp = resource.clock.millis(); + } return resource; }