diff --git a/core/identity-hub-participants/build.gradle.kts b/core/identity-hub-participants/build.gradle.kts new file mode 100644 index 000000000..0da5a5385 --- /dev/null +++ b/core/identity-hub-participants/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + `java-library` +} + +dependencies { + api(project(":spi:identity-hub-spi")) + api(project(":spi:identity-hub-store-spi")) + api(libs.edc.spi.transaction) + + testImplementation(libs.edc.junit) +} 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 new file mode 100644 index 000000000..17bbbbef3 --- /dev/null +++ b/core/identity-hub-participants/src/main/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextServiceImpl.java @@ -0,0 +1,97 @@ +/* + * 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.ParticipantContextService; +import org.eclipse.edc.identityhub.spi.RandomStringGenerator; +import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext; +import org.eclipse.edc.identityhub.spi.store.ParticipantContextStore; +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.spi.security.Vault; +import org.eclipse.edc.transaction.spi.TransactionContext; + +import static org.eclipse.edc.spi.result.ServiceResult.fromFailure; +import static org.eclipse.edc.spi.result.ServiceResult.notFound; +import static org.eclipse.edc.spi.result.ServiceResult.success; + +/** + * Default implementation of the {@link ParticipantContextService}. Uses a {@link Vault} to store API tokens and a {@link RandomStringGenerator} + * to generate API tokens. Please use a generator that produces Strings of a reasonable length. + *

+ * This service is transactional. + */ +public class ParticipantContextServiceImpl implements ParticipantContextService { + + private final ParticipantContextStore participantContextStore; + private final Vault vault; + private final TransactionContext transactionContext; + private final RandomStringGenerator tokenGenerator; + + public ParticipantContextServiceImpl(ParticipantContextStore participantContextStore, Vault vault, TransactionContext transactionContext, RandomStringGenerator tokenGenerator) { + this.participantContextStore = participantContextStore; + this.vault = vault; + this.transactionContext = transactionContext; + this.tokenGenerator = tokenGenerator; + } + + @Override + public ServiceResult createParticipantContext(ParticipantContext context) { + return transactionContext.execute(() -> { + var storeRes = participantContextStore.create(context); + return storeRes.succeeded() ? + success() : + fromFailure(storeRes); + }); + } + + @Override + public ServiceResult getParticipantContext(String participantId) { + return transactionContext.execute(() -> { + var res = participantContextStore.query(QuerySpec.Builder.newInstance().filter(new Criterion("participantContext", "=", participantId)).build()); + if (res.succeeded()) { + return res.getContent().findFirst() + .map(ServiceResult::success) + .orElse(notFound("No ParticipantContext with ID '%s' was found.".formatted(participantId))); + } + return fromFailure(res); + }); + } + + @Override + public ServiceResult deleteParticipantContext(String participantId) { + return transactionContext.execute(() -> { + var res = participantContextStore.deleteById(participantId); + return res.succeeded() ? ServiceResult.success() : ServiceResult.fromFailure(res); + }); + } + + @Override + public ServiceResult regenerateApiToken(String participantId) { + return transactionContext.execute(() -> { + var participantContext = getParticipantContext(participantId); + if (participantContext.failed()) { + return participantContext.map(pc -> null); + } + var alias = participantContext.getContent().getApiTokenAlias(); + + var newToken = tokenGenerator.generate(); + return vault.storeSecret(alias, newToken).map(unused -> ServiceResult.success(newToken)).orElse(f -> ServiceResult.conflict("Could not store new API token: %s.".formatted(f.getFailureDetail()))); + }); + } +} + + diff --git a/core/identity-hub-participants/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/core/identity-hub-participants/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 000000000..ed393669f --- /dev/null +++ b/core/identity-hub-participants/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,13 @@ +# +# Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# +# Contributors: +# Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation +# +# 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 new file mode 100644 index 000000000..b57a80652 --- /dev/null +++ b/core/identity-hub-participants/src/test/java/org/eclipse/edc/identityhub/participantcontext/ParticipantContextServiceImplTest.java @@ -0,0 +1,199 @@ +/* + * 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.assertj.core.api.Assertions; +import org.eclipse.edc.identityhub.spi.RandomStringGenerator; +import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext; +import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContextState; +import org.eclipse.edc.identityhub.spi.store.ParticipantContextStore; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.result.ServiceFailure; +import org.eclipse.edc.spi.result.StoreResult; +import org.eclipse.edc.spi.security.Vault; +import org.eclipse.edc.transaction.spi.NoopTransactionContext; +import org.junit.jupiter.api.Test; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.stream.Stream; + +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class ParticipantContextServiceImplTest { + + private final Vault vault = mock(); + private final ParticipantContextStore participantContextStore = mock(); + private final SecureRandom secureRandom = new SecureRandom(); + // generates 64 random bytes and base64-encodes them + private final RandomStringGenerator randomBase64Generator = () -> { + byte[] array = new byte[64]; + secureRandom.nextBytes(array); + var enc = Base64.getEncoder(); + return enc.encodeToString(array); + }; + private final ParticipantContextServiceImpl participantContextService = new ParticipantContextServiceImpl(participantContextStore, vault, new NoopTransactionContext(), randomBase64Generator); + + @Test + void createParticipantContext() { + when(participantContextStore.create(any())).thenReturn(StoreResult.success()); + + var ctx = createContext(); + assertThat(participantContextService.createParticipantContext(ctx)) + .isSucceeded(); + + verify(participantContextStore).create(any()); + verifyNoMoreInteractions(vault, participantContextStore); + } + + @Test + void createParticipantContext_storageFails() { + when(participantContextStore.create(any())).thenReturn(StoreResult.success()); + + var ctx = createContext(); + assertThat(participantContextService.createParticipantContext(ctx)) + .isSucceeded(); + + verify(participantContextStore).create(any()); + verifyNoMoreInteractions(vault, participantContextStore); + } + + @Test + void createParticipantContext_whenExists() { + when(participantContextStore.create(any())).thenReturn(StoreResult.alreadyExists("test-failure")); + + var ctx = createContext(); + assertThat(participantContextService.createParticipantContext(ctx)) + .isFailed() + .satisfies(f -> Assertions.assertThat(f.getReason()).isEqualTo(ServiceFailure.Reason.CONFLICT)); + verify(participantContextStore).create(any()); + verifyNoMoreInteractions(vault, participantContextStore); + + } + + @Test + void getParticipantContext() { + var ctx = createContext(); + when(participantContextStore.query(any())).thenReturn(StoreResult.success(Stream.of(ctx))); + + assertThat(participantContextService.getParticipantContext("test-id")) + .isSucceeded() + .usingRecursiveComparison() + .isEqualTo(ctx); + + verify(participantContextStore).query(any()); + verifyNoMoreInteractions(vault); + } + + @Test + void getParticipantContext_whenNotExists() { + when(participantContextStore.query(any())).thenReturn(StoreResult.success(Stream.of())); + assertThat(participantContextService.getParticipantContext("test-id")) + .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."); + }); + + verify(participantContextStore).query(any()); + verifyNoMoreInteractions(vault); + } + + + @Test + void getParticipantContext_whenStorageFails() { + when(participantContextStore.query(any())).thenReturn(StoreResult.notFound("foo bar")); + assertThat(participantContextService.getParticipantContext("test-id")) + .isFailed() + .satisfies(f -> { + Assertions.assertThat(f.getReason()).isEqualTo(ServiceFailure.Reason.NOT_FOUND); + Assertions.assertThat(f.getFailureDetail()).isEqualTo("foo bar"); + }); + + verify(participantContextStore).query(any()); + verifyNoMoreInteractions(vault); + } + + @Test + void deleteParticipantContext() { + when(participantContextStore.deleteById(anyString())).thenReturn(StoreResult.success()); + assertThat(participantContextService.deleteParticipantContext("test-id")).isSucceeded(); + + verify(participantContextStore).deleteById(anyString()); + verifyNoMoreInteractions(vault); + } + + @Test + void deleteParticipantContext_whenNotExists() { + when(participantContextStore.deleteById(any())).thenReturn(StoreResult.notFound("foo bar")); + assertThat(participantContextService.deleteParticipantContext("test-id")) + .isFailed() + .satisfies(f -> { + Assertions.assertThat(f.getReason()).isEqualTo(ServiceFailure.Reason.NOT_FOUND); + Assertions.assertThat(f.getFailureDetail()).isEqualTo("foo bar"); + }); + + verify(participantContextStore).deleteById(anyString()); + verifyNoMoreInteractions(vault); + } + + @Test + void regenerateApiToken() { + when(participantContextStore.query(any())).thenReturn(StoreResult.success(Stream.of(createContext()))); + when(vault.storeSecret(eq("test-alias"), anyString())).thenReturn(Result.success()); + + assertThat(participantContextService.regenerateApiToken("test-id")).isSucceeded().isNotNull(); + + verify(participantContextStore).query(any()); + verify(vault).storeSecret(eq("test-alias"), argThat(s -> s.length() >= 64)); + } + + @Test + void regenerateApiToken_vaultFails() { + when(participantContextStore.query(any())).thenReturn(StoreResult.success(Stream.of(createContext()))); + when(vault.storeSecret(eq("test-alias"), anyString())).thenReturn(Result.failure("test failure")); + + assertThat(participantContextService.regenerateApiToken("test-id")).isFailed().detail().isEqualTo("Could not store new API token: test failure."); + + verify(participantContextStore).query(any()); + verify(vault).storeSecret(eq("test-alias"), anyString()); + } + + @Test + void regenerateApiToken_whenNotFound() { + when(participantContextStore.query(any())).thenReturn(StoreResult.success(Stream.of())); + + assertThat(participantContextService.regenerateApiToken("test-id")).isFailed().detail().isEqualTo("No ParticipantContext with ID 'test-id' was found."); + + verify(participantContextStore).query(any()); + verifyNoMoreInteractions(participantContextStore, vault); + } + + private ParticipantContext createContext() { + return ParticipantContext.Builder.newInstance() + .participantId("test-id") + .state(ParticipantContextState.CREATED) + .apiTokenAlias("test-alias") + .build(); + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 5026d7230..0fb0a4724 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,6 +30,7 @@ include(":spi:identity-hub-did-spi") // core modules include(":core:identity-hub-api") include(":core:identity-hub-credentials") +include(":core:identity-hub-participants") include(":core:identity-hub-did") // extension modules diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/RandomStringGenerator.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/RandomStringGenerator.java new file mode 100644 index 000000000..ead4e8419 --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/RandomStringGenerator.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; + +/** + * Generates a random string, e.g. a UUID. Actual production implementations sould be more sophisticated, e.g. using seeds/salts and {@link java.security.SecureRandom} + */ +@FunctionalInterface +public interface RandomStringGenerator { + String generate(); +} diff --git a/spi/identity-hub-store-spi/src/main/java/org/eclipse/edc/identityhub/spi/store/ParticipantContextStore.java b/spi/identity-hub-store-spi/src/main/java/org/eclipse/edc/identityhub/spi/store/ParticipantContextStore.java index 0f83b9a01..d501e98d7 100644 --- a/spi/identity-hub-store-spi/src/main/java/org/eclipse/edc/identityhub/spi/store/ParticipantContextStore.java +++ b/spi/identity-hub-store-spi/src/main/java/org/eclipse/edc/identityhub/spi/store/ParticipantContextStore.java @@ -36,7 +36,7 @@ public interface ParticipantContextStore { * Queries the store for ParticipantContexts based on the given query specification. * * @param querySpec The {@link QuerySpec} indicating the criteria for the query. - * @return A {@link StoreResult} object containing a list of {@link ParticipantContext} objects that match the query. + * @return A {@link StoreResult} object containing a list of {@link ParticipantContext} objects that match the query. If none are found, returns an empty stream. */ StoreResult> query(QuerySpec querySpec);