From 4ea5163e1e217e2d43327d29ed96ec161504f80c Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger <43503240+paullatzelsperger@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:40:28 +0200 Subject: [PATCH] chore: align deleteParticipantContext with DR (#446) --- .../did/DidDocumentServiceImpl.java | 45 +-- .../identityhub/did/DidServicesExtension.java | 2 - .../did/DidDocumentServiceImplTest.java | 345 ++++++++++++++---- .../keypairs/KeyPairServiceExtension.java | 5 +- .../keypairs/KeyPairServiceImpl.java | 188 +++++----- .../keypairs/KeyPairServiceImplTest.java | 3 +- .../ParticipantContextEventCoordinator.java | 22 ++ .../ParticipantContextEventPublisher.java | 10 + .../ParticipantContextExtension.java | 2 + .../ParticipantContextServiceImpl.java | 2 + .../ParticipantContextServiceImplTest.java | 6 +- .../tests/DidManagementApiEndToEndTest.java | 78 ++-- .../ParticipantContextApiEndToEndTest.java | 14 +- .../IdentityHubEndToEndTestContext.java | 7 +- .../did/local/LocalDidPublisher.java | 5 - .../did/local/LocalDidPublisherTest.java | 1 - .../events/ParticipantContextDeleting.java | 61 ++++ .../events/ParticipantContextListener.java | 15 +- 18 files changed, 565 insertions(+), 246 deletions(-) create mode 100644 spi/participant-context-spi/src/main/java/org/eclipse/edc/identityhub/spi/participantcontext/events/ParticipantContextDeleting.java 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 index 80759d4c3..6b22cd8b6 100644 --- 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 @@ -24,8 +24,6 @@ import org.eclipse.edc.identithub.spi.did.store.DidResourceStore; import org.eclipse.edc.identityhub.spi.keypair.events.KeyPairAdded; import org.eclipse.edc.identityhub.spi.keypair.events.KeyPairRevoked; -import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextCreated; -import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextDeleted; import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextUpdated; import org.eclipse.edc.identityhub.spi.participantcontext.model.ParticipantResource; import org.eclipse.edc.keys.spi.KeyParserRegistry; @@ -130,11 +128,15 @@ public ServiceResult unpublish(String did) { if (publisher == null) { return ServiceResult.badRequest(noPublisherFoundMessage(did)); } - var publishResult = publisher.unpublish(did); - return publishResult.succeeded() ? - success() : - ServiceResult.badRequest(publishResult.getFailureDetail()); - + // only unpublish if published, NOOP otherwise + if (existingDoc.getState() == DidState.PUBLISHED.code()) { + var publishResult = publisher.unpublish(did); + return publishResult.succeeded() ? + success() : + ServiceResult.badRequest(publishResult.getFailureDetail()); + } + monitor.info("Unpublishing DID Document '%s': not in state '%s', unpublishing is a NOOP.".formatted(did, existingDoc.getStateAsEnum())); + return success(); }); } @@ -216,8 +218,6 @@ public void on(EventEnvelope eventEnvelope) { var payload = eventEnvelope.getPayload(); if (payload instanceof ParticipantContextUpdated event) { updated(event); - } else if (payload instanceof ParticipantContextDeleted event) { - deleted(event); } else if (payload instanceof KeyPairAdded event) { keypairAdded(event); } else if (payload instanceof KeyPairRevoked event) { @@ -296,35 +296,8 @@ private void updated(ParticipantContextUpdated event) { } } - private void deleted(ParticipantContextDeleted event) { - var participantId = event.getParticipantId(); - //unpublish and delete all DIDs associated with that participant - var errors = findByParticipantId(participantId) - .stream() - .map(didResource -> unpublish(didResource.getDid()).compose(u -> deleteById(didResource.getDid()))) - .filter(AbstractResult::failed) - .map(AbstractResult::getFailureDetail) - .collect(Collectors.joining(", ")); - - if (!errors.isEmpty()) { - monitor.warning("Unpublishing/deleting DID documents after deleting a ParticipantContext failed: %s".formatted(errors)); - } - } - private Collection findByParticipantId(String participantId) { return didResourceStore.query(ParticipantResource.queryByParticipantId(participantId).build()); } - - private void created(ParticipantContextCreated event) { - var manifest = event.getManifest(); - var doc = DidDocument.Builder.newInstance() - .id(manifest.getDid()) - .service(manifest.getServiceEndpoints().stream().toList()) - // updating and adding a verification method happens as a result of the KeyPairAddedEvent - .build(); - store(doc, manifest.getParticipantId()) - .compose(u -> manifest.isActive() ? publish(doc.getId()) : success()) - .onFailure(f -> monitor.warning("Creating a DID document after creating a ParticipantContext creation failed: %s".formatted(f.getFailureDetail()))); - } } 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 4f219de85..92f108d70 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 @@ -19,7 +19,6 @@ import org.eclipse.edc.identithub.spi.did.store.DidResourceStore; import org.eclipse.edc.identityhub.spi.keypair.events.KeyPairAdded; import org.eclipse.edc.identityhub.spi.keypair.events.KeyPairRevoked; -import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextDeleted; import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextUpdated; import org.eclipse.edc.keys.spi.KeyParserRegistry; import org.eclipse.edc.runtime.metamodel.annotation.Extension; @@ -65,7 +64,6 @@ public DidDocumentPublisherRegistry getDidPublisherRegistry() { public DidDocumentService createDidDocumentService(ServiceExtensionContext context) { var service = new DidDocumentServiceImpl(transactionContext, didResourceStore, getDidPublisherRegistry(), context.getMonitor().withPrefix("DidDocumentService"), keyParserRegistry); eventRouter.registerSync(ParticipantContextUpdated.class, service); - eventRouter.registerSync(ParticipantContextDeleted.class, service); eventRouter.registerSync(KeyPairAdded.class, service); eventRouter.registerSync(KeyPairRevoked.class, service); return service; 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 index 4ad2bb584..32a0ce376 100644 --- 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 @@ -15,6 +15,9 @@ package org.eclipse.edc.identityhub.did; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; 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; @@ -23,9 +26,16 @@ import org.eclipse.edc.identithub.spi.did.model.DidResource; import org.eclipse.edc.identithub.spi.did.model.DidState; import org.eclipse.edc.identithub.spi.did.store.DidResourceStore; +import org.eclipse.edc.identityhub.spi.keypair.events.KeyPairAdded; +import org.eclipse.edc.identityhub.spi.keypair.events.KeyPairRevoked; +import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextUpdated; +import org.eclipse.edc.identityhub.spi.participantcontext.model.ParticipantContextState; import org.eclipse.edc.keys.KeyParserRegistryImpl; import org.eclipse.edc.keys.keyparsers.JwkParser; import org.eclipse.edc.keys.keyparsers.PemParser; +import org.eclipse.edc.spi.event.Event; +import org.eclipse.edc.spi.event.EventEnvelope; +import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.spi.result.StoreResult; @@ -34,9 +44,12 @@ import org.junit.jupiter.api.Test; import java.util.List; +import java.util.UUID; +import static org.eclipse.edc.iam.did.spi.document.DidConstants.JSON_WEB_KEY_2020; import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.startsWith; @@ -47,10 +60,11 @@ import static org.mockito.Mockito.when; class DidDocumentServiceImplTest { - private final DidResourceStore storeMock = mock(); + private final DidResourceStore didResourceStoreMock = mock(); private final DidDocumentPublisherRegistry publisherRegistry = mock(); private final DidDocumentPublisher publisherMock = mock(); private DidDocumentServiceImpl service; + private Monitor monitorMock; @BeforeEach void setUp() { @@ -60,23 +74,24 @@ void setUp() { var registry = new KeyParserRegistryImpl(); registry.register(new JwkParser(new ObjectMapper(), mock())); registry.register(new PemParser(mock())); - service = new DidDocumentServiceImpl(trx, storeMock, publisherRegistry, mock(), registry); + monitorMock = mock(); + service = new DidDocumentServiceImpl(trx, didResourceStoreMock, publisherRegistry, monitorMock, registry); } @Test void store() { var doc = createDidDocument().build(); - when(storeMock.save(argThat(dr -> dr.getDocument().equals(doc)))).thenReturn(StoreResult.success()); + when(didResourceStoreMock.save(argThat(dr -> dr.getDocument().equals(doc)))).thenReturn(StoreResult.success()); assertThat(service.store(doc, "test-participant")).isSucceeded(); - verify(storeMock).save(argThat(didResource -> didResource.getState() == DidState.GENERATED.code())); + verify(didResourceStoreMock).save(argThat(didResource -> didResource.getState() == DidState.GENERATED.code())); } @Test void store_alreadyExists() { var doc = createDidDocument().build(); - when(storeMock.save(argThat(dr -> dr.getDocument().equals(doc)))).thenReturn(StoreResult.alreadyExists("foo")); + when(didResourceStoreMock.save(argThat(dr -> dr.getDocument().equals(doc)))).thenReturn(StoreResult.alreadyExists("foo")); assertThat(service.store(doc, "test-participant")).isFailed().detail().isEqualTo("foo"); - verify(storeMock).save(any()); + verify(didResourceStoreMock).save(any()); verifyNoInteractions(publisherMock); } @@ -84,68 +99,68 @@ void store_alreadyExists() { 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()); + when(didResourceStoreMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.UNPUBLISHED).document(doc).build()); + when(didResourceStoreMock.deleteById(any())).thenReturn(StoreResult.success()); assertThat(service.deleteById(did)).isSucceeded(); - verify(storeMock).findById(did); - verify(storeMock).deleteById(did); - verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + verify(didResourceStoreMock).findById(did); + verify(didResourceStoreMock).deleteById(did); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock, 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()); + when(didResourceStoreMock.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); + verify(didResourceStoreMock).findById(did); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock, 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")); + when(didResourceStoreMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).state(DidState.UNPUBLISHED).document(doc).build()); + when(didResourceStoreMock.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); + verify(didResourceStoreMock).findById(did); + verify(didResourceStoreMock).deleteById(did); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock, publisherRegistry); } @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(didResourceStoreMock.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(didResourceStoreMock).findById(did); verify(publisherMock).publish(did); - verifyNoMoreInteractions(publisherMock, storeMock); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock); } @Test void publish_notExist() { var did = "did:web:test-did"; - when(storeMock.findById(eq(did))).thenReturn(null); + when(didResourceStoreMock.findById(eq(did))).thenReturn(null); assertThat(service.publish(did)).isFailed() .detail().isEqualTo(service.notFoundMessage(did)); - verify(storeMock).findById(did); - verifyNoMoreInteractions(publisherMock, storeMock); + verify(didResourceStoreMock).findById(did); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock); } @Test @@ -153,56 +168,56 @@ 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()); + when(didResourceStoreMock.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(didResourceStoreMock).findById(did); verify(publisherRegistry).getPublisher(did); - verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock, 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(didResourceStoreMock.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(didResourceStoreMock).findById(did); verify(publisherMock).publish(did); - verifyNoMoreInteractions(publisherMock, storeMock); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock); } @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(didResourceStoreMock.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(didResourceStoreMock).findById(did); verify(publisherMock).unpublish(did); - verifyNoMoreInteractions(publisherMock, storeMock); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock); } @Test void unpublish_notExist() { var did = "did:web:test-did"; - when(storeMock.findById(eq(did))).thenReturn(null); + when(didResourceStoreMock.findById(eq(did))).thenReturn(null); assertThat(service.unpublish(did)).isFailed() .detail().isEqualTo(service.notFoundMessage(did)); - verify(storeMock).findById(did); - verifyNoMoreInteractions(publisherMock, storeMock); + verify(didResourceStoreMock).findById(did); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock); } @Test @@ -210,30 +225,30 @@ 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()); + when(didResourceStoreMock.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(didResourceStoreMock).findById(did); verify(publisherRegistry).getPublisher(did); - verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock, 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(didResourceStoreMock.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(didResourceStoreMock).findById(did); verify(publisherMock).unpublish(did); - verifyNoMoreInteractions(publisherMock, storeMock); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock); } @Test @@ -241,26 +256,26 @@ 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)); + when(didResourceStoreMock.query(any())).thenReturn(List.of(res)); assertThat(service.queryDocuments(q)).isSucceeded(); - verify(storeMock).query(eq(q)); - verifyNoMoreInteractions(publisherMock, storeMock, publisherRegistry); + verify(didResourceStoreMock).query(eq(q)); + verifyNoMoreInteractions(publisherMock, didResourceStoreMock, publisherRegistry); } @Test void addEndpoint() { var doc = createDidDocument().build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); - when(storeMock.update(any())).thenReturn(StoreResult.success()); + when(didResourceStoreMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + when(didResourceStoreMock.update(any())).thenReturn(StoreResult.success()); var res = service.addService(did, new Service("new-id", "test-type", "https://test.com")); assertThat(res).isSucceeded(); - verify(storeMock).findById(eq(did)); - verify(storeMock).update(any()); - verifyNoMoreInteractions(storeMock, publisherMock); + verify(didResourceStoreMock).findById(eq(did)); + verify(didResourceStoreMock).update(any()); + verifyNoMoreInteractions(didResourceStoreMock, publisherMock); } @Test @@ -268,28 +283,28 @@ void addEndpoint_alreadyExists() { var newService = new Service("new-id", "test-type", "https://test.com"); var doc = createDidDocument().service(List.of(newService)).build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + when(didResourceStoreMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); var res = service.addService(did, newService); assertThat(res).isFailed() .detail() .isEqualTo("DID 'did:web:testdid' already contains a service endpoint with ID 'new-id'."); - verify(storeMock).findById(eq(did)); - verifyNoMoreInteractions(storeMock, publisherMock); + verify(didResourceStoreMock).findById(eq(did)); + verifyNoMoreInteractions(didResourceStoreMock, publisherMock); } @Test void addEndpoint_didNotFound() { var doc = createDidDocument().build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(null); + when(didResourceStoreMock.findById(eq(did))).thenReturn(null); var res = service.addService(did, new Service("test-id", "test-type", "https://test.com")); assertThat(res).isFailed() .detail() .isEqualTo("DID 'did:web:testdid' not found."); - verify(storeMock).findById(eq(did)); - verifyNoMoreInteractions(storeMock, publisherMock); + verify(didResourceStoreMock).findById(eq(did)); + verifyNoMoreInteractions(didResourceStoreMock, publisherMock); } @Test @@ -297,15 +312,15 @@ void replaceEndpoint() { var toReplace = new Service("new-id", "test-type", "https://test.com"); var doc = createDidDocument().service(List.of(toReplace)).build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); - when(storeMock.update(any())).thenReturn(StoreResult.success()); + when(didResourceStoreMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + when(didResourceStoreMock.update(any())).thenReturn(StoreResult.success()); var res = service.replaceService(did, toReplace); assertThat(res).isSucceeded(); - verify(storeMock).findById(eq(did)); - verify(storeMock).update(any()); - verifyNoMoreInteractions(storeMock, publisherMock); + verify(didResourceStoreMock).findById(eq(did)); + verify(didResourceStoreMock).update(any()); + verifyNoMoreInteractions(didResourceStoreMock, publisherMock); } @Test @@ -313,29 +328,29 @@ void replaceEndpoint_doesNotExist() { var replace = new Service("new-id", "test-type", "https://test.com"); var doc = createDidDocument().build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + when(didResourceStoreMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); var res = service.replaceService(did, replace); assertThat(res).isFailed() .detail() .isEqualTo("DID 'did:web:testdid' does not contain a service endpoint with ID 'new-id'."); - verify(storeMock).findById(eq(did)); - verifyNoMoreInteractions(storeMock, publisherMock); + verify(didResourceStoreMock).findById(eq(did)); + verifyNoMoreInteractions(didResourceStoreMock, publisherMock); } @Test void replaceEndpoint_didNotFound() { var doc = createDidDocument().build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(null); + when(didResourceStoreMock.findById(eq(did))).thenReturn(null); var res = service.replaceService(did, new Service("test-id", "test-type", "https://test.com")); assertThat(res).isFailed() .detail() .isEqualTo("DID 'did:web:testdid' not found."); - verify(storeMock).findById(eq(did)); - verifyNoMoreInteractions(storeMock, publisherMock); + verify(didResourceStoreMock).findById(eq(did)); + verifyNoMoreInteractions(didResourceStoreMock, publisherMock); } @Test @@ -343,45 +358,217 @@ void removeEndpoint() { var toRemove = new Service("new-id", "test-type", "https://test.com"); var doc = createDidDocument().service(List.of(toRemove)).build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); - when(storeMock.update(any())).thenReturn(StoreResult.success()); + when(didResourceStoreMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + when(didResourceStoreMock.update(any())).thenReturn(StoreResult.success()); var res = service.removeService(did, toRemove.getId()); assertThat(res).isSucceeded(); - verify(storeMock).findById(eq(did)); - verify(storeMock).update(any()); - verifyNoMoreInteractions(storeMock, publisherMock); + verify(didResourceStoreMock).findById(eq(did)); + verify(didResourceStoreMock).update(any()); + verifyNoMoreInteractions(didResourceStoreMock, publisherMock); } @Test void removeEndpoint_doesNotExist() { var doc = createDidDocument().build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); + when(didResourceStoreMock.findById(eq(did))).thenReturn(DidResource.Builder.newInstance().did(did).document(doc).build()); var res = service.removeService(did, "not-exist-id"); assertThat(res).isFailed() .detail().isEqualTo("DID 'did:web:testdid' does not contain a service endpoint with ID 'not-exist-id'."); - verify(storeMock).findById(eq(did)); - verifyNoMoreInteractions(storeMock, publisherMock); + verify(didResourceStoreMock).findById(eq(did)); + verifyNoMoreInteractions(didResourceStoreMock, publisherMock); } @Test void removeEndpoint_didNotFound() { var doc = createDidDocument().build(); var did = doc.getId(); - when(storeMock.findById(eq(did))).thenReturn(null); + when(didResourceStoreMock.findById(eq(did))).thenReturn(null); var res = service.removeService(did, "does-not-matter-id"); assertThat(res).isFailed() .detail() .isEqualTo("DID 'did:web:testdid' not found."); - verify(storeMock).findById(eq(did)); - verifyNoMoreInteractions(storeMock, publisherMock); + verify(didResourceStoreMock).findById(eq(did)); + verifyNoMoreInteractions(didResourceStoreMock, publisherMock); } + @SuppressWarnings("unchecked") + @Test + void onParticipantContextUpdated_whenDeactivates_shouldUnpublish() { + var doc = createDidDocument().build(); + var did = doc.getId(); + var participantId = "test-id"; + var didResource = DidResource.Builder.newInstance().did(did).state(DidState.PUBLISHED).document(doc).build(); + when(didResourceStoreMock.findById(eq(did))).thenReturn(didResource); + when(didResourceStoreMock.query(any())).thenReturn(List.of(didResource)); + when(publisherMock.unpublish(anyString())).thenReturn(Result.success()); + + service.on(EventEnvelope.Builder.newInstance() + .payload(ParticipantContextUpdated.Builder.newInstance() + .newState(ParticipantContextState.DEACTIVATED) + .participantId(participantId) + .build()) + .at(System.currentTimeMillis()) + .id(UUID.randomUUID().toString()) + .build()); + + verify(publisherMock).unpublish(eq(did)); + } + + @SuppressWarnings("unchecked") + @Test + void onParticipantContextUpdated_whenDeactivated_notPublished_shouldBeNoop() { + var doc = createDidDocument().build(); + var did = doc.getId(); + var participantId = "test-id"; + var didResource = DidResource.Builder.newInstance().did(did).state(DidState.GENERATED).document(doc).build(); + when(didResourceStoreMock.findById(eq(did))).thenReturn(didResource); + when(didResourceStoreMock.query(any())).thenReturn(List.of(didResource)); + when(publisherMock.unpublish(anyString())).thenReturn(Result.success()); + + service.on(EventEnvelope.Builder.newInstance() + .payload(ParticipantContextUpdated.Builder.newInstance() + .newState(ParticipantContextState.DEACTIVATED) + .participantId(participantId) + .build()) + .at(System.currentTimeMillis()) + .id(UUID.randomUUID().toString()) + .build()); + + verifyNoInteractions(publisherMock); + } + + @SuppressWarnings("unchecked") + @Test + void onParticipantContextUpdated_whenDeactivated_published_shouldBeNoop() { + var doc = createDidDocument().build(); + var did = doc.getId(); + var participantId = "test-id"; + var didResource = DidResource.Builder.newInstance().did(did).state(DidState.PUBLISHED).document(doc).build(); + when(didResourceStoreMock.findById(eq(did))).thenReturn(didResource); + when(didResourceStoreMock.query(any())).thenReturn(List.of(didResource)); + when(publisherMock.unpublish(anyString())).thenReturn(Result.success()); + + service.on(EventEnvelope.Builder.newInstance() + .payload(ParticipantContextUpdated.Builder.newInstance() + .newState(ParticipantContextState.DEACTIVATED) + .participantId(participantId) + .build()) + .at(System.currentTimeMillis()) + .id(UUID.randomUUID().toString()) + .build()); + + verify(publisherMock).unpublish(eq(did)); + } + + @SuppressWarnings("unchecked") + @Test + void onParticipantContextUpdated_whenActivated_shouldPublish() { + var doc = createDidDocument().build(); + var did = doc.getId(); + var participantId = "test-id"; + var didResource = DidResource.Builder.newInstance().did(did).state(DidState.GENERATED).document(doc).build(); + when(didResourceStoreMock.findById(eq(did))).thenReturn(didResource); + when(didResourceStoreMock.query(any())).thenReturn(List.of(didResource)); + when(publisherMock.publish(anyString())).thenReturn(Result.success()); + + service.on(EventEnvelope.Builder.newInstance() + .payload(ParticipantContextUpdated.Builder.newInstance() + .newState(ParticipantContextState.ACTIVATED) + .participantId(participantId) + .build()) + .at(System.currentTimeMillis()) + .id(UUID.randomUUID().toString()) + .build()); + + verify(publisherMock).publish(eq(did)); + } + + @SuppressWarnings("unchecked") + @Test + void onKeyPairAdded() throws JOSEException { + var keyId = "key-id"; + var key = new ECKeyGenerator(Curve.P_256).keyID(keyId).generate(); + var doc = createDidDocument().build(); + var did = doc.getId(); + var didResource = DidResource.Builder.newInstance().did(did).state(DidState.GENERATED).document(doc).build(); + + when(didResourceStoreMock.query(any(QuerySpec.class))).thenReturn(List.of(didResource)); + when(didResourceStoreMock.update(any())).thenReturn(StoreResult.success()); + + var event = EventEnvelope.Builder.newInstance() + .at(System.currentTimeMillis()) + .id(UUID.randomUUID().toString()) + .payload(KeyPairAdded.Builder.newInstance() + .keyId(keyId) + .keyPairResourceId("test-resource-id") + .participantId("test-participant") + .publicKey(key.toPublicJWK().toJSONString(), JSON_WEB_KEY_2020) + .build()) + .build(); + + service.on(event); + + verify(didResourceStoreMock).query(any(QuerySpec.class)); + verify(didResourceStoreMock).update(argThat(dr -> dr.getDocument().getVerificationMethod().stream().anyMatch(vm -> vm.getId().equals(keyId)))); + verifyNoMoreInteractions(didResourceStoreMock); + verifyNoInteractions(publisherMock); + } + + @SuppressWarnings("unchecked") + @Test + void onKeyPairRevoked() throws JOSEException { + var keyId = "key-id"; + var doc = createDidDocument().verificationMethod(List.of(VerificationMethod.Builder.newInstance() + .id(keyId) + .publicKeyJwk(new ECKeyGenerator(Curve.P_256).keyID(keyId).generate().toJSONObject()) + .build())) + .build(); + var did = doc.getId(); + var didResource = DidResource.Builder.newInstance().did(did).state(DidState.GENERATED).document(doc).build(); + + when(didResourceStoreMock.query(any(QuerySpec.class))).thenReturn(List.of(didResource)); + when(didResourceStoreMock.update(any())).thenReturn(StoreResult.success()); + + var event = EventEnvelope.Builder.newInstance() + .at(System.currentTimeMillis()) + .id(UUID.randomUUID().toString()) + .payload(KeyPairRevoked.Builder.newInstance() + .keyId(keyId) + .keyPairResourceId("test-resource-id") + .participantId("test-participant") + .build()) + .build(); + + service.on(event); + + verify(didResourceStoreMock).query(any(QuerySpec.class)); + // assert that the DID Doc does not contain a VerificationMethod with the ID that was revoked + verify(didResourceStoreMock).update(argThat(dr -> dr.getDocument().getVerificationMethod().stream().noneMatch(vm -> vm.getId().equals(keyId)))); + verifyNoMoreInteractions(didResourceStoreMock); + verifyNoInteractions(publisherMock); + } + + @SuppressWarnings("unchecked") + @Test + void onOtherEvent_shouldLogWarning() { + service.on(EventEnvelope.Builder.newInstance() + .at(System.currentTimeMillis()) + .id(UUID.randomUUID().toString()) + .payload(new Event() { + @Override + public String name() { + return "TestEvent"; + } + }) + .build()); + verify(monitorMock).warning(startsWith("Received event with unexpected payload type: ")); + } private DidDocument.Builder createDidDocument() { return DidDocument.Builder.newInstance() diff --git a/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceExtension.java b/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceExtension.java index 0bad113bd..60e4f5479 100644 --- a/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceExtension.java +++ b/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceExtension.java @@ -25,6 +25,7 @@ import org.eclipse.edc.spi.security.Vault; import org.eclipse.edc.spi.system.ServiceExtension; import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.transaction.spi.TransactionContext; import java.time.Clock; @@ -42,6 +43,8 @@ public class KeyPairServiceExtension implements ServiceExtension { private EventRouter eventRouter; @Inject private Clock clock; + @Inject + private TransactionContext transactionContext; private KeyPairObservable observable; @@ -52,7 +55,7 @@ public String name() { @Provider public KeyPairService createParticipantService(ServiceExtensionContext context) { - var service = new KeyPairServiceImpl(keyPairResourceStore, vault, context.getMonitor().withPrefix("KeyPairService"), keyPairObservable()); + var service = new KeyPairServiceImpl(keyPairResourceStore, vault, context.getMonitor().withPrefix("KeyPairService"), keyPairObservable(), transactionContext); eventRouter.registerSync(ParticipantContextDeleted.class, service); return service; } diff --git a/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImpl.java b/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImpl.java index 4cc1586a7..aecedfc41 100644 --- a/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImpl.java +++ b/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImpl.java @@ -35,6 +35,7 @@ import org.eclipse.edc.spi.result.ServiceResult; import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.spi.security.Vault; +import org.eclipse.edc.transaction.spi.TransactionContext; import org.jetbrains.annotations.Nullable; import java.time.Instant; @@ -49,97 +50,104 @@ public class KeyPairServiceImpl implements KeyPairService, EventSubscriber { private final Vault vault; private final Monitor monitor; private final KeyPairObservable observable; + private final TransactionContext transactionContext; - public KeyPairServiceImpl(KeyPairResourceStore keyPairResourceStore, Vault vault, Monitor monitor, KeyPairObservable observable) { + public KeyPairServiceImpl(KeyPairResourceStore keyPairResourceStore, Vault vault, Monitor monitor, KeyPairObservable observable, TransactionContext transactionContext) { this.keyPairResourceStore = keyPairResourceStore; this.vault = vault; this.monitor = monitor; this.observable = observable; + this.transactionContext = transactionContext; } @Override public ServiceResult addKeyPair(String participantId, KeyDescriptor keyDescriptor, boolean makeDefault) { - var key = generateOrGetKey(keyDescriptor); - if (key.failed()) { - return ServiceResult.badRequest(key.getFailureDetail()); - } - - // check if the new key is not active, and no other active key exists - if (!keyDescriptor.isActive()) { - var hasActiveKeys = keyPairResourceStore.query(ParticipantResource.queryByParticipantId(participantId).build()) - .orElse(failure -> Collections.emptySet()) - .stream().filter(kpr -> kpr.getState() == KeyPairState.ACTIVE.code()) - .findAny() - .isEmpty(); + return transactionContext.execute(() -> { + var key = generateOrGetKey(keyDescriptor); + if (key.failed()) { + return ServiceResult.badRequest(key.getFailureDetail()); + } - if (!hasActiveKeys) { - monitor.warning("Participant '%s' has no active key pairs, and adding an inactive one will prevent the participant from becoming operational."); + // check if the new key is not active, and no other active key exists + if (!keyDescriptor.isActive()) { + var hasActiveKeys = keyPairResourceStore.query(ParticipantResource.queryByParticipantId(participantId).build()) + .orElse(failure -> Collections.emptySet()) + .stream().filter(kpr -> kpr.getState() == KeyPairState.ACTIVE.code()) + .findAny() + .isEmpty(); + + if (!hasActiveKeys) { + monitor.warning("Participant '%s' has no active key pairs, and adding an inactive one will prevent the participant from becoming operational."); + } } - } - var newResource = KeyPairResource.Builder.newInstance() - .id(keyDescriptor.getResourceId()) - .keyId(keyDescriptor.getKeyId()) - .state(keyDescriptor.isActive() ? KeyPairState.ACTIVE : KeyPairState.CREATED) - .isDefaultPair(makeDefault) - .privateKeyAlias(keyDescriptor.getPrivateKeyAlias()) - .serializedPublicKey(key.getContent()) - .timestamp(Instant.now().toEpochMilli()) - .participantId(participantId) - .build(); - - return ServiceResult.from(keyPairResourceStore.create(newResource)) - .onSuccess(v -> observable.invokeForEach(l -> l.added(newResource, keyDescriptor.getType()))); + var newResource = KeyPairResource.Builder.newInstance() + .id(keyDescriptor.getResourceId()) + .keyId(keyDescriptor.getKeyId()) + .state(keyDescriptor.isActive() ? KeyPairState.ACTIVE : KeyPairState.CREATED) + .isDefaultPair(makeDefault) + .privateKeyAlias(keyDescriptor.getPrivateKeyAlias()) + .serializedPublicKey(key.getContent()) + .timestamp(Instant.now().toEpochMilli()) + .participantId(participantId) + .build(); + + return ServiceResult.from(keyPairResourceStore.create(newResource)) + .onSuccess(v -> observable.invokeForEach(l -> l.added(newResource, keyDescriptor.getType()))); + }); } @Override public ServiceResult rotateKeyPair(String oldId, @Nullable KeyDescriptor newKeySpec, long duration) { + return transactionContext.execute(() -> { + var oldKey = findById(oldId); + if (oldKey == null) { + return ServiceResult.notFound("A KeyPairResource with ID '%s' does not exist.".formatted(oldId)); + } - var oldKey = findById(oldId); - if (oldKey == null) { - return ServiceResult.notFound("A KeyPairResource with ID '%s' does not exist.".formatted(oldId)); - } - - var participantId = oldKey.getParticipantId(); - boolean wasDefault = oldKey.isDefaultPair(); + var participantId = oldKey.getParticipantId(); + boolean wasDefault = oldKey.isDefaultPair(); - // deactivate the old key - var oldAlias = oldKey.getPrivateKeyAlias(); - vault.deleteSecret(oldAlias); - oldKey.rotate(duration); - var updateResult = ServiceResult.from(keyPairResourceStore.update(oldKey)) - .onSuccess(v -> observable.invokeForEach(l -> l.rotated(oldKey))); + // deactivate the old key + var oldAlias = oldKey.getPrivateKeyAlias(); + vault.deleteSecret(oldAlias); + oldKey.rotate(duration); + var updateResult = ServiceResult.from(keyPairResourceStore.update(oldKey)) + .onSuccess(v -> observable.invokeForEach(l -> l.rotated(oldKey))); - if (newKeySpec != null) { - return updateResult.compose(v -> addKeyPair(participantId, newKeySpec, wasDefault)); - } - monitor.warning("Rotating keys without a successor key may leave the participant without an active keypair."); - return updateResult; + if (newKeySpec != null) { + return updateResult.compose(v -> addKeyPair(participantId, newKeySpec, wasDefault)); + } + monitor.warning("Rotating keys without a successor key may leave the participant without an active keypair."); + return updateResult; + }); } @Override public ServiceResult revokeKey(String id, @Nullable KeyDescriptor newKeySpec) { - var oldKey = findById(id); - if (oldKey == null) { - return ServiceResult.notFound("A KeyPairResource with ID '%s' does not exist.".formatted(id)); - } + return transactionContext.execute(() -> { + var oldKey = findById(id); + if (oldKey == null) { + return ServiceResult.notFound("A KeyPairResource with ID '%s' does not exist.".formatted(id)); + } - var participantId = oldKey.getParticipantId(); - boolean wasDefault = oldKey.isDefaultPair(); + var participantId = oldKey.getParticipantId(); + boolean wasDefault = oldKey.isDefaultPair(); - // deactivate the old key - var oldAlias = oldKey.getPrivateKeyAlias(); - vault.deleteSecret(oldAlias); - oldKey.revoke(); - var updateResult = ServiceResult.from(keyPairResourceStore.update(oldKey)) - .onSuccess(v -> observable.invokeForEach(l -> l.revoked(oldKey))); + // deactivate the old key + var oldAlias = oldKey.getPrivateKeyAlias(); + vault.deleteSecret(oldAlias); + oldKey.revoke(); + var updateResult = ServiceResult.from(keyPairResourceStore.update(oldKey)) + .onSuccess(v -> observable.invokeForEach(l -> l.revoked(oldKey))); - if (newKeySpec != null) { - return updateResult.compose(v -> addKeyPair(participantId, newKeySpec, wasDefault)); - } - monitor.warning("Revoking keys without a successor key may leave the participant without an active keypair."); - return updateResult; + if (newKeySpec != null) { + return updateResult.compose(v -> addKeyPair(participantId, newKeySpec, wasDefault)); + } + monitor.warning("Revoking keys without a successor key may leave the participant without an active keypair."); + return updateResult; + }); } @Override @@ -149,19 +157,21 @@ public ServiceResult> query(QuerySpec querySpec) { @Override public ServiceResult activate(String keyPairResourceId) { - var oldKey = findById(keyPairResourceId); - if (oldKey == null) { - return ServiceResult.notFound("A KeyPairResource with ID '%s' does not exist.".formatted(keyPairResourceId)); - } + return transactionContext.execute(() -> { + var oldKey = findById(keyPairResourceId); + if (oldKey == null) { + return ServiceResult.notFound("A KeyPairResource with ID '%s' does not exist.".formatted(keyPairResourceId)); + } - var allowedStates = List.of(KeyPairState.ACTIVE.code(), KeyPairState.CREATED.code()); - if (!allowedStates.contains(oldKey.getState())) { - return ServiceResult.badRequest("The key pair resource is expected to be in %s, but was %s".formatted(allowedStates, oldKey.getState())); - } + var allowedStates = List.of(KeyPairState.ACTIVE.code(), KeyPairState.CREATED.code()); + if (!allowedStates.contains(oldKey.getState())) { + return ServiceResult.badRequest("The key pair resource is expected to be in %s, but was %s".formatted(allowedStates, oldKey.getState())); + } - oldKey.activate(); + oldKey.activate(); - return ServiceResult.from(keyPairResourceStore.update(oldKey)); + return ServiceResult.from(keyPairResourceStore.update(oldKey)); + }); } @Override @@ -182,20 +192,22 @@ private void created(ParticipantContextCreated event) { private void deleted(ParticipantContextDeleted event) { //hard-delete all keypairs that are associated with the deleted participant var query = ParticipantResource.queryByParticipantId(event.getParticipantId()).build(); - keyPairResourceStore.query(query) - .compose(list -> { - var x = list.stream().map(r -> keyPairResourceStore.deleteById(r.getId())) - .filter(StoreResult::failed) - .map(AbstractResult::getFailureDetail) - .collect(Collectors.joining(",")); - - if (x.isEmpty()) { - return StoreResult.success(); - } - // not-found is not necessarily correct, but we only care about the error message - return StoreResult.notFound("An error occurred when deleting KeyPairResources: %s".formatted(x)); - }) - .onFailure(f -> monitor.warning("Removing key pairs from a deleted ParticipantContext failed: %s".formatted(f.getFailureDetail()))); + transactionContext.execute(() -> { + keyPairResourceStore.query(query) + .compose(list -> { + var errors = list.stream() + .map(r -> keyPairResourceStore.deleteById(r.getId())) + .filter(StoreResult::failed) + .map(AbstractResult::getFailureDetail) + .collect(Collectors.joining(",")); + + if (errors.isEmpty()) { + return StoreResult.success(); + } + return StoreResult.generalError("An error occurred when deleting KeyPairResources: %s".formatted(errors)); + }) + .onFailure(f -> monitor.warning("Removing key pairs from a deleted ParticipantContext failed: %s".formatted(f.getFailureDetail()))); + }); } private KeyPairResource findById(String oldId) { diff --git a/core/identity-hub-keypairs/src/test/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImplTest.java b/core/identity-hub-keypairs/src/test/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImplTest.java index c69383c2d..c621215e8 100644 --- a/core/identity-hub-keypairs/src/test/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImplTest.java +++ b/core/identity-hub-keypairs/src/test/java/org/eclipse/edc/identityhub/keypairs/KeyPairServiceImplTest.java @@ -24,6 +24,7 @@ import org.eclipse.edc.identityhub.spi.store.KeyPairResourceStore; import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.spi.security.Vault; +import org.eclipse.edc.transaction.spi.NoopTransactionContext; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -53,7 +54,7 @@ class KeyPairServiceImplTest { private final KeyPairResourceStore keyPairResourceStore = mock(i -> StoreResult.success()); private final Vault vault = mock(); private final KeyPairObservable observableMock = mock(); - private final KeyPairServiceImpl keyPairService = new KeyPairServiceImpl(keyPairResourceStore, vault, mock(), observableMock); + private final KeyPairServiceImpl keyPairService = new KeyPairServiceImpl(keyPairResourceStore, vault, mock(), observableMock, new NoopTransactionContext()); @ParameterizedTest(name = "make default: {0}") diff --git a/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextEventCoordinator.java b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextEventCoordinator.java index 30eff60a9..a0c478367 100644 --- a/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextEventCoordinator.java +++ b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextEventCoordinator.java @@ -17,11 +17,16 @@ import org.eclipse.edc.iam.did.spi.document.DidDocument; import org.eclipse.edc.identithub.spi.did.DidDocumentService; import org.eclipse.edc.identityhub.spi.keypair.KeyPairService; +import org.eclipse.edc.identityhub.spi.keypair.model.KeyPairResource; import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextCreated; +import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextDeleting; import org.eclipse.edc.spi.event.Event; import org.eclipse.edc.spi.event.EventEnvelope; import org.eclipse.edc.spi.event.EventSubscriber; import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.ServiceResult; + +import java.util.stream.Stream; import static org.eclipse.edc.spi.result.ServiceResult.success; @@ -66,8 +71,25 @@ public void on(EventEnvelope event) { .compose(u -> manifest.isActive() ? didDocumentService.publish(doc.getId()) : success()) .onFailure(f -> monitor.warning("%s".formatted(f.getFailureDetail()))); + } else if (payload instanceof ParticipantContextDeleting deletionEvent) { + var participantContext = deletionEvent.getParticipantContext(); + + // unpublish and delete did document, remove keypairs + didDocumentService.unpublish(participantContext.getDid()) + .compose(u -> didDocumentService.deleteById(participantContext.getDid())) + .compose(u -> keyPairService.query(KeyPairResource.queryByParticipantId(participantContext.getParticipantId()).build())) + .compose(keyPairs -> keyPairs.stream() + .map(r -> keyPairService.revokeKey(r.getId(), null)) + .reduce(this::merge) + .orElse(success())) + .onFailure(f -> monitor.warning("Removing key pairs from a deleted ParticipantContext failed: %s".formatted(f.getFailureDetail()))); } else { monitor.warning("Received event with unexpected payload type: %s".formatted(payload.getClass())); } } + + private ServiceResult merge(ServiceResult sr1, ServiceResult sr2) { + return sr1.succeeded() && sr2.succeeded() ? success() : + ServiceResult.unexpected(Stream.concat(sr1.getFailureMessages().stream(), sr2.getFailureMessages().stream()).toArray(String[]::new)); + } } diff --git a/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextEventPublisher.java b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextEventPublisher.java index bf7caffd3..0b6b1f8f2 100644 --- a/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextEventPublisher.java +++ b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextEventPublisher.java @@ -16,6 +16,7 @@ import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextCreated; import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextDeleted; +import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextDeleting; import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextEvent; import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextListener; import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextUpdated; @@ -53,6 +54,15 @@ public void updated(ParticipantContext updatedContext) { publish(event); } + @Override + public void deleting(ParticipantContext deletedContext) { + var event = ParticipantContextDeleting.Builder.newInstance() + .participantId(deletedContext.getParticipantId()) + .participant(deletedContext) + .build(); + publish(event); + } + @Override public void deleted(ParticipantContext deletedContext) { var event = ParticipantContextDeleted.Builder.newInstance() diff --git a/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextExtension.java b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextExtension.java index 35dadbb25..689d031fb 100644 --- a/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextExtension.java +++ b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextExtension.java @@ -19,6 +19,7 @@ import org.eclipse.edc.identityhub.spi.keypair.KeyPairService; import org.eclipse.edc.identityhub.spi.participantcontext.ParticipantContextService; import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextCreated; +import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextDeleting; import org.eclipse.edc.identityhub.spi.participantcontext.events.ParticipantContextObservable; import org.eclipse.edc.identityhub.spi.store.ParticipantContextStore; import org.eclipse.edc.keys.spi.KeyParserRegistry; @@ -71,6 +72,7 @@ public void initialize(ServiceExtensionContext context) { didDocumentService, keyPairService); eventRouter.registerSync(ParticipantContextCreated.class, coordinator); + eventRouter.registerSync(ParticipantContextDeleting.class, coordinator); } @Provider diff --git a/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextServiceImpl.java b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextServiceImpl.java index fa9953b0c..3fd8d6d98 100644 --- a/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextServiceImpl.java +++ b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextServiceImpl.java @@ -97,7 +97,9 @@ public ServiceResult deleteParticipantContext(String participantId) { return ServiceResult.notFound("A ParticipantContext with ID '%s' does not exist."); } + observable.invokeForEach(l -> l.deleting(participantContext)); var res = participantContextStore.deleteById(participantId); + vault.deleteSecret(participantContext.getApiTokenAlias()); if (res.failed()) { return fromFailure(res); } diff --git a/core/identity-hub-participants/src/test/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextServiceImplTest.java b/core/identity-hub-participants/src/test/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextServiceImplTest.java index 448268756..aef2f2d97 100644 --- a/core/identity-hub-participants/src/test/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextServiceImplTest.java +++ b/core/identity-hub-participants/src/test/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextServiceImplTest.java @@ -49,6 +49,7 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -223,7 +224,8 @@ void deleteParticipantContext() { assertThat(participantContextService.deleteParticipantContext("test-id")).isSucceeded(); verify(participantContextStore).deleteById(anyString()); - verify(observableMock).invokeForEach(any()); + verify(observableMock, times(2)).invokeForEach(any()); + verify(vault).deleteSecret(anyString()); verifyNoMoreInteractions(vault, observableMock); } @@ -239,7 +241,9 @@ void deleteParticipantContext_whenNotExists() { Assertions.assertThat(f.getFailureDetail()).isEqualTo("foo bar"); }); + verify(observableMock).invokeForEach(any()); //deleting verify(participantContextStore).deleteById(anyString()); + verify(vault).deleteSecret(anyString()); verifyNoMoreInteractions(vault, observableMock); } diff --git a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/DidManagementApiEndToEndTest.java b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/DidManagementApiEndToEndTest.java index 5e0429607..fc43a9ecf 100644 --- a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/DidManagementApiEndToEndTest.java +++ b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/DidManagementApiEndToEndTest.java @@ -170,40 +170,68 @@ void unpublishDid_notOwner_expect403(IdentityHubEndToEndTestContext context, Eve } @Test - void unpublishDid(IdentityHubEndToEndTestContext context, EventRouter router) { + void unpublishDid_withSuperUserToken(IdentityHubEndToEndTestContext context, EventRouter router) { var superUserKey = context.createSuperUser(); var subscriber = mock(EventSubscriber.class); router.registerSync(DidDocumentUnpublished.class, subscriber); var user = "test-user"; - var token = context.createParticipant(user); + context.createParticipant(user); - assertThat(Arrays.asList(token, superUserKey)) - .allSatisfy(t -> { - reset(subscriber); - context.getIdentityApiEndpoint().baseRequest() - .contentType(JSON) - .header(new Header("x-api-key", t)) - .body(""" - { - "did": "did:web:test-user" - } - """) - .post("/v1alpha/participants/%s/dids/unpublish".formatted(user)) - .then() - .log().ifValidationFails() - .statusCode(204) - .body(Matchers.notNullValue()); + reset(subscriber); + context.getIdentityApiEndpoint().baseRequest() + .contentType(JSON) + .header(new Header("x-api-key", superUserKey)) + .body(""" + { + "did": "did:web:test-user" + } + """) + .post("/v1alpha/participants/%s/dids/unpublish".formatted(user)) + .then() + .log().ifValidationFails() + .statusCode(204) + .body(Matchers.notNullValue()); - // verify that the publish event was fired twice - verify(subscriber).on(argThat(env -> { - if (env.getPayload() instanceof DidDocumentUnpublished event) { - return event.getDid().equals("did:web:test-user"); + // verify that the publish event was fired twice + verify(subscriber).on(argThat(env -> { + if (env.getPayload() instanceof DidDocumentUnpublished event) { + return event.getDid().equals("did:web:test-user"); + } + return false; + })); + } + + @Test + void unpublishDid_withUserToken(IdentityHubEndToEndTestContext context, EventRouter router) { + var subscriber = mock(EventSubscriber.class); + router.registerSync(DidDocumentUnpublished.class, subscriber); + + var user = "test-user"; + var token = context.createParticipant(user); + + reset(subscriber); + context.getIdentityApiEndpoint().baseRequest() + .contentType(JSON) + .header(new Header("x-api-key", token)) + .body(""" + { + "did": "did:web:test-user" } - return false; - })); - }); + """) + .post("/v1alpha/participants/%s/dids/unpublish".formatted(user)) + .then() + .log().ifValidationFails() + .statusCode(204) + .body(Matchers.notNullValue()); + // verify that the unpublish event was fired + verify(subscriber).on(argThat(env -> { + if (env.getPayload() instanceof DidDocumentUnpublished event) { + return event.getDid().equals("did:web:test-user"); + } + return false; + })); } @Test diff --git a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/ParticipantContextApiEndToEndTest.java b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/ParticipantContextApiEndToEndTest.java index 37964eba8..33479f169 100644 --- a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/ParticipantContextApiEndToEndTest.java +++ b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/ParticipantContextApiEndToEndTest.java @@ -40,6 +40,7 @@ import org.eclipse.edc.spi.event.EventSubscriber; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.security.Vault; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -75,7 +76,7 @@ abstract static class Tests { @AfterEach void tearDown(ParticipantContextService pcService, DidResourceStore didResourceStore, KeyPairResourceStore keyPairResourceStore) { // purge all users, dids, keypairs - + pcService.query(QuerySpec.max()).getContent() .forEach(pc -> pcService.deleteParticipantContext(pc.getParticipantId()).getContent()); @@ -345,13 +346,17 @@ void activateParticipant_principalIsSuperser(IdentityHubEndToEndTestContext cont } @Test - void deleteParticipant(IdentityHubEndToEndTestContext context) { + void deleteParticipant(IdentityHubEndToEndTestContext context, Vault vault) { var superUserKey = context.createSuperUser(); var participantId = "another-user"; context.createParticipant(participantId); - assertThat(context.getDidForParticipant(participantId)).hasSize(1); + var pc = context.getParticipant(participantId); + var alias = context.getKeyPairsForParticipant(participantId).stream().findFirst().map(KeyPairResource::getPrivateKeyAlias).orElseThrow(); + var apiTokenAlias = pc.getApiTokenAlias(); + + context.getIdentityApiEndpoint().baseRequest() .header(new Header("x-api-key", superUserKey)) .contentType(ContentType.JSON) @@ -361,6 +366,9 @@ void deleteParticipant(IdentityHubEndToEndTestContext context) { .statusCode(204); assertThat(context.getDidForParticipant(participantId)).isEmpty(); + assertThat(context.getKeyPairsForParticipant(participantId)).isEmpty(); + assertThat(vault.resolveSecret(alias)).isNull(); + assertThat(vault.resolveSecret(apiTokenAlias)).isNull(); } @Test diff --git a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/fixtures/IdentityHubEndToEndTestContext.java b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/fixtures/IdentityHubEndToEndTestContext.java index 94a9bc5c7..8d989e834 100644 --- a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/fixtures/IdentityHubEndToEndTestContext.java +++ b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/fixtures/IdentityHubEndToEndTestContext.java @@ -50,6 +50,8 @@ import java.util.Optional; import java.util.UUID; +import static org.mockito.Mockito.spy; + /** * Identity Hub end to end context used in tests extended with {@link IdentityHubEndToEndExtension} */ @@ -136,7 +138,6 @@ public IdentityHubRuntimeConfiguration.Endpoint getPresentationEndpoint() { return configuration.getPresentationEndpoint(); } - public Collection getDidForParticipant(String participantId) { return runtime.getService(DidDocumentService.class).queryDocuments(QuerySpec.Builder.newInstance() .filter(new Criterion("participantId", "=", participantId)) @@ -200,4 +201,8 @@ public Optional getCredential(String credentialId) .stream().findFirst(); } + public S spyService(Class serviceClass) { + return spy(runtime.getService(serviceClass)); + } + } 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 a9491a674..d7d0780e5 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 @@ -74,11 +74,6 @@ public Result unpublish(String did) { return Result.failure("A DID Resource with the ID '%s' was not found.".formatted(did)); } - if (!isPublished(existingDocument)) { - monitor.info("Un-publish DID Resource '%s': not published -> NOOP.".formatted(did)); - // do not return early, the state could be anything - } - existingDocument.transitionState(DidState.UNPUBLISHED); return didResourceStore.update(existingDocument) .map(v -> success()) 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 d68a56f56..e16eb086c 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 @@ -157,7 +157,6 @@ void unpublish_notPublished_expectWarning() { verify(storeMock).update(any()); verify(observableMock).invokeForEach(any()); verifyNoMoreInteractions(storeMock, observableMock); - verify(monitor).info("Un-publish DID Resource 'did:web:test': not published -> NOOP."); } @Test diff --git a/spi/participant-context-spi/src/main/java/org/eclipse/edc/identityhub/spi/participantcontext/events/ParticipantContextDeleting.java b/spi/participant-context-spi/src/main/java/org/eclipse/edc/identityhub/spi/participantcontext/events/ParticipantContextDeleting.java new file mode 100644 index 000000000..2286b3d4d --- /dev/null +++ b/spi/participant-context-spi/src/main/java/org/eclipse/edc/identityhub/spi/participantcontext/events/ParticipantContextDeleting.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024 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.spi.participantcontext.events; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import org.eclipse.edc.identityhub.spi.participantcontext.model.ParticipantContext; + +/** + * Event that signals that a {@link ParticipantContext} is in the process of being deleted. This event is emitted before + * any storage interaction (= deletion) occurs. + */ +@JsonDeserialize(builder = ParticipantContextDeleting.Builder.class) +public class ParticipantContextDeleting extends ParticipantContextEvent { + private ParticipantContext participantContext; + + @Override + public String name() { + return "participantcontext.deleting"; + } + + public ParticipantContext getParticipantContext() { + return participantContext; + } + + @JsonPOJOBuilder(withPrefix = "") + public static class Builder extends ParticipantContextEvent.Builder { + + private Builder() { + super(new ParticipantContextDeleting()); + } + + @JsonCreator + public static Builder newInstance() { + return new Builder(); + } + + @Override + public Builder self() { + return this; + } + + public Builder participant(ParticipantContext deletedContext) { + this.event.participantContext = deletedContext; + return self(); + } + } +} diff --git a/spi/participant-context-spi/src/main/java/org/eclipse/edc/identityhub/spi/participantcontext/events/ParticipantContextListener.java b/spi/participant-context-spi/src/main/java/org/eclipse/edc/identityhub/spi/participantcontext/events/ParticipantContextListener.java index 12ab7a102..c8d727973 100644 --- a/spi/participant-context-spi/src/main/java/org/eclipse/edc/identityhub/spi/participantcontext/events/ParticipantContextListener.java +++ b/spi/participant-context-spi/src/main/java/org/eclipse/edc/identityhub/spi/participantcontext/events/ParticipantContextListener.java @@ -46,10 +46,19 @@ default void updated(ParticipantContext updatedContext) { } /** - * Notifies about the fact that a {@link ParticipantContext} has been deleted, and further action, such as deleting keypairs or updating DID documents - * can now happen. + * Notifies about the fact that the deletion of a {@link ParticipantContext} is imminent. This is useful if resources like keypairs, + * DID documents etc. should be cleaned up before the deletion of the {@link ParticipantContext}. + * + * @param deletedContext The ParticipantContext that is going to be deleted. + */ + default void deleting(ParticipantContext deletedContext) { + + } + + /** + * Notifies about the fact that a {@link ParticipantContext} and all associated data (key-pairs, DID documents, etc) have been deleted. * - * @param deletedContext The updated (already persisted) participant context + * @param deletedContext The deleted participant context */ default void deleted(ParticipantContext deletedContext) {