diff --git a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/PresentationApiController.java b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/PresentationApiController.java index f8ac55747..efcdf392a 100644 --- a/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/PresentationApiController.java +++ b/core/identity-hub-api/src/main/java/org/eclipse/edc/identityhub/api/v1/PresentationApiController.java @@ -45,6 +45,7 @@ import static jakarta.ws.rs.core.HttpHeaders.AUTHORIZATION; import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static org.eclipse.edc.identityhub.spi.ParticipantContextId.onEncoded; import static org.eclipse.edc.identitytrust.model.credentialservice.PresentationQueryMessage.PRESENTATION_QUERY_MESSAGE_TYPE_PROPERTY; import static org.eclipse.edc.web.spi.exception.ServiceResultHandler.exceptionMapper; @@ -82,6 +83,7 @@ public Response queryPresentation(@PathParam("participantId") String participant } validatorRegistry.validate(PRESENTATION_QUERY_MESSAGE_TYPE_PROPERTY, query).orElseThrow(ValidationFailureException::new); + participantContextId = onEncoded(participantContextId).orElseThrow(InvalidRequestException::new); var presentationQuery = transformerRegistry.transform(query, PresentationQueryMessage.class).orElseThrow(InvalidRequestException::new); if (presentationQuery.getPresentationDefinition() != null) { diff --git a/docs/developer/architecture/mgmt-api.security.md b/docs/developer/architecture/mgmt-api.security.md index 49f6c79ed..a9926ac33 100644 --- a/docs/developer/architecture/mgmt-api.security.md +++ b/docs/developer/architecture/mgmt-api.security.md @@ -2,11 +2,12 @@ ## 1. Definition of terms -- _Service principal_: the identifier for the entity that owns a resource. In IdentityHub, this is the ID of - the `ParticipantContext`. Not that this is **not** a user! Also referred to as: principal +- _Service principal_ (also referred as _principal_): the identifier for the entity that owns a resource. In + IdentityHub, this is the ID of + the `ParticipantContext`. Note that this is **not** a user! - _User_: a physical entity that may be able to perform different operations on a resource belonging to a service principal. While a participant (context) would be analogous to a company or an organization, a user would be one - single individual within that company / participant. Invidual users don't exist as first-level concept in + single individual within that company / participant. **Individual users don't exist as first-level concept in IdentityHub!** - _Participant context_: this is the unit of management, that owns all resources. Its identifier must be equal to the `participantId` that is defined 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 01e8fff45..255bff06b 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 @@ -29,6 +29,7 @@ import org.junit.jupiter.api.Test; import java.util.Arrays; +import java.util.Base64; import java.util.Map; import java.util.UUID; import java.util.stream.IntStream; @@ -59,7 +60,6 @@ void findById_notAuthorized() { var user1 = "user1"; createParticipant(user1); - // create second user var user2 = "user2"; var user2Context = ParticipantContext.Builder.newInstance() @@ -75,7 +75,7 @@ void findById_notAuthorized() { RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .contentType(JSON) .header(new Header("x-api-key", user2Token)) - .get("/v1/participants/%s/keypairs/%s".formatted(user1, key)) + .get("/v1/participants/%s/keypairs/%s".formatted(toBase64(user1), key)) .then() .log().ifValidationFails() .statusCode(403) @@ -87,14 +87,13 @@ void findById() { var user1 = "user1"; var token = createParticipant(user1); - var key = createKeyPair(user1); assertThat(Arrays.asList(token, getSuperUserApiKey())) .allSatisfy(t -> RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .contentType(JSON) .header(new Header("x-api-key", t)) - .get("/v1/participants/%s/keypairs/%s".formatted(user1, key)) + .get("/v1/participants/%s/keypairs/%s".formatted(toBase64(user1), key)) .then() .log().ifValidationFails() .statusCode(200) @@ -106,7 +105,6 @@ void findForParticipant_notAuthorized() { var user1 = "user1"; createParticipant(user1); - // create second user var user2 = "user2"; var user2Context = ParticipantContext.Builder.newInstance() @@ -116,13 +114,13 @@ void findForParticipant_notAuthorized() { .build(); var user2Token = storeParticipant(user2Context); - var key = createKeyPair(user1); + createKeyPair(user1); // attempt to publish user1's DID document, which should fail var res = RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .contentType(JSON) .header(new Header("x-api-key", user2Token)) - .get("/v1/participants/%s/keypairs".formatted(user1)) + .get("/v1/participants/%s/keypairs".formatted(toBase64(user1))) .then() .log().ifValidationFails() .statusCode(200) @@ -136,15 +134,13 @@ void findForParticipant_notAuthorized() { void findForParticipant() { var user1 = "user1"; var token = createParticipant(user1); - - - var key = createKeyPair(user1); + createKeyPair(user1); assertThat(Arrays.asList(token, getSuperUserApiKey())) .allSatisfy(t -> RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .contentType(JSON) .header(new Header("x-api-key", t)) - .get("/v1/participants/%s/keypairs".formatted(user1)) + .get("/v1/participants/%s/keypairs".formatted(toBase64(user1))) .then() .log().ifValidationFails() .statusCode(200) @@ -167,7 +163,7 @@ void addKeyPair() { .contentType(JSON) .header(new Header("x-api-key", t)) .body(keyDesc) - .put("/v1/participants/%s/keypairs?participantId=%s".formatted(user1, user1)) + .put("/v1/participants/%s/keypairs".formatted(toBase64(user1))) .then() .log().ifValidationFails() .statusCode(204) @@ -198,7 +194,7 @@ void addKeyPair_notAuthorized() { .contentType(JSON) .header(new Header("x-api-key", token2)) .body(keyDesc) - .put("/v1/participants/%s/keypairs?participantId=%s".formatted(user1, user1)) + .put("/v1/participants/%s/keypairs".formatted(toBase64(user1))) .then() .log().ifValidationFails() .statusCode(403) @@ -232,7 +228,7 @@ void rotate() { .contentType(JSON) .header(new Header("x-api-key", t)) .body(keyDesc) - .post("/v1/participants/%s/keypairs/%s/rotate".formatted(user1, keyId)) + .post("/v1/participants/%s/keypairs/%s/rotate".formatted(toBase64(user1), keyId)) .then() .log().ifValidationFails() .statusCode(204) @@ -303,7 +299,7 @@ void revoke() { .contentType(JSON) .header(new Header("x-api-key", t)) .body(keyDesc) - .post("/v1/participants/%s/keypairs/%s/revoke".formatted(user1, keyId)) + .post("/v1/participants/%s/keypairs/%s/revoke".formatted(toBase64(user1), keyId)) .then() .log().ifValidationFails() .statusCode(204) @@ -317,7 +313,7 @@ void revoke() { @Test void revoke_notAuthorized() { var user1 = "user1"; - var token = createParticipant(user1); + var token1 = createParticipant(user1); var user2 = "user2"; var token2 = createParticipant(user2); @@ -330,7 +326,7 @@ void revoke_notAuthorized() { .contentType(JSON) .header(new Header("x-api-key", token2)) .body(keyDesc) - .post("/v1/participants/%s/keypairs/%s/revoke".formatted(user1, keyId)) + .post("/v1/participants/%s/keypairs/%s/revoke".formatted(toBase64(user1), keyId)) .then() .log().ifValidationFails() .statusCode(403) @@ -419,4 +415,8 @@ private String createKeyPair(String participantId) { return descriptor.getKeyId(); } + private String toBase64(String s) { + return Base64.getUrlEncoder().encodeToString(s.getBytes()); + } + } 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 a99d6059e..9a2d010f2 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 @@ -30,6 +30,7 @@ import org.junit.jupiter.params.provider.ValueSource; import java.util.Arrays; +import java.util.Base64; import java.util.List; import java.util.stream.IntStream; @@ -53,7 +54,7 @@ void getUserById() { var su = RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .header(new Header("x-api-key", apikey)) - .get("/v1/participants/" + SUPER_USER) + .get("/v1/participants/" + toBase64(SUPER_USER)) .then() .statusCode(200) .extract().body().as(ParticipantContext.class); @@ -82,7 +83,7 @@ void getUserById_notOwner_expect403() { RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .header(new Header("x-api-key", apiToken1)) .contentType(ContentType.JSON) - .get("/v1/participants/" + user2) + .get("/v1/participants/" + toBase64(user2)) .then() .log().ifValidationFails() .statusCode(403); @@ -180,7 +181,7 @@ void activateParticipant_principalIsSuperser() { RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .header(new Header("x-api-key", getSuperUserApiKey())) .contentType(ContentType.JSON) - .post("/v1/participants/%s/state?isActive=true".formatted(participantId)) + .post("/v1/participants/%s/state?isActive=true".formatted(toBase64(participantId))) .then() .log().ifError() .statusCode(204); @@ -205,7 +206,7 @@ void deleteParticipant() { RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .header(new Header("x-api-key", getSuperUserApiKey())) .contentType(ContentType.JSON) - .delete("/v1/participants/%s".formatted(participantId)) + .delete("/v1/participants/%s".formatted(toBase64(participantId))) .then() .log().ifError() .statusCode(204); @@ -215,7 +216,6 @@ void deleteParticipant() { @Test void regenerateToken() { - var participantId = "another-user"; var userToken = createParticipant(participantId); @@ -223,7 +223,7 @@ void regenerateToken() { .allSatisfy(t -> RUNTIME_CONFIGURATION.getManagementEndpoint().baseRequest() .header(new Header("x-api-key", t)) .contentType(ContentType.JSON) - .post("/v1/participants/%s/token".formatted(participantId)) + .post("/v1/participants/%s/token".formatted(toBase64(participantId))) .then() .log().ifError() .statusCode(200) @@ -239,7 +239,7 @@ void updateRoles() { .header(new Header("x-api-key", getSuperUserApiKey())) .contentType(ContentType.JSON) .body(List.of("role1", "role2", "admin")) - .put("/v1/participants/%s/roles".formatted(participantId)) + .put("/v1/participants/%s/roles".formatted(toBase64(participantId))) .then() .log().ifError() .statusCode(204); @@ -257,7 +257,7 @@ void updateRoles_whenNotSuperuser(String role) { .header(new Header("x-api-key", userToken)) .contentType(ContentType.JSON) .body(List.of(role)) - .put("/v1/participants/%s/roles".formatted(participantId)) + .put("/v1/participants/%s/roles".formatted(toBase64(participantId))) .then() .log().ifError() .statusCode(403); @@ -334,4 +334,8 @@ void getAll_notAuthorized() { .log().ifValidationFails() .statusCode(403); } + + private String toBase64(String s) { + return Base64.getUrlEncoder().encodeToString(s.getBytes()); + } } diff --git a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/PresentationApiComponentTest.java b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/PresentationApiComponentTest.java index a866f9478..2a8d6a688 100644 --- a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/PresentationApiComponentTest.java +++ b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/PresentationApiComponentTest.java @@ -37,6 +37,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import org.mockito.ArgumentMatchers; +import java.util.Base64; import java.util.List; import java.util.Map; import java.util.stream.Stream; @@ -57,7 +58,8 @@ @ComponentTest public class PresentationApiComponentTest { - public static final String VALID_QUERY_WITH_SCOPE = """ + + private static final String VALID_QUERY_WITH_SCOPE = """ { "@context": [ "https://identity.foundation/presentation-exchange/submission/v1", @@ -74,6 +76,7 @@ public class PresentationApiComponentTest { .id("identity-hub") .build(); private static final String TEST_PARTICIPANT_CONTEXT_ID = "test-participant"; + private static final String TEST_PARTICIPANT_CONTEXT_ID_ENCODED = Base64.getUrlEncoder().encodeToString(TEST_PARTICIPANT_CONTEXT_ID.getBytes()); // todo: these mocks should be replaced, once their respective implementations exist! private static final CredentialQueryResolver CREDENTIAL_QUERY_RESOLVER = mock(); private static final VerifiablePresentationService PRESENTATION_GENERATOR = mock(); @@ -97,7 +100,7 @@ void query_tokenNotPresent_shouldReturn401() { createParticipant(TEST_PARTICIPANT_CONTEXT_ID); IDENTITY_HUB_PARTICIPANT.getResolutionEndpoint().baseRequest() .contentType("application/json") - .post("/v1/participants/test-participant/presentation/query") + .post("/v1/participants/%s/presentation/query".formatted(TEST_PARTICIPANT_CONTEXT_ID_ENCODED)) .then() .statusCode(401) .extract().body().asString(); @@ -119,7 +122,7 @@ void query_validationError_shouldReturn400() { .contentType(JSON) .header(AUTHORIZATION, generateSiToken()) .body(query) - .post("/v1/participants/test-participant/presentation/query") + .post("/v1/participants/%s/presentation/query".formatted(TEST_PARTICIPANT_CONTEXT_ID_ENCODED)) .then() .statusCode(400) .extract().body().asString(); @@ -144,7 +147,7 @@ void query_withPresentationDefinition_shouldReturn503() { .contentType(JSON) .header(AUTHORIZATION, generateSiToken()) .body(query) - .post("/v1/participants/test-participant/presentation/query") + .post("/v1/participants/%s/presentation/query".formatted(TEST_PARTICIPANT_CONTEXT_ID_ENCODED)) .then() .statusCode(503) .extract().body().asString(); @@ -160,7 +163,7 @@ void query_tokenVerificationFails_shouldReturn401() { .contentType(JSON) .header(AUTHORIZATION, token) .body(VALID_QUERY_WITH_SCOPE) - .post("/v1/participants/test-participant/presentation/query") + .post("/v1/participants/%s/presentation/query".formatted(TEST_PARTICIPANT_CONTEXT_ID_ENCODED)) .then() .statusCode(401) .log().ifValidationFails() @@ -179,7 +182,7 @@ void query_queryResolutionFails_shouldReturn403() { .contentType(JSON) .header(AUTHORIZATION, token) .body(VALID_QUERY_WITH_SCOPE) - .post("/v1/participants/test-participant/presentation/query") + .post("/v1/participants/%s/presentation/query".formatted(TEST_PARTICIPANT_CONTEXT_ID_ENCODED)) .then() .statusCode(403) .log().ifValidationFails() @@ -199,7 +202,7 @@ void query_presentationGenerationFails_shouldReturn500() { .contentType(JSON) .header(AUTHORIZATION, token) .body(VALID_QUERY_WITH_SCOPE) - .post("/v1/participants/test-participant/presentation/query") + .post("/v1/participants/%s/presentation/query".formatted(TEST_PARTICIPANT_CONTEXT_ID_ENCODED)) .then() .statusCode(500) .log().ifValidationFails(); @@ -220,7 +223,7 @@ void query_success() throws JOSEException { .contentType(JSON) .header(AUTHORIZATION, token) .body(VALID_QUERY_WITH_SCOPE) - .post("/v1/participants/test-participant/presentation/query") + .post("/v1/participants/%s/presentation/query".formatted(TEST_PARTICIPANT_CONTEXT_ID_ENCODED)) .then() .statusCode(200) .log().ifValidationFails() 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 8c47bfb2b..10606591a 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 @@ -33,6 +33,7 @@ import org.eclipse.edc.spi.EdcException; import org.eclipse.edc.spi.query.Criterion; import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.web.spi.exception.InvalidRequestException; import org.eclipse.edc.web.spi.exception.ObjectNotFoundException; import org.eclipse.edc.web.spi.exception.ValidationFailureException; import org.jetbrains.annotations.Nullable; @@ -41,6 +42,7 @@ import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; import static org.eclipse.edc.identityhub.spi.AuthorizationResultHandler.exceptionMapper; +import static org.eclipse.edc.identityhub.spi.ParticipantContextId.onEncoded; @Consumes(APPLICATION_JSON) @Produces(APPLICATION_JSON) @@ -78,11 +80,15 @@ public KeyPairResource findById(@PathParam("keyPairId") String id, @Context Secu @GET @Override public Collection findForParticipant(@PathParam("participantId") String participantId, @Context SecurityContext securityContext) { - var query = QuerySpec.Builder.newInstance().filter(new Criterion("participantId", "=", participantId)).build(); - return keyPairService.query(query) - .orElseThrow(exceptionMapper(KeyPairResource.class, participantId)) - .stream().filter(kpr -> authorizationService.isAuthorized(securityContext, kpr.getId(), KeyPairResource.class).succeeded()) - .toList(); + 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 @@ -90,9 +96,11 @@ public Collection findForParticipant(@PathParam("participantId" public void addKeyPair(@PathParam("participantId") String participantId, KeyDescriptor keyDescriptor, @QueryParam("makeDefault") boolean makeDefault, @Context SecurityContext securityContext) { keyDescriptorValidator.validate(keyDescriptor).orElseThrow(ValidationFailureException::new); - authorizationService.isAuthorized(securityContext, participantId, ParticipantContext.class) - .compose(u -> keyPairService.addKeyPair(participantId, keyDescriptor, makeDefault)) - .orElseThrow(exceptionMapper(KeyPairResource.class)); + onEncoded(participantId) + .onSuccess(decoded -> authorizationService.isAuthorized(securityContext, decoded, ParticipantContext.class) + .compose(u -> keyPairService.addKeyPair(decoded, keyDescriptor, makeDefault)) + .orElseThrow(exceptionMapper(KeyPairResource.class))) + .orElseThrow(InvalidRequestException::new); } @POST 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 096e37626..759353fff 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 @@ -21,6 +21,7 @@ import org.eclipse.edc.identityhub.spi.KeyPairService; import org.eclipse.edc.identityhub.spi.model.KeyPairResource; 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; @@ -31,6 +32,7 @@ import org.junit.jupiter.params.provider.ValueSource; import java.time.Duration; +import java.util.Base64; import java.util.List; import java.util.Map; @@ -48,8 +50,12 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +@ApiTest class KeyPairResourceApiControllerTest extends RestControllerTestBase { + private static final String PARTICIPANT_ID = "test-participant"; + private static final String PARTICIPANT_ID_ENCODED = Base64.getUrlEncoder().encodeToString(PARTICIPANT_ID.getBytes()); + private final KeyPairService keyPairService = mock(); private final AuthorizationService authService = mock(); private final KeyDescriptorValidator validator = mock(); @@ -85,7 +91,7 @@ void findById() { void findById_notExist() { when(keyPairService.query(any())).thenReturn(ServiceResult.notFound("tst-msg")); - var found = baseRequest() + baseRequest() .get("/test-keypairId") .then() .log().ifValidationFails() @@ -110,7 +116,7 @@ void findForParticipant() { var criterion = q.getFilterExpression().get(0); return criterion.getOperandLeft().equals("participantId") && criterion.getOperator().equals("=") && - criterion.getOperandRight().equals("test-participant"); + criterion.getOperandRight().equals(PARTICIPANT_ID); })); } @@ -132,7 +138,7 @@ void findForParticipant_noResult() { var criterion = q.getFilterExpression().get(0); return criterion.getOperandLeft().equals("participantId") && criterion.getOperator().equals("=") && - criterion.getOperandRight().equals("test-participant"); + criterion.getOperandRight().equals(PARTICIPANT_ID); })); } @@ -150,7 +156,7 @@ void findForParticipant_notfound() { var criterion = q.getFilterExpression().get(0); return criterion.getOperandLeft().equals("participantId") && criterion.getOperator().equals("=") && - criterion.getOperandRight().equals("test-participant"); + criterion.getOperandRight().equals(PARTICIPANT_ID); })); } @@ -160,17 +166,17 @@ void addKeyPair(boolean makeDefault) { var descriptor = createKeyDescriptor() .build(); when(validator.validate(any())).thenReturn(ValidationResult.success()); - when(keyPairService.addKeyPair(eq("test-participant"), any(), eq(makeDefault))).thenReturn(ServiceResult.success()); + when(keyPairService.addKeyPair(eq(PARTICIPANT_ID), any(), eq(makeDefault))).thenReturn(ServiceResult.success()); baseRequest() .contentType(ContentType.JSON) .body(descriptor) - .put("?participantId=%s&makeDefault=%s".formatted("test-participant", makeDefault)) + .put("?makeDefault=%s".formatted(makeDefault)) .then() .log().ifError() .statusCode(204); - verify(keyPairService).addKeyPair(eq("test-participant"), argThat(d -> d.getKeyId().equals(descriptor.getKeyId())), eq(makeDefault)); + verify(keyPairService).addKeyPair(eq(PARTICIPANT_ID), argThat(d -> d.getKeyId().equals(descriptor.getKeyId())), eq(makeDefault)); verifyNoMoreInteractions(keyPairService); } @@ -183,7 +189,7 @@ void addKeyPair_invalidInput() { baseRequest() .contentType(ContentType.JSON) .body(descriptor) - .put("?participantId=%s&makeDefault=%s".formatted("test-participant", true)) + .put("?makeDefault=%s".formatted(true)) .then() .log().ifError() .statusCode(400); @@ -323,7 +329,7 @@ protected Object controller() { private KeyPairResource.Builder createKeyPair() { return KeyPairResource.Builder.newInstance() .id("test-keypair") - .participantId("test-participant") + .participantId(PARTICIPANT_ID) .isDefaultPair(true) .privateKeyAlias("test-alias") .useDuration(Duration.ofDays(365).toMillis()); @@ -332,7 +338,7 @@ private KeyPairResource.Builder createKeyPair() { private RequestSpecification baseRequest() { return given() .contentType("application/json") - .baseUri("http://localhost:" + port + "/v1/participants/test-participant/keypairs") + .baseUri("http://localhost:" + port + "/v1/participants/%s/keypairs".formatted(PARTICIPANT_ID_ENCODED)) .when(); } } \ No newline at end of file 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 dff1395fb..43e531e21 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 @@ -34,6 +34,7 @@ import org.eclipse.edc.identityhub.spi.model.participant.ParticipantContext; import org.eclipse.edc.identityhub.spi.model.participant.ParticipantManifest; import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.web.spi.exception.InvalidRequestException; import org.eclipse.edc.web.spi.exception.ValidationFailureException; import java.util.Collection; @@ -41,6 +42,7 @@ import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; import static org.eclipse.edc.identityhub.spi.AuthorizationResultHandler.exceptionMapper; +import static org.eclipse.edc.identityhub.spi.ParticipantContextId.onEncoded; @Consumes(APPLICATION_JSON) @Produces(APPLICATION_JSON) @@ -70,18 +72,22 @@ public String createParticipant(ParticipantManifest manifest) { @GET @Path("/{participantId}") public ParticipantContext getParticipant(@PathParam("participantId") String participantId, @Context SecurityContext securityContext) { - return authorizationService.isAuthorized(securityContext, participantId, ParticipantContext.class) - .compose(u -> participantContextService.getParticipantContext(participantId)) - .orElseThrow(exceptionMapper(ParticipantContext.class, participantId)); + return onEncoded(participantId) + .map(decoded -> authorizationService.isAuthorized(securityContext, decoded, ParticipantContext.class) + .compose(u -> participantContextService.getParticipantContext(decoded)) + .orElseThrow(exceptionMapper(ParticipantContext.class, decoded))) + .orElseThrow(InvalidRequestException::new); } @Override @POST @Path("/{participantId}/token") public String regenerateToken(@PathParam("participantId") String participantId, @Context SecurityContext securityContext) { - return authorizationService.isAuthorized(securityContext, participantId, ParticipantContext.class) - .compose(u -> participantContextService.regenerateApiToken(participantId)) - .orElseThrow(exceptionMapper(ParticipantContext.class, participantId)); + return onEncoded(participantId) + .map(decoded -> authorizationService.isAuthorized(securityContext, decoded, ParticipantContext.class) + .compose(u -> participantContextService.regenerateApiToken(decoded)) + .orElseThrow(exceptionMapper(ParticipantContext.class, decoded))) + .orElseThrow(InvalidRequestException::new); } @Override @@ -89,12 +95,10 @@ public String regenerateToken(@PathParam("participantId") String participantId, @Path("/{participantId}/state") @RolesAllowed(ServicePrincipal.ROLE_ADMIN) public void activateParticipant(@PathParam("participantId") String participantId, @QueryParam("isActive") boolean isActive) { - if (isActive) { - participantContextService.updateParticipant(participantId, ParticipantContext::activate); - } else { - participantContextService.updateParticipant(participantId, ParticipantContext::deactivate); - } - + onEncoded(participantId) + .onSuccess(decoded -> participantContextService.updateParticipant(decoded, isActive ? ParticipantContext::activate : ParticipantContext::deactivate) + .orElseThrow(exceptionMapper(ParticipantContext.class, decoded))) + .orElseThrow(InvalidRequestException::new); } @Override @@ -102,8 +106,10 @@ public void activateParticipant(@PathParam("participantId") String participantId @Path("/{participantId}") @RolesAllowed(ServicePrincipal.ROLE_ADMIN) public void deleteParticipant(@PathParam("participantId") String participantId, @Context SecurityContext securityContext) { - participantContextService.deleteParticipantContext(participantId) - .orElseThrow(exceptionMapper(ParticipantContext.class, participantId)); + onEncoded(participantId) + .onSuccess(decoded -> participantContextService.deleteParticipantContext(decoded) + .orElseThrow(exceptionMapper(ParticipantContext.class, decoded))) + .orElseThrow(InvalidRequestException::new); } @Override @@ -111,7 +117,10 @@ public void deleteParticipant(@PathParam("participantId") String participantId, @Path("/{participantId}/roles") @RolesAllowed(ServicePrincipal.ROLE_ADMIN) public void updateRoles(@PathParam("participantId") String participantId, List roles) { - participantContextService.updateParticipant(participantId, participantContext -> participantContext.setRoles(roles)).orElseThrow(exceptionMapper(ParticipantContext.class, participantId)); + onEncoded(participantId) + .onSuccess(decoded -> participantContextService.updateParticipant(decoded, participantContext -> participantContext.setRoles(roles)) + .orElseThrow(exceptionMapper(ParticipantContext.class, decoded))) + .orElseThrow(InvalidRequestException::new); } @GET diff --git a/extensions/api/participant-context-mgmt-api/src/test/java/org/eclipse/edc/identityhub/api/participantcontext/v1/ParticipantContextApiControllerTest.java b/extensions/api/participant-context-mgmt-api/src/test/java/org/eclipse/edc/identityhub/api/participantcontext/v1/ParticipantContextApiControllerTest.java index 87963514c..10ea04ee1 100644 --- a/extensions/api/participant-context-mgmt-api/src/test/java/org/eclipse/edc/identityhub/api/participantcontext/v1/ParticipantContextApiControllerTest.java +++ b/extensions/api/participant-context-mgmt-api/src/test/java/org/eclipse/edc/identityhub/api/participantcontext/v1/ParticipantContextApiControllerTest.java @@ -35,6 +35,7 @@ import org.junit.jupiter.api.Test; import java.time.Instant; +import java.util.Base64; import java.util.List; import java.util.Map; import java.util.stream.IntStream; @@ -55,6 +56,9 @@ @ApiTest class ParticipantContextApiControllerTest extends RestControllerTestBase { + private static final String PARTICIPANT_ID = "test-participant"; + private static final String PARTICIPANT_ID_ENCODED = Base64.getUrlEncoder().encodeToString(PARTICIPANT_ID.getBytes()); + private final ParticipantContextService participantContextServiceMock = mock(); private final AuthorizationService authService = mock(); private final ParticipantManifestValidator participantManifestValidator = mock(); @@ -85,7 +89,7 @@ void getById_whenNotFound() { when(participantContextServiceMock.getParticipantContext(any())).thenReturn(ServiceResult.notFound("foo bar")); baseRequest() - .get("/not-exist") + .get("/unknown") .then() .statusCode(404) .log().ifError(); @@ -160,61 +164,61 @@ void createParticipant_alreadyExists() { void regenerateToken() { when(participantContextServiceMock.regenerateApiToken(any())).thenReturn(ServiceResult.success("new-api-token")); baseRequest() - .post("/test-participant/token") + .post("/%s/token".formatted(PARTICIPANT_ID_ENCODED)) .then() .statusCode(200) .body(equalTo("new-api-token")); - verify(participantContextServiceMock).regenerateApiToken(eq("test-participant")); + verify(participantContextServiceMock).regenerateApiToken(PARTICIPANT_ID); } @Test void regenerateToken_notFound() { when(participantContextServiceMock.regenerateApiToken(any())).thenReturn(ServiceResult.notFound("foo-bar")); baseRequest() - .post("/test-participant/token") + .post("/%s/token".formatted(PARTICIPANT_ID_ENCODED)) .then() .statusCode(404); - verify(participantContextServiceMock).regenerateApiToken(eq("test-participant")); + verify(participantContextServiceMock).regenerateApiToken(PARTICIPANT_ID); } @Test void activateParticipant() { when(participantContextServiceMock.updateParticipant(any(), any())).thenReturn(ServiceResult.success()); baseRequest() - .post("/test-participant/state?isActive=true") + .post("/%s/state?isActive=true".formatted(PARTICIPANT_ID_ENCODED)) .then() .statusCode(204); - verify(participantContextServiceMock).updateParticipant(eq("test-participant"), any()); + verify(participantContextServiceMock).updateParticipant(eq(PARTICIPANT_ID), any()); } @Test void deactivateParticipant() { when(participantContextServiceMock.updateParticipant(any(), any())).thenReturn(ServiceResult.success()); baseRequest() - .post("/test-participant/state?isActive=false") + .post("/%s/state?isActive=false".formatted(PARTICIPANT_ID_ENCODED)) .then() .statusCode(204); - verify(participantContextServiceMock).updateParticipant(eq("test-participant"), any()); + verify(participantContextServiceMock).updateParticipant(eq(PARTICIPANT_ID), any()); } @Test void delete() { when(participantContextServiceMock.deleteParticipantContext(any())).thenReturn(ServiceResult.success()); baseRequest() - .delete("/test-participant") + .delete("/%s".formatted(PARTICIPANT_ID_ENCODED)) .then() .statusCode(204); - verify(participantContextServiceMock).deleteParticipantContext(eq("test-participant")); + verify(participantContextServiceMock).deleteParticipantContext(PARTICIPANT_ID); } @Test void delete_notFound() { when(participantContextServiceMock.deleteParticipantContext(any())).thenReturn(ServiceResult.notFound("foo bar")); baseRequest() - .delete("/test-participant") + .delete("/%s".formatted(PARTICIPANT_ID_ENCODED)) .then() .statusCode(404); - verify(participantContextServiceMock).deleteParticipantContext(eq("test-participant")); + verify(participantContextServiceMock).deleteParticipantContext(PARTICIPANT_ID); } @Test diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/ParticipantContextId.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/ParticipantContextId.java new file mode 100644 index 000000000..666c8557c --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/ParticipantContextId.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 Amadeus. + * + * 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: + * Amadeus - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.spi; + +import org.eclipse.edc.spi.result.Result; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class ParticipantContextId { + + private ParticipantContextId() { + } + + /** + * Decode a base64-url encoded participantId. + * + * @param encoded base64-url encoded participantId. + * @return human-readable participantId. + */ + public static Result onEncoded(String encoded) { + var bytes = Base64.getUrlDecoder().decode(encoded.getBytes()); + return Result.success(new String(bytes, StandardCharsets.UTF_8)); + } +}