diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVault.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVault.java index baba64ea978..ef1f4967a23 100644 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVault.java +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVault.java @@ -15,49 +15,154 @@ package org.eclipse.edc.vault.hashicorp; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.RequestBody; +import org.eclipse.edc.http.spi.EdcHttpClient; +import org.eclipse.edc.spi.EdcException; import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.spi.security.Vault; -import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultClient; +import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings; +import org.eclipse.edc.vault.hashicorp.util.PathUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.eclipse.edc.vault.hashicorp.VaultConstants.VAULT_SECRET_METADATA_PATH; +import static org.eclipse.edc.vault.hashicorp.VaultConstants.VAULT_TOKEN_HEADER; + /** * Implements a vault backed by Hashicorp Vault. */ public class HashicorpVault implements Vault { + private static final String VAULT_SECRET_DATA_PATH = "data"; + private static final String VAULT_DATA_ENTRY_NAME = "content"; - @NotNull - private final HashicorpVaultClient hashicorpVaultClient; - @NotNull private final Monitor monitor; - public HashicorpVault(@NotNull HashicorpVaultClient hashicorpVaultClient, @NotNull Monitor monitor) { - this.hashicorpVaultClient = hashicorpVaultClient; + private final HashicorpVaultSettings settings; + private final EdcHttpClient httpClient; + private final ObjectMapper objectMapper; + + public HashicorpVault(@NotNull Monitor monitor, HashicorpVaultSettings settings, EdcHttpClient httpClient, ObjectMapper objectMapper) { this.monitor = monitor; + this.settings = settings; + this.httpClient = httpClient; + this.objectMapper = objectMapper; } @Override public @Nullable String resolveSecret(String key) { - var result = hashicorpVaultClient.getSecretValue(key); - if (result.failed()) { - monitor.debug("Failed to resolve secret '%s': %s".formatted(key, result.getFailureMessages())); - return null; - } + var requestUri = getSecretUrl(key, VAULT_SECRET_DATA_PATH); + var request = new Request.Builder() + .url(requestUri) + .header(VAULT_TOKEN_HEADER, settings.token()) + .get() + .build(); + + try (var response = httpClient.execute(request)) { + + if (response.isSuccessful()) { - return result.getContent(); + var responseBody = response.body(); + if (responseBody != null) { + // using JsonNode here because it makes traversing down the tree null-safe + var payload = objectMapper.readValue(responseBody.string(), JsonNode.class); + return payload.path("data").path("data").get(VAULT_DATA_ENTRY_NAME).asText(); + } + monitor.debug("Secret response body is empty"); + + } else { + if (response.code() == 404) { + monitor.debug("Secret not found"); + } else { + monitor.debug("Failed to get secret with status %d".formatted(response.code())); + } + } + } catch (IOException e) { + monitor.warning("Failed to get secret with reason: %s".formatted(e.getMessage())); + } + return null; } @Override public Result storeSecret(String key, String value) { - var result = hashicorpVaultClient.setSecret(key, value); - return result.succeeded() ? Result.success() : Result.failure(result.getFailureMessages()); + var requestUri = getSecretUrl(key, VAULT_SECRET_DATA_PATH); + + var requestPayload = Map.of("data", Map.of(VAULT_DATA_ENTRY_NAME, value)); + var request = new Request.Builder() + .url(requestUri) + .header(VAULT_TOKEN_HEADER, settings.token()) + .post(jsonBody(requestPayload)) + .build(); + + try (var response = httpClient.execute(request)) { + if (response.isSuccessful()) { + return response.body() == null ? Result.failure("Setting secret returned empty body") : Result.success(); + } else { + return Result.failure("Failed to set secret with status %d".formatted(response.code())); + } + } catch (IOException e) { + return Result.failure("Failed to set secret with reason: %s".formatted(e.getMessage())); + } } @Override public Result deleteSecret(String key) { - return hashicorpVaultClient.destroySecret(key); + var requestUri = getSecretUrl(key, VAULT_SECRET_METADATA_PATH); + var request = new Request.Builder() + .url(requestUri) + .header(VAULT_TOKEN_HEADER, settings.token()) + .delete() + .build(); + + try (var response = httpClient.execute(request)) { + return response.isSuccessful() || response.code() == 404 ? Result.success() : Result.failure("Failed to destroy secret with status %d".formatted(response.code())); + } catch (IOException e) { + return Result.failure("Failed to destroy secret with reason: %s".formatted(e.getMessage())); + } + } + + private HttpUrl getSecretUrl(String key, String entryType) { + key = URLEncoder.encode(key, StandardCharsets.UTF_8); + + // restore '/' characters to allow subdirectories + var sanitizedKey = key.replace("%2F", "/"); + + var vaultApiPath = settings.secretPath(); + var folderPath = settings.getFolderPath(); + + var builder = HttpUrl.parse(settings.url()) + .newBuilder() + .addPathSegments(PathUtil.trimLeadingOrEndingSlash(vaultApiPath)) + .addPathSegment(entryType); + + if (folderPath != null) { + builder.addPathSegments(PathUtil.trimLeadingOrEndingSlash(folderPath)); + } + + return builder + .addPathSegments(sanitizedKey) + .build(); + } + + private RequestBody jsonBody(Object body) { + String jsonRepresentation; + try { + jsonRepresentation = objectMapper.writeValueAsString(body); + } catch (JsonProcessingException e) { + throw new EdcException(e); + } + return RequestBody.create(jsonRepresentation, VaultConstants.MEDIA_TYPE_APPLICATION_JSON); } } diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultExtension.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultExtension.java index 5f9734c6531..c3f216244fc 100644 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultExtension.java +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultExtension.java @@ -22,19 +22,22 @@ import org.eclipse.edc.runtime.metamodel.annotation.Inject; import org.eclipse.edc.runtime.metamodel.annotation.Provider; import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.security.SignatureService; import org.eclipse.edc.spi.security.Vault; import org.eclipse.edc.spi.system.ExecutorInstrumentation; import org.eclipse.edc.spi.system.ServiceExtension; import org.eclipse.edc.spi.system.ServiceExtensionContext; -import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultClient; +import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultHealthService; import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings; import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultTokenRenewTask; +import org.jetbrains.annotations.NotNull; import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; @Extension(value = HashicorpVaultExtension.NAME) public class HashicorpVaultExtension implements ServiceExtension { public static final String NAME = "Hashicorp Vault"; + public static final ObjectMapper MAPPER = new ObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false); @Inject private EdcHttpClient httpClient; @@ -45,9 +48,9 @@ public class HashicorpVaultExtension implements ServiceExtension { @Configuration private HashicorpVaultSettings config; - private HashicorpVaultClient client; private HashicorpVaultTokenRenewTask tokenRenewalTask; private Monitor monitor; + private HashicorpVaultHealthService healthService; @Override public String name() { @@ -55,20 +58,13 @@ public String name() { } @Provider - public HashicorpVaultClient hashicorpVaultClient() { - if (client == null) { - // the default type manager cannot be used as the Vault is a primordial service loaded at boot - var mapper = new ObjectMapper(); - mapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false); - - client = new HashicorpVaultClient(httpClient, mapper, monitor, config); - } - return client; + public Vault hashicorpVault() { + return new HashicorpVault(monitor, config, httpClient, MAPPER); } @Provider - public Vault hashicorpVault() { - return new HashicorpVault(hashicorpVaultClient(), monitor); + public SignatureService signatureService() { + return new HashicorpVaultSignatureService(monitor, config, httpClient, MAPPER); } @Override @@ -77,11 +73,19 @@ public void initialize(ServiceExtensionContext context) { tokenRenewalTask = new HashicorpVaultTokenRenewTask( NAME, executorInstrumentation, - hashicorpVaultClient(), + createHealthService(), config.renewBuffer(), monitor); } + @Provider + public @NotNull HashicorpVaultHealthService createHealthService() { + if (healthService == null) { + healthService = new HashicorpVaultHealthService(httpClient, MAPPER, monitor, config); + } + return healthService; + } + @Override public void start() { if (config.scheduledTokenRenewEnabled()) { diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSignatureService.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSignatureService.java new file mode 100644 index 00000000000..4acb0db20f6 --- /dev/null +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSignatureService.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Confinity-X + * + * 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: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.vault.hashicorp; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.Request; +import okhttp3.RequestBody; +import org.eclipse.edc.http.spi.EdcHttpClient; +import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.security.SignatureService; +import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings; + +import java.io.IOException; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; + +import static java.util.Optional.ofNullable; + +/** + * Signature service using Hashicorp Vault with the Transit secrets engine + */ +public class HashicorpVaultSignatureService implements SignatureService { + + private final Monitor monitor; + private final HashicorpVaultSettings settings; + private final EdcHttpClient httpClient; + private final ObjectMapper objectMapper; + + + public HashicorpVaultSignatureService(Monitor monitor, HashicorpVaultSettings settings, EdcHttpClient httpClient, ObjectMapper objectMapper) { + this.monitor = monitor; + this.settings = settings; + this.httpClient = httpClient; + this.objectMapper = objectMapper; + } + + /** + * Signs the given payload with the key identified by the key parameter. Instead of transmitting the key out of the Vault + * and signing the payload locally, the payload is transmitted to the remote service, signed there with the specified key, + * and the signature is then transmitted back. + * + * @param key The key that is used for signing. This key must be available and accessible by this {@link SignatureService} + * @param payload A non-empty, non-null byte array to be signed. Note that this implementation will base64-encode the payload. + * @param signatureAlgorithm Ignored for this implementation. + * @return A Result containing the signature in the form {@code "vault::"} in bytes, or an error. + * @throws UnsupportedOperationException if this operation is not supported by this {@link SignatureService}. + * @throws IllegalArgumentException if {@code signatureAlgorithm} is not recognized by this signing service + */ + @Override + public Result sign(String key, byte[] payload, String signatureAlgorithm) { + Objects.requireNonNull(key, "key cannot be null"); + Objects.requireNonNull(payload, "payload cannot be null"); + var url = settings.url() + settings.secretsEnginePath() + "/sign/" + key; + + // omit key version from request body -> we'll always sign with the latest one + var body = Map.of("input", Base64.getEncoder().encodeToString(payload)); + + var request = new Request.Builder() + .url(url) + .header(VaultConstants.VAULT_TOKEN_HEADER, settings.token()) + .post(jsonBody(body)) + .build(); + + try (var response = httpClient.execute(request)) { + if (response.isSuccessful()) { + if (response.body() != null) { + var r = objectMapper.readValue(response.body().string(), VaultConstants.MAP_TYPE_REFERENCE); + + return ofNullable(r.get("data")) + .map(o -> (Map) o) + .map(dataObj -> dataObj.get("signature")) + .map(Object::toString) + .map(String::getBytes) + .map(Result::success) + .orElseGet(() -> Result.failure("JSON response did not contain signature")); + } + return Result.failure("Received empty body from Vault"); + } + return Result.failure("Failed to sign payload with status %d, %s".formatted(response.code(), response.message())); + } catch (IOException e) { + monitor.warning("Error signing content: %s".formatted(e.getMessage())); + return Result.failure("Error signing content: %s".formatted(e.getMessage())); + } + } + + /** + * Verifies the given input data with the given signature. Instead of transmitting the key out of the Vault + * and performing the verification locally, the input and signature are transmitted to the remote service and verified + * with the specified key, and the result is transmitted back. + * + * @param key The key that is used for signing. This key must exist in the Vault. + * @param signingInput The content from which the signature was created. Note that this implementation will base64-encode the payload + * @param signature The signature in the form {@code "vault::"} + * @param signatureAlgorithm Ignored for this implementation + * @return A Result indicating the success of the operation. {@link Result#success()} if the signature is valid. + */ + @Override + public Result verify(String key, byte[] signingInput, byte[] signature, String signatureAlgorithm) { + Objects.requireNonNull(key, "key cannot be null"); + Objects.requireNonNull(signingInput, "signingInput cannot be null"); + Objects.requireNonNull(signature, "signature cannot be null"); + + var url = settings.url() + settings.secretsEnginePath() + "/verify/" + key; + + // omit key version from request body -> we'll always sign with the latest one + var body = Map.of("input", Base64.getEncoder().encodeToString(signingInput), + "signature", new String(signature)); + + var request = new Request.Builder() + .url(url) + .header(VaultConstants.VAULT_TOKEN_HEADER, settings.token()) + .post(jsonBody(body)) + .build(); + + try (var response = httpClient.execute(request)) { + if (response.isSuccessful()) { + if (response.body() != null) { + var r = objectMapper.readValue(response.body().string(), VaultConstants.MAP_TYPE_REFERENCE); + + return ofNullable(r.get("data")) + .map(o -> (Map) o) + .map(dataObj -> dataObj.get("valid")) + .map(o -> Boolean.parseBoolean(o.toString())) + .map(b -> b ? Result.success() : Result.failure("Signature validation failed")) + .orElseGet(() -> Result.failure("JSON response did not contain valid verification data")); + } + return Result.failure("Received empty body from Vault"); + } + return Result.failure("Failed to verify signature with status %d, %s".formatted(response.code(), response.message())); + } catch (IOException e) { + monitor.warning("Error signing content: %s".formatted(e.getMessage())); + return Result.failure("Error signing content: %s".formatted(e.getMessage())); + } + } + + /** + * Rotates the key in Hashicorp Transit engine. + * + * @param key The unique identifier for the key that should be rotated + * @param ignored Hashicorp's Transit secrets engine does not take any additional parameters + * @return A result indicating the success of the operation + */ + @Override + public Result rotate(String key, Map ignored) { + + Objects.requireNonNull(key, "key cannot be null"); + + var url = settings.url() + settings.secretsEnginePath() + "/keys/" + key + "/rotate"; + + var request = new Request.Builder() + .url(url) + .header(VaultConstants.VAULT_TOKEN_HEADER, settings.token()) + .post(RequestBody.create(new byte[0], null)) + .build(); + + try (var response = httpClient.execute(request)) { + if (response.isSuccessful()) { + if (response.body() != null) { + return Result.success(); + } + } + return Result.failure("Failed to rotate key, status %d, %s".formatted(response.code(), response.message())); + } catch (IOException e) { + var msg = "Error rotating key: %s".formatted(e.getMessage()); + monitor.warning(msg); + return Result.failure(msg); + } + } + + private RequestBody jsonBody(Object body) { + String jsonRepresentation; + try { + jsonRepresentation = objectMapper.writeValueAsString(body); + } catch (JsonProcessingException e) { + throw new EdcException(e); + } + return RequestBody.create(jsonRepresentation, VaultConstants.MEDIA_TYPE_APPLICATION_JSON); + } + +} diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/VaultConstants.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/VaultConstants.java new file mode 100644 index 00000000000..3b8fbff3f64 --- /dev/null +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/VaultConstants.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 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 + * + */ + +package org.eclipse.edc.vault.hashicorp; + +import com.fasterxml.jackson.core.type.TypeReference; +import okhttp3.MediaType; + +import java.util.Map; + +public interface VaultConstants { + TypeReference> MAP_TYPE_REFERENCE = new TypeReference<>() { + }; + String VAULT_TOKEN_HEADER = "X-Vault-Token"; + String VAULT_REQUEST_HEADER = "X-Vault-Request"; + String VAULT_SECRET_METADATA_PATH = "metadata"; + MediaType MEDIA_TYPE_APPLICATION_JSON = MediaType.get("application/json"); +} diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClient.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthService.java similarity index 68% rename from extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClient.java rename to extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthService.java index 1c5514de603..c694c45ead3 100644 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClient.java +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthService.java @@ -28,34 +28,24 @@ import org.eclipse.edc.spi.EdcException; import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.result.Result; -import org.eclipse.edc.vault.hashicorp.model.CreateEntryRequestPayload; -import org.eclipse.edc.vault.hashicorp.model.CreateEntryResponsePayload; -import org.eclipse.edc.vault.hashicorp.model.GetEntryResponsePayload; import org.eclipse.edc.vault.hashicorp.util.PathUtil; import org.jetbrains.annotations.NotNull; import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Objects; /** * This is a client implementation for interacting with Hashicorp Vault. + * In particular, this performs token renewal and periodic health checks. */ -public class HashicorpVaultClient { - private static final String VAULT_DATA_ENTRY_NAME = "content"; +public class HashicorpVaultHealthService { private static final String VAULT_TOKEN_HEADER = "X-Vault-Token"; private static final String VAULT_REQUEST_HEADER = "X-Vault-Request"; private static final MediaType MEDIA_TYPE_APPLICATION_JSON = MediaType.get("application/json"); - private static final String VAULT_SECRET_DATA_PATH = "data"; - private static final String VAULT_SECRET_METADATA_PATH = "metadata"; private static final String TOKEN_LOOK_UP_SELF_PATH = "v1/auth/token/lookup-self"; private static final String TOKEN_RENEW_SELF_PATH = "v1/auth/token/renew-self"; private static final List FALLBACK_FACTORIES = List.of(new HashicorpVaultClientFallbackFactory()); - private static final int HTTP_CODE_404 = 404; private static final String DATA_KEY = "data"; private static final String RENEWABLE_KEY = "renewable"; private static final String AUTH_KEY = "auth"; @@ -72,10 +62,10 @@ public class HashicorpVaultClient { private final HttpUrl healthCheckUrl; private final Monitor monitor; - public HashicorpVaultClient(@NotNull EdcHttpClient httpClient, - @NotNull ObjectMapper objectMapper, - @NotNull Monitor monitor, - @NotNull HashicorpVaultSettings settings) { + public HashicorpVaultHealthService(@NotNull EdcHttpClient httpClient, + @NotNull ObjectMapper objectMapper, + @NotNull Monitor monitor, + @NotNull HashicorpVaultSettings settings) { this.httpClient = httpClient; this.objectMapper = objectMapper; this.monitor = monitor; @@ -120,7 +110,7 @@ public Result doHealthCheck() { * @return boolean indicating if the token is renewable */ public Result isTokenRenewable() { - var uri = baseUrl(settings) + var uri = HttpUrl.parse(settings.url()) .newBuilder() .addPathSegments(TOKEN_LOOK_UP_SELF_PATH) .build(); @@ -157,7 +147,7 @@ public Result isTokenRenewable() { * @return long representing the remaining ttl of the token in seconds */ public Result renewToken() { - var uri = baseUrl(settings) + var uri = HttpUrl.parse(settings.url()) .newBuilder() .addPathSegments(TOKEN_RENEW_SELF_PATH) .build(); @@ -185,74 +175,6 @@ public Result renewToken() { } } - public Result getSecretValue(@NotNull String key) { - var requestUri = getSecretUrl(key, VAULT_SECRET_DATA_PATH); - var request = httpGet(requestUri); - - try (var response = httpClient.execute(request)) { - - if (response.isSuccessful()) { - if (response.code() == HTTP_CODE_404) { - return Result.failure("Secret not found"); - } - - var responseBody = response.body(); - if (responseBody == null) { - return Result.failure("Secret response body is empty"); - } - var payload = objectMapper.readValue(responseBody.string(), GetEntryResponsePayload.class); - var value = payload.getData().getData().get(VAULT_DATA_ENTRY_NAME); - - return Result.success(value); - } else { - return Result.failure("Failed to get secret with status %d".formatted(response.code())); - } - } catch (IOException e) { - return Result.failure("Failed to get secret with reason: %s".formatted(e.getMessage())); - } - } - - public Result setSecret(@NotNull String key, @NotNull String value) { - var requestUri = getSecretUrl(key, VAULT_SECRET_DATA_PATH); - var requestPayload = CreateEntryRequestPayload.Builder.newInstance() - .data(Collections.singletonMap(VAULT_DATA_ENTRY_NAME, value)) - .build(); - var request = new Request.Builder() - .url(requestUri) - .headers(headers) - .post(createRequestBody(requestPayload)) - .build(); - - try (var response = httpClient.execute(request)) { - if (response.isSuccessful()) { - if (response.body() == null) { - return Result.failure("Setting secret returned empty body"); - } - var responseBody = response.body().string(); - var responsePayload = - objectMapper.readValue(responseBody, CreateEntryResponsePayload.class); - return Result.success(responsePayload); - } else { - return Result.failure("Failed to set secret with status %d".formatted(response.code())); - } - } catch (IOException e) { - return Result.failure("Failed to set secret with reason: %s".formatted(e.getMessage())); - } - } - - public Result destroySecret(@NotNull String key) { - var requestUri = getSecretUrl(key, VAULT_SECRET_METADATA_PATH); - var request = new Request.Builder().url(requestUri).headers(headers).delete().build(); - - try (var response = httpClient.execute(request)) { - return response.isSuccessful() || response.code() == HTTP_CODE_404 - ? Result.success() - : Result.failure("Failed to destroy secret with status %d".formatted(response.code())); - } catch (IOException e) { - return Result.failure("Failed to destroy secret with reason: %s".formatted(e.getMessage())); - } - } - @NotNull private HttpUrl getHealthCheckUrl() { var vaultHealthPath = settings.healthCheckPath(); @@ -262,7 +184,7 @@ private HttpUrl getHealthCheckUrl() { // status // code instead of the standby status codes - return baseUrl(settings) + return HttpUrl.parse(settings.url()) .newBuilder() .addPathSegments(PathUtil.trimLeadingOrEndingSlash(vaultHealthPath)) .addQueryParameter("standbyok", isVaultHealthStandbyOk ? "true" : "false") @@ -270,29 +192,6 @@ private HttpUrl getHealthCheckUrl() { .build(); } - private HttpUrl getSecretUrl(String key, String entryType) { - key = URLEncoder.encode(key, StandardCharsets.UTF_8); - - // restore '/' characters to allow subdirectories - key = key.replace("%2F", "/"); - - var vaultApiPath = settings.secretPath(); - var folderPath = settings.getFolderPath(); - - var builder = baseUrl(settings) - .newBuilder() - .addPathSegments(PathUtil.trimLeadingOrEndingSlash(vaultApiPath)) - .addPathSegment(entryType); - - if (folderPath != null) { - builder.addPathSegments(PathUtil.trimLeadingOrEndingSlash(folderPath)); - } - - return builder - .addPathSegments(key) - .build(); - } - @NotNull private Request httpGet(HttpUrl requestUri) { return new Request.Builder() @@ -311,13 +210,6 @@ private Request httpPost(HttpUrl requestUri, Object requestBody) { .build(); } - @NotNull - private HttpUrl baseUrl(HashicorpVaultSettings settings) { - var url = settings.url(); - return Objects.requireNonNull(HttpUrl.parse(url)); - } - - @NotNull private Headers getHeaders() { var headersBuilder = new Headers.Builder().add(VAULT_REQUEST_HEADER, Boolean.toString(true)); headersBuilder.add(VAULT_TOKEN_HEADER, settings.token()); diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultSettings.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultSettings.java index e6908309993..b96781d5147 100644 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultSettings.java +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultSettings.java @@ -20,12 +20,13 @@ import static java.util.Objects.requireNonNull; /** - * Settings for the {@link HashicorpVaultClient}. + * Settings for the {@link HashicorpVaultHealthService}. */ @Settings public class HashicorpVaultSettings { public static final String VAULT_API_HEALTH_PATH_DEFAULT = "/v1/sys/health"; public static final String VAULT_API_SECRET_PATH_DEFAULT = "/v1/secret"; + public static final String VAULT_API_TRANSIT_PATH_DEFAULT = "/v1/transit"; public static final boolean VAULT_HEALTH_CHECK_STANDBY_OK_DEFAULT = false; public static final long VAULT_TOKEN_RENEW_BUFFER_DEFAULT = 30; public static final long VAULT_TOKEN_TTL_DEFAULT = 300; @@ -70,6 +71,10 @@ public String healthCheckPath() { return healthCheckPath; } + public String secretsEnginePath() { + return VAULT_API_TRANSIT_PATH_DEFAULT; + } + public boolean healthStandbyOk() { return healthStandbyOk; } diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultTokenRenewTask.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultTokenRenewTask.java index 3961ee89f1d..03c639f6efd 100644 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultTokenRenewTask.java +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultTokenRenewTask.java @@ -38,7 +38,7 @@ public class HashicorpVaultTokenRenewTask { private final ExecutorInstrumentation executorInstrumentation; - private final HashicorpVaultClient client; + private final HashicorpVaultHealthService client; private final Monitor monitor; private final long renewBuffer; @@ -58,7 +58,7 @@ public class HashicorpVaultTokenRenewTask { */ public HashicorpVaultTokenRenewTask(@NotNull String name, @NotNull ExecutorInstrumentation executorInstrumentation, - @NotNull HashicorpVaultClient client, + @NotNull HashicorpVaultHealthService client, long renewBuffer, @NotNull Monitor monitor) { this.name = name; diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthCheck.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthCheck.java index b76b570b8fb..9090699ed1d 100644 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthCheck.java +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthCheck.java @@ -20,7 +20,7 @@ import org.eclipse.edc.spi.system.health.LivenessProvider; import org.eclipse.edc.spi.system.health.ReadinessProvider; import org.eclipse.edc.spi.system.health.StartupStatusProvider; -import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultClient; +import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultHealthService; /** * Implements the healthcheck of the Hashicorp Vault. @@ -31,19 +31,19 @@ * */ public class HashicorpVaultHealthCheck implements ReadinessProvider, LivenessProvider, StartupStatusProvider { - private final HashicorpVaultClient client; + private final HashicorpVaultHealthService healthService; private final Monitor monitor; - public HashicorpVaultHealthCheck(HashicorpVaultClient client, Monitor monitor) { - this.client = client; + public HashicorpVaultHealthCheck(HashicorpVaultHealthService healthService, Monitor monitor) { + this.healthService = healthService; this.monitor = monitor; } @Override public HealthCheckResult get() { - return client + return healthService .doHealthCheck() - .merge(client.isTokenRenewable()) + .merge(healthService.isTokenRenewable()) .flatMap(result -> { var statusBuilder = HealthCheckResult.Builder.newInstance().component("HashicorpVault"); if (result.succeeded()) { diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthExtension.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthExtension.java index c448d3df12a..3c1964e3a8e 100644 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthExtension.java +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthExtension.java @@ -22,7 +22,7 @@ import org.eclipse.edc.spi.system.ServiceExtension; import org.eclipse.edc.spi.system.ServiceExtensionContext; import org.eclipse.edc.spi.system.health.HealthCheckService; -import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultClient; +import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultHealthService; import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings; @@ -36,7 +36,7 @@ public class HashicorpVaultHealthExtension implements ServiceExtension { private HealthCheckService healthCheckService; @Inject - private HashicorpVaultClient client; + private HashicorpVaultHealthService client; @Configuration private HashicorpVaultSettings settings; diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/CreateEntryRequestPayload.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/CreateEntryRequestPayload.java deleted file mode 100644 index 50bb0ffc6ba..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/CreateEntryRequestPayload.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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 - * - */ - -package org.eclipse.edc.vault.hashicorp.model; - -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; - -import java.util.Map; - -public class CreateEntryRequestPayload { - - private CreateEntryRequestPayloadOptions options; - - private Map data; - - CreateEntryRequestPayload() { - } - - public CreateEntryRequestPayloadOptions getOptions() { - return this.options; - } - - public Map getData() { - return this.data; - } - - @JsonPOJOBuilder(withPrefix = "") - public static class Builder { - private final CreateEntryRequestPayload createEntryRequestPayload; - - private Builder() { - createEntryRequestPayload = new CreateEntryRequestPayload(); - } - - public static Builder newInstance() { - return new Builder(); - } - - public Builder options(CreateEntryRequestPayloadOptions options) { - createEntryRequestPayload.options = options; - return this; - } - - public Builder data(Map data) { - createEntryRequestPayload.data = data; - return this; - } - - public CreateEntryRequestPayload build() { - return createEntryRequestPayload; - } - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/CreateEntryRequestPayloadOptions.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/CreateEntryRequestPayloadOptions.java deleted file mode 100644 index c0f77eb6d16..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/CreateEntryRequestPayloadOptions.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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 - * - */ - -package org.eclipse.edc.vault.hashicorp.model; - -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; - -public class CreateEntryRequestPayloadOptions { - - private Integer cas; - - CreateEntryRequestPayloadOptions() { - } - - public Integer getCas() { - return this.cas; - } - - @JsonPOJOBuilder(withPrefix = "") - public static class Builder { - private final CreateEntryRequestPayloadOptions createEntryRequestPayloadOptions; - - private Builder() { - createEntryRequestPayloadOptions = new CreateEntryRequestPayloadOptions(); - } - - public static Builder newInstance() { - return new Builder(); - } - - public Builder cas(Integer cas) { - createEntryRequestPayloadOptions.cas = cas; - return this; - } - - public CreateEntryRequestPayloadOptions build() { - return createEntryRequestPayloadOptions; - } - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/CreateEntryResponsePayload.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/CreateEntryResponsePayload.java deleted file mode 100644 index 9aa4caa8f1c..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/CreateEntryResponsePayload.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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 - * - */ - -package org.eclipse.edc.vault.hashicorp.model; - -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; - -public class CreateEntryResponsePayload { - - private EntryMetadata data; - - private CreateEntryResponsePayload() { - } - - public EntryMetadata getData() { - return this.data; - } - - @JsonPOJOBuilder(withPrefix = "") - public static class Builder { - private final CreateEntryResponsePayload createEntryResponsePayload; - - private Builder() { - createEntryResponsePayload = new CreateEntryResponsePayload(); - } - - public static Builder newInstance() { - return new Builder(); - } - - public Builder data(EntryMetadata data) { - createEntryResponsePayload.data = data; - return this; - } - - public CreateEntryResponsePayload build() { - return createEntryResponsePayload; - } - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/EntryMetadata.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/EntryMetadata.java deleted file mode 100644 index 911daaaef03..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/EntryMetadata.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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 - * - */ - -package org.eclipse.edc.vault.hashicorp.model; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; - -import java.util.Map; - -@JsonDeserialize(builder = EntryMetadata.Builder.class) -public class EntryMetadata { - - private Map customMetadata; - - private Boolean destroyed; - - private Integer version; - - private EntryMetadata() {} - - public Map getCustomMetadata() { - return this.customMetadata; - } - - public Boolean getDestroyed() { - return this.destroyed; - } - - public Integer getVersion() { - return this.version; - } - - @JsonPOJOBuilder(withPrefix = "") - public static class Builder { - private final EntryMetadata entryMetadata; - - Builder() { - entryMetadata = new EntryMetadata(); - } - - @JsonCreator - public static Builder newInstance() { - return new Builder(); - } - - public Builder customMetadata(Map customMetadata) { - entryMetadata.customMetadata = customMetadata; - return this; - } - - public Builder destroyed(Boolean destroyed) { - entryMetadata.destroyed = destroyed; - return this; - } - - public Builder version(Integer version) { - entryMetadata.version = version; - return this; - } - - public EntryMetadata build() { - return entryMetadata; - } - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/GetEntryResponsePayload.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/GetEntryResponsePayload.java deleted file mode 100644 index 13ef6a6d8a3..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/GetEntryResponsePayload.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 - * - */ - -package org.eclipse.edc.vault.hashicorp.model; - -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; - -public class GetEntryResponsePayload { - - private GetEntryResponsePayloadGetVaultEntryData data; - - private GetEntryResponsePayload() {} - - public GetEntryResponsePayloadGetVaultEntryData getData() { - return this.data; - } - - @JsonPOJOBuilder(withPrefix = "") - public static class Builder { - private final GetEntryResponsePayload getEntryResponsePayload; - - private Builder() { - getEntryResponsePayload = new GetEntryResponsePayload(); - } - - public static Builder newInstance() { - return new Builder(); - } - - public Builder data(GetEntryResponsePayloadGetVaultEntryData data) { - getEntryResponsePayload.data = data; - return this; - } - - public GetEntryResponsePayload build() { - return getEntryResponsePayload; - } - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/GetEntryResponsePayloadGetVaultEntryData.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/GetEntryResponsePayloadGetVaultEntryData.java deleted file mode 100644 index 0f0d5228842..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/GetEntryResponsePayloadGetVaultEntryData.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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 - * - */ - -package org.eclipse.edc.vault.hashicorp.model; - -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; - -import java.util.Map; - -public class GetEntryResponsePayloadGetVaultEntryData { - - private Map data; - - private EntryMetadata metadata; - - GetEntryResponsePayloadGetVaultEntryData() { - } - - public Map getData() { - return this.data; - } - - public EntryMetadata getMetadata() { - return this.metadata; - } - - @JsonPOJOBuilder(withPrefix = "") - public static class Builder { - private final GetEntryResponsePayloadGetVaultEntryData getEntryResponsePayloadGetVaultEntryData; - - private Builder() { - getEntryResponsePayloadGetVaultEntryData = new GetEntryResponsePayloadGetVaultEntryData(); - } - - public static Builder newInstance() { - return new Builder(); - } - - public Builder data(Map data) { - getEntryResponsePayloadGetVaultEntryData.data = data; - return this; - } - - public Builder metadata(EntryMetadata metadata) { - getEntryResponsePayloadGetVaultEntryData.metadata = metadata; - return this; - } - - public GetEntryResponsePayloadGetVaultEntryData build() { - return getEntryResponsePayloadGetVaultEntryData; - } - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultIntegrationTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultIntegrationTest.java index 24fcf572506..74d25f4bb40 100644 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultIntegrationTest.java +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultIntegrationTest.java @@ -15,14 +15,16 @@ package org.eclipse.edc.vault.hashicorp; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.failsafe.RetryPolicy; +import okhttp3.OkHttpClient; +import org.eclipse.edc.http.client.EdcHttpClientImpl; import org.eclipse.edc.junit.annotations.ComponentTest; -import org.eclipse.edc.junit.extensions.EmbeddedRuntime; -import org.eclipse.edc.junit.extensions.RuntimeExtension; -import org.eclipse.edc.junit.extensions.RuntimePerClassExtension; -import org.eclipse.edc.spi.security.Vault; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.testcontainers.containers.wait.strategy.Wait; @@ -30,44 +32,56 @@ import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.vault.VaultContainer; -import java.util.HashMap; -import java.util.Map; import java.util.UUID; +import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; import static java.lang.String.format; import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings.VAULT_API_HEALTH_PATH_DEFAULT; +import static org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings.VAULT_API_SECRET_PATH_DEFAULT; +import static org.mockito.Mockito.mock; @ComponentTest @Testcontainers class HashicorpVaultIntegrationTest { - static final String DOCKER_IMAGE_NAME = "vault:1.9.6"; + static final String DOCKER_IMAGE_NAME = "hashicorp/vault:1.18.3"; static final String VAULT_ENTRY_KEY = "testing"; static final String VAULT_ENTRY_VALUE = UUID.randomUUID().toString(); static final String VAULT_DATA_ENTRY_NAME = "content"; static final String TOKEN = UUID.randomUUID().toString(); - @Container private static final VaultContainer VAULTCONTAINER = new VaultContainer<>(DOCKER_IMAGE_NAME) .withVaultToken(TOKEN) .withSecretInVault("secret/" + VAULT_ENTRY_KEY, format("%s=%s", VAULT_DATA_ENTRY_NAME, VAULT_ENTRY_VALUE)); - private static final String VAULT_URL = "edc.vault.hashicorp.url"; - private static final String VAULT_TOKEN = "edc.vault.hashicorp.token"; - - - @RegisterExtension - protected static RuntimeExtension runtime = new RuntimePerClassExtension(new EmbeddedRuntime("vault-runtime", getConfig(), "extensions:common:vault:vault-hashicorp")); - + public final Monitor monitor = mock(); + private final ObjectMapper objectMapper = new ObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false); + private HashicorpVault vault; + + + @BeforeEach + void setUp() { + var settings = HashicorpVaultSettings.Builder.newInstance() + .url("http://localhost:%d".formatted(getPort())) + .token(TOKEN) + .ttl(100) + .healthCheckPath(VAULT_API_HEALTH_PATH_DEFAULT) + .secretPath(VAULT_API_SECRET_PATH_DEFAULT) + .build(); + var httpClient = new EdcHttpClientImpl(new OkHttpClient.Builder().build(), RetryPolicy.ofDefaults(), monitor); + vault = new HashicorpVault(monitor, settings, httpClient, objectMapper); + } @Test @DisplayName("Resolve a secret that exists") - void testResolveSecret_exists(Vault vault) { + void testResolveSecret_exists() { var secretValue = vault.resolveSecret(VAULT_ENTRY_KEY); assertThat(secretValue).isEqualTo(VAULT_ENTRY_VALUE); } @Test @DisplayName("Resolve a secret from a sub directory") - void testResolveSecret_inSubDirectory(Vault vault) { + void testResolveSecret_inSubDirectory() { var key = "sub/" + VAULT_ENTRY_KEY; var value = key + "value"; @@ -77,9 +91,9 @@ void testResolveSecret_inSubDirectory(Vault vault) { } @ParameterizedTest - @ValueSource(strings = { "foo!bar", "foo.bar", "foo[bar]", "sub/foo{bar}" }) + @ValueSource(strings = {"foo!bar", "foo.bar", "foo[bar]", "sub/foo{bar}"}) @DisplayName("Resolve a secret with url encoded characters") - void testResolveSecret_withUrlEncodedCharacters(String key, Vault vault) { + void testResolveSecret_withUrlEncodedCharacters(String key) { var value = key + "value"; vault.storeSecret(key, value); var secretValue = vault.resolveSecret(key); @@ -88,13 +102,13 @@ void testResolveSecret_withUrlEncodedCharacters(String key, Vault vault) { @Test @DisplayName("Resolve a secret that does not exist") - void testResolveSecret_doesNotExist(Vault vault) { + void testResolveSecret_doesNotExist() { assertThat(vault.resolveSecret("wrong_key")).isNull(); } @Test @DisplayName("Update a secret that exists") - void testSetSecret_exists(Vault vault) { + void testSetSecret_exists() { var key = UUID.randomUUID().toString(); var value1 = UUID.randomUUID().toString(); var value2 = UUID.randomUUID().toString(); @@ -107,7 +121,7 @@ void testSetSecret_exists(Vault vault) { @Test @DisplayName("Create a secret that does not exist") - void testSetSecret_doesNotExist(Vault vault) { + void testSetSecret_doesNotExist() { var key = UUID.randomUUID().toString(); var value = UUID.randomUUID().toString(); @@ -118,36 +132,33 @@ void testSetSecret_doesNotExist(Vault vault) { @Test @DisplayName("Delete a secret that exists") - void testDeleteSecret_exists(Vault vault) { + void testDeleteSecret_exists() { var key = UUID.randomUUID().toString(); var value = UUID.randomUUID().toString(); vault.storeSecret(key, value); - vault.deleteSecret(key); + assertThat(vault.deleteSecret(key)).isSucceeded(); + assertThat(vault.resolveSecret(key)).isNull(); } @Test @DisplayName("Try to delete a secret that does not exist") - void testDeleteSecret_doesNotExist(Vault vault) { + void testDeleteSecret_doesNotExist() { var key = UUID.randomUUID().toString(); - vault.deleteSecret(key); + assertThat(vault.deleteSecret(key)).isSucceeded(); assertThat(vault.resolveSecret(key)).isNull(); } - private static Map getConfig() { + private Integer getPort() { // container might not be started, lazily start and wait for it to come up if (!VAULTCONTAINER.isRunning()) { VAULTCONTAINER.start(); VAULTCONTAINER.waitingFor(Wait.forHealthcheck()); } - return new HashMap<>() { - { - put(VAULT_URL, "http://%s:%d".formatted(VAULTCONTAINER.getHost(), VAULTCONTAINER.getFirstMappedPort())); - put(VAULT_TOKEN, TOKEN); - } - }; + return VAULTCONTAINER.getFirstMappedPort(); } + } diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSignatureServiceIntegrationTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSignatureServiceIntegrationTest.java new file mode 100644 index 00000000000..43065740a59 --- /dev/null +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSignatureServiceIntegrationTest.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025 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 + * + */ + +package org.eclipse.edc.vault.hashicorp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.failsafe.RetryPolicy; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import org.eclipse.edc.http.client.EdcHttpClientImpl; +import org.eclipse.edc.junit.annotations.ComponentTest; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.vault.VaultContainer; + +import java.io.IOException; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings.VAULT_API_HEALTH_PATH_DEFAULT; +import static org.mockito.Mockito.mock; + +@ComponentTest +@Testcontainers +class HashicorpVaultSignatureServiceIntegrationTest { + static final String DOCKER_IMAGE_NAME = "hashicorp/vault:1.18.3"; + static final String TOKEN = "root"; + @Container + private static final VaultContainer VAULTCONTAINER = new VaultContainer<>(DOCKER_IMAGE_NAME) + .withVaultToken(TOKEN); + private static Monitor monitor; + private static HashicorpVaultSettings settings; + private static EdcHttpClientImpl httpClient; + private final byte[] testPayload = "test signing input // * ".getBytes(); + private String vaultKey; + private HashicorpVaultSignatureService service; + + private static Integer getPort() { + // container might not be started, lazily start and wait for it to come up + if (!VAULTCONTAINER.isRunning()) { + VAULTCONTAINER.start(); + VAULTCONTAINER.waitingFor(Wait.forHealthcheck()); + } + return VAULTCONTAINER.getFirstMappedPort(); + } + + @BeforeAll + static void prepare() throws IOException { + + monitor = mock(Monitor.class); + settings = org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings.Builder.newInstance() + .url("http://localhost:%s".formatted(getPort())) + .healthCheckPath(VAULT_API_HEALTH_PATH_DEFAULT) + .ttl(24 * 60) + .token(TOKEN) + .build(); + httpClient = new EdcHttpClientImpl(new OkHttpClient.Builder().build(), RetryPolicy.ofDefaults(), monitor); + + + // activate transit secrets engine + var payload = """ + {"type":"transit"} + """; + var rq = new Request.Builder() + .url("http://localhost:%s/v1/sys/mounts/transit".formatted(getPort())) + .header("X-Vault-Token", TOKEN) + .post(RequestBody.create(payload.getBytes(), MediaType.parse("application/json"))) + .build(); + try (var response = httpClient.execute(rq)) { + assertThat(response.isSuccessful()).describedAs(response.message()).isTrue(); + } + } + + @BeforeEach + void setUp() throws IOException { + + vaultKey = UUID.randomUUID().toString(); + service = new HashicorpVaultSignatureService(monitor, settings, httpClient, new ObjectMapper()); + + + // create a new testing key + var payload2 = """ + {"type":"ecdsa-p256"} + """; + var rq2 = new Request.Builder() + .url("http://localhost:%s/v1/transit/keys/%s".formatted(getPort(), vaultKey)) + .header("X-Vault-Token", TOKEN) + .post(RequestBody.create(payload2.getBytes(), MediaType.parse("application/json"))) + .build(); + try (var response = httpClient.execute(rq2)) { + assertThat(response.isSuccessful()).describedAs(response.message()).isTrue(); + } + } + + @Test + void sign() { + var signature = service.sign(vaultKey, testPayload, ""); + assertThat(signature).withFailMessage(signature::getFailureDetail) + .isSucceeded() + .extracting(String::new) + .matches(sig -> sig.startsWith("vault:v1")); + } + + @Test + void sign_keyNotExist() { + assertThat(service.sign("notexist", testPayload, "")).isFailed() + .detail().isEqualTo("Failed to sign payload with status 400, Bad Request"); + + } + + @Test + void verify() { + var signature = new String(service.sign(vaultKey, testPayload, "").getContent()); + + assertThat(service.verify(vaultKey, testPayload, signature.getBytes(), null)) + .isSucceeded(); + + } + + @Test + void verify_keyNotExist() { + var signature = new String(service.sign(vaultKey, testPayload, "").getContent()); + + assertThat(service.verify("not-exist", testPayload, signature.getBytes(), null)) + .isFailed() + .detail().isEqualTo("Failed to verify signature with status 400, Bad Request"); + + } + + @Test + void verify_invalidSignature() { + assertThat(service.verify(vaultKey, testPayload, "vault:v1:invalid-signature".getBytes(), null)) + .isFailed() + .detail().isEqualTo("Failed to verify signature with status 400, Bad Request"); + + } + + @Test + void rotate() { + assertThat(service.rotate(vaultKey, Map.of())).isSucceeded(); + } + + @Test + void rotate_whenNotExist() { + assertThat(service.rotate("not-exist", Map.of())).isFailed(); + } +} diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultTest.java deleted file mode 100644 index ddafeac4ffa..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultTest.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright (c) 2022 Mercedes-Benz Tech Innovation GmbH - * - * 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: - * Mercedes-Benz Tech Innovation GmbH - Initial Test - * - */ - -package org.eclipse.edc.vault.hashicorp; - -import org.eclipse.edc.spi.monitor.Monitor; -import org.eclipse.edc.spi.result.Result; -import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultClient; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class HashicorpVaultTest { - private static final String KEY = "key"; - - // mocks - private HashicorpVaultClient vaultClient; - private HashicorpVault vault; - - @BeforeEach - void setup() { - vaultClient = mock(); - var monitor = mock(Monitor.class); - vault = new HashicorpVault(vaultClient, monitor); - } - - @Test - void getSecretSuccess() { - when(vaultClient.getSecretValue(KEY)).thenReturn(Result.success("test-secret")); - - var returnValue = vault.resolveSecret(KEY); - - verify(vaultClient, times(1)).getSecretValue(KEY); - assertThat(returnValue).isEqualTo("test-secret"); - } - - @Test - void getSecretFailure() { - when(vaultClient.getSecretValue(KEY)).thenReturn(Result.failure("test-failure")); - - var returnValue = vault.resolveSecret(KEY); - - verify(vaultClient, times(1)).getSecretValue(KEY); - assertThat(returnValue).isNull(); - } - - @Test - void setSecretSuccess() { - var value = UUID.randomUUID().toString(); - when(vaultClient.setSecret(KEY, value)).thenReturn(Result.success(null)); - - var returnValue = vault.storeSecret(KEY, value); - - verify(vaultClient, times(1)).setSecret(KEY, value); - assertThat(returnValue.succeeded()).isTrue(); - } - - @Test - void setSecretFailure() { - var value = UUID.randomUUID().toString(); - when(vaultClient.setSecret(KEY, value)).thenReturn(Result.failure("test-failure")); - - var returnValue = vault.storeSecret(KEY, value); - - verify(vaultClient, times(1)).setSecret(KEY, value); - assertThat(returnValue.failed()).isTrue(); - } - - @Test - void destroySecretSuccess() { - when(vaultClient.destroySecret(KEY)).thenReturn(Result.success()); - - var returnValue = vault.deleteSecret(KEY); - - verify(vaultClient, times(1)).destroySecret(KEY); - assertThat(returnValue.succeeded()).isTrue(); - } - - @Test - void destroySecretFailure() { - when(vaultClient.destroySecret(KEY)).thenReturn(Result.failure("test-failure")); - - var returnValue = vault.deleteSecret(KEY); - - verify(vaultClient, times(1)).destroySecret(KEY); - assertThat(returnValue.failed()).isTrue(); - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClientFallbackFactoryTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthServiceFallbackFactoryTest.java similarity index 88% rename from extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClientFallbackFactoryTest.java rename to extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthServiceFallbackFactoryTest.java index a045388cc86..6afcef17511 100644 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClientFallbackFactoryTest.java +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthServiceFallbackFactoryTest.java @@ -20,9 +20,9 @@ import static org.mockito.Mockito.mockStatic; -class HashicorpVaultClientFallbackFactoryTest { +class HashicorpVaultHealthServiceFallbackFactoryTest { - private static final int[] NON_RETRYABLE_STATUS_CODES = { 200, 204, 400, 403, 404, 405 }; + private static final int[] NON_RETRYABLE_STATUS_CODES = {200, 204, 400, 403, 404, 405}; @Test void create_shouldInitializeWithCorrectStatusCodes() { diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClientIntegrationTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthServiceIntegrationTest.java similarity index 96% rename from extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClientIntegrationTest.java rename to extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthServiceIntegrationTest.java index c9128ba7fe1..017ea8ea0ff 100644 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClientIntegrationTest.java +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthServiceIntegrationTest.java @@ -37,7 +37,7 @@ import static org.testcontainers.shaded.org.awaitility.Awaitility.await; -class HashicorpVaultClientIntegrationTest { +class HashicorpVaultHealthServiceIntegrationTest { @ComponentTest @Testcontainers @@ -51,7 +51,7 @@ abstract static class Tests { protected static final long CREATION_TTL = 6L; protected static final long TTL = 5L; protected static final long RENEW_BUFFER = 4L; - protected HashicorpVaultClient client; + protected HashicorpVaultHealthService client; protected final ObjectMapper mapper = new ObjectMapper(); protected final ConsoleMonitor monitor = new ConsoleMonitor(); @@ -136,7 +136,7 @@ public static HashicorpVaultSettings getSettings() throws IOException, Interrupt @BeforeEach void beforeEach() throws IOException, InterruptedException { - client = new HashicorpVaultClient( + client = new HashicorpVaultHealthService( testHttpClient(), mapper, monitor, @@ -150,7 +150,7 @@ void beforeEach() throws IOException, InterruptedException { @Nested class Latest extends Tests { @Container - static final VaultContainer VAULT_CONTAINER = new VaultContainer<>("hashicorp/vault:1.17.3") + static final VaultContainer VAULT_CONTAINER = new VaultContainer<>("hashicorp/vault:1.18.3") .withVaultToken(UUID.randomUUID().toString()); public static HashicorpVaultSettings getSettings() throws IOException, InterruptedException { @@ -182,7 +182,7 @@ public static HashicorpVaultSettings getSettings() throws IOException, Interrupt @BeforeEach void beforeEach() throws IOException, InterruptedException { - client = new HashicorpVaultClient( + client = new HashicorpVaultHealthService( testHttpClient(), mapper, monitor, diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClientTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthServiceTest.java similarity index 76% rename from extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClientTest.java rename to extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthServiceTest.java index 37efd6dd2af..e01f5d081c5 100644 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultClientTest.java +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultHealthServiceTest.java @@ -17,7 +17,6 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import okhttp3.Call; import okhttp3.MediaType; import okhttp3.Protocol; import okhttp3.Request; @@ -27,10 +26,6 @@ import org.eclipse.edc.http.spi.EdcHttpClient; import org.eclipse.edc.http.spi.FallbackFactory; import org.eclipse.edc.spi.monitor.Monitor; -import org.eclipse.edc.vault.hashicorp.model.CreateEntryResponsePayload; -import org.eclipse.edc.vault.hashicorp.model.EntryMetadata; -import org.eclipse.edc.vault.hashicorp.model.GetEntryResponsePayload; -import org.eclipse.edc.vault.hashicorp.model.GetEntryResponsePayloadGetVaultEntryData; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtensionContext; @@ -42,7 +37,6 @@ import org.mockito.ArgumentCaptor; import java.io.IOException; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -51,7 +45,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.params.provider.Arguments.arguments; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.any; @@ -60,7 +53,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -class HashicorpVaultClientTest { +class HashicorpVaultHealthServiceTest { private static final String VAULT_URL = "https://mock.url"; private static final String SECRET_FOLDER = "/foo"; @@ -101,13 +94,13 @@ class HashicorpVaultClientTest { private final EdcHttpClient httpClient = mock(); private final Monitor monitor = mock(); - private final HashicorpVaultClient vaultClient = new HashicorpVaultClient( + private final HashicorpVaultHealthService vaultClient = new HashicorpVaultHealthService( httpClient, OBJECT_MAPPER, monitor, HASHICORP_VAULT_CLIENT_CONFIG_VALUES); - private final HashicorpVaultClient vaultClientWithFolder = new HashicorpVaultClient( + private final HashicorpVaultHealthService vaultClientWithFolder = new HashicorpVaultHealthService( httpClient, OBJECT_MAPPER, monitor, @@ -385,102 +378,4 @@ public Stream provideArguments(ExtensionContext context) { } } - @Nested - class Secret { - @Test - void getSecret_whenApiReturns200_shouldSucceed() throws IOException { - var ow = new ObjectMapper().writer(); - var data = GetEntryResponsePayloadGetVaultEntryData.Builder.newInstance().data(new HashMap<>(0)).build(); - var body = GetEntryResponsePayload.Builder.newInstance().data(data).build(); - var bodyString = ow.writeValueAsString(body); - var response = new Response.Builder() - .code(200) - .message("any") - .body(ResponseBody.create(bodyString, MediaType.get("application/json"))) - .protocol(Protocol.HTTP_1_1) - .request(new Request.Builder().url("http://any").build()) - .build(); - - when(httpClient.execute(any(Request.class))).thenReturn(response); - - var result = vaultClient.getSecretValue(KEY); - - assertNotNull(result); - verify(httpClient).execute(argThat(request -> request.method().equalsIgnoreCase("GET") && - request.url().encodedPath().contains(CUSTOM_SECRET_PATH + "/data") && - request.url().encodedPathSegments().contains(KEY))); - } - - @Test - void getSecret_with_folder_whenApiReturns200_shouldSucceed() throws IOException { - var ow = new ObjectMapper().writer(); - var data = GetEntryResponsePayloadGetVaultEntryData.Builder.newInstance().data(new HashMap<>(0)).build(); - var body = GetEntryResponsePayload.Builder.newInstance().data(data).build(); - var bodyString = ow.writeValueAsString(body); - var response = new Response.Builder() - .code(200) - .message("any") - .body(ResponseBody.create(bodyString, MediaType.get("application/json"))) - .protocol(Protocol.HTTP_1_1) - .request(new Request.Builder().url("http://any").build()) - .build(); - - when(httpClient.execute(any(Request.class))).thenReturn(response); - - var result = vaultClientWithFolder.getSecretValue(KEY); - - assertNotNull(result); - verify(httpClient).execute(argThat(request -> request.method().equalsIgnoreCase("GET") && - request.url().encodedPath().contains(CUSTOM_SECRET_PATH + "/data" + SECRET_FOLDER) && - request.url().encodedPathSegments().contains(KEY))); - } - - @Test - void setSecret_whenApiReturns200_shouldSucceed() throws IOException { - var ow = new ObjectMapper().writer(); - var data = EntryMetadata.Builder.newInstance().build(); - var body = CreateEntryResponsePayload.Builder.newInstance().data(data).build(); - var bodyString = ow.writeValueAsString(body); - var response = new Response.Builder() - .code(200) - .message("any") - .body(ResponseBody.create(bodyString, MediaType.get("application/json"))) - .protocol(Protocol.HTTP_1_1) - .request(new Request.Builder().url("http://any").build()) - .build(); - var call = mock(Call.class); - var secretValue = UUID.randomUUID().toString(); - - when(httpClient.execute(any(Request.class))).thenReturn(response); - when(call.execute()).thenReturn(response); - - var result = vaultClient.setSecret(KEY, secretValue); - - assertNotNull(result); - verify(httpClient).execute(argThat(request -> request.method().equalsIgnoreCase("POST") && - request.url().encodedPath().contains(CUSTOM_SECRET_PATH + "/data") && - request.url().encodedPathSegments().contains(KEY))); - } - - @Test - void destroySecret_whenApiReturns200_shouldSucceed() throws IOException { - var response = new Response.Builder() - .code(200) - .message("any") - .body(ResponseBody.create("", MediaType.get("application/json"))) - .protocol(Protocol.HTTP_1_1) - .request(new Request.Builder().url("http://any").build()) - .build(); - when(httpClient.execute(any(Request.class))).thenReturn(response); - - var result = vaultClient.destroySecret(KEY); - - assertThat(result).isNotNull(); - assertThat(result.succeeded()).isTrue(); - verify(httpClient).execute(argThat(request -> request.method().equalsIgnoreCase("DELETE") && - request.url().encodedPath().contains(CUSTOM_SECRET_PATH + "/metadata") - /*request.url().encodedPathSegments().contains(KEY)*/)); - } - } - } diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultTokenRenewTaskTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultTokenRenewTaskTest.java index 7f0256a15ad..b67b9b95778 100644 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultTokenRenewTaskTest.java +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/client/HashicorpVaultTokenRenewTaskTest.java @@ -36,7 +36,7 @@ class HashicorpVaultTokenRenewTaskTest { private static final long VAULT_TOKEN_TTL = 5L; private static final long RENEW_BUFFER = 5L; private final Monitor monitor = mock(); - private final HashicorpVaultClient client = mock(); + private final HashicorpVaultHealthService client = mock(); private final HashicorpVaultTokenRenewTask tokenRenewTask = new HashicorpVaultTokenRenewTask( "Hashicorp Vault", ExecutorInstrumentation.noop(), diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthCheckTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthCheckTest.java index f139d6a669d..228c23a2f38 100644 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthCheckTest.java +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/health/HashicorpVaultHealthCheckTest.java @@ -17,7 +17,7 @@ import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.result.Result; -import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultClient; +import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultHealthService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -32,7 +32,7 @@ class HashicorpVaultHealthCheckTest { private static final Result TOKEN_LOOK_UP_RESULT_200 = Result.success(Boolean.TRUE); - private final HashicorpVaultClient client = mock(); + private final HashicorpVaultHealthService client = mock(); private final Monitor monitor = mock(); private final HashicorpVaultHealthCheck healthCheck = new HashicorpVaultHealthCheck(client, monitor); diff --git a/spi/common/boot-spi/src/main/java/org/eclipse/edc/spi/security/SignatureService.java b/spi/common/boot-spi/src/main/java/org/eclipse/edc/spi/security/SignatureService.java new file mode 100644 index 00000000000..5e3b1c2604f --- /dev/null +++ b/spi/common/boot-spi/src/main/java/org/eclipse/edc/spi/security/SignatureService.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 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 + * + */ + +package org.eclipse.edc.spi.security; + +import org.eclipse.edc.runtime.metamodel.annotation.ExtensionPoint; +import org.eclipse.edc.spi.result.Result; + +import java.util.Map; + +/** + * Allows signing and verifying of data. + */ +@ExtensionPoint +public interface SignatureService { + /** + * Signs the given payload with the key identified by the key parameter. Instead of transmitting the key out of the Vault + * and signing the payload locally, some implementations may choose to transmit the payload to the remote service, + * signed there with the specified key, and the signature is then transmitted back. + * + * @param key The key that is used for signing. This key must be available and accessible by this {@link SignatureService} + * @param payload A non-empty, non-null byte array to be signed. + * @param signatureAlgorithm A string identifying the signature algorithm + * @return A Result containing the signature bytes, or an error. + * @throws UnsupportedOperationException if this operation is not supported by this {@link SignatureService}. + * @throws IllegalArgumentException if {@code signatureAlgorithm} is not recognized by this signing service + */ + Result sign(String key, byte[] payload, String signatureAlgorithm); + + /** + * Verifies the given input data with the given signature. Instead of transmitting the key out of the Vault + * and performing the verification locally, some implementations may choose to transmit the input and signature + * to the remote service and verified with the specified key, and the result is transmitted back. + * + * @param key The key that is used for verifying the signature. This key must exist in the Vault. + * @param signingInput The content from which the signature was created. + * @param signature The signature + * @param signatureAlgorithm A string identifying the signature algorithm + * @return A Result indicating whether the signature is valid or not. + * @throws UnsupportedOperationException if this operation is not supported by this {@link SignatureService}. + * @throws IllegalArgumentException if {@code signatureAlgorithm} is not recognized by this signing service + */ + Result verify(String key, byte[] signingInput, byte[] signature, String signatureAlgorithm); + + + /** + * Manually rotates the key. This means provisioning a new version of the key with new key material. + * The behaviour of this method is quite loosely defined, as the following aspects are highly implementation-specific: + *
    + *
  • whether the old key gets disabled automatically
  • + *
  • rotation period, i.e. the time until the old key gets disabled
  • + *
  • time-to-live, validity period for the new key. Can be globally defined or configured using the {@code keyProperties}
  • + *
  • key parameters, such as key algorithm, EC curve, bit length etc. Can be globally defined or configured using the {@code keyProperties}
  • + *
+ * + * @param keyId The unique identifier for the key that should be rotated + * @param keyProperties A set of parameters that are supported by the implementation. + * @return {@link Result#success()} if the rotation process was successfully started, {@link Result#failure(String)} otherwise. + * @throws UnsupportedOperationException if this operation is not supported by this {@link SignatureService}. + * @throws IllegalArgumentException if {@code keyProperties} contains illegal parameters, or required parameters are missing. + */ + Result rotate(String keyId, Map keyProperties); + +}