From a2f6cdbe5370a9989d5faca4376668770eb16c95 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger <43503240+paullatzelsperger@users.noreply.github.com> Date: Tue, 30 Jan 2024 17:04:10 +0100 Subject: [PATCH] feat: use events to decouple services (#245) * events: participantcontext * decouple participantcontext service * KeyPairService consumes participant events * DidDocumentService consumes PArticipantContext events * moved participant context events to "events" package * added KeyPairEvent classes * KeyPairService emits events * Added test assertions to e2e test * added events to the did publisher * checkstyle * DEPENDENCIES * javadoc * pr remarks - jackson annotations, tests * pr remarks - doc * Apply suggestions from code review Co-authored-by: Jim Marino * Update spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentEvent.java Co-authored-by: Jim Marino * react to KeyPairAdded and KeyPairRevoked * pr remarks --------- Co-authored-by: Jim Marino --- core/identity-hub-did/build.gradle.kts | 3 +- .../did/DidDocumentServiceImpl.java | 159 ++++++++++++++++-- .../identityhub/did/DidServicesExtension.java | 24 ++- .../did/DidDocumentServiceImplTest.java | 9 +- .../keypairs/KeyPairEventListenerImpl.java | 73 ++++++++ .../keypairs/KeyPairObservableImpl.java | 22 +++ .../keypairs/KeyPairServiceExtension.java | 26 ++- .../keypairs/KeyPairServiceImpl.java | 68 ++++++-- .../keypairs/KeyPairServiceImplTest.java | 39 +++-- .../ParticipantContextExtension.java | 21 ++- .../ParticipantContextListenerImpl.java | 71 ++++++++ .../ParticipantContextObservableImpl.java | 22 +++ .../ParticipantContextServiceImpl.java | 93 ++-------- .../ParticipantContextServiceImplTest.java | 82 +++------ .../tests/DidManagementApiEndToEndTest.java | 97 +++++++++++ .../tests/KeyPairResourceApiEndToEndTest.java | 58 +++++++ .../tests/ManagementApiEndToEndTest.java | 23 +++ .../ParticipantContextApiEndToEndTest.java | 53 ++++++ .../AuthorizationServiceImpl.java | 9 +- .../v1/ParticipantContextApiController.java | 3 +- .../did/local/DidDocumentListenerImpl.java | 62 +++++++ .../did/local/DidDocumentObservableImpl.java | 22 +++ .../did/local/LocalDidPublisher.java | 11 +- .../did/local/LocalDidPublisherExtension.java | 21 ++- .../did/local/LocalDidPublisherTest.java | 24 ++- .../events/diddocument/DidDocumentEvent.java | 61 +++++++ .../diddocument/DidDocumentListener.java | 39 +++++ .../diddocument/DidDocumentObservable.java | 23 +++ .../diddocument/DidDocumentPublished.java | 48 ++++++ .../diddocument/DidDocumentUnpublished.java | 48 ++++++ .../spi/events/keypair/KeyPairAdded.java | 59 +++++++ .../spi/events/keypair/KeyPairEvent.java | 67 ++++++++ .../events/keypair/KeyPairEventListener.java | 55 ++++++ .../spi/events/keypair/KeyPairObservable.java | 23 +++ .../spi/events/keypair/KeyPairRevoked.java | 48 ++++++ .../spi/events/keypair/KeyPairRotated.java | 48 ++++++ .../ParticipantContextCreated.java | 60 +++++++ .../ParticipantContextDeleted.java | 48 ++++++ .../participant/ParticipantContextEvent.java | 51 ++++++ .../ParticipantContextListener.java | 57 +++++++ .../ParticipantContextObservable.java | 23 +++ .../ParticipantContextUpdated.java | 61 +++++++ .../diddocument/DidDocumentPublishedTest.java | 40 +++++ .../DidDocumentUnpublishedTest.java | 40 +++++ .../spi/events/keypair/KeyPairAddedTest.java | 38 +++++ .../events/keypair/KeyPairRevokedTest.java | 38 +++++ .../events/keypair/KeyPairRotatedTest.java | 38 +++++ .../ParticipantContextCreatedTest.java | 39 +++++ .../ParticipantContextDeletedTest.java | 39 +++++ .../ParticipantContextUpdatedTest.java | 38 +++++ 50 files changed, 2027 insertions(+), 197 deletions(-) create mode 100644 core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairEventListenerImpl.java create mode 100644 core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairObservableImpl.java create mode 100644 core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextListenerImpl.java create mode 100644 core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextObservableImpl.java create mode 100644 extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidDocumentListenerImpl.java create mode 100644 extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidDocumentObservableImpl.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentEvent.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentListener.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentObservable.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentPublished.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentUnpublished.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairAdded.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairEvent.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairEventListener.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairObservable.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRevoked.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRotated.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextCreated.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextDeleted.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextEvent.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextListener.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextObservable.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextUpdated.java create mode 100644 spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentPublishedTest.java create mode 100644 spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentUnpublishedTest.java create mode 100644 spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairAddedTest.java create mode 100644 spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRevokedTest.java create mode 100644 spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRotatedTest.java create mode 100644 spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextCreatedTest.java create mode 100644 spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextDeletedTest.java create mode 100644 spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextUpdatedTest.java diff --git a/core/identity-hub-did/build.gradle.kts b/core/identity-hub-did/build.gradle.kts index 41f9be43d..a8cf59ef6 100644 --- a/core/identity-hub-did/build.gradle.kts +++ b/core/identity-hub-did/build.gradle.kts @@ -7,7 +7,8 @@ dependencies { api(project(":spi:identity-hub-did-spi")) implementation(libs.edc.core.connector) // for the reflection-based query resolver - + implementation(libs.edc.common.crypto) + testImplementation(libs.edc.junit) testImplementation(libs.edc.ext.jsonld) testImplementation(testFixtures(project(":spi:identity-hub-spi"))) 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 343284162..65a38e299 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 @@ -16,31 +16,55 @@ 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.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.identityhub.spi.events.keypair.KeyPairAdded; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairRevoked; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextCreated; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextDeleted; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextUpdated; +import org.eclipse.edc.security.token.jwt.CryptoConverter; +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.query.Criterion; import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.AbstractResult; import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.spi.result.StoreResult; +import org.eclipse.edc.spi.security.KeyParserRegistry; import org.eclipse.edc.transaction.spi.TransactionContext; +import java.security.KeyPair; +import java.security.PublicKey; import java.util.Collection; +import java.util.stream.Collectors; + +import static org.eclipse.edc.spi.result.ServiceResult.success; /** * 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 { +public class DidDocumentServiceImpl implements DidDocumentService, EventSubscriber { private final TransactionContext transactionContext; private final DidResourceStore didResourceStore; private final DidDocumentPublisherRegistry registry; + private final Monitor monitor; + private final KeyParserRegistry keyParserRegistry; - public DidDocumentServiceImpl(TransactionContext transactionContext, DidResourceStore didResourceStore, DidDocumentPublisherRegistry registry) { + public DidDocumentServiceImpl(TransactionContext transactionContext, DidResourceStore didResourceStore, DidDocumentPublisherRegistry registry, Monitor monitor, KeyParserRegistry keyParserRegistry) { this.transactionContext = transactionContext; this.didResourceStore = didResourceStore; this.registry = registry; + this.monitor = monitor; + this.keyParserRegistry = keyParserRegistry; } @Override @@ -53,7 +77,7 @@ public ServiceResult store(DidDocument document, String participantId) { .build(); var result = didResourceStore.save(res); return result.succeeded() ? - ServiceResult.success() : + success() : ServiceResult.fromFailure(result); }); } @@ -70,7 +94,7 @@ public ServiceResult deleteById(String did) { } var res = didResourceStore.deleteById(did); return res.succeeded() ? - ServiceResult.success() : + success() : ServiceResult.fromFailure(res); }); } @@ -88,7 +112,7 @@ public ServiceResult publish(String did) { } var publishResult = publisher.publish(did); return publishResult.succeeded() ? - ServiceResult.success() : + success() : ServiceResult.badRequest(publishResult.getFailureDetail()); }); @@ -107,7 +131,7 @@ public ServiceResult unpublish(String did) { } var publishResult = publisher.unpublish(did); return publishResult.succeeded() ? - ServiceResult.success() : + success() : ServiceResult.badRequest(publishResult.getFailureDetail()); }); @@ -118,7 +142,7 @@ public ServiceResult unpublish(String did) { public ServiceResult> queryDocuments(QuerySpec query) { return transactionContext.execute(() -> { var res = didResourceStore.query(query); - return ServiceResult.success(res.stream().map(DidResource::getDocument).toList()); + return success(res.stream().map(DidResource::getDocument).toList()); }); } @@ -141,7 +165,7 @@ public ServiceResult addService(String did, Service service) { services.add(service); var updateResult = didResourceStore.update(didResource); return updateResult.succeeded() ? - ServiceResult.success() : + success() : ServiceResult.fromFailure(updateResult); }); @@ -161,9 +185,8 @@ public ServiceResult replaceService(String did, Service service) { services.add(service); var updateResult = didResourceStore.update(didResource); return updateResult.succeeded() ? - ServiceResult.success() : + success() : ServiceResult.fromFailure(updateResult); - }); } @@ -181,9 +204,123 @@ public ServiceResult removeService(String did, String serviceId) { } var updateResult = didResourceStore.update(didResource); return updateResult.succeeded() ? - ServiceResult.success() : + success() : ServiceResult.fromFailure(updateResult); }); } + + @Override + public void on(EventEnvelope eventEnvelope) { + var payload = eventEnvelope.getPayload(); + if (payload instanceof ParticipantContextCreated event) { + created(event); + } else 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) { + keypairRevoked(event); + } else { + monitor.warning("KeyPairServiceImpl Received an event with unexpected payload type: %s".formatted(payload.getClass())); + } + } + + private void keypairRevoked(KeyPairRevoked event) { + var didResources = findByParticipantId(event.getParticipantId()); + var keyId = event.getKeyId(); + + var errors = didResources.stream() + .peek(didResource -> didResource.getDocument().getVerificationMethod().removeIf(vm -> vm.getId().equals(keyId))) + .map(didResourceStore::update) + .filter(StoreResult::failed) + .map(AbstractResult::getFailureDetail) + .collect(Collectors.joining(",")); + + if (!errors.isEmpty()) { + monitor.warning("Updating DID documents after revoking a KeyPair failed: %s".formatted(errors)); + } + } + + private void keypairAdded(KeyPairAdded event) { + var didResources = findByParticipantId(event.getParticipantId()); + var serialized = event.getPublicKeySerialized(); + var publicKey = keyParserRegistry.parse(serialized); + + if (publicKey.failed()) { + monitor.warning("Error adding KeyPair '%s' to DID Document of participant '%s': %s".formatted(event.getKeyId(), event.getParticipantId(), publicKey.getFailureDetail())); + return; + } + + var jwk = CryptoConverter.createJwk(new KeyPair((PublicKey) publicKey.getContent(), null)); + + var errors = didResources.stream() + .peek(dd -> dd.getDocument().getVerificationMethod().add(VerificationMethod.Builder.newInstance() + .id(event.getKeyId()) + .publicKeyJwk(jwk.toJSONObject()) + .controller(dd.getDocument().getId()) + .build())) + .map(didResourceStore::update) + .filter(StoreResult::failed) + .map(AbstractResult::getFailureDetail) + .collect(Collectors.joining(",")); + + if (!errors.isEmpty()) { + monitor.warning("Updating DID documents after adding a KeyPair failed: %s".formatted(errors)); + } + + } + + private void updated(ParticipantContextUpdated event) { + var newState = event.getNewState(); + var forParticipant = findByParticipantId(event.getParticipantId()); + var errors = forParticipant + .stream() + .map(resource -> switch (newState) { + case ACTIVATED -> publish(resource.getDid()); + case DEACTIVATED -> unpublish(resource.getDid()); + default -> ServiceResult.success(); + }) + .filter(AbstractResult::failed) + .map(AbstractResult::getFailureDetail) + .collect(Collectors.joining(", ")); + + if (!errors.isEmpty()) { + monitor.warning("Updating DID documents after updating a ParticipantContext failed: %s".formatted(errors)); + } + } + + 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()))) + .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(QuerySpec.Builder.newInstance().filter(new Criterion("participantId", "=", 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 c9fe89f52..cedcb5bb4 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 @@ -17,10 +17,18 @@ 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.identityhub.spi.events.keypair.KeyPairAdded; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairRevoked; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextCreated; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextDeleted; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextUpdated; 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.event.EventRouter; +import org.eclipse.edc.spi.security.KeyParserRegistry; import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; import org.eclipse.edc.transaction.spi.TransactionContext; import static org.eclipse.edc.identityhub.did.DidServicesExtension.NAME; @@ -33,8 +41,14 @@ public class DidServicesExtension implements ServiceExtension { @Inject private DidResourceStore didResourceStore; + @Inject + private EventRouter eventRouter; + private DidDocumentPublisherRegistry didPublisherRegistry; + @Inject + private KeyParserRegistry keyParserRegistry; + @Override public String name() { return NAME; @@ -49,7 +63,13 @@ public DidDocumentPublisherRegistry getDidPublisherRegistry() { } @Provider - public DidDocumentService createDidDocumentService() { - return new DidDocumentServiceImpl(transactionContext, didResourceStore, getDidPublisherRegistry()); + public DidDocumentService createDidDocumentService(ServiceExtensionContext context) { + var service = new DidDocumentServiceImpl(transactionContext, didResourceStore, getDidPublisherRegistry(), context.getMonitor(), keyParserRegistry); + eventRouter.registerSync(ParticipantContextCreated.class, service); + 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 5717e3a22..184e33704 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 @@ -14,6 +14,10 @@ package org.eclipse.edc.identityhub.did; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.edc.connector.core.security.KeyParserRegistryImpl; +import org.eclipse.edc.connector.core.security.keyparsers.JwkParser; +import org.eclipse.edc.connector.core.security.keyparsers.PemParser; 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; @@ -53,7 +57,10 @@ void setUp() { var trx = new NoopTransactionContext(); when(publisherRegistry.getPublisher(startsWith("did:web:"))).thenReturn(publisherMock); - service = new DidDocumentServiceImpl(trx, storeMock, publisherRegistry); + var registry = new KeyParserRegistryImpl(); + registry.register(new JwkParser(new ObjectMapper(), mock())); + registry.register(new PemParser(mock())); + service = new DidDocumentServiceImpl(trx, storeMock, publisherRegistry, mock(), registry); } @Test diff --git a/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairEventListenerImpl.java b/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairEventListenerImpl.java new file mode 100644 index 000000000..2edf947c0 --- /dev/null +++ b/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairEventListenerImpl.java @@ -0,0 +1,73 @@ +/* + * 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.keypairs; + +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairAdded; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairEvent; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairEventListener; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairRevoked; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairRotated; +import org.eclipse.edc.identityhub.spi.model.KeyPairResource; +import org.eclipse.edc.spi.event.EventEnvelope; +import org.eclipse.edc.spi.event.EventRouter; + +import java.time.Clock; + +public class KeyPairEventListenerImpl implements KeyPairEventListener { + private final Clock clock; + private final EventRouter eventRouter; + + public KeyPairEventListenerImpl(Clock clock, EventRouter eventRouter) { + this.clock = clock; + this.eventRouter = eventRouter; + } + + @Override + public void added(KeyPairResource keyPair) { + var event = KeyPairAdded.Builder.newInstance() + .participantId(keyPair.getParticipantId()) + .keyId(keyPair.getId()) + .publicKey(keyPair.getSerializedPublicKey()) + .build(); + publish(event); + } + + @Override + public void revoked(KeyPairResource keyPair) { + var event = KeyPairRevoked.Builder.newInstance() + .participantId(keyPair.getParticipantId()) + .keyId(keyPair.getId()) + .build(); + publish(event); + } + + @Override + public void rotated(KeyPairResource keyPair) { + var event = KeyPairRotated.Builder.newInstance() + .participantId(keyPair.getParticipantId()) + .keyId(keyPair.getId()) + .build(); + publish(event); + } + + + private void publish(KeyPairEvent event) { + var envelope = EventEnvelope.Builder.newInstance() + .payload(event) + .at(clock.millis()) + .build(); + eventRouter.publish(envelope); + } +} diff --git a/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairObservableImpl.java b/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairObservableImpl.java new file mode 100644 index 000000000..c740bec7c --- /dev/null +++ b/core/identity-hub-keypairs/src/main/java/org/eclipse/edc/identityhub/keypairs/KeyPairObservableImpl.java @@ -0,0 +1,22 @@ +/* + * 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.keypairs; + +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairEventListener; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairObservable; +import org.eclipse.edc.spi.observe.ObservableImpl; + +public class KeyPairObservableImpl extends ObservableImpl implements KeyPairObservable { +} 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 bb4b738bf..1244f2d0a 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 @@ -15,14 +15,20 @@ package org.eclipse.edc.identityhub.keypairs; import org.eclipse.edc.identityhub.spi.KeyPairService; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairObservable; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextCreated; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextDeleted; import org.eclipse.edc.identityhub.spi.store.KeyPairResourceStore; 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.event.EventRouter; import org.eclipse.edc.spi.security.Vault; import org.eclipse.edc.spi.system.ServiceExtension; import org.eclipse.edc.spi.system.ServiceExtensionContext; +import java.time.Clock; + import static org.eclipse.edc.identityhub.keypairs.KeyPairServiceExtension.NAME; @Extension(NAME) @@ -33,9 +39,27 @@ public class KeyPairServiceExtension implements ServiceExtension { private Vault vault; @Inject private KeyPairResourceStore keyPairResourceStore; + @Inject + private EventRouter eventRouter; + @Inject + private Clock clock; + + private KeyPairObservable observable; @Provider public KeyPairService createParticipantService(ServiceExtensionContext context) { - return new KeyPairServiceImpl(keyPairResourceStore, vault, context.getMonitor()); + var service = new KeyPairServiceImpl(keyPairResourceStore, vault, context.getMonitor(), keyPairObservable()); + eventRouter.registerSync(ParticipantContextCreated.class, service); + eventRouter.registerSync(ParticipantContextDeleted.class, service); + return service; + } + + @Provider + public KeyPairObservable keyPairObservable() { + if (observable == null) { + observable = new KeyPairObservableImpl(); + observable.registerListener(new KeyPairEventListenerImpl(clock, eventRouter)); + } + return observable; } } 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 517fbac91..0c57d044e 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 @@ -16,32 +16,43 @@ import org.eclipse.edc.identityhub.security.KeyPairGenerator; import org.eclipse.edc.identityhub.spi.KeyPairService; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairObservable; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextCreated; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextDeleted; import org.eclipse.edc.identityhub.spi.model.KeyPairResource; import org.eclipse.edc.identityhub.spi.model.KeyPairState; import org.eclipse.edc.identityhub.spi.model.participant.KeyDescriptor; import org.eclipse.edc.identityhub.spi.store.KeyPairResourceStore; import org.eclipse.edc.security.token.jwt.CryptoConverter; +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.query.Criterion; import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.AbstractResult; import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.spi.security.Vault; import org.jetbrains.annotations.Nullable; import java.time.Instant; import java.util.Collection; import java.util.Optional; +import java.util.stream.Collectors; -public class KeyPairServiceImpl implements KeyPairService { +public class KeyPairServiceImpl implements KeyPairService, EventSubscriber { private final KeyPairResourceStore keyPairResourceStore; private final Vault vault; private final Monitor monitor; + private final KeyPairObservable observable; - public KeyPairServiceImpl(KeyPairResourceStore keyPairResourceStore, Vault vault, Monitor monitor) { + public KeyPairServiceImpl(KeyPairResourceStore keyPairResourceStore, Vault vault, Monitor monitor, KeyPairObservable observable) { this.keyPairResourceStore = keyPairResourceStore; this.vault = vault; this.monitor = monitor; + this.observable = observable; } @Override @@ -63,7 +74,7 @@ public ServiceResult addKeyPair(String participantId, KeyDescriptor keyDes .participantId(participantId) .build(); - return ServiceResult.from(keyPairResourceStore.create(newResource)); + return ServiceResult.from(keyPairResourceStore.create(newResource)).onSuccess(v -> observable.invokeForEach(l -> l.added(newResource))); } @Override @@ -81,13 +92,14 @@ public ServiceResult rotateKeyPair(String oldId, @Nullable KeyDescriptor n var oldAlias = oldKey.getPrivateKeyAlias(); vault.deleteSecret(oldAlias); oldKey.rotate(duration); - keyPairResourceStore.update(oldKey); + var updateResult = ServiceResult.from(keyPairResourceStore.update(oldKey)) + .onSuccess(v -> observable.invokeForEach(l -> l.rotated(oldKey))); if (newKeySpec != null) { - return addKeyPair(participantId, newKeySpec, wasDefault); + 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 ServiceResult.success(); + return updateResult; } @Override @@ -104,14 +116,14 @@ public ServiceResult revokeKey(String id, @Nullable KeyDescriptor newKeySp var oldAlias = oldKey.getPrivateKeyAlias(); vault.deleteSecret(oldAlias); oldKey.revoke(); - keyPairResourceStore.update(oldKey); - //todo: emit event for the did service, which should update the did document + var updateResult = ServiceResult.from(keyPairResourceStore.update(oldKey)) + .onSuccess(v -> observable.invokeForEach(l -> l.revoked(oldKey))); if (newKeySpec != null) { - return addKeyPair(participantId, newKeySpec, wasDefault); + 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 ServiceResult.success(); + return updateResult; } @Override @@ -119,6 +131,42 @@ public ServiceResult> query(QuerySpec querySpec) { return ServiceResult.from(keyPairResourceStore.query(querySpec)); } + @Override + public void on(EventEnvelope eventEnvelope) { + var payload = eventEnvelope.getPayload(); + if (payload instanceof ParticipantContextCreated created) { + created(created); + } else if (payload instanceof ParticipantContextDeleted deleted) { + deleted(deleted); + } else { + monitor.warning("KeyPairServiceImpl Received event with unexpected payload type: %s".formatted(payload.getClass())); + } + } + + private void created(ParticipantContextCreated event) { + addKeyPair(event.getParticipantId(), event.getManifest().getKey(), true) + .onFailure(f -> monitor.warning("Adding the key pair to a new ParticipantContext failed: %s".formatted(f.getFailureDetail()))); + } + + private void deleted(ParticipantContextDeleted event) { + //hard-delete all keypairs that are associated with the deleted participant + var query = QuerySpec.Builder.newInstance().filter(new Criterion("participantId", "=", 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()))); + } + private KeyPairResource findById(String oldId) { var q = QuerySpec.Builder.newInstance() .filter(new Criterion("id", "=", oldId)).build(); 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 18250a2f1..8392c9279 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 @@ -17,10 +17,12 @@ import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.jwk.Curve; import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairObservable; import org.eclipse.edc.identityhub.spi.model.KeyPairResource; import org.eclipse.edc.identityhub.spi.model.KeyPairState; import org.eclipse.edc.identityhub.spi.model.participant.KeyDescriptor; import org.eclipse.edc.identityhub.spi.store.KeyPairResourceStore; +import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.spi.security.Vault; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; @@ -39,15 +41,17 @@ 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; class KeyPairServiceImplTest { - private final KeyPairResourceStore keyPairResourceStore = mock(); + private final KeyPairResourceStore keyPairResourceStore = mock(i -> StoreResult.success()); private final Vault vault = mock(); - private final KeyPairServiceImpl keyPairService = new KeyPairServiceImpl(keyPairResourceStore, vault, mock()); + private final KeyPairObservable observableMock = mock(); + private final KeyPairServiceImpl keyPairService = new KeyPairServiceImpl(keyPairResourceStore, vault, mock(), observableMock); @ParameterizedTest(name = "make default: {0}") @@ -60,7 +64,8 @@ void addKeyPair_publicKeyGiven(boolean makeDefault) { assertThat(keyPairService.addKeyPair("some-participant", key, makeDefault)).isSucceeded(); verify(keyPairResourceStore).create(argThat(kpr -> kpr.isDefaultPair() == makeDefault && kpr.getParticipantId().equals("some-participant"))); - verifyNoMoreInteractions(keyPairResourceStore, vault); + verify(observableMock).invokeForEach(any()); + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } @ParameterizedTest(name = "make default: {0}") @@ -77,7 +82,8 @@ void addKeyPair_shouldGenerate_storesInVault(boolean makeDefault) { verify(vault).storeSecret(eq(key.getPrivateKeyAlias()), anyString()); verify(keyPairResourceStore).create(argThat(kpr -> kpr.isDefaultPair() == makeDefault && kpr.getParticipantId().equals("some-participant"))); - verifyNoMoreInteractions(keyPairResourceStore, vault); + verify(observableMock).invokeForEach(any()); + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } @Test @@ -101,7 +107,8 @@ void rotateKeyPair_withNewKey() { verify(keyPairResourceStore).update(argThat(kpr -> kpr.getId().equals(oldId))); verify(keyPairResourceStore).create(any()); verify(vault).deleteSecret(eq(oldKey.getPrivateKeyAlias())); //deletes old private key - verifyNoMoreInteractions(vault, keyPairResourceStore); + verify(observableMock, times(2)).invokeForEach(any()); // 1 for rotate, 1 for add + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } @Test @@ -117,7 +124,8 @@ void rotateKeyPair_withoutNewKey() { verify(keyPairResourceStore).query(any()); verify(keyPairResourceStore).update(argThat(kpr -> kpr.getId().equals(oldId))); verify(vault).deleteSecret(eq(oldKey.getPrivateKeyAlias())); //deletes old private key - verifyNoMoreInteractions(vault, keyPairResourceStore); + verify(observableMock).invokeForEach(any()); + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } @Test @@ -140,7 +148,8 @@ void rotateKeyPair_withNewKeyGenerate() { verify(keyPairResourceStore).create(any()); verify(vault).deleteSecret(eq(oldKey.getPrivateKeyAlias())); //deletes old private key verify(vault).storeSecret(eq(newKey.getPrivateKeyAlias()), anyString()); - verifyNoMoreInteractions(vault, keyPairResourceStore); + verify(observableMock, times(2)).invokeForEach(any()); // 1 for rotate, 1 for add + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } @Test @@ -163,7 +172,8 @@ void rotateKeyPair_oldKeyWasDefault_withNewKey() { verify(keyPairResourceStore).create(argThat(KeyPairResource::isDefaultPair)); verify(vault).deleteSecret(eq(oldKey.getPrivateKeyAlias())); //deletes old private key verify(vault).storeSecret(eq(newKey.getPrivateKeyAlias()), anyString()); - verifyNoMoreInteractions(vault, keyPairResourceStore); + verify(observableMock, times(2)).invokeForEach(any()); //1 for revoke, 1 for add + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } @Test @@ -177,7 +187,7 @@ void rotateKeyPair_oldKeyNotFound() { .detail().isEqualTo("A KeyPairResource with ID 'not-exist' does not exist."); verify(keyPairResourceStore).query(any()); - verifyNoMoreInteractions(vault, keyPairResourceStore); + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } @Test @@ -208,7 +218,8 @@ void revokeKey_withoutNewKey() { verify(keyPairResourceStore).query(any()); verify(keyPairResourceStore).update(argThat(kpr -> kpr.getId().equals(oldId) && kpr.getState() == KeyPairState.REVOKED.code())); verify(vault).deleteSecret(oldKey.getPrivateKeyAlias()); - verifyNoMoreInteractions(vault, keyPairResourceStore); + verify(observableMock).invokeForEach(any()); + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } @Test @@ -225,7 +236,8 @@ void revokeKey_oldKeyWasDefault_withNewKey() { verify(keyPairResourceStore).update(argThat(kpr -> kpr.getId().equals(oldId) && kpr.getState() == KeyPairState.REVOKED.code())); verify(vault).deleteSecret(oldKey.getPrivateKeyAlias()); verify(keyPairResourceStore).create(argThat(KeyPairResource::isDefaultPair)); - verifyNoMoreInteractions(vault, keyPairResourceStore); + verify(observableMock, times(2)).invokeForEach(any()); // 1 for revoke, 1 for add + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } @Test @@ -239,7 +251,8 @@ void revokeKey_oldKeyWasDefault_withoutNewKey() { verify(keyPairResourceStore).query(any()); verify(keyPairResourceStore).update(argThat(kpr -> kpr.getId().equals(oldId) && kpr.getState() == KeyPairState.REVOKED.code())); verify(vault).deleteSecret(oldKey.getPrivateKeyAlias()); - verifyNoMoreInteractions(vault, keyPairResourceStore); + verify(observableMock).invokeForEach(any()); + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } @Test @@ -252,7 +265,7 @@ void revokeKey_notfound() { .detail().isEqualTo("A KeyPairResource with ID 'not-exist' does not exist."); verify(keyPairResourceStore).query(any()); - verifyNoMoreInteractions(vault, keyPairResourceStore); + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } private KeyPairResource.Builder createKeyPairResource() { 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 906342673..08280d45c 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 @@ -16,15 +16,19 @@ import org.eclipse.edc.identithub.did.spi.DidDocumentService; import org.eclipse.edc.identityhub.spi.ParticipantContextService; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextObservable; import org.eclipse.edc.identityhub.spi.store.ParticipantContextStore; 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.event.EventRouter; import org.eclipse.edc.spi.security.KeyParserRegistry; import org.eclipse.edc.spi.security.Vault; import org.eclipse.edc.spi.system.ServiceExtension; import org.eclipse.edc.transaction.spi.TransactionContext; +import java.time.Clock; + import static org.eclipse.edc.identityhub.participantcontext.ParticipantContextExtension.NAME; @Extension(NAME) @@ -41,9 +45,24 @@ public class ParticipantContextExtension implements ServiceExtension { private KeyParserRegistry keyParserRegistry; @Inject private DidDocumentService didDocumentService; + @Inject + private Clock clock; + @Inject + private EventRouter eventRouter; + + private ParticipantContextObservable participantContextObservable; @Provider public ParticipantContextService createParticipantService() { - return new ParticipantContextServiceImpl(participantContextStore, vault, transactionContext, keyParserRegistry, didDocumentService); + return new ParticipantContextServiceImpl(participantContextStore, vault, transactionContext, keyParserRegistry, participantContextObservable()); + } + + @Provider + public ParticipantContextObservable participantContextObservable() { + if (participantContextObservable == null) { + participantContextObservable = new ParticipantContextObservableImpl(); + participantContextObservable.registerListener(new ParticipantContextListenerImpl(clock, eventRouter)); + } + return participantContextObservable; } } diff --git a/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextListenerImpl.java b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextListenerImpl.java new file mode 100644 index 000000000..c003c145d --- /dev/null +++ b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextListenerImpl.java @@ -0,0 +1,71 @@ +/* + * 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.participantcontext; + +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextCreated; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextDeleted; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextEvent; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextListener; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextUpdated; +import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext; +import org.eclipse.edc.identityhub.spi.model.participant.ParticipantManifest; +import org.eclipse.edc.spi.event.EventEnvelope; +import org.eclipse.edc.spi.event.EventRouter; + +import java.time.Clock; + +public class ParticipantContextListenerImpl implements ParticipantContextListener { + private final Clock clock; + private final EventRouter eventRouter; + + public ParticipantContextListenerImpl(Clock clock, EventRouter eventRouter) { + this.clock = clock; + this.eventRouter = eventRouter; + } + + @Override + public void created(ParticipantContext newContext, ParticipantManifest manifest) { + var event = ParticipantContextCreated.Builder.newInstance() + .participantId(newContext.getParticipantId()) + .manifest(manifest) + .build(); + publish(event); + } + + @Override + public void deleted(ParticipantContext deletedContext) { + var event = ParticipantContextDeleted.Builder.newInstance() + .participantId(deletedContext.getParticipantId()) + .build(); + publish(event); + } + + @Override + public void updated(ParticipantContext updatedContext) { + var event = ParticipantContextUpdated.Builder.newInstance() + .participantId(updatedContext.getParticipantId()) + .newState(updatedContext.getStateAsEnum()) + .build(); + publish(event); + } + + private void publish(ParticipantContextEvent event) { + var envelope = EventEnvelope.Builder.newInstance() + .payload(event) + .at(clock.millis()) + .build(); + eventRouter.publish(envelope); + } +} diff --git a/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextObservableImpl.java b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextObservableImpl.java new file mode 100644 index 000000000..27db0d008 --- /dev/null +++ b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextObservableImpl.java @@ -0,0 +1,22 @@ +/* + * 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.participantcontext; + +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextListener; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextObservable; +import org.eclipse.edc.spi.observe.ObservableImpl; + +public class ParticipantContextObservableImpl extends ObservableImpl implements ParticipantContextObservable { +} 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 8a8c2f194..b474d3e8e 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 @@ -14,19 +14,12 @@ package org.eclipse.edc.identityhub.participantcontext; -import com.nimbusds.jose.jwk.JWK; -import com.nimbusds.jose.jwk.JWKParameterNames; -import org.eclipse.edc.iam.did.spi.document.DidDocument; -import org.eclipse.edc.iam.did.spi.document.VerificationMethod; -import org.eclipse.edc.identithub.did.spi.DidDocumentService; -import org.eclipse.edc.identityhub.security.KeyPairGenerator; import org.eclipse.edc.identityhub.spi.ParticipantContextService; -import org.eclipse.edc.identityhub.spi.model.participant.KeyDescriptor; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextObservable; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContextState; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantManifest; import org.eclipse.edc.identityhub.spi.store.ParticipantContextStore; -import org.eclipse.edc.security.token.jwt.CryptoConverter; import org.eclipse.edc.spi.query.Criterion; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.ServiceResult; @@ -34,15 +27,9 @@ import org.eclipse.edc.spi.security.Vault; import org.eclipse.edc.transaction.spi.TransactionContext; -import java.security.KeyPair; -import java.security.PublicKey; -import java.text.ParseException; -import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -import static java.util.Optional.ofNullable; -import static org.eclipse.edc.spi.result.ServiceResult.badRequest; import static org.eclipse.edc.spi.result.ServiceResult.conflict; import static org.eclipse.edc.spi.result.ServiceResult.fromFailure; import static org.eclipse.edc.spi.result.ServiceResult.notFound; @@ -62,16 +49,16 @@ public class ParticipantContextServiceImpl implements ParticipantContextService private final TransactionContext transactionContext; private final ApiTokenGenerator tokenGenerator; private final KeyParserRegistry keyParserRegistry; - private final DidDocumentService didDocumentService; + private final ParticipantContextObservable observable; public ParticipantContextServiceImpl(ParticipantContextStore participantContextStore, Vault vault, TransactionContext transactionContext, - KeyParserRegistry registry, DidDocumentService didDocumentService) { + KeyParserRegistry registry, ParticipantContextObservable observable) { this.participantContextStore = participantContextStore; this.vault = vault; this.transactionContext = transactionContext; + this.observable = observable; this.tokenGenerator = new ApiTokenGenerator(); this.keyParserRegistry = registry; - this.didDocumentService = didDocumentService; } @Override @@ -80,9 +67,8 @@ public ServiceResult createParticipantContext(ParticipantManifest manife var apiKey = new AtomicReference(); var context = convert(manifest); var res = createParticipantContext(context) - .compose(u -> generateAndStoreToken(context)).onSuccess(apiKey::set) - .compose(ak -> createOrUpdateKey(manifest.getKey())) - .compose(jwk -> generateDidDocument(manifest, jwk)); + .compose(u -> createTokenAndStoreInVault(context)).onSuccess(apiKey::set) + .onSuccess(apiToken -> observable.invokeForEach(l -> l.created(context, manifest))); return res.map(u -> apiKey.get()); }); } @@ -94,7 +80,7 @@ public ServiceResult getParticipantContext(String participan if (res.succeeded()) { return res.getContent().stream().findFirst() .map(ServiceResult::success) - .orElse(notFound("No ParticipantContext with ID '%s' was found.".formatted(participantId))); + .orElse(notFound("ParticipantContext with ID '%s' does not exist.".formatted(participantId))); } return fromFailure(res); }); @@ -103,14 +89,18 @@ public ServiceResult getParticipantContext(String participan @Override public ServiceResult deleteParticipantContext(String participantId) { return transactionContext.execute(() -> { - var did = ofNullable(findByIdInternal(participantId)).map(ParticipantContext::getDid); + var participantContext = findByIdInternal(participantId); + if (participantContext == null) { + return ServiceResult.notFound("A ParticipantContext with ID '%s' does not exist."); + } + var res = participantContextStore.deleteById(participantId); if (res.failed()) { return fromFailure(res); } - return did.map(d -> didDocumentService.unpublish(d).compose(u -> didDocumentService.deleteById(d))) - .orElseGet(ServiceResult::success); + observable.invokeForEach(l -> l.deleted(participantContext)); + return ServiceResult.success(); }); } @@ -121,7 +111,7 @@ public ServiceResult regenerateApiToken(String participantId) { if (participantContext.failed()) { return participantContext.map(pc -> null); } - return generateAndStoreToken(participantContext.getContent()); + return createTokenAndStoreInVault(participantContext.getContent()); }); } @@ -133,13 +123,14 @@ public ServiceResult updateParticipant(String participantId, Consumer observable.invokeForEach(l -> l.updated(participant))); return res.succeeded() ? success() : fromFailure(res); }); } - private ServiceResult generateAndStoreToken(ParticipantContext participantContext) { + private ServiceResult createTokenAndStoreInVault(ParticipantContext participantContext) { var alias = participantContext.getApiTokenAlias(); var newToken = tokenGenerator.generate(participantContext.getParticipantId()); return vault.storeSecret(alias, newToken) @@ -147,54 +138,6 @@ private ServiceResult generateAndStoreToken(ParticipantContext participa .orElse(f -> conflict("Could not store new API token: %s.".formatted(f.getFailureDetail()))); } - private ServiceResult generateDidDocument(ParticipantManifest manifest, JWK publicKey) { - var doc = DidDocument.Builder.newInstance() - .id(manifest.getDid()) - .service(manifest.getServiceEndpoints().stream().toList()) - .verificationMethod(List.of(VerificationMethod.Builder.newInstance() - .publicKeyJwk(publicKey.toJSONObject()) - .build())) - .build(); - return didDocumentService.store(doc, manifest.getParticipantId()) - .compose(u -> manifest.isActive() ? didDocumentService.publish(doc.getId()) : success()); - } - - private ServiceResult createOrUpdateKey(KeyDescriptor key) { - // do we need to generate the key? - var keyGeneratorParams = key.getKeyGeneratorParams(); - JWK publicKeyJwk; - if (keyGeneratorParams != null) { - var kp = KeyPairGenerator.generateKeyPair(keyGeneratorParams); - if (kp.failed()) { - return badRequest("Could not generate KeyPair from generator params: %s".formatted(kp.getFailureDetail())); - } - var alias = key.getPrivateKeyAlias(); - var storeResult = vault.storeSecret(alias, CryptoConverter.createJwk(kp.getContent()).toJSONString()); - if (storeResult.failed()) { - return badRequest(storeResult.getFailureDetail()); - } - publicKeyJwk = CryptoConverter.createJwk(kp.getContent()).toPublicJWK(); - } else if (key.getPublicKeyJwk() != null) { - publicKeyJwk = CryptoConverter.create(key.getPublicKeyJwk()); - } else if (key.getPublicKeyPem() != null) { - var pubKey = keyParserRegistry.parse(key.getPublicKeyPem()); - if (pubKey.failed()) { - return badRequest("Cannot parse public key from PEM: %s".formatted(pubKey.getFailureDetail())); - } - publicKeyJwk = CryptoConverter.createJwk(new KeyPair((PublicKey) pubKey.getContent(), null)); - } else { - return badRequest("No public key information found in KeyDescriptor."); - } - // insert the "kid" parameter - var json = publicKeyJwk.toJSONObject(); - json.put(JWKParameterNames.KEY_ID, key.getKeyId()); - try { - publicKeyJwk = JWK.parse(json); - return success(publicKeyJwk); - } catch (ParseException e) { - return badRequest("Could not create JWK: %s".formatted(e.getMessage())); - } - } private ServiceResult createParticipantContext(ParticipantContext context) { var storeRes = participantContextStore.create(context); 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 607255694..ac383e499 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 @@ -20,7 +20,7 @@ import org.assertj.core.api.Assertions; import org.eclipse.edc.connector.core.security.KeyParserRegistryImpl; import org.eclipse.edc.connector.core.security.keyparsers.PemParser; -import org.eclipse.edc.identithub.did.spi.DidDocumentService; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextObservable; import org.eclipse.edc.identityhub.spi.model.participant.KeyDescriptor; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContextState; @@ -41,14 +41,11 @@ import java.util.Map; import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; -import static org.eclipse.edc.spi.result.ServiceResult.badRequest; -import static org.eclipse.edc.spi.result.ServiceResult.success; 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.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; @@ -57,17 +54,14 @@ class ParticipantContextServiceImplTest { private final Vault vault = mock(); private final ParticipantContextStore participantContextStore = mock(); + private final ParticipantContextObservable observableMock = mock(); private ParticipantContextServiceImpl participantContextService; - private DidDocumentService didDocumentService; @BeforeEach void setUp() { - didDocumentService = mock(); - when(didDocumentService.store(any(), anyString())).thenReturn(success()); - when(didDocumentService.publish(anyString())).thenReturn(success()); var keyParserRegistry = new KeyParserRegistryImpl(); keyParserRegistry.register(new PemParser(mock())); - participantContextService = new ParticipantContextServiceImpl(participantContextStore, vault, new NoopTransactionContext(), keyParserRegistry, didDocumentService); + participantContextService = new ParticipantContextServiceImpl(participantContextStore, vault, new NoopTransactionContext(), keyParserRegistry, observableMock); } @ParameterizedTest(name = "isActive: {0}") @@ -93,12 +87,12 @@ void createParticipantContext_withPublicKeyPem(boolean isActive) { .isSucceeded(); verify(participantContextStore).create(any()); - verify(didDocumentService).store(argThat(dd -> dd.getId().equals(ctx.getDid())), anyString()); - verify(didDocumentService, times(isActive ? 1 : 0)).publish(anyString()); verify(vault).storeSecret(eq(ctx.getParticipantId() + "-apikey"), anyString()); verifyNoMoreInteractions(vault, participantContextStore); + verify(observableMock).invokeForEach(any()); } + @ParameterizedTest(name = "isActive: {0}") @ValueSource(booleans = {true, false}) void createParticipantContext_withPublicKeyJwk(boolean isActive) { @@ -111,10 +105,9 @@ void createParticipantContext_withPublicKeyJwk(boolean isActive) { .isSucceeded(); verify(participantContextStore).create(any()); - verify(didDocumentService).store(argThat(dd -> dd.getId().equals(ctx.getDid())), anyString()); - verify(didDocumentService, times(isActive ? 1 : 0)).publish(anyString()); verify(vault).storeSecret(eq(ctx.getParticipantId() + "-apikey"), anyString()); verifyNoMoreInteractions(vault, participantContextStore); + verify(observableMock).invokeForEach(any()); } @ParameterizedTest(name = "isActive: {0}") @@ -132,26 +125,23 @@ void createParticipantContext_withKeyGenParams(boolean isActive) { .isSucceeded(); verify(participantContextStore).create(any()); - verify(vault).storeSecret(eq(ctx.getKey().getPrivateKeyAlias()), anyString()); verify(vault).storeSecret(eq(ctx.getParticipantId() + "-apikey"), anyString()); - verify(didDocumentService).store(argThat(dd -> dd.getId().equals(ctx.getDid())), anyString()); - verify(didDocumentService, times(isActive ? 1 : 0)).publish(anyString()); + verify(observableMock).invokeForEach(any()); verifyNoMoreInteractions(vault, participantContextStore); } @Test void createParticipantContext_storageFails() { - when(participantContextStore.create(any())).thenReturn(StoreResult.success()); + when(participantContextStore.create(any())).thenReturn(StoreResult.duplicateKeys("foobar")); when(vault.storeSecret(anyString(), anyString())).thenReturn(Result.success()); var ctx = createManifest().build(); assertThat(participantContextService.createParticipantContext(ctx)) - .isSucceeded(); + .isFailed(); verify(participantContextStore).create(any()); - verify(vault).storeSecret(eq(ctx.getParticipantId() + "-apikey"), anyString()); - verifyNoMoreInteractions(vault, participantContextStore); + verifyNoMoreInteractions(vault, participantContextStore, observableMock); } @Test @@ -163,7 +153,7 @@ void createParticipantContext_whenExists() { .isFailed() .satisfies(f -> Assertions.assertThat(f.getReason()).isEqualTo(ServiceFailure.Reason.CONFLICT)); verify(participantContextStore).create(any()); - verifyNoMoreInteractions(vault, participantContextStore); + verifyNoMoreInteractions(vault, participantContextStore, observableMock); } @@ -188,7 +178,7 @@ void getParticipantContext_whenNotExists() { .isFailed() .satisfies(f -> { Assertions.assertThat(f.getReason()).isEqualTo(ServiceFailure.Reason.NOT_FOUND); - Assertions.assertThat(f.getFailureDetail()).isEqualTo("No ParticipantContext with ID 'test-id' was found."); + Assertions.assertThat(f.getFailureDetail()).isEqualTo("ParticipantContext with ID 'test-id' does not exist."); }); verify(participantContextStore).query(any()); @@ -213,52 +203,18 @@ void getParticipantContext_whenStorageFails() { void deleteParticipantContext() { when(participantContextStore.query(any())).thenReturn(StoreResult.success(List.of(createContext()))); when(participantContextStore.deleteById(anyString())).thenReturn(StoreResult.success()); - when(didDocumentService.unpublish(any())).thenReturn(success()); - when(didDocumentService.deleteById(any())).thenReturn(success()); assertThat(participantContextService.deleteParticipantContext("test-id")).isSucceeded(); verify(participantContextStore).deleteById(anyString()); - verify(didDocumentService).unpublish(any()); - verify(didDocumentService).deleteById(any()); - verifyNoMoreInteractions(vault); + verify(observableMock).invokeForEach(any()); + verifyNoMoreInteractions(vault, observableMock); } - @Test - void deleteParticipantContext_deleteDidFails() { - when(participantContextStore.query(any())).thenReturn(StoreResult.success(List.of(createContext()))); - when(participantContextStore.deleteById(anyString())).thenReturn(StoreResult.success()); - when(didDocumentService.unpublish(any())).thenReturn(success()); - when(didDocumentService.deleteById(any())).thenReturn(badRequest("test-message")); - assertThat(participantContextService.deleteParticipantContext("test-id")).isFailed() - .detail().isEqualTo("test-message"); - - verify(participantContextStore).deleteById(anyString()); - verify(participantContextStore).query(any()); - verify(didDocumentService).unpublish(any()); - verify(didDocumentService).deleteById(any()); - verifyNoMoreInteractions(vault, didDocumentService, participantContextStore); - } - - @Test - void deleteParticipantContext_unpublishDidFails() { - when(participantContextStore.query(any())).thenReturn(StoreResult.success(List.of(createContext()))); - when(participantContextStore.deleteById(anyString())).thenReturn(StoreResult.success()); - when(didDocumentService.unpublish(any())).thenReturn(badRequest("test-message")); - assertThat(participantContextService.deleteParticipantContext("test-id")).isFailed() - .detail().isEqualTo("test-message"); - - verify(participantContextStore).deleteById(anyString()); - verify(participantContextStore).query(any()); - verify(didDocumentService).unpublish(any()); - verifyNoMoreInteractions(vault, didDocumentService, participantContextStore); - } @Test void deleteParticipantContext_whenNotExists() { when(participantContextStore.query(any())).thenReturn(StoreResult.success(List.of(createContext()))); when(participantContextStore.deleteById(any())).thenReturn(StoreResult.notFound("foo bar")); - when(didDocumentService.unpublish(any())).thenReturn(success()); - when(didDocumentService.deleteById(any())).thenReturn(success()); assertThat(participantContextService.deleteParticipantContext("test-id")) .isFailed() .satisfies(f -> { @@ -267,7 +223,7 @@ void deleteParticipantContext_whenNotExists() { }); verify(participantContextStore).deleteById(anyString()); - verifyNoMoreInteractions(vault, didDocumentService); + verifyNoMoreInteractions(vault, observableMock); } @Test @@ -296,7 +252,7 @@ void regenerateApiToken_vaultFails() { void regenerateApiToken_whenNotFound() { when(participantContextStore.query(any())).thenReturn(StoreResult.success(List.of())); - assertThat(participantContextService.regenerateApiToken("test-id")).isFailed().detail().isEqualTo("No ParticipantContext with ID 'test-id' was found."); + assertThat(participantContextService.regenerateApiToken("test-id")).isFailed().detail().isEqualTo("ParticipantContext with ID 'test-id' does not exist."); verify(participantContextStore).query(any()); verifyNoMoreInteractions(participantContextStore, vault); @@ -311,7 +267,7 @@ void update() { verify(participantContextStore).query(any()); verify(participantContextStore).update(any()); - + verify(observableMock).invokeForEach(any()); } @Test @@ -322,7 +278,7 @@ void update_whenNotFound() { .detail().isEqualTo("ParticipantContext with ID 'test-id' not found."); verify(participantContextStore).query(any()); - verifyNoMoreInteractions(participantContextStore); + verifyNoMoreInteractions(participantContextStore, observableMock); } @Test @@ -336,7 +292,7 @@ void update_whenStoreUpdateFails() { verify(participantContextStore).query(any()); verify(participantContextStore).update(any()); - verifyNoMoreInteractions(participantContextStore); + verifyNoMoreInteractions(participantContextStore, observableMock); } private ParticipantManifest.Builder createManifest() { 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 b712cb72b..9ecb2a244 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 @@ -15,18 +15,31 @@ package org.eclipse.edc.identityhub.tests; import io.restassured.http.Header; +import org.eclipse.edc.identityhub.spi.events.diddocument.DidDocumentPublished; +import org.eclipse.edc.identityhub.spi.events.diddocument.DidDocumentUnpublished; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext; import org.eclipse.edc.junit.annotations.EndToEndTest; +import org.eclipse.edc.spi.event.EventRouter; +import org.eclipse.edc.spi.event.EventSubscriber; import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; import static io.restassured.http.ContentType.JSON; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; @EndToEndTest public class DidManagementApiEndToEndTest extends ManagementApiEndToEndTest { @Test void publishDid_notOwner_expect403() { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(DidDocumentPublished.class, subscriber); + var user1 = "user1"; createParticipant(user1); @@ -40,6 +53,8 @@ void publishDid_notOwner_expect403() { .build(); var user2Token = storeParticipant(user2Context); + reset(subscriber); // need to reset here, to ignore a previous interaction + // attempt to publish user1's DID document, which should fail RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .contentType(JSON) @@ -54,11 +69,16 @@ void publishDid_notOwner_expect403() { .log().ifValidationFails() .statusCode(403) .body(Matchers.notNullValue()); + + verifyNoInteractions(subscriber); } @Test void publishDid() { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(DidDocumentPublished.class, subscriber); + var user = "test-user"; var token = createParticipant(user); RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() @@ -74,6 +94,83 @@ void publishDid() { .log().ifValidationFails() .statusCode(204) .body(Matchers.notNullValue()); + + // verify that the publish event was fired twice + verify(subscriber, times(2)).on(argThat(env -> { + if (env.getPayload() instanceof DidDocumentPublished event) { + return event.getDid().equals("did:web:test-user"); + } + return false; + })); + } + + @Test + void unpublishDid_notOwner_expect403() { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(DidDocumentPublished.class, subscriber); + + var user1 = "user1"; + createParticipant(user1); + + + // create second user + var user2 = "user2"; + var user2Context = ParticipantContext.Builder.newInstance() + .participantId(user2) + .did("did:web:" + user2) + .apiTokenAlias(user2 + "-alias") + .build(); + var user2Token = storeParticipant(user2Context); + + reset(subscriber); // need to reset here, to ignore a previous interaction + + // attempt to publish user1's DID document, which should fail + RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() + .contentType(JSON) + .header(new Header("x-api-key", user2Token)) + .body(""" + { + "did": "did:web:user1" + } + """) + .post("/v1/dids/unpublish") + .then() + .log().ifValidationFails() + .statusCode(403) + .body(Matchers.notNullValue()); + + verifyNoInteractions(subscriber); + } + + @Test + void unpublishDid() { + + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(DidDocumentUnpublished.class, subscriber); + + var user = "test-user"; + var token = createParticipant(user); + RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() + .contentType(JSON) + .header(new Header("x-api-key", token)) + .body(""" + { + "did": "did:web:test-user" + } + """) + .post("/v1/dids/unpublish") + .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"); + } + return false; + })); } @Test diff --git a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/KeyPairResourceApiEndToEndTest.java b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/KeyPairResourceApiEndToEndTest.java index 0919c74b9..83670ae1b 100644 --- a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/KeyPairResourceApiEndToEndTest.java +++ b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/KeyPairResourceApiEndToEndTest.java @@ -17,11 +17,15 @@ import com.nimbusds.jose.jwk.Curve; import io.restassured.http.Header; import org.eclipse.edc.identityhub.spi.KeyPairService; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairAdded; +import org.eclipse.edc.identityhub.spi.events.keypair.KeyPairRotated; import org.eclipse.edc.identityhub.spi.model.KeyPairResource; import org.eclipse.edc.identityhub.spi.model.participant.KeyDescriptor; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext; import org.eclipse.edc.junit.annotations.EndToEndTest; import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.event.EventRouter; +import org.eclipse.edc.spi.event.EventSubscriber; import org.junit.jupiter.api.Test; import java.util.Map; @@ -30,6 +34,10 @@ import static io.restassured.http.ContentType.JSON; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @EndToEndTest public class KeyPairResourceApiEndToEndTest extends ManagementApiEndToEndTest { @@ -133,6 +141,9 @@ void findForParticipant() { @Test void addKeyPair() { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(KeyPairAdded.class, subscriber); + var user1 = "user1"; var token = createParticipant(user1); @@ -148,10 +159,17 @@ void addKeyPair() { .log().ifValidationFails() .statusCode(204) .body(notNullValue()); + verify(subscriber).on(argThat(env -> { + var evt = (KeyPairAdded) env.getPayload(); + return evt.getParticipantId().equals(user1) && evt.getKeyId().equals(keyDesc.getKeyId()); + })); } @Test void addKeyPair_notAuthorized() { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(KeyPairAdded.class, subscriber); + var user1 = "user1"; var token = createParticipant(user1); @@ -170,10 +188,21 @@ void addKeyPair_notAuthorized() { .log().ifValidationFails() .statusCode(403) .body(notNullValue()); + + verify(subscriber, never()).on(argThat(env -> { + if (env.getPayload() instanceof KeyPairAdded evt) { + return evt.getKeyId().equals(keyDesc.getKeyId()); + } + return false; + })); } @Test void rotate() { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(KeyPairRotated.class, subscriber); + getService(EventRouter.class).registerSync(KeyPairAdded.class, subscriber); + var user1 = "user1"; var token = createParticipant(user1); @@ -190,10 +219,28 @@ void rotate() { .log().ifValidationFails() .statusCode(204) .body(notNullValue()); + + // verify that the "rotated" event fired once + verify(subscriber).on(argThat(env -> { + if (env.getPayload() instanceof KeyPairRotated evt) { + return evt.getParticipantId().equals(user1); + } + return false; + })); + // verify that the correct "added" event fired + verify(subscriber).on(argThat(env -> { + if (env.getPayload() instanceof KeyPairAdded evt) { + return evt.getKeyId().equals(keyDesc.getKeyId()); + } + return false; + })); } @Test void rotate_notAuthorized() { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(KeyPairRotated.class, subscriber); + var user1 = "user1"; var token = createParticipant(user1); @@ -213,6 +260,14 @@ void rotate_notAuthorized() { .log().ifValidationFails() .statusCode(403) .body(notNullValue()); + + // make sure that the event to add the _new_ keypair was never fired + verify(subscriber, never()).on(argThat(env -> { + if (env.getPayload() instanceof KeyPairRotated evt) { + return evt.getParticipantId().equals(user1) && evt.getKeyId().equals(keyDesc.getKeyId()); + } + return false; + })); } @Test @@ -231,6 +286,9 @@ void revoke() { .log().ifValidationFails() .statusCode(204) .body(notNullValue()); + + assertThat(getDidForParticipant(user1)).hasSize(1) + .allSatisfy(dd -> assertThat(dd.getVerificationMethod()).noneMatch(vm -> vm.getId().equals(keyId))); } @Test diff --git a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/ManagementApiEndToEndTest.java b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/ManagementApiEndToEndTest.java index 9cd378fda..3ea7f729f 100644 --- a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/ManagementApiEndToEndTest.java +++ b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/ManagementApiEndToEndTest.java @@ -14,19 +14,26 @@ package org.eclipse.edc.identityhub.tests; +import org.eclipse.edc.iam.did.spi.document.DidDocument; import org.eclipse.edc.iam.did.spi.document.Service; +import org.eclipse.edc.identithub.did.spi.DidDocumentService; import org.eclipse.edc.identityhub.participantcontext.ApiTokenGenerator; import org.eclipse.edc.identityhub.spi.ParticipantContextService; +import org.eclipse.edc.identityhub.spi.model.KeyPairResource; import org.eclipse.edc.identityhub.spi.model.participant.KeyDescriptor; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantManifest; +import org.eclipse.edc.identityhub.spi.store.KeyPairResourceStore; import org.eclipse.edc.identityhub.spi.store.ParticipantContextStore; import org.eclipse.edc.identityhub.tests.fixtures.IdentityHubRuntimeConfiguration; import org.eclipse.edc.junit.extensions.EdcRuntimeExtension; import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.security.Vault; import org.junit.jupiter.api.extension.RegisterExtension; +import java.util.Collection; import java.util.Map; /** @@ -76,6 +83,22 @@ protected String createTokenFor(String userId) { return new ApiTokenGenerator().generate(userId); } + protected T getService(Class type) { + return RUNTIME.getContext().getService(type); + } + + protected Collection getKeyPairsForParticipant(ParticipantManifest manifest) { + return getService(KeyPairResourceStore.class).query(QuerySpec.Builder.newInstance() + .filter(new Criterion("participantId", "=", manifest.getParticipantId())) + .build()).getContent(); + } + + protected Collection getDidForParticipant(String participantId) { + return getService(DidDocumentService.class).queryDocuments(QuerySpec.Builder.newInstance() + .filter(new Criterion("participantId", "=", participantId)) + .build()).getContent(); + } + protected static ParticipantManifest createNewParticipant() { var manifest = ParticipantManifest.Builder.newInstance() .participantId("another-participant") 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 60f5b16eb..85679ff5a 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 @@ -17,16 +17,24 @@ import io.restassured.http.ContentType; import io.restassured.http.Header; import org.eclipse.edc.identityhub.spi.ParticipantContextService; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextCreated; +import org.eclipse.edc.identityhub.spi.events.participant.ParticipantContextUpdated; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContextState; import org.eclipse.edc.junit.annotations.EndToEndTest; import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.event.EventRouter; +import org.eclipse.edc.spi.event.EventSubscriber; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.anyOf; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; @EndToEndTest public class ParticipantContextApiEndToEndTest extends ManagementApiEndToEndTest { @@ -74,6 +82,8 @@ void getUserById_notOwner_expect403() { @Test void createNewUser_principalIsAdmin() { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(ParticipantContextCreated.class, subscriber); var apikey = getSuperUserApiKey(); var manifest = createNewParticipant(); @@ -87,10 +97,20 @@ void createNewUser_principalIsAdmin() { .log().ifError() .statusCode(anyOf(equalTo(200), equalTo(204))) .body(notNullValue()); + + verify(subscriber).on(argThat(env -> ((ParticipantContextCreated) env.getPayload()).getParticipantId().equals(manifest.getParticipantId()))); + + assertThat(getKeyPairsForParticipant(manifest)).hasSize(1); + assertThat(getDidForParticipant(manifest.getParticipantId())).hasSize(1) + .allSatisfy(dd -> assertThat(dd.getVerificationMethod()).hasSize(1)); } + @Test void createNewUser_principalIsNotAdmin_expect403() { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(ParticipantContextCreated.class, subscriber); + var principal = "another-user"; var anotherUser = ParticipantContext.Builder.newInstance() .participantId(principal) @@ -109,10 +129,15 @@ void createNewUser_principalIsNotAdmin_expect403() { .log().ifError() .statusCode(403) .body(notNullValue()); + verifyNoInteractions(subscriber); + + assertThat(getKeyPairsForParticipant(manifest)).isEmpty(); } @Test void createNewUser_principalIsKnown_expect401() { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(ParticipantContextCreated.class, subscriber); var principal = "another-user"; var manifest = createNewParticipant(); @@ -126,10 +151,15 @@ void createNewUser_principalIsKnown_expect401() { .log().ifError() .statusCode(401) .body(notNullValue()); + verifyNoInteractions(subscriber); + assertThat(getKeyPairsForParticipant(manifest)).isEmpty(); } @Test void activateParticipant_principalIsAdmin() { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(ParticipantContextUpdated.class, subscriber); + var participantId = "another-user"; var anotherUser = ParticipantContext.Builder.newInstance() .participantId(participantId) @@ -149,7 +179,30 @@ void activateParticipant_principalIsAdmin() { var updatedParticipant = RUNTIME.getContext().getService(ParticipantContextService.class).getParticipantContext(participantId).orElseThrow(f -> new EdcException(f.getFailureDetail())); assertThat(updatedParticipant.getState()).isEqualTo(ParticipantContextState.ACTIVATED.ordinal()); + // verify the correct event was emitted + verify(subscriber).on(argThat(env -> { + var evt = (ParticipantContextUpdated) env.getPayload(); + return evt.getParticipantId().equals(participantId) && evt.getNewState() == ParticipantContextState.ACTIVATED; + })); + } + @Test + void deleteParticipant() { + var participantId = "another-user"; + createParticipant(participantId); + + assertThat(getDidForParticipant(participantId)).hasSize(1); + + RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() + .header(new Header("x-api-key", getSuperUserApiKey())) + .contentType(ContentType.JSON) + .delete("/v1/participants/%s".formatted(participantId)) + .then() + .log().ifError() + .statusCode(204); + + assertThat(getDidForParticipant(participantId)).isEmpty(); + } } diff --git a/extensions/api/identityhub-api-auth/src/main/java/org/eclipse/edc/identityhub/api/authorization/AuthorizationServiceImpl.java b/extensions/api/identityhub-api-auth/src/main/java/org/eclipse/edc/identityhub/api/authorization/AuthorizationServiceImpl.java index 0953a5988..b74e51c18 100644 --- a/extensions/api/identityhub-api-auth/src/main/java/org/eclipse/edc/identityhub/api/authorization/AuthorizationServiceImpl.java +++ b/extensions/api/identityhub-api-auth/src/main/java/org/eclipse/edc/identityhub/api/authorization/AuthorizationServiceImpl.java @@ -30,17 +30,18 @@ public class AuthorizationServiceImpl implements AuthorizationService { private final Map, Function> authorizationCheckFunctions = new HashMap<>(); @Override - public ServiceResult isAuthorized(Principal user, String resourceId, Class resourceClass) { + public ServiceResult isAuthorized(Principal principal, String resourceId, Class resourceClass) { + var function = authorizationCheckFunctions.get(resourceClass); if (function == null) { - return ServiceResult.unauthorized("User access for '%s' to resource ID '%s' of type '%s' cannot be verified".formatted(user.getName(), resourceClass, resourceClass)); + return ServiceResult.unauthorized("User access for '%s' to resource ID '%s' of type '%s' cannot be verified".formatted(principal.getName(), resourceClass, resourceClass)); } var isAuthorized = ofNullable(function.apply(resourceId)) - .map(pr -> Objects.equals(pr.getParticipantId(), user.getName())) + .map(pr -> Objects.equals(pr.getParticipantId(), principal.getName())) .orElse(false); - return isAuthorized ? ServiceResult.success() : ServiceResult.unauthorized("User '%s' is not authorized to access resource of type %s with ID '%s'.".formatted(user.getName(), resourceClass, resourceId)); + return isAuthorized ? ServiceResult.success() : ServiceResult.unauthorized("User '%s' is not authorized to access resource of type %s with ID '%s'.".formatted(principal.getName(), resourceClass, resourceId)); } diff --git a/extensions/api/participant-context-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/participantcontext/v1/ParticipantContextApiController.java b/extensions/api/participant-context-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/participantcontext/v1/ParticipantContextApiController.java index ccd779d27..d16ca15fe 100644 --- a/extensions/api/participant-context-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/participantcontext/v1/ParticipantContextApiController.java +++ b/extensions/api/participant-context-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/participantcontext/v1/ParticipantContextApiController.java @@ -95,8 +95,7 @@ public void activateParticipant(@PathParam("participantId") String participantId @Path("/{participantId}") @RolesAllowed("admin") public void deleteParticipant(@PathParam("participantId") String participantId, @Context SecurityContext securityContext) { - authorizationService.isAuthorized(securityContext.getUserPrincipal(), participantId, ParticipantContext.class) - .compose(u -> participantContextService.deleteParticipantContext(participantId)) + participantContextService.deleteParticipantContext(participantId) .orElseThrow(exceptionMapper(ParticipantContext.class, participantId)); } diff --git a/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidDocumentListenerImpl.java b/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidDocumentListenerImpl.java new file mode 100644 index 000000000..a9974b6ca --- /dev/null +++ b/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidDocumentListenerImpl.java @@ -0,0 +1,62 @@ +/* + * 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.publisher.did.local; + +import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.identityhub.spi.events.diddocument.DidDocumentEvent; +import org.eclipse.edc.identityhub.spi.events.diddocument.DidDocumentListener; +import org.eclipse.edc.identityhub.spi.events.diddocument.DidDocumentPublished; +import org.eclipse.edc.identityhub.spi.events.diddocument.DidDocumentUnpublished; +import org.eclipse.edc.spi.event.EventEnvelope; +import org.eclipse.edc.spi.event.EventRouter; + +import java.time.Clock; + +public class DidDocumentListenerImpl implements DidDocumentListener { + + private final Clock clock; + private final EventRouter eventRouter; + + public DidDocumentListenerImpl(Clock clock, EventRouter eventRouter) { + this.clock = clock; + this.eventRouter = eventRouter; + } + + @Override + public void published(DidDocument document, String participantId) { + var event = DidDocumentPublished.Builder.newInstance() + .participantId(participantId) + .did(document.getId()) + .build(); + publish(event); + } + + @Override + public void unpublished(DidDocument document, String participantId) { + var event = DidDocumentUnpublished.Builder.newInstance() + .participantId(participantId) + .did(document.getId()) + .build(); + publish(event); + } + + private void publish(DidDocumentEvent event) { + var envelope = EventEnvelope.Builder.newInstance() + .payload(event) + .at(clock.millis()) + .build(); + eventRouter.publish(envelope); + } +} diff --git a/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidDocumentObservableImpl.java b/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidDocumentObservableImpl.java new file mode 100644 index 000000000..8025204cc --- /dev/null +++ b/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/DidDocumentObservableImpl.java @@ -0,0 +1,22 @@ +/* + * 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.publisher.did.local; + +import org.eclipse.edc.identityhub.spi.events.diddocument.DidDocumentListener; +import org.eclipse.edc.identityhub.spi.events.diddocument.DidDocumentObservable; +import org.eclipse.edc.spi.observe.ObservableImpl; + +public class DidDocumentObservableImpl extends ObservableImpl implements DidDocumentObservable { +} 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 3bc28262f..3e501d94d 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 @@ -18,6 +18,7 @@ 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.identityhub.spi.events.diddocument.DidDocumentObservable; import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.result.Result; @@ -32,10 +33,12 @@ */ public class LocalDidPublisher implements DidDocumentPublisher { + private final DidDocumentObservable observable; private final DidResourceStore didResourceStore; private final Monitor monitor; - public LocalDidPublisher(DidResourceStore didResourceStore, Monitor monitor) { + public LocalDidPublisher(DidDocumentObservable observable, DidResourceStore didResourceStore, Monitor monitor) { + this.observable = observable; this.didResourceStore = didResourceStore; this.monitor = monitor; } @@ -60,7 +63,8 @@ public Result publish(String did) { return didResourceStore.update(existingDocument) .map(v -> success()) - .orElse(f -> failure(f.getFailureDetail())); + .orElse(f -> failure(f.getFailureDetail())) + .onSuccess(v -> observable.invokeForEach(l -> l.published(existingDocument.getDocument(), existingDocument.getParticipantId()))); } @Override @@ -78,7 +82,8 @@ public Result unpublish(String did) { existingDocument.transitionState(DidState.UNPUBLISHED); return didResourceStore.update(existingDocument) .map(v -> success()) - .orElse(f -> failure(f.getFailureDetail())); + .orElse(f -> failure(f.getFailureDetail())) + .onSuccess(v -> observable.invokeForEach(l -> l.unpublished(existingDocument.getDocument(), existingDocument.getParticipantId()))); } diff --git a/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherExtension.java b/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherExtension.java index 1fb486e97..4fa711cfa 100644 --- a/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherExtension.java +++ b/extensions/did/local-did-publisher/src/main/java/org/eclipse/edc/identityhub/publisher/did/local/LocalDidPublisherExtension.java @@ -18,8 +18,11 @@ import org.eclipse.edc.identithub.did.spi.DidDocumentPublisherRegistry; import org.eclipse.edc.identithub.did.spi.DidWebParser; import org.eclipse.edc.identithub.did.spi.store.DidResourceStore; +import org.eclipse.edc.identityhub.spi.events.diddocument.DidDocumentObservable; 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.event.EventRouter; import org.eclipse.edc.spi.system.ServiceExtension; import org.eclipse.edc.spi.system.ServiceExtensionContext; import org.eclipse.edc.web.spi.WebServer; @@ -27,6 +30,8 @@ import org.eclipse.edc.web.spi.configuration.WebServiceConfigurer; import org.eclipse.edc.web.spi.configuration.WebServiceSettings; +import java.time.Clock; + import static org.eclipse.edc.identityhub.publisher.did.local.LocalDidPublisherExtension.NAME; @Extension(value = NAME) @@ -59,6 +64,11 @@ public class LocalDidPublisherExtension implements ServiceExtension { */ @Inject(required = false) private DidWebParser didWebParser; + @Inject + private Clock clock; + @Inject + private EventRouter eventRouter; + private DidDocumentObservableImpl observable; @Override public String name() { @@ -68,11 +78,20 @@ public String name() { @Override public void initialize(ServiceExtensionContext context) { var webServiceConfiguration = configurator.configure(context, webServer, SETTINGS); - var localPublisher = new LocalDidPublisher(didResourceStore, context.getMonitor()); + var localPublisher = new LocalDidPublisher(didDocumentObservable(), didResourceStore, context.getMonitor()); registry.addPublisher(DidConstants.DID_WEB_METHOD, localPublisher); webService.registerResource(webServiceConfiguration.getContextAlias(), new DidWebController(context.getMonitor(), didResourceStore, getDidParser())); } + @Provider + public DidDocumentObservable didDocumentObservable() { + if (observable == null) { + observable = new DidDocumentObservableImpl(); + observable.registerListener(new DidDocumentListenerImpl(clock, eventRouter)); + } + return observable; + } + private DidWebParser getDidParser() { return didWebParser != null ? didWebParser : new DidWebParser(); } 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 caa46c042..bc12417cc 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 @@ -16,6 +16,7 @@ import org.eclipse.edc.identithub.did.spi.model.DidState; import org.eclipse.edc.identithub.did.spi.store.DidResourceStore; +import org.eclipse.edc.identityhub.spi.events.diddocument.DidDocumentObservable; import org.eclipse.edc.junit.assertions.AbstractResultAssert; import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.result.StoreResult; @@ -40,11 +41,12 @@ class LocalDidPublisherTest { private final DidResourceStore storeMock = mock(); private LocalDidPublisher publisher; private Monitor monitor; + private final DidDocumentObservable observableMock = mock(); @BeforeEach void setUp() { monitor = mock(); - publisher = new LocalDidPublisher(storeMock, monitor); + publisher = new LocalDidPublisher(observableMock, storeMock, monitor); } @@ -69,7 +71,8 @@ void publish_success() { verify(storeMock).findById(anyString()); verify(storeMock).update(argThat(dr -> dr.getState() == DidState.PUBLISHED.code())); - verifyNoMoreInteractions(storeMock); + verify(observableMock).invokeForEach(any()); + verifyNoMoreInteractions(storeMock, observableMock); } @Test @@ -81,7 +84,7 @@ void publish_notExists_returnsFailure() { .isEqualTo("A DID Resource with the ID 'did:web:foo' was not found."); verify(storeMock).findById(anyString()); - verifyNoMoreInteractions(storeMock); + verifyNoMoreInteractions(storeMock, observableMock); } @Test @@ -96,7 +99,8 @@ void publish_alreadyPublished_expectWarning() { verify(storeMock).findById(anyString()); verify(storeMock).update(any()); verify(monitor).warning("DID 'did:web:test' is already published - this action will overwrite it."); - verifyNoMoreInteractions(storeMock); + verify(observableMock).invokeForEach(any()); + verifyNoMoreInteractions(storeMock, observableMock); } @Test @@ -110,7 +114,7 @@ void publish_storeFailsUpdate_returnsFailure() { verify(storeMock).findById(anyString()); verify(storeMock).update(any()); - verifyNoMoreInteractions(storeMock); + verifyNoMoreInteractions(storeMock, observableMock); } @Test @@ -124,7 +128,8 @@ void unpublish_success() { verify(storeMock).findById(anyString()); verify(storeMock).update(argThat(dr -> dr.getState() == DidState.UNPUBLISHED.code())); - verifyNoMoreInteractions(storeMock); + verify(observableMock).invokeForEach(any()); + verifyNoMoreInteractions(storeMock, observableMock); } @Test @@ -136,7 +141,7 @@ void unpublish_notExists_returnsFailure() { .contains("A DID Resource with the ID 'did:web:test' was not found."); verify(storeMock).findById(anyString()); - verifyNoMoreInteractions(storeMock); + verifyNoMoreInteractions(storeMock, observableMock); } @Test @@ -150,7 +155,8 @@ void unpublish_notPublished_expectWarning() { verify(storeMock).findById(anyString()); verify(storeMock).update(any()); - verifyNoMoreInteractions(storeMock); + verify(observableMock).invokeForEach(any()); + verifyNoMoreInteractions(storeMock, observableMock); verify(monitor).info("Un-publish DID Resource 'did:web:test': not published -> NOOP."); } @@ -167,6 +173,6 @@ void unpublish_storeFailsUpdate_returnsFailure() { verify(storeMock).findById(anyString()); verify(storeMock).update(any()); - verifyNoMoreInteractions(storeMock); + verifyNoMoreInteractions(storeMock, observableMock); } } \ No newline at end of file diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentEvent.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentEvent.java new file mode 100644 index 000000000..bed79dbc9 --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentEvent.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.events.diddocument; + +import org.eclipse.edc.spi.event.Event; + +import java.util.Objects; + +/** + * Base class for all events related to DID document activites and state changes. + */ +public abstract class DidDocumentEvent extends Event { + protected String did; + protected String participantId; + + public String getDid() { + return did; + } + + public String getParticipantId() { + return participantId; + } + + public abstract static class Builder> { + + protected final T event; + + protected Builder(T event) { + this.event = event; + } + + public abstract B self(); + + public B participantId(String assetId) { + event.participantId = assetId; + return self(); + } + + public B did(String did) { + event.did = did; + return self(); + } + + public T build() { + Objects.requireNonNull((event.participantId)); + return event; + } + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentListener.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentListener.java new file mode 100644 index 000000000..7df44e80b --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentListener.java @@ -0,0 +1,39 @@ +/* + * 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.events.diddocument; + +import org.eclipse.edc.iam.did.spi.document.DidDocument; +import org.eclipse.edc.spi.observe.Observable; + +/** + * Interface implemented by listeners registered to observe DID document changes via {@link Observable#registerListener}. + * The listener must be called after the state changes are persisted. + */ +public interface DidDocumentListener { + + /** + * A DID document got published + */ + default void published(DidDocument document, String participantId) { + + } + + /** + * A DID document got un-published + */ + default void unpublished(DidDocument document, String participantId) { + + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentObservable.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentObservable.java new file mode 100644 index 000000000..ce9e7a4bc --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentObservable.java @@ -0,0 +1,23 @@ +/* + * 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.events.diddocument; + +import org.eclipse.edc.spi.observe.Observable; + +/** + * Manages and invokes {@link DidDocumentListener}s when a state change related to a DID document resource has happened. + */ +public interface DidDocumentObservable extends Observable { +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentPublished.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentPublished.java new file mode 100644 index 000000000..05cfddf68 --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentPublished.java @@ -0,0 +1,48 @@ +/* + * 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.events.diddocument; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; + +/** + * Event that signals that a DID document was published. + */ +@JsonDeserialize(builder = DidDocumentPublished.Builder.class) +public class DidDocumentPublished extends DidDocumentEvent { + @Override + public String name() { + return "diddocument.published"; + } + + @JsonPOJOBuilder(withPrefix = "") + public static class Builder extends DidDocumentEvent.Builder { + + private Builder() { + super(new DidDocumentPublished()); + } + + @Override + public Builder self() { + return this; + } + + @JsonCreator + public static Builder newInstance() { + return new Builder(); + } + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentUnpublished.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentUnpublished.java new file mode 100644 index 000000000..cbb5be192 --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentUnpublished.java @@ -0,0 +1,48 @@ +/* + * 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.events.diddocument; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; + +/** + * Event that signals that a DID document was un-published. + */ +@JsonDeserialize(builder = DidDocumentUnpublished.Builder.class) +public class DidDocumentUnpublished extends DidDocumentEvent { + @Override + public String name() { + return "diddocument.unpublished"; + } + + @JsonPOJOBuilder(withPrefix = "") + public static class Builder extends DidDocumentEvent.Builder { + + private Builder() { + super(new DidDocumentUnpublished()); + } + + @Override + public Builder self() { + return this; + } + + @JsonCreator + public static Builder newInstance() { + return new Builder(); + } + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairAdded.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairAdded.java new file mode 100644 index 000000000..f8b2f03ed --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairAdded.java @@ -0,0 +1,59 @@ +/* + * 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.events.keypair; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; + +/** + * Event that signals that a key pair was added for a particular participant + */ +@JsonDeserialize(builder = KeyPairAdded.Builder.class) +public class KeyPairAdded extends KeyPairEvent { + private String publicKeySerialized; + + @Override + public String name() { + return "keypair.added"; + } + + public String getPublicKeySerialized() { + return publicKeySerialized; + } + + @JsonPOJOBuilder(withPrefix = "") + public static class Builder extends KeyPairEvent.Builder { + + private Builder() { + super(new KeyPairAdded()); + } + + @Override + public KeyPairAdded.Builder self() { + return this; + } + + public Builder publicKey(String publicKey) { + event.publicKeySerialized = publicKey; + return this; + } + + @JsonCreator + public static Builder newInstance() { + return new KeyPairAdded.Builder(); + } + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairEvent.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairEvent.java new file mode 100644 index 000000000..1aa13e37e --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairEvent.java @@ -0,0 +1,67 @@ +/* + * 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.events.keypair; + +import org.eclipse.edc.spi.event.Event; + +import java.util.Objects; + +/** + * Base class for all events that relate to state changes or actions regarding KeyPairs + */ +public abstract class KeyPairEvent extends Event { + protected String participantId; + protected String keyId; + + /** + * The ID of the Key stored in the {@link org.eclipse.edc.identityhub.spi.model.KeyPairResource} + */ + public String getKeyId() { + return keyId; + } + + /** + * The ID of the {@link org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext} that owns the KeyPair resource. + */ + public String getParticipantId() { + return participantId; + } + + public abstract static class Builder> { + + protected final T event; + + protected Builder(T event) { + this.event = event; + } + + public abstract B self(); + + public B participantId(String assetId) { + event.participantId = assetId; + return self(); + } + + public B keyId(String keyId) { + event.keyId = keyId; + return self(); + } + + public T build() { + Objects.requireNonNull((event.participantId)); + return event; + } + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairEventListener.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairEventListener.java new file mode 100644 index 000000000..fc0f8532d --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairEventListener.java @@ -0,0 +1,55 @@ +/* + * 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.events.keypair; + +import org.eclipse.edc.identityhub.spi.model.KeyPairResource; +import org.eclipse.edc.spi.observe.Observable; + +/** + * Interface implemented by listeners registered to observe key pair resource changes via {@link Observable#registerListener}. + * The listener must be called after the state changes are persisted. + */ +public interface KeyPairEventListener { + + /** + * A {@link KeyPairResource} was added to a particular {@link org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext}. This could happen either + * by simply adding a keypair, or after a keypair was revoked, and a successor was specified. + * + * @param keypair The new (added) key pair + */ + default void added(KeyPairResource keypair) { + + } + + /** + * A {@link KeyPairResource} was rotated (=phased out). If the rotation was done with a successor keypair, this would be communicated using the {@link KeyPairEventListener#added(KeyPairResource)} + * callback. + * + * @param keyPair the old (outgoing) {@link KeyPairResource} + */ + default void rotated(KeyPairResource keyPair) { + + } + + /** + * A {@link KeyPairResource} was revoked (=deleted). If the revocation was done with a successor keypair, this would be communicated using the {@link KeyPairEventListener#added(KeyPairResource)} + * callback. + * + * @param keyPair the old (outgoing) {@link KeyPairResource} + */ + default void revoked(KeyPairResource keyPair) { + + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairObservable.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairObservable.java new file mode 100644 index 000000000..e2211ae5c --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairObservable.java @@ -0,0 +1,23 @@ +/* + * 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.events.keypair; + +import org.eclipse.edc.spi.observe.Observable; + +/** + * Manages and invokes {@link KeyPairEventListener}s when a state change related to a key pair resource has happened. + */ +public interface KeyPairObservable extends Observable { +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRevoked.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRevoked.java new file mode 100644 index 000000000..6424d4a3b --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRevoked.java @@ -0,0 +1,48 @@ +/* + * 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.events.keypair; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; + +/** + * Event that signals that a KeyPair was revoked. + */ +@JsonDeserialize(builder = KeyPairRevoked.Builder.class) +public class KeyPairRevoked extends KeyPairEvent { + @Override + public String name() { + return "keypair.revoked"; + } + + @JsonPOJOBuilder(withPrefix = "") + public static class Builder extends KeyPairEvent.Builder { + + private Builder() { + super(new KeyPairRevoked()); + } + + @Override + public KeyPairRevoked.Builder self() { + return this; + } + + @JsonCreator + public static KeyPairRevoked.Builder newInstance() { + return new KeyPairRevoked.Builder(); + } + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRotated.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRotated.java new file mode 100644 index 000000000..ff8771136 --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRotated.java @@ -0,0 +1,48 @@ +/* + * 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.events.keypair; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; + +/** + * Event that signals that a KeyPair was rotated. + */ +@JsonDeserialize(builder = KeyPairRotated.Builder.class) +public class KeyPairRotated extends KeyPairEvent { + @Override + public String name() { + return "keypair.rotated"; + } + + @JsonPOJOBuilder(withPrefix = "") + public static class Builder extends KeyPairEvent.Builder { + + private Builder() { + super(new KeyPairRotated()); + } + + @Override + public KeyPairRotated.Builder self() { + return this; + } + + @JsonCreator + public static KeyPairRotated.Builder newInstance() { + return new KeyPairRotated.Builder(); + } + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextCreated.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextCreated.java new file mode 100644 index 000000000..e1de53e59 --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextCreated.java @@ -0,0 +1,60 @@ +/* + * 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.events.participant; + +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.model.participant.ParticipantManifest; + +/** + * Event that signals that a {@link org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext} was created + */ +@JsonDeserialize(builder = ParticipantContextCreated.Builder.class) +public class ParticipantContextCreated extends ParticipantContextEvent { + private ParticipantManifest manifest; + + @Override + public String name() { + return "participantcontext.created"; + } + + public ParticipantManifest getManifest() { + return manifest; + } + + @JsonPOJOBuilder(withPrefix = "") + public static class Builder extends ParticipantContextEvent.Builder { + + private Builder() { + super(new ParticipantContextCreated()); + } + + @Override + public Builder self() { + return this; + } + + public Builder manifest(ParticipantManifest manifest) { + this.event.manifest = manifest; + return this; + } + + @JsonCreator + public static Builder newInstance() { + return new Builder(); + } + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextDeleted.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextDeleted.java new file mode 100644 index 000000000..cfc7efa42 --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextDeleted.java @@ -0,0 +1,48 @@ +/* + * 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.events.participant; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; + +/** + * Event that signals that a {@link org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext} was deleted + */ +@JsonDeserialize(builder = ParticipantContextDeleted.Builder.class) +public class ParticipantContextDeleted extends ParticipantContextEvent { + @Override + public String name() { + return "participantcontext.deleted"; + } + + @JsonPOJOBuilder(withPrefix = "") + public static class Builder extends ParticipantContextEvent.Builder { + + private Builder() { + super(new ParticipantContextDeleted()); + } + + @Override + public Builder self() { + return this; + } + + @JsonCreator + public static Builder newInstance() { + return new Builder(); + } + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextEvent.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextEvent.java new file mode 100644 index 000000000..bdcb0c303 --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextEvent.java @@ -0,0 +1,51 @@ +/* + * 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.events.participant; + +import org.eclipse.edc.spi.event.Event; + +import java.util.Objects; + +/** + * Base class for all events related to state changes and actions of {@link org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext}s + */ +public abstract class ParticipantContextEvent extends Event { + protected String participantId; + + public String getParticipantId() { + return participantId; + } + + public abstract static class Builder> { + + protected final T event; + + protected Builder(T event) { + this.event = event; + } + + public abstract B self(); + + public B participantId(String assetId) { + event.participantId = assetId; + return self(); + } + + public T build() { + Objects.requireNonNull((event.participantId)); + return event; + } + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextListener.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextListener.java new file mode 100644 index 000000000..dc409f1aa --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextListener.java @@ -0,0 +1,57 @@ +/* + * 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.events.participant; + +import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext; +import org.eclipse.edc.identityhub.spi.model.participant.ParticipantManifest; +import org.eclipse.edc.spi.observe.Observable; + +/** + * Interface implemented by listeners registered to observe participant context changes via {@link Observable#registerListener}. + * The listener must be called after the state changes are persisted. + */ +public interface ParticipantContextListener { + + /** + * Notifies about the fact that a new {@link ParticipantContext} has been created, and further action, such as creating keypairs or updating DID documents + * can now happen. + * + * @param newContext The newly created (already persisted) participant context + * @param manifest The original manifest based on which the context was created + */ + default void created(ParticipantContext newContext, ParticipantManifest manifest) { + + } + + /** + * Notifies about the fact that a {@link ParticipantContext} has been updated, and further action, such as creating keypairs or updating DID documents + * can now happen. + * + * @param updatedContext The updated (already persisted) participant context + */ + 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. + * + * @param deletedContext The updated (already persisted) participant context + */ + default void deleted(ParticipantContext deletedContext) { + + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextObservable.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextObservable.java new file mode 100644 index 000000000..dec1ead71 --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextObservable.java @@ -0,0 +1,23 @@ +/* + * 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.events.participant; + +import org.eclipse.edc.spi.observe.Observable; + +/** + * Manages and invokes {@link ParticipantContextListener}s when a state change related to a participant context has happened. + */ +public interface ParticipantContextObservable extends Observable { +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextUpdated.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextUpdated.java new file mode 100644 index 000000000..4cb73f814 --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextUpdated.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.events.participant; + +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.model.participant.ParticipantContextState; + +/** + * Event that signals that a {@link org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext} was updated + */ +@JsonDeserialize(builder = ParticipantContextUpdated.Builder.class) +public class ParticipantContextUpdated extends ParticipantContextEvent { + + private ParticipantContextState newState; + + @Override + public String name() { + return "participantcontext.updated"; + } + + public ParticipantContextState getNewState() { + return newState; + } + + @JsonPOJOBuilder(withPrefix = "") + public static class Builder extends ParticipantContextEvent.Builder { + + private Builder() { + super(new ParticipantContextUpdated()); + } + + @Override + public Builder self() { + return this; + } + + public Builder newState(ParticipantContextState state) { + this.event.newState = state; + return this; + } + + @JsonCreator + public static Builder newInstance() { + return new Builder(); + } + } +} diff --git a/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentPublishedTest.java b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentPublishedTest.java new file mode 100644 index 000000000..c2c1b7065 --- /dev/null +++ b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentPublishedTest.java @@ -0,0 +1,40 @@ +/* + * 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.events.diddocument; + +import org.eclipse.edc.spi.types.TypeManager; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + + +class DidDocumentPublishedTest { + + private final TypeManager manager = new TypeManager(); + + @Test + void verify_serDes() { + + var event = DidDocumentPublished.Builder.newInstance() + .did("did:web:test") + .participantId("test-id") + .build(); + + var json = manager.writeValueAsString(event); + assertThat(json).isNotNull(); + + assertThat(manager.readValue(json, DidDocumentPublished.class)).usingRecursiveComparison().isEqualTo(event); + } +} \ No newline at end of file diff --git a/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentUnpublishedTest.java b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentUnpublishedTest.java new file mode 100644 index 000000000..78e75791d --- /dev/null +++ b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/diddocument/DidDocumentUnpublishedTest.java @@ -0,0 +1,40 @@ +/* + * 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.events.diddocument; + +import org.eclipse.edc.spi.types.TypeManager; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + + +class DidDocumentUnpublishedTest { + + private final TypeManager manager = new TypeManager(); + + @Test + void verify_serDes() { + + var event = DidDocumentUnpublished.Builder.newInstance() + .did("did:web:test") + .participantId("test-id") + .build(); + + var json = manager.writeValueAsString(event); + assertThat(json).isNotNull(); + + assertThat(manager.readValue(json, DidDocumentUnpublished.class)).usingRecursiveComparison().isEqualTo(event); + } +} \ No newline at end of file diff --git a/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairAddedTest.java b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairAddedTest.java new file mode 100644 index 000000000..f472b4a28 --- /dev/null +++ b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairAddedTest.java @@ -0,0 +1,38 @@ +/* + * 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.events.keypair; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.eclipse.edc.spi.types.TypeManager; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class KeyPairAddedTest { + + private final TypeManager typeManager = new TypeManager(); + + @Test + void verify_serDes() throws JsonProcessingException { + var evt = KeyPairAdded.Builder.newInstance().keyId("resource-id") + .participantId("participant-id") + .build(); + + var json = typeManager.writeValueAsString(evt); + assertThat(json).isNotNull(); + + assertThat(typeManager.readValue(json, KeyPairAdded.class)).usingRecursiveComparison().isEqualTo(evt); + } +} \ No newline at end of file diff --git a/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRevokedTest.java b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRevokedTest.java new file mode 100644 index 000000000..696a2a9f4 --- /dev/null +++ b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRevokedTest.java @@ -0,0 +1,38 @@ +/* + * 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.events.keypair; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.eclipse.edc.spi.types.TypeManager; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class KeyPairRevokedTest { + + private final TypeManager typeManager = new TypeManager(); + + @Test + void verify_serDes() throws JsonProcessingException { + var evt = KeyPairRevoked.Builder.newInstance().keyId("resource-id") + .participantId("participant-id") + .build(); + + var json = typeManager.writeValueAsString(evt); + assertThat(json).isNotNull(); + + assertThat(typeManager.readValue(json, KeyPairRevoked.class)).usingRecursiveComparison().isEqualTo(evt); + } +} \ No newline at end of file diff --git a/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRotatedTest.java b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRotatedTest.java new file mode 100644 index 000000000..25c799b60 --- /dev/null +++ b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/keypair/KeyPairRotatedTest.java @@ -0,0 +1,38 @@ +/* + * 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.events.keypair; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.eclipse.edc.spi.types.TypeManager; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class KeyPairRotatedTest { + + private final TypeManager typeManager = new TypeManager(); + + @Test + void verify_serDes() throws JsonProcessingException { + var evt = KeyPairRotated.Builder.newInstance().keyId("resource-id") + .participantId("participant-id") + .build(); + + var json = typeManager.writeValueAsString(evt); + assertThat(json).isNotNull(); + + assertThat(typeManager.readValue(json, KeyPairRotated.class)).usingRecursiveComparison().isEqualTo(evt); + } +} \ No newline at end of file diff --git a/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextCreatedTest.java b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextCreatedTest.java new file mode 100644 index 000000000..beaf8095e --- /dev/null +++ b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextCreatedTest.java @@ -0,0 +1,39 @@ +/* + * 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.events.participant; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.eclipse.edc.spi.types.TypeManager; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ParticipantContextCreatedTest { + + private final TypeManager manager = new TypeManager(); + + @Test + void verify_serDes() throws JsonProcessingException { + var evt = ParticipantContextCreated.Builder.newInstance() + .participantId("test-participantId") + .build(); + + var json = manager.writeValueAsString(evt); + + assertThat(json).isNotNull(); + + assertThat(manager.readValue(json, ParticipantContextCreated.class)).usingRecursiveComparison().isEqualTo(evt); + } +} \ No newline at end of file diff --git a/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextDeletedTest.java b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextDeletedTest.java new file mode 100644 index 000000000..7c3e7e2da --- /dev/null +++ b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextDeletedTest.java @@ -0,0 +1,39 @@ +/* + * 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.events.participant; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.eclipse.edc.spi.types.TypeManager; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ParticipantContextDeletedTest { + + private final TypeManager manager = new TypeManager(); + + @Test + void verify_serDes() throws JsonProcessingException { + var evt = ParticipantContextDeleted.Builder.newInstance() + .participantId("test-participantId") + .build(); + + var json = manager.writeValueAsString(evt); + + assertThat(json).isNotNull(); + + assertThat(manager.readValue(json, ParticipantContextDeleted.class)).usingRecursiveComparison().isEqualTo(evt); + } +} \ No newline at end of file diff --git a/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextUpdatedTest.java b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextUpdatedTest.java new file mode 100644 index 000000000..6f034ebd3 --- /dev/null +++ b/spi/identity-hub-spi/src/test/java/org/eclipse/edc/identityhub/spi/events/participant/ParticipantContextUpdatedTest.java @@ -0,0 +1,38 @@ +/* + * 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.events.participant; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.eclipse.edc.spi.types.TypeManager; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ParticipantContextUpdatedTest { + private final TypeManager manager = new TypeManager(); + + @Test + void verify_serDes() throws JsonProcessingException { + var evt = ParticipantContextUpdated.Builder.newInstance() + .participantId("test-participantId") + .build(); + + var json = manager.writeValueAsString(evt); + + assertThat(json).isNotNull(); + + assertThat(manager.readValue(json, ParticipantContextUpdated.class)).usingRecursiveComparison().isEqualTo(evt); + } +} \ No newline at end of file