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);