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 bd1c9315a..f76ae733e 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 @@ -39,6 +39,8 @@ import java.time.Instant; import java.util.Collection; +import java.util.Collections; +import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -63,10 +65,23 @@ public ServiceResult addKeyPair(String participantId, KeyDescriptor keyDes return ServiceResult.badRequest(key.getFailureDetail()); } + // check if the new key is not active, and no other active key exists + if (!keyDescriptor.isActive()) { + var hasActiveKeys = keyPairResourceStore.query(QuerySpec.Builder.newInstance().filter(new Criterion("participantId", "=", participantId)).build()) + .orElse(failure -> Collections.emptySet()) + .stream().filter(kpr -> kpr.getState() == KeyPairState.ACTIVE.code()) + .findAny() + .isEmpty(); + + if (!hasActiveKeys) { + monitor.warning("Participant '%s' has no active key pairs, and adding an inactive one will prevent the participant from becoming operational."); + } + } + var newResource = KeyPairResource.Builder.newInstance() .id(keyDescriptor.getKeyId()) .keyId(keyDescriptor.getKeyId()) - .state(KeyPairState.CREATED) + .state(keyDescriptor.isActive() ? KeyPairState.ACTIVE : KeyPairState.CREATED) .isDefaultPair(makeDefault) .privateKeyAlias(keyDescriptor.getPrivateKeyAlias()) .serializedPublicKey(key.getContent()) @@ -132,6 +147,23 @@ public ServiceResult> query(QuerySpec querySpec) { return ServiceResult.from(keyPairResourceStore.query(querySpec)); } + @Override + public ServiceResult activate(String keyPairResourceId) { + var oldKey = findById(keyPairResourceId); + if (oldKey == null) { + return ServiceResult.notFound("A KeyPairResource with ID '%s' does not exist.".formatted(keyPairResourceId)); + } + + var allowedStates = List.of(KeyPairState.ACTIVE.code(), KeyPairState.CREATED.code()); + if (!allowedStates.contains(oldKey.getState())) { + return ServiceResult.badRequest("The key pair resource is expected to be in %s, but was %s".formatted(allowedStates, oldKey.getState())); + } + + oldKey.activate(); + + return ServiceResult.from(keyPairResourceStore.update(oldKey)); + } + @Override public void on(EventEnvelope eventEnvelope) { var payload = eventEnvelope.getPayload(); 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 8392c9279..2bb73898b 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 @@ -30,6 +30,7 @@ import org.junit.jupiter.params.provider.ValueSource; import java.time.Duration; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; @@ -41,6 +42,7 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -55,7 +57,7 @@ class KeyPairServiceImplTest { @ParameterizedTest(name = "make default: {0}") - @ValueSource(booleans = {true, false}) + @ValueSource(booleans = { true, false }) void addKeyPair_publicKeyGiven(boolean makeDefault) { when(keyPairResourceStore.create(any())).thenReturn(success()); @@ -69,7 +71,7 @@ void addKeyPair_publicKeyGiven(boolean makeDefault) { } @ParameterizedTest(name = "make default: {0}") - @ValueSource(booleans = {true, false}) + @ValueSource(booleans = { true, false }) void addKeyPair_shouldGenerate_storesInVault(boolean makeDefault) { when(keyPairResourceStore.create(any())).thenReturn(success()); @@ -81,7 +83,34 @@ void addKeyPair_shouldGenerate_storesInVault(boolean makeDefault) { assertThat(keyPairService.addKeyPair("some-participant", key, makeDefault)).isSucceeded(); verify(vault).storeSecret(eq(key.getPrivateKeyAlias()), anyString()); - verify(keyPairResourceStore).create(argThat(kpr -> kpr.isDefaultPair() == makeDefault && kpr.getParticipantId().equals("some-participant"))); + verify(keyPairResourceStore).create(argThat(kpr -> kpr.isDefaultPair() == makeDefault && + kpr.getParticipantId().equals("some-participant") && + kpr.getState() == KeyPairState.ACTIVE.code())); + verify(observableMock).invokeForEach(any()); + verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); + } + + @ParameterizedTest(name = "make active: {0}") + @ValueSource(booleans = { true, false }) + void addKeyPair_assertActiveState(boolean isActive) { + when(keyPairResourceStore.query(any())).thenReturn(success(Collections.emptySet())); + when(keyPairResourceStore.create(any())).thenReturn(success()); + + var key = createKey().publicKeyJwk(null).publicKeyPem(null) + .active(isActive) + .keyGeneratorParams(Map.of( + "algorithm", "EdDSA", + "curve", "Ed25519" + )).build(); + + assertThat(keyPairService.addKeyPair("some-participant", key, true)).isSucceeded(); + + verify(vault).storeSecret(eq(key.getPrivateKeyAlias()), anyString()); + //expect the query for other active keys at least once, if the new key is inactive + verify(keyPairResourceStore, isActive ? never() : times(1)).query(any()); + verify(keyPairResourceStore).create(argThat(kpr -> kpr.isDefaultPair() && + kpr.getParticipantId().equals("some-participant") && + kpr.getState() == (isActive ? KeyPairState.ACTIVE.code() : KeyPairState.CREATED.code()))); verify(observableMock).invokeForEach(any()); verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } @@ -268,6 +297,46 @@ void revokeKey_notfound() { verifyNoMoreInteractions(keyPairResourceStore, vault, observableMock); } + @ParameterizedTest(name = "Valid state = {0}") + // cannot use enum literals and the .code() method -> needs to be compile constant + @ValueSource(ints = { 100, 200 }) + void activate(int validState) { + var oldId = "old-id"; + var oldKey = createKeyPairResource().id(oldId).state(validState).build(); + + when(keyPairResourceStore.query(any())).thenReturn(success(List.of(oldKey))); + when(keyPairResourceStore.update(any())).thenReturn(success()); + + assertThat(keyPairService.activate(oldId)).isSucceeded(); + } + + @ParameterizedTest(name = "Valid state = {0}") + // cannot use enum literals and the .code() method -> needs to be compile constant + @ValueSource(ints = { 0, 30, 400, -10 }) + void activate_invalidState(int validState) { + var oldId = "old-id"; + var oldKey = createKeyPairResource().id(oldId).state(validState).build(); + + when(keyPairResourceStore.query(any())).thenReturn(success(List.of(oldKey))); + when(keyPairResourceStore.update(any())).thenReturn(success()); + + assertThat(keyPairService.activate(oldId)) + .isFailed() + .detail() + .isEqualTo("The key pair resource is expected to be in [200, 100], but was %s".formatted(validState)); + } + + @Test + void activate_notExists() { + + when(keyPairResourceStore.query(any())).thenReturn(success(List.of())); + + assertThat(keyPairService.activate("notexists")) + .isFailed() + .detail() + .isEqualTo("A KeyPairResource with ID 'notexists' does not exist."); + } + private KeyPairResource.Builder createKeyPairResource() { return KeyPairResource.Builder.newInstance() .id(UUID.randomUUID().toString()) 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 f325b016d..8c9993bfc 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 @@ -20,6 +20,7 @@ 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.KeyPairState; 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; @@ -413,6 +414,78 @@ void getAll_notAuthorized() { .statusCode(403); } + @Test + void activate() { + var superUserKey = createSuperUser(); + var user1 = "user1"; + var token = createParticipant(user1); + var keyId = createKeyPair(user1); + + assertThat(Arrays.asList(token, superUserKey)) + .allSatisfy(t -> { + RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() + .contentType(JSON) + .header(new Header("x-api-key", t)) + .post("/v1/participants/%s/keypairs/%s/activate".formatted(toBase64(user1), keyId)) + .then() + .log().ifValidationFails() + .statusCode(204) + .body(notNullValue()); + + assertThat(getDidForParticipant(user1)) + .hasSize(1) + .allSatisfy(dd -> assertThat(dd.getVerificationMethod()).noneMatch(vm -> vm.getId().equals(keyId))); + }); + } + + @Test + void activate_notAuthorized() { + var user1 = "user1"; + createParticipant(user1); + var keyId = createKeyPair(user1); + var attackerToken = createParticipant("attacker"); + + RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() + .contentType(JSON) + .header(new Header("x-api-key", attackerToken)) + .post("/v1/participants/%s/keypairs/%s/activate".formatted(toBase64(user1), keyId)) + .then() + .log().ifValidationFails() + .statusCode(403) + .body(notNullValue()); + + assertThat(getKeyPairsForParticipant(user1)) + .hasSize(2) + .allMatch(keyPairResource -> keyPairResource.getState() == KeyPairState.ACTIVE.code()); + } + + @Test + void activate_illegalState() { + var user1 = "user1"; + var token = createParticipant(user1); + var keyId = createKeyPair(user1); + + // first revoke the key, which puts it in the REVOKED state + RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() + .contentType(JSON) + .header(new Header("x-api-key", token)) + .post("/v1/participants/%s/keypairs/%s/revoke".formatted(toBase64(user1), keyId)) + .then() + .log().ifValidationFails() + .statusCode(204) + .body(notNullValue()); + + // now attempt to activate + RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() + .contentType(JSON) + .header(new Header("x-api-key", token)) + .post("/v1/participants/%s/keypairs/%s/activate".formatted(toBase64(user1), keyId)) + .then() + .log().ifValidationFails() + .statusCode(400) + .body(notNullValue()); + } + private String createKeyPair(String participantId) { var descriptor = createKeyDescriptor(participantId).build(); 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 ca9ba780d..f5dd1e4a7 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 @@ -32,6 +32,7 @@ import org.eclipse.edc.spi.query.Criterion; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.security.Vault; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.extension.RegisterExtension; import java.util.Collection; @@ -50,23 +51,26 @@ public abstract class ManagementApiEndToEndTest { @RegisterExtension protected static final EdcRuntimeExtension RUNTIME = new EdcRuntimeExtension(":launcher", "identity-hub", RUNTIME_CONFIGURATION.controlPlaneConfiguration()); - protected static ParticipantManifest createNewParticipant() { + protected static ParticipantManifest.Builder createNewParticipant() { var manifest = ParticipantManifest.Builder.newInstance() .participantId("another-participant") .active(false) .did("did:web:another:participant") .serviceEndpoint(new Service("test-service", "test-service-type", "https://test.com")) - .key(KeyDescriptor.Builder.newInstance() - .privateKeyAlias("another-alias") - .keyGeneratorParams(Map.of("algorithm", "EdDSA", "curve", "Ed25519")) - .keyId("another-keyid") - .build()) - .build(); + .key(createKeyDescriptor().build()); return manifest; } + @NotNull + public static KeyDescriptor.Builder createKeyDescriptor() { + return KeyDescriptor.Builder.newInstance() + .privateKeyAlias("another-alias") + .keyGeneratorParams(Map.of("algorithm", "EdDSA", "curve", "Ed25519")) + .keyId("another-keyid"); + } + protected String createSuperUser() { - return createParticipant("super-user", List.of(ServicePrincipal.ROLE_ADMIN)); + return createParticipant(SUPER_USER, List.of(ServicePrincipal.ROLE_ADMIN)); } protected String storeParticipant(ParticipantContext pc) { @@ -91,9 +95,9 @@ protected T getService(Class type) { return RUNTIME.getContext().getService(type); } - protected Collection getKeyPairsForParticipant(ParticipantManifest manifest) { + protected Collection getKeyPairsForParticipant(String participantId) { return getService(KeyPairResourceStore.class).query(QuerySpec.Builder.newInstance() - .filter(new Criterion("participantId", "=", manifest.getParticipantId())) + .filter(new Criterion("participantId", "=", participantId)) .build()).getContent(); } 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 464956ed5..67746ec62 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 @@ -19,6 +19,7 @@ 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.KeyPairState; 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; @@ -32,6 +33,7 @@ import java.util.Arrays; import java.util.Base64; import java.util.List; +import java.util.UUID; import java.util.stream.IntStream; import static io.restassured.http.ContentType.JSON; @@ -95,7 +97,7 @@ void createNewUser_principalIsSuperser() { getService(EventRouter.class).registerSync(ParticipantContextCreated.class, subscriber); var apikey = createSuperUser(); - var manifest = createNewParticipant(); + var manifest = createNewParticipant().build(); RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .header(new Header("x-api-key", apikey)) @@ -109,11 +111,44 @@ void createNewUser_principalIsSuperser() { verify(subscriber).on(argThat(env -> ((ParticipantContextCreated) env.getPayload()).getParticipantId().equals(manifest.getParticipantId()))); - assertThat(getKeyPairsForParticipant(manifest)).hasSize(1); + assertThat(getKeyPairsForParticipant(manifest.getParticipantId())).hasSize(1); assertThat(getDidForParticipant(manifest.getParticipantId())).hasSize(1) .allSatisfy(dd -> assertThat(dd.getVerificationMethod()).hasSize(1)); } + @ParameterizedTest(name = "Create participant with key pair active = {0}") + @ValueSource(booleans = { true, false }) + void createNewUser_verifyKeyPairActive(boolean isActive) { + var subscriber = mock(EventSubscriber.class); + getService(EventRouter.class).registerSync(ParticipantContextCreated.class, subscriber); + var apikey = createSuperUser(); + + var participantId = UUID.randomUUID().toString(); + var manifest = createNewParticipant() + .participantId(participantId) + .did("did:web:" + participantId) + .key(createKeyDescriptor().active(isActive).build()) + .build(); + + RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() + .header(new Header("x-api-key", apikey)) + .contentType(ContentType.JSON) + .body(manifest) + .post("/v1/participants/") + .then() + .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.getParticipantId())).hasSize(1) + .allSatisfy(kpr -> assertThat(kpr.getState()).isEqualTo(isActive ? KeyPairState.ACTIVE.code() : KeyPairState.CREATED.code())); + assertThat(getDidForParticipant(manifest.getParticipantId())).hasSize(1) + .allSatisfy(dd -> assertThat(dd.getVerificationMethod()).hasSize(1)); + + } + @Test void createNewUser_principalIsNotSuperuser_expect403() { @@ -127,7 +162,7 @@ void createNewUser_principalIsNotSuperuser_expect403() { .apiTokenAlias(principal + "-alias") .build(); var apiToken = storeParticipant(anotherUser); - var manifest = createNewParticipant(); + var manifest = createNewParticipant().build(); RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .header(new Header("x-api-key", apiToken)) @@ -140,7 +175,7 @@ void createNewUser_principalIsNotSuperuser_expect403() { .body(notNullValue()); verifyNoInteractions(subscriber); - assertThat(getKeyPairsForParticipant(manifest)).isEmpty(); + assertThat(getKeyPairsForParticipant(manifest.getParticipantId())).isEmpty(); } @Test @@ -149,7 +184,7 @@ void createNewUser_principalIsKnown_expect401() { getService(EventRouter.class).registerSync(ParticipantContextCreated.class, subscriber); var principal = "another-user"; - var manifest = createNewParticipant(); + var manifest = createNewParticipant().build(); RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .header(new Header("x-api-key", createTokenFor(principal))) @@ -161,7 +196,7 @@ void createNewUser_principalIsKnown_expect401() { .statusCode(401) .body(notNullValue()); verifyNoInteractions(subscriber); - assertThat(getKeyPairsForParticipant(manifest)).isEmpty(); + assertThat(getKeyPairsForParticipant(manifest.getParticipantId())).isEmpty(); } @Test diff --git a/extensions/api/keypair-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApi.java b/extensions/api/keypair-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApi.java index 9ae8feff5..77c4e8781 100644 --- a/extensions/api/keypair-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApi.java +++ b/extensions/api/keypair-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApi.java @@ -82,6 +82,22 @@ public interface KeyPairResourceApi { ) void addKeyPair(String participantId, KeyDescriptor keyDescriptor, boolean makeDefault, SecurityContext securityContext); + + @Tag(name = "KeyPairResources Management API") + @Operation(description = "Sets a KeyPairResource to the ACTIVE state. Will fail if the current state is anything other than ACTIVE or CREATED.", + responses = { + @ApiResponse(responseCode = "200", description = "The KeyPairResource.", + content = @Content(schema = @Schema(implementation = ParticipantContext.class))), + @ApiResponse(responseCode = "400", description = "Request body was malformed, or the request could not be processed.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "A KeyPairResource with the given ID does not exist.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiErrorDetail.class)), mediaType = "application/json")) + } + ) + void setActive(String keyPairResourceId, SecurityContext securityContext); + @Tag(name = "KeyPairResources Management API") @Operation(description = "Rotates (=retires) a particular key pair, identified by their ID and optionally create a new successor key.", requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = KeyDescriptor.class), mediaType = "application/json")), diff --git a/extensions/api/keypair-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApiController.java b/extensions/api/keypair-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApiController.java index 10606591a..7c0f595c3 100644 --- a/extensions/api/keypair-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApiController.java +++ b/extensions/api/keypair-mgmt-api/src/main/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApiController.java @@ -63,8 +63,7 @@ public KeyPairResourceApiController(AuthorizationService authorizationService, K @Path("/{keyPairId}") @Override public KeyPairResource findById(@PathParam("keyPairId") String id, @Context SecurityContext securityContext) { - authorizationService.isAuthorized(securityContext, id, KeyPairResource.class) - .orElseThrow(exceptionMapper(KeyPairResource.class, id)); + authorizationService.isAuthorized(securityContext, id, KeyPairResource.class).orElseThrow(exceptionMapper(KeyPairResource.class, id)); var query = QuerySpec.Builder.newInstance().filter(new Criterion("id", "=", id)).build(); var result = keyPairService.query(query).orElseThrow(exceptionMapper(KeyPairResource.class, id)); @@ -80,46 +79,49 @@ public KeyPairResource findById(@PathParam("keyPairId") String id, @Context Secu @GET @Override public Collection findForParticipant(@PathParam("participantId") String participantId, @Context SecurityContext securityContext) { - return onEncoded(participantId) - .map(decoded -> { - var query = QuerySpec.Builder.newInstance().filter(new Criterion("participantId", "=", decoded)).build(); - return keyPairService.query(query) - .orElseThrow(exceptionMapper(KeyPairResource.class, decoded)) - .stream().filter(kpr -> authorizationService.isAuthorized(securityContext, kpr.getId(), KeyPairResource.class).succeeded()) - .toList(); - }) - .orElseThrow(InvalidRequestException::new); + return onEncoded(participantId).map(decoded -> { + var query = QuerySpec.Builder.newInstance().filter(new Criterion("participantId", "=", decoded)).build(); + return keyPairService.query(query).orElseThrow(exceptionMapper(KeyPairResource.class, decoded)).stream().filter(kpr -> authorizationService.isAuthorized(securityContext, kpr.getId(), KeyPairResource.class).succeeded()).toList(); + }).orElseThrow(InvalidRequestException::new); } @PUT @Override - public void addKeyPair(@PathParam("participantId") String participantId, KeyDescriptor keyDescriptor, @QueryParam("makeDefault") boolean makeDefault, - @Context SecurityContext securityContext) { + public void addKeyPair(@PathParam("participantId") String participantId, KeyDescriptor keyDescriptor, @QueryParam("makeDefault") boolean makeDefault, @Context SecurityContext securityContext) { keyDescriptorValidator.validate(keyDescriptor).orElseThrow(ValidationFailureException::new); onEncoded(participantId) - .onSuccess(decoded -> authorizationService.isAuthorized(securityContext, decoded, ParticipantContext.class) - .compose(u -> keyPairService.addKeyPair(decoded, keyDescriptor, makeDefault)) - .orElseThrow(exceptionMapper(KeyPairResource.class))) + .onSuccess(decoded -> + authorizationService.isAuthorized(securityContext, decoded, ParticipantContext.class) + .compose(u -> keyPairService.addKeyPair(decoded, keyDescriptor, makeDefault)) + .orElseThrow(exceptionMapper(KeyPairResource.class))) .orElseThrow(InvalidRequestException::new); } + @POST + @Path("/{keyPairId}/activate") + @Override + public void setActive(@PathParam("keyPairId") String keyPairResourceId, @Context SecurityContext context) { + authorizationService.isAuthorized(context, keyPairResourceId, KeyPairResource.class).compose(u -> keyPairService.activate(keyPairResourceId)).orElseThrow(exceptionMapper(KeyPairResource.class, keyPairResourceId)); + + } + @POST @Path("/{keyPairId}/rotate") @Override public void rotateKeyPair(@PathParam("keyPairId") String id, @Nullable KeyDescriptor newKey, @QueryParam("duration") long duration, @Context SecurityContext securityContext) { - keyDescriptorValidator.validate(newKey).orElseThrow(ValidationFailureException::new); - authorizationService.isAuthorized(securityContext, id, KeyPairResource.class) - .compose(u -> keyPairService.rotateKeyPair(id, newKey, duration)) - .orElseThrow(exceptionMapper(KeyPairResource.class, id)); + if (newKey != null) { + keyDescriptorValidator.validate(newKey).orElseThrow(ValidationFailureException::new); + } + authorizationService.isAuthorized(securityContext, id, KeyPairResource.class).compose(u -> keyPairService.rotateKeyPair(id, newKey, duration)).orElseThrow(exceptionMapper(KeyPairResource.class, id)); } @POST @Path("/{keyPairId}/revoke") @Override public void revokeKey(@PathParam("keyPairId") String id, KeyDescriptor newKey, @Context SecurityContext securityContext) { - keyDescriptorValidator.validate(newKey).orElseThrow(ValidationFailureException::new); - authorizationService.isAuthorized(securityContext, id, KeyPairResource.class) - .compose(u -> keyPairService.revokeKey(id, newKey)) - .orElseThrow(exceptionMapper(KeyPairResource.class, id)); + if (newKey != null) { + keyDescriptorValidator.validate(newKey).orElseThrow(ValidationFailureException::new); + } + authorizationService.isAuthorized(securityContext, id, KeyPairResource.class).compose(u -> keyPairService.revokeKey(id, newKey)).orElseThrow(exceptionMapper(KeyPairResource.class, id)); } } diff --git a/extensions/api/keypair-mgmt-api/src/test/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApiControllerTest.java b/extensions/api/keypair-mgmt-api/src/test/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApiControllerTest.java index 759353fff..6911446df 100644 --- a/extensions/api/keypair-mgmt-api/src/test/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApiControllerTest.java +++ b/extensions/api/keypair-mgmt-api/src/test/java/org/eclipse/edc/identityhub/api/keypair/v1/KeyPairResourceApiControllerTest.java @@ -23,7 +23,6 @@ import org.eclipse.edc.identityhub.spi.model.participant.KeyDescriptor; import org.eclipse.edc.junit.annotations.ApiTest; import org.eclipse.edc.spi.result.ServiceResult; -import org.eclipse.edc.validator.spi.ValidationResult; import org.eclipse.edc.web.jersey.testfixtures.RestControllerTestBase; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; @@ -37,7 +36,6 @@ import java.util.Map; import static io.restassured.RestAssured.given; -import static java.util.Collections.emptyList; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -58,12 +56,12 @@ class KeyPairResourceApiControllerTest extends RestControllerTestBase { private final KeyPairService keyPairService = mock(); private final AuthorizationService authService = mock(); - private final KeyDescriptorValidator validator = mock(); @NotNull private static KeyDescriptor.Builder createKeyDescriptor() { return KeyDescriptor.Builder.newInstance() .keyId("new-key-id") + .privateKeyAlias("test-alias") .keyGeneratorParams(Map.of("algorithm", "EC", "curve", "secp256r1")); } @@ -161,11 +159,10 @@ void findForParticipant_notfound() { } @ParameterizedTest(name = "Make default: {0}") - @ValueSource(booleans = {true, false}) + @ValueSource(booleans = { true, false }) void addKeyPair(boolean makeDefault) { var descriptor = createKeyDescriptor() .build(); - when(validator.validate(any())).thenReturn(ValidationResult.success()); when(keyPairService.addKeyPair(eq(PARTICIPANT_ID), any(), eq(makeDefault))).thenReturn(ServiceResult.success()); baseRequest() @@ -183,8 +180,8 @@ void addKeyPair(boolean makeDefault) { @Test void addKeyPair_invalidInput() { var descriptor = createKeyDescriptor() + .privateKeyAlias(null) .build(); - when(validator.validate(any())).thenReturn(ValidationResult.failure(emptyList())); baseRequest() .contentType(ContentType.JSON) @@ -200,7 +197,6 @@ void addKeyPair_invalidInput() { @Test void rotate() { var duration = Duration.ofDays(100).toMillis(); - when(validator.validate(any())).thenReturn(ValidationResult.success()); when(keyPairService.rotateKeyPair(eq("old-id"), any(), eq(duration))).thenReturn(ServiceResult.success()); var descriptor = createKeyDescriptor().build(); @@ -219,9 +215,10 @@ void rotate() { @Test void rotate_invalidInput() { var duration = Duration.ofDays(100).toMillis(); - when(validator.validate(any())).thenReturn(ValidationResult.failure(emptyList())); - var descriptor = createKeyDescriptor().build(); + var descriptor = createKeyDescriptor() + .privateKeyAlias(null) + .build(); baseRequest() .contentType(ContentType.JSON) .body(descriptor) @@ -236,7 +233,6 @@ void rotate_invalidInput() { @Test void rotate_idNotFound() { var duration = Duration.ofDays(100).toMillis(); - when(validator.validate(any())).thenReturn(ValidationResult.success()); when(keyPairService.rotateKeyPair(eq("old-id"), any(), eq(duration))).thenReturn(ServiceResult.notFound("test-message")); var descriptor = createKeyDescriptor().build(); @@ -255,7 +251,6 @@ void rotate_idNotFound() { @Test void rotate_withoutSuccessor() { var duration = Duration.ofDays(100).toMillis(); - when(validator.validate(any())).thenReturn(ValidationResult.success()); when(keyPairService.rotateKeyPair(eq("old-id"), any(), eq(duration))).thenReturn(ServiceResult.success()); baseRequest() @@ -271,7 +266,6 @@ void rotate_withoutSuccessor() { @Test void revoke() { - when(validator.validate(any())).thenReturn(ValidationResult.success()); when(keyPairService.revokeKey(eq("old-id"), any())).thenReturn(ServiceResult.success()); var descriptor = createKeyDescriptor().build(); @@ -289,9 +283,10 @@ void revoke() { @Test void revoke_invalidInput() { - when(validator.validate(any())).thenReturn(ValidationResult.failure(emptyList())); - var descriptor = createKeyDescriptor().build(); + var descriptor = createKeyDescriptor() + .privateKeyAlias(null) + .build(); baseRequest() .contentType(ContentType.JSON) .body(descriptor) @@ -305,7 +300,6 @@ void revoke_invalidInput() { @Test void revoke_notFound() { - when(validator.validate(any())).thenReturn(ValidationResult.success()); when(keyPairService.revokeKey(eq("old-id"), any())).thenReturn(ServiceResult.notFound("test-message")); var descriptor = createKeyDescriptor().build(); @@ -321,9 +315,39 @@ void revoke_notFound() { verifyNoMoreInteractions(keyPairService); } + @Test + void activate() { + var id = "keypair-id"; + when(keyPairService.activate(eq(id))).thenReturn(ServiceResult.success()); + baseRequest() + .contentType(ContentType.JSON) + .post("/%s/activate".formatted(id)) + .then() + .log().ifError() + .statusCode(204); + + verify(keyPairService).activate(eq(id)); + verifyNoMoreInteractions(keyPairService); + } + + @Test + void actvate_whenNotAllowed() { + var id = "keypair-id"; + when(keyPairService.activate(eq(id))).thenReturn(ServiceResult.badRequest("foo-bar")); + baseRequest() + .contentType(ContentType.JSON) + .post("/%s/activate".formatted(id)) + .then() + .log().ifError() + .statusCode(400); + + verify(keyPairService).activate(eq(id)); + verifyNoMoreInteractions(keyPairService); + } + @Override protected Object controller() { - return new KeyPairResourceApiController(authService, keyPairService, validator); + return new KeyPairResourceApiController(authService, keyPairService, new KeyDescriptorValidator(mock())); } private KeyPairResource.Builder createKeyPair() { diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/KeyPairService.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/KeyPairService.java index 6f85c3796..9b2352ce6 100644 --- a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/KeyPairService.java +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/KeyPairService.java @@ -15,6 +15,7 @@ package org.eclipse.edc.identityhub.spi; 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.spi.query.QuerySpec; import org.eclipse.edc.spi.result.ServiceResult; @@ -67,4 +68,12 @@ public interface KeyPairService { ServiceResult revokeKey(String id, @Nullable KeyDescriptor newKeySpec); ServiceResult> query(QuerySpec querySpec); + + /** + * Sets a key pair to the {@link KeyPairState#ACTIVE} state. + * + * @param keyPairResourceId The ID of the {@link KeyPairResource} + * @return return a failure if the key pair resource is not in either {@link KeyPairState#CREATED} or {@link KeyPairState#ACTIVE} + */ + ServiceResult activate(String keyPairResourceId); } diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/model/KeyPairResource.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/model/KeyPairResource.java index b3d88e93a..53c6854c3 100644 --- a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/model/KeyPairResource.java +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/model/KeyPairResource.java @@ -119,6 +119,10 @@ public void revoke() { defaultPair = false; } + public void activate() { + state = KeyPairState.ACTIVE.code(); + } + public static final class Builder extends ParticipantResource.Builder { private Builder() { @@ -144,6 +148,13 @@ public Builder self() { return this; } + public KeyPairResource build() { + if (entity.useDuration == 0) { + entity.useDuration = Duration.ofDays(6 * 30).toMillis(); + } + return super.build(); + } + public Builder timestamp(long timestamp) { entity.timestamp = timestamp; return this; @@ -188,12 +199,5 @@ public Builder state(KeyPairState state) { entity.state = state.code(); return this; } - - public KeyPairResource build() { - if (entity.useDuration == 0) { - entity.useDuration = Duration.ofDays(6 * 30).toMillis(); - } - return super.build(); - } } } diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/model/participant/KeyDescriptor.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/model/participant/KeyDescriptor.java index 8a7a5d6b2..fe234f426 100644 --- a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/model/participant/KeyDescriptor.java +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/model/participant/KeyDescriptor.java @@ -39,6 +39,7 @@ public class KeyDescriptor { private Map publicKeyJwk; private String publicKeyPem; private Map keyGeneratorParams; + private boolean isActive = true; private KeyDescriptor() { } @@ -88,6 +89,15 @@ public Map getKeyGeneratorParams() { return keyGeneratorParams; } + /** + * Determines whether the new key should be set to {@link org.eclipse.edc.identityhub.spi.model.KeyPairState#ACTIVE}. + * If this is set to {@code false}, the key pair will be created in the {@link org.eclipse.edc.identityhub.spi.model.KeyPairState#CREATED} state. + * Defaults to {@code true}. + */ + public boolean isActive() { + return isActive; + } + @JsonPOJOBuilder(withPrefix = "") public static final class Builder { @@ -132,6 +142,11 @@ public Builder keyGeneratorParams(Map keyGeneratorParams) { return this; } + public Builder active(boolean isActive) { + keyDescriptor.isActive = isActive; + return this; + } + public KeyDescriptor build() { if (keyDescriptor.type == null) { keyDescriptor.type = DidConstants.JSON_WEB_KEY_2020;