diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpCertificateResolver.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpCertificateResolver.java deleted file mode 100644 index af6c80d1107..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpCertificateResolver.java +++ /dev/null @@ -1,58 +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 API and Implementation - * - */ - -package org.eclipse.edc.vault.hashicorp; - -import org.eclipse.edc.spi.EdcException; -import org.eclipse.edc.spi.monitor.Monitor; -import org.eclipse.edc.spi.security.CertificateResolver; -import org.eclipse.edc.spi.security.Vault; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.security.cert.X509Certificate; - -/** - * Resolves an X.509 certificate in Hashicorp vault. - */ -public class HashicorpCertificateResolver implements CertificateResolver { - private final Vault vault; - private final Monitor monitor; - - public HashicorpCertificateResolver(Vault vault, Monitor monitor) { - this.vault = vault; - this.monitor = monitor; - } - - @Override - public X509Certificate resolveCertificate(String id) { - String certificateRepresentation = vault.resolveSecret(id); - if (certificateRepresentation == null) { - return null; - } - try (InputStream inputStream = - new ByteArrayInputStream(certificateRepresentation.getBytes(StandardCharsets.UTF_8))) { - X509Certificate x509Certificate = PemUtil.readX509Certificate(inputStream); - if (x509Certificate == null) { - monitor.warning( - String.format("Expected PEM certificate on key %s, but value not PEM.", id)); - } - return x509Certificate; - } catch (IOException e) { - throw new EdcException(e.getMessage(), e); - } - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClient.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClient.java index 6cff52a764a..f8dae1d99f9 100644 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClient.java +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClient.java @@ -9,13 +9,14 @@ * * Contributors: * Mercedes-Benz Tech Innovation GmbH - Initial API and Implementation + * Mercedes-Benz Tech Innovation GmbH - Implement automatic Hashicorp Vault token renewal * */ package org.eclipse.edc.vault.hashicorp; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import okhttp3.Headers; import okhttp3.HttpUrl; @@ -24,76 +25,193 @@ import okhttp3.RequestBody; import org.eclipse.edc.spi.EdcException; import org.eclipse.edc.spi.http.EdcHttpClient; +import org.eclipse.edc.spi.http.FallbackFactory; +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.model.HealthResponse; -import org.eclipse.edc.vault.hashicorp.model.HealthResponsePayload; 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.Objects; +import java.util.List; +import java.util.Map; +/** + * This is a client implementation for interacting with Hashicorp Vault. + */ public class HashicorpVaultClient { - static final String VAULT_DATA_ENTRY_NAME = "content"; + private static final String VAULT_DATA_ENTRY_NAME = "content"; 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 CALL_UNSUCCESSFUL_ERROR_TEMPLATE = "[Hashicorp Vault] Call unsuccessful: %s"; + 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; - @NotNull - private final HashicorpVaultClientConfig hashicorpVaultConfig; - @NotNull + private static final String DATA_KEY = "data"; + private static final String RENEWABLE_KEY = "renewable"; + private static final String AUTH_KEY = "auth"; + private static final String LEASE_DURATION_KEY = "lease_duration"; + private static final TypeReference> MAP_TYPE_REFERENCE = new TypeReference<>() { + }; + private static final String INCREMENT_SECONDS_FORMAT = "%ds"; + private static final String INCREMENT_KEY = "increment"; + private final EdcHttpClient httpClient; - @NotNull + private final Headers headers; private final ObjectMapper objectMapper; + private final HashicorpVaultSettings settings; + private final HttpUrl healthCheckUrl; + private final Monitor monitor; - HashicorpVaultClient(@NotNull HashicorpVaultClientConfig hashicorpVaultConfig, @NotNull EdcHttpClient httpClient, - @NotNull ObjectMapper objectMapper) { - this.hashicorpVaultConfig = hashicorpVaultConfig; + HashicorpVaultClient(@NotNull EdcHttpClient httpClient, + @NotNull ObjectMapper objectMapper, + @NotNull Monitor monitor, + @NotNull HashicorpVaultSettings settings) { this.httpClient = httpClient; this.objectMapper = objectMapper; + this.monitor = monitor; + this.settings = settings; + this.headers = getHeaders(); + this.healthCheckUrl = getHealthCheckUrl(); + } + + public Result doHealthCheck() { + var request = httpGet(healthCheckUrl); + + try (var response = httpClient.execute(request)) { + if (response.isSuccessful()) { + return Result.success(); + } + + var code = response.code(); + var errMsg = switch (code) { + case 429 -> "Vault is in standby"; + case 472 -> "Vault is in recovery mode"; + case 473 -> "Vault is in performance standby"; + case 501 -> "Vault is not initialized"; + case 503 -> "Vault is sealed"; + default -> "Vault returned unspecified code %d".formatted(code); + }; + var body = response.body(); + if (body == null) { + return Result.failure("Healthcheck returned empty response body"); + } + return Result.failure("Vault is not available. Reason: %s, additional information: %s".formatted(errMsg, body.string())); + } catch (IOException e) { + return Result.failure("Failed to perform healthcheck with reason: %s".formatted(e.getMessage())); + } + } + + /** + * Attempts to look up the Vault token defined in the configurations and returns a boolean indicating if the token + * is renewable. + *

+ * Will retry in some error cases. + * + * @return boolean indicating if the token is renewable + */ + public Result isTokenRenewable() { + var uri = settings.url() + .newBuilder() + .addPathSegment(TOKEN_LOOK_UP_SELF_PATH) + .build(); + var request = httpGet(uri); + + try (var response = httpClient.execute(request, FALLBACK_FACTORIES)) { + if (response.isSuccessful()) { + var responseBody = response.body(); + if (responseBody == null) { + return Result.failure("Token look up returned empty body"); + } + var payload = objectMapper.readValue(responseBody.string(), MAP_TYPE_REFERENCE); + var parseRenewableResult = parseRenewable(payload); + if (parseRenewableResult.failed()) { + return Result.failure("Token look up response could not be parsed: %s".formatted(parseRenewableResult.getFailureDetail())); + } + var isRenewable = parseRenewableResult.getContent(); + return Result.success(isRenewable); + } else { + return Result.failure("Token look up failed with status %d".formatted(response.code())); + } + } catch (IOException e) { + return Result.failure("Failed to look up token with reason: %s".formatted(e.getMessage())); + } + } + + /** + * Attempts to renew the Vault token with the configured ttl. Note that Vault will not honor the passed + * ttl (or increment) for periodic tokens. Therefore, the ttl returned by this operation should always be used + * for further calculations. + *

+ * Will retry in some error cases. + * + * @return long representing the remaining ttl of the token in seconds + */ + public Result renewToken() { + var uri = settings.url() + .newBuilder() + .addPathSegments(TOKEN_RENEW_SELF_PATH) + .build(); + var requestPayload = getTokenRenewRequestPayload(); + var request = httpPost(uri, requestPayload); + + try (var response = httpClient.execute(request, FALLBACK_FACTORIES)) { + if (response.isSuccessful()) { + var responseBody = response.body(); + if (responseBody == null) { + return Result.failure("Token renew returned empty body"); + } + var payload = objectMapper.readValue(responseBody.string(), MAP_TYPE_REFERENCE); + var parseTtlResult = parseTtl(payload); + if (parseTtlResult.failed()) { + return Result.failure("Token renew response could not be parsed: %s".formatted(parseTtlResult.getFailureDetail())); + } + var ttl = parseTtlResult.getContent(); + return Result.success(ttl); + } else { + return Result.failure("Token renew failed with status: %d".formatted(response.code())); + } + } catch (IOException e) { + return Result.failure("Failed to renew token with reason: %s".formatted(e.getMessage())); + } } public Result getSecretValue(@NotNull String key) { var requestUri = getSecretUrl(key, VAULT_SECRET_DATA_PATH); - var headers = getHeaders(); - var request = new Request.Builder().url(requestUri).headers(headers).get().build(); + var request = httpGet(requestUri); try (var response = httpClient.execute(request)) { if (response.isSuccessful()) { if (response.code() == HTTP_CODE_404) { - return Result.failure( - String.format(CALL_UNSUCCESSFUL_ERROR_TEMPLATE, "Secret not found")); + return Result.failure("Secret not found"); } var responseBody = response.body(); if (responseBody == null) { - return Result.failure(String.format(CALL_UNSUCCESSFUL_ERROR_TEMPLATE, "Response body empty")); + 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(String.format(CALL_UNSUCCESSFUL_ERROR_TEMPLATE, response.code())); + return Result.failure("Failed to get secret with status %d".formatted(response.code())); } - } catch (IOException e) { - return Result.failure(e.getMessage()); + 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 headers = getHeaders(); var requestPayload = CreateEntryRequestPayload.Builder.newInstance() .data(Collections.singletonMap(VAULT_DATA_ENTRY_NAME, value)) .build(); @@ -105,76 +223,44 @@ public Result setSecret(@NotNull String key, @NotNul try (var response = httpClient.execute(request)) { if (response.isSuccessful()) { - var responseBody = Objects.requireNonNull(response.body()).string(); + 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(String.format(CALL_UNSUCCESSFUL_ERROR_TEMPLATE, response.code())); + return Result.failure("Failed to set secret with status %d".formatted(response.code())); } } catch (IOException e) { - return Result.failure(e.getMessage()); + 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 headers = getHeaders(); 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(String.format(CALL_UNSUCCESSFUL_ERROR_TEMPLATE, response.code())); + : Result.failure("Failed to destroy secret with status %d".formatted(response.code())); } catch (IOException e) { - return Result.failure(e.getMessage()); + return Result.failure("Failed to destroy secret with reason: %s".formatted(e.getMessage())); } } - public HealthResponse getHealth() { - - var healthResponseBuilder = HealthResponse.Builder.newInstance(); - - var requestUri = getHealthUrl(); - var headers = getHeaders(); - var request = new Request.Builder().url(requestUri).headers(headers).get().build(); - try (var response = httpClient.execute(request)) { - final var code = response.code(); - healthResponseBuilder.code(code); - - try { - var responseBody = Objects.requireNonNull(response.body()).string(); - var responsePayload = objectMapper.readValue(responseBody, HealthResponsePayload.class); - healthResponseBuilder.payload(responsePayload); - } catch (JsonMappingException e) { - // ignore. status code not checked, so it may be possible that no payload was - // provided - } - } catch (IOException e) { - throw new EdcException(e); - } - - return healthResponseBuilder.build(); - } - @NotNull - private Headers getHeaders() { - var headersBuilder = new Headers.Builder().add(VAULT_REQUEST_HEADER, Boolean.toString(true)); - if (hashicorpVaultConfig.vaultToken() != null) { - headersBuilder.add(VAULT_TOKEN_HEADER, hashicorpVaultConfig.vaultToken()); - } - return headersBuilder.build(); - } - - private HttpUrl getHealthUrl() { - final var vaultHealthPath = hashicorpVaultConfig.vaultApiHealthPath(); - final var isVaultHealthStandbyOk = hashicorpVaultConfig.isVaultApiHealthStandbyOk(); + private HttpUrl getHealthCheckUrl() { + var vaultHealthPath = settings.healthCheckPath(); + var isVaultHealthStandbyOk = settings.healthStandbyOk(); // by setting 'standbyok' and/or 'perfstandbyok' the vault will return an active // status // code instead of the standby status codes - return Objects.requireNonNull(HttpUrl.parse(hashicorpVaultConfig.getVaultUrl())) + return settings.url() .newBuilder() .addPathSegments(PathUtil.trimLeadingOrEndingSlash(vaultHealthPath)) .addQueryParameter("standbyok", isVaultHealthStandbyOk ? "true" : "false") @@ -185,12 +271,12 @@ private HttpUrl getHealthUrl() { private HttpUrl getSecretUrl(String key, String entryType) { key = URLEncoder.encode(key, StandardCharsets.UTF_8); - // restore '/' characters to allow sub-directories + // restore '/' characters to allow subdirectories key = key.replace("%2F", "/"); - final var vaultApiPath = hashicorpVaultConfig.getVaultApiSecretPath(); + var vaultApiPath = settings.secretPath(); - return Objects.requireNonNull(HttpUrl.parse(hashicorpVaultConfig.getVaultUrl())) + return settings.url() .newBuilder() .addPathSegments(PathUtil.trimLeadingOrEndingSlash(vaultApiPath)) .addPathSegment(entryType) @@ -198,13 +284,76 @@ private HttpUrl getSecretUrl(String key, String entryType) { .build(); } + @NotNull + private Request httpGet(HttpUrl requestUri) { + return new Request.Builder() + .url(requestUri) + .headers(headers) + .get() + .build(); + } + + @NotNull + private Request httpPost(HttpUrl requestUri, Object requestBody) { + return new Request.Builder() + .url(requestUri) + .headers(headers) + .post(createRequestBody(requestBody)) + .build(); + } + + @NotNull + private Headers getHeaders() { + var headersBuilder = new Headers.Builder().add(VAULT_REQUEST_HEADER, Boolean.toString(true)); + headersBuilder.add(VAULT_TOKEN_HEADER, settings.token()); + return headersBuilder.build(); + } + private RequestBody createRequestBody(Object requestPayload) { - String jsonRepresentation = null; + String jsonRepresentation; try { jsonRepresentation = objectMapper.writeValueAsString(requestPayload); } catch (JsonProcessingException e) { - throw new RuntimeException(e); + throw new EdcException(e); } return RequestBody.create(jsonRepresentation, MEDIA_TYPE_APPLICATION_JSON); } + + private Map getTokenRenewRequestPayload() { + return Map.of(INCREMENT_KEY, INCREMENT_SECONDS_FORMAT.formatted(settings.ttl())); + } + + private Result parseRenewable(Map map) { + try { + var data = objectMapper.convertValue(getValueFromMap(map, DATA_KEY), new TypeReference>() { + }); + var isRenewable = objectMapper.convertValue(getValueFromMap(data, RENEWABLE_KEY), Boolean.class); + return Result.success(isRenewable); + } catch (IllegalArgumentException e) { + var errMsgFormat = "Failed to parse renewable flag from token look up response %s with reason: %s"; + monitor.warning(errMsgFormat.formatted(map, e.getStackTrace())); + return Result.failure(errMsgFormat.formatted(map, e.getMessage())); + } + } + + private Result parseTtl(Map map) { + try { + var auth = objectMapper.convertValue(getValueFromMap(map, AUTH_KEY), new TypeReference>() { + }); + var ttl = objectMapper.convertValue(getValueFromMap(auth, LEASE_DURATION_KEY), Long.class); + return Result.success(ttl); + } catch (IllegalArgumentException e) { + var errMsgFormat = "Failed to parse ttl from token renewal response %s with reason: %s"; + monitor.warning(errMsgFormat.formatted(map, e.getStackTrace())); + return Result.failure(errMsgFormat.formatted(map, e.getMessage())); + } + } + + private Object getValueFromMap(Map map, String key) { + var value = map.get(key); + if (value == null) { + throw new IllegalArgumentException("Key '%s' does not exist".formatted(key)); + } + return value; + } } diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientConfig.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientConfig.java deleted file mode 100644 index efd7aa2ed8f..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientConfig.java +++ /dev/null @@ -1,177 +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 API and Implementation - * - */ - -package org.eclipse.edc.vault.hashicorp; - -import org.eclipse.edc.spi.EdcException; -import org.eclipse.edc.spi.system.ServiceExtensionContext; - -import java.time.Duration; -import java.util.Objects; - -import static java.lang.String.format; -import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_API_SECRET_PATH; -import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_API_SECRET_PATH_DEFAULT; -import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_TIMEOUT_SECONDS; -import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_TIMEOUT_SECONDS_DEFAULT; -import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_TOKEN; -import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_URL; -import static org.eclipse.edc.vault.hashicorp.HashicorpVaultHealthExtension.VAULT_API_HEALTH_PATH; -import static org.eclipse.edc.vault.hashicorp.HashicorpVaultHealthExtension.VAULT_API_HEALTH_PATH_DEFAULT; -import static org.eclipse.edc.vault.hashicorp.HashicorpVaultHealthExtension.VAULT_HEALTH_CHECK_STANDBY_OK; -import static org.eclipse.edc.vault.hashicorp.HashicorpVaultHealthExtension.VAULT_HEALTH_CHECK_STANDBY_OK_DEFAULT; - -class HashicorpVaultClientConfig { - - - private String vaultUrl; - private String vaultToken; - private String vaultApiSecretPath; - private String vaultApiHealthPath; - private Duration timeout; - private boolean isVaultApiHealthStandbyOk; - - private HashicorpVaultClientConfig() { - - } - - public static HashicorpVaultClientConfig create(ServiceExtensionContext context) { - var vaultUrl = context.getSetting(VAULT_URL, null); - if (vaultUrl == null) { - throw new EdcException(format("Vault URL (%s) must be defined", VAULT_URL)); - } - - var vaultTimeoutSeconds = Math.max(0, context.getSetting(VAULT_TIMEOUT_SECONDS, VAULT_TIMEOUT_SECONDS_DEFAULT)); - var vaultTimeoutDuration = Duration.ofSeconds(vaultTimeoutSeconds); - - var vaultToken = context.getSetting(VAULT_TOKEN, null); - - if (vaultToken == null) { - throw new EdcException(format("For Vault authentication [%s] is required", VAULT_TOKEN)); - } - - var apiSecretPath = context.getSetting(VAULT_API_SECRET_PATH, VAULT_API_SECRET_PATH_DEFAULT); - var apiHealthPath = context.getSetting(VAULT_API_HEALTH_PATH, VAULT_API_HEALTH_PATH_DEFAULT); - var isHealthStandbyOk = context.getSetting(VAULT_HEALTH_CHECK_STANDBY_OK, VAULT_HEALTH_CHECK_STANDBY_OK_DEFAULT); - - return HashicorpVaultClientConfig.Builder.newInstance() - .vaultUrl(vaultUrl) - .vaultToken(vaultToken) - .vaultApiSecretPath(apiSecretPath) - .vaultApiHealthPath(apiHealthPath) - .isVaultApiHealthStandbyOk(isHealthStandbyOk) - .timeout(vaultTimeoutDuration) - .build(); - } - - public String getVaultUrl() { - return vaultUrl; - } - - public String vaultToken() { - return vaultToken; - } - - public String getVaultApiSecretPath() { - return vaultApiSecretPath; - } - - public String vaultApiHealthPath() { - return vaultApiHealthPath; - } - - public Duration timeout() { - return timeout; - } - - public boolean isVaultApiHealthStandbyOk() { - return isVaultApiHealthStandbyOk; - } - - @Override - public int hashCode() { - return Objects.hash(vaultUrl, vaultToken, vaultApiSecretPath, vaultApiHealthPath, timeout, isVaultApiHealthStandbyOk); - } - - @Override - public boolean equals(Object obj) { - if (obj == this) return true; - if (obj == null || obj.getClass() != this.getClass()) return false; - var that = (HashicorpVaultClientConfig) obj; - return Objects.equals(this.vaultUrl, that.vaultUrl) && - Objects.equals(this.vaultToken, that.vaultToken) && - Objects.equals(this.vaultApiSecretPath, that.vaultApiSecretPath) && - Objects.equals(this.vaultApiHealthPath, that.vaultApiHealthPath) && - Objects.equals(this.timeout, that.timeout) && - this.isVaultApiHealthStandbyOk == that.isVaultApiHealthStandbyOk; - } - - @Override - public String toString() { - return "HashicorpVaultConfig[" + - "vaultUrl=" + vaultUrl + ", " + - "vaultToken=" + vaultToken + ", " + - "vaultApiSecretPath=" + vaultApiSecretPath + ", " + - "vaultApiHealthPath=" + vaultApiHealthPath + ", " + - "timeout=" + timeout + ", " + - "isVaultApiHealthStandbyOk=" + isVaultApiHealthStandbyOk + ']'; - } - - public static class Builder { - - private final HashicorpVaultClientConfig config; - - private Builder() { - config = new HashicorpVaultClientConfig(); - } - - public static Builder newInstance() { - return new Builder(); - } - - public Builder vaultUrl(String vaultUrl) { - this.config.vaultUrl = vaultUrl; - return this; - } - - public Builder vaultToken(String vaultToken) { - this.config.vaultToken = vaultToken; - return this; - } - - public Builder vaultApiSecretPath(String vaultApiSecretPath) { - this.config.vaultApiSecretPath = vaultApiSecretPath; - return this; - } - - public Builder vaultApiHealthPath(String vaultApiHealthPath) { - this.config.vaultApiHealthPath = vaultApiHealthPath; - return this; - } - - public Builder timeout(Duration timeout) { - this.config.timeout = timeout; - return this; - } - - public Builder isVaultApiHealthStandbyOk(boolean isStandbyOk) { - this.config.isVaultApiHealthStandbyOk = isStandbyOk; - return this; - } - - public HashicorpVaultClientConfig build() { - return config; - } - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientFallbackFactory.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientFallbackFactory.java new file mode 100644 index 00000000000..ef39ef6607f --- /dev/null +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientFallbackFactory.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 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 - Implement automatic Hashicorp Vault token renewal + * + */ + +package org.eclipse.edc.vault.hashicorp; + +import dev.failsafe.Fallback; +import okhttp3.Request; +import okhttp3.Response; +import org.eclipse.edc.spi.http.FallbackFactory; + +import static org.eclipse.edc.spi.http.FallbackFactories.retryWhenStatusIsNotIn; + +/** + * Implements a {@link Fallback}factory for requests executed against the Hashicorp Vault. + * + * @see Hashicorp Vault Api for more information on retryable error codes. + */ +public class HashicorpVaultClientFallbackFactory implements FallbackFactory { + + private static final int[] NON_RETRYABLE_STATUS_CODES = {200, 204, 400, 403, 404, 405}; + + @Override + public Fallback create(Request request) { + return retryWhenStatusIsNotIn(NON_RETRYABLE_STATUS_CODES).create(request); + } +} 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 1b1b447f80b..1e097c2e899 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 @@ -9,6 +9,7 @@ * * Contributors: * Mercedes-Benz Tech Innovation GmbH - Initial API and Implementation + * Mercedes-Benz Tech Innovation GmbH - Implement automatic Hashicorp Vault token renewal * */ @@ -17,31 +18,53 @@ import org.eclipse.edc.runtime.metamodel.annotation.Extension; import org.eclipse.edc.runtime.metamodel.annotation.Inject; import org.eclipse.edc.runtime.metamodel.annotation.Provider; -import org.eclipse.edc.runtime.metamodel.annotation.Provides; import org.eclipse.edc.runtime.metamodel.annotation.Setting; import org.eclipse.edc.spi.http.EdcHttpClient; -import org.eclipse.edc.spi.security.CertificateResolver; +import org.eclipse.edc.spi.monitor.Monitor; 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.spi.types.TypeManager; -@Provides({ CertificateResolver.class }) @Extension(value = HashicorpVaultExtension.NAME) public class HashicorpVaultExtension implements ServiceExtension { - public static final String NAME = "Hashicorp Vault"; + + public static final boolean VAULT_HEALTH_CHECK_ENABLED_DEFAULT = true; + public static final boolean VAULT_HEALTH_CHECK_STANDBY_OK_DEFAULT = false; + public static final String VAULT_API_HEALTH_PATH_DEFAULT = "/v1/sys/health"; + public static final boolean VAULT_TOKEN_SCHEDULED_RENEW_ENABLED_DEFAULT = true; + public static final long VAULT_TOKEN_RENEW_BUFFER_DEFAULT = 30; + public static final long VAULT_TOKEN_TTL_DEFAULT = 300; public static final String VAULT_API_SECRET_PATH_DEFAULT = "/v1/secret"; - public static final int VAULT_TIMEOUT_SECONDS_DEFAULT = 30; - @Setting(value = "The URL path of the vault's /secret endpoint", defaultValue = VAULT_API_SECRET_PATH_DEFAULT) - public static final String VAULT_API_SECRET_PATH = "edc.vault.hashicorp.api.secret.path"; - @Setting(value = "Sets the timeout for HTTP requests to the vault, in seconds", defaultValue = "30", type = "integer") - public static final String VAULT_TIMEOUT_SECONDS = "edc.vault.hashicorp.timeout.seconds"; + @Setting(value = "The URL of the Hashicorp Vault", required = true) public static final String VAULT_URL = "edc.vault.hashicorp.url"; + + @Setting(value = "Whether or not the vault health check is enabled", defaultValue = "true", type = "boolean") + public static final String VAULT_HEALTH_CHECK_ENABLED = "edc.vault.hashicorp.health.check.enabled"; + + @Setting(value = "The URL path of the vault's /health endpoint", defaultValue = VAULT_API_HEALTH_PATH_DEFAULT) + public static final String VAULT_API_HEALTH_PATH = "edc.vault.hashicorp.api.health.check.path"; + + @Setting(value = "Specifies if being a standby should still return the active status code instead of the standby status code", defaultValue = "false", type = "boolean") + public static final String VAULT_HEALTH_CHECK_STANDBY_OK = "edc.vault.hashicorp.health.check.standby.ok"; + @Setting(value = "The token used to access the Hashicorp Vault", required = true) public static final String VAULT_TOKEN = "edc.vault.hashicorp.token"; + @Setting(value = "Whether the automatic token renewal process will be triggered or not. Should be disabled only for development and testing purposes", defaultValue = "true") + public static final String VAULT_TOKEN_SCHEDULED_RENEW_ENABLED = "edc.vault.hashicorp.token.scheduled-renew-enabled"; + + @Setting(value = "The time-to-live (ttl) value of the Hashicorp Vault token in seconds", defaultValue = "300", type = "long") + public static final String VAULT_TOKEN_TTL = "edc.vault.hashicorp.token.ttl"; + + @Setting(value = "The renew buffer of the Hashicorp Vault token in seconds", defaultValue = "30", type = "long") + public static final String VAULT_TOKEN_RENEW_BUFFER = "edc.vault.hashicorp.token.renew-buffer"; + + @Setting(value = "The URL path of the vault's /secret endpoint", defaultValue = VAULT_API_SECRET_PATH_DEFAULT) + public static final String VAULT_API_SECRET_PATH = "edc.vault.hashicorp.api.secret.path"; @Inject private EdcHttpClient httpClient; @@ -49,7 +72,13 @@ public class HashicorpVaultExtension implements ServiceExtension { @Inject private TypeManager typeManager; - private Vault vault; + @Inject + private ExecutorInstrumentation executorInstrumentation; + + private HashicorpVaultClient client; + private HashicorpVaultTokenRenewTask tokenRenewalTask; + private Monitor monitor; + private HashicorpVaultSettings settings; @Override public String name() { @@ -57,24 +86,68 @@ public String name() { } @Provider - public Vault vault() { - return vault; + public HashicorpVaultClient hashicorpVaultClient() { + if (client == null) { + client = new HashicorpVaultClient( + httpClient, + typeManager.getMapper(), + monitor, + settings); + } + return client; } @Provider - public Vault hashicorpVault(ServiceExtensionContext context) { - if (vault == null) { - var config = HashicorpVaultClientConfig.create(context); - var client = new HashicorpVaultClient(config, httpClient, typeManager.getMapper()); + public Vault hashicorpVault() { + return new HashicorpVault(hashicorpVaultClient(), monitor); + } + + @Override + public void initialize(ServiceExtensionContext context) { + monitor = context.getMonitor().withPrefix(NAME); + settings = getSettings(context); + tokenRenewalTask = new HashicorpVaultTokenRenewTask( + executorInstrumentation, + hashicorpVaultClient(), + settings.renewBuffer(), + monitor); + } - vault = new HashicorpVault(client, context.getMonitor()); + @Override + public void start() { + if (settings.scheduledTokenRenewEnabled()) { + tokenRenewalTask.start(); } - return vault; } - @Provider - public CertificateResolver vaultResolver(ServiceExtensionContext context) { - return new HashicorpCertificateResolver(hashicorpVault(context), context.getMonitor().withPrefix("HashicorpVaultCertificateResolver")); + @Override + public void shutdown() { + if (tokenRenewalTask.isRunning()) { + tokenRenewalTask.stop(); + } } + private HashicorpVaultSettings getSettings(ServiceExtensionContext context) { + var url = context.getSetting(VAULT_URL, null); + var healthCheckEnabled = context.getSetting(VAULT_HEALTH_CHECK_ENABLED, VAULT_HEALTH_CHECK_ENABLED_DEFAULT); + var healthCheckPath = context.getSetting(VAULT_API_HEALTH_PATH, VAULT_API_HEALTH_PATH_DEFAULT); + var healthStandbyOk = context.getSetting(VAULT_HEALTH_CHECK_STANDBY_OK, VAULT_HEALTH_CHECK_STANDBY_OK_DEFAULT); + var token = context.getSetting(VAULT_TOKEN, null); + var isScheduledTokenRenewEnabled = context.getSetting(VAULT_TOKEN_SCHEDULED_RENEW_ENABLED, VAULT_TOKEN_SCHEDULED_RENEW_ENABLED_DEFAULT); + var ttl = context.getSetting(VAULT_TOKEN_TTL, VAULT_TOKEN_TTL_DEFAULT); + var renewBuffer = context.getSetting(VAULT_TOKEN_RENEW_BUFFER, VAULT_TOKEN_RENEW_BUFFER_DEFAULT); + var secretPath = context.getSetting(VAULT_API_SECRET_PATH, VAULT_API_SECRET_PATH_DEFAULT); + + return HashicorpVaultSettings.Builder.newInstance() + .url(url) + .healthCheckEnabled(healthCheckEnabled) + .healthCheckPath(healthCheckPath) + .healthStandbyOk(healthStandbyOk) + .token(token) + .scheduledTokenRenewEnabled(isScheduledTokenRenewEnabled) + .ttl(ttl) + .renewBuffer(renewBuffer) + .secretPath(secretPath) + .build(); + } } diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthCheck.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthCheck.java index d5ff52de484..32b8389b628 100644 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthCheck.java +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthCheck.java @@ -9,26 +9,27 @@ * * Contributors: * Mercedes-Benz Tech Innovation GmbH - Initial API and Implementation + * Mercedes-Benz Tech Innovation GmbH - Implement automatic Hashicorp Vault token renewal * */ package org.eclipse.edc.vault.hashicorp; -import org.eclipse.edc.spi.EdcException; import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.system.health.HealthCheckResult; 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.model.HealthResponse; - -import static java.lang.String.format; +/** + * Implements the healthcheck of the Hashicorp Vault. + * The healthcheck is a combination of: + *

    + *
  1. The actual Vault healthcheck which is performed by calling the healthcheck api of the Vault
  2. + *
  3. Token validation by calling the token lookup api of the Vault
  4. + *
+ */ public class HashicorpVaultHealthCheck implements ReadinessProvider, LivenessProvider, StartupStatusProvider { - - private static final String HEALTH_CHECK_ERROR_TEMPLATE = "HashiCorp Vault HealthCheck unsuccessful. %s %s"; - private static final String COMPONENT_NAME = "HashiCorpVault"; - private final HashicorpVaultClient client; private final Monitor monitor; @@ -39,52 +40,16 @@ public HashicorpVaultHealthCheck(HashicorpVaultClient client, Monitor monitor) { @Override public HealthCheckResult get() { - - HealthResponse response; - HealthCheckResult result; - try { - response = client.getHealth(); - } catch (EdcException e) { // can be thrown by the client, e.g. on JSON parsing error, etc. - var exceptionMsg = format(HEALTH_CHECK_ERROR_TEMPLATE, "EdcException: " + e.getMessage(), ""); - monitor.severe(exceptionMsg, e); - return HealthCheckResult.failed(exceptionMsg).forComponent(COMPONENT_NAME); - } - - switch (response.getCodeAsEnum()) { - case INITIALIZED_UNSEALED_AND_ACTIVE -> { - result = HealthCheckResult.success(); - } - case UNSEALED_AND_STANDBY -> { - var standbyMsg = format(HEALTH_CHECK_ERROR_TEMPLATE, "Vault is in standby", response.getPayload()); - monitor.warning(standbyMsg); - result = HealthCheckResult.failed(standbyMsg); - } - case DISASTER_RECOVERY_MODE_REPLICATION_SECONDARY_AND_ACTIVE -> { - var recoveryModeMsg = format(HEALTH_CHECK_ERROR_TEMPLATE, "Vault is in recovery mode", response.getPayload()); - monitor.warning(recoveryModeMsg); - result = HealthCheckResult.failed(recoveryModeMsg); - } - case PERFORMANCE_STANDBY -> { - var performanceStandbyMsg = format(HEALTH_CHECK_ERROR_TEMPLATE, "Vault is in performance standby", response.getPayload()); - monitor.warning(performanceStandbyMsg); - result = HealthCheckResult.failed(performanceStandbyMsg); - } - case NOT_INITIALIZED -> { - var notInitializedMsg = format(HEALTH_CHECK_ERROR_TEMPLATE, "Vault is not initialized", response.getPayload()); - monitor.warning(notInitializedMsg); - result = HealthCheckResult.failed(notInitializedMsg); - } - case SEALED -> { - var sealedMsg = format(HEALTH_CHECK_ERROR_TEMPLATE, "Vault is sealed", response.getPayload()); - monitor.warning(sealedMsg); - result = HealthCheckResult.failed(sealedMsg); - } - default -> { - var unspecifiedMsg = format(HEALTH_CHECK_ERROR_TEMPLATE, "Unspecified response from vault. Code: " + response.getCode(), response.getPayload()); - monitor.warning(unspecifiedMsg); - result = HealthCheckResult.failed(unspecifiedMsg); - } - } - return result.forComponent(COMPONENT_NAME); + return client + .doHealthCheck() + .merge(client.isTokenRenewable()) + .flatMap(result -> { + if (result.succeeded()) { + return HealthCheckResult.success(); + } else { + monitor.debug("Vault health check failed with reason(s): " + result.getFailureDetail()); + return HealthCheckResult.failed(result.getFailureMessages()); + } + }).forComponent(HashicorpVaultHealthExtension.NAME); } } diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthExtension.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthExtension.java index 09f09235c10..99efbc9a554 100644 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthExtension.java +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthExtension.java @@ -9,67 +9,51 @@ * * Contributors: * Mercedes-Benz Tech Innovation GmbH - Initial API and Implementation + * Mercedes-Benz Tech Innovation GmbH - Implement automatic Hashicorp Vault token renewal * */ package org.eclipse.edc.vault.hashicorp; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; import org.eclipse.edc.runtime.metamodel.annotation.Inject; import org.eclipse.edc.runtime.metamodel.annotation.Requires; -import org.eclipse.edc.runtime.metamodel.annotation.Setting; -import org.eclipse.edc.spi.http.EdcHttpClient; 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.spi.types.TypeManager; + +import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_HEALTH_CHECK_ENABLED; +import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_HEALTH_CHECK_ENABLED_DEFAULT; @Requires(HealthCheckService.class) +@Extension(value = HashicorpVaultHealthExtension.NAME) public class HashicorpVaultHealthExtension implements ServiceExtension { - public static final boolean VAULT_HEALTH_CHECK_DEFAULT = true; - @Setting(value = "Whether or not the vault health check is enabled", defaultValue = "true", type = "boolean") - public static final String VAULT_HEALTH_CHECK = "edc.vault.hashicorp.health.check.enabled"; - public static final String VAULT_API_HEALTH_PATH_DEFAULT = "/v1/sys/health"; - @Setting(value = "The URL path of the vault's /health endpoint", defaultValue = VAULT_API_HEALTH_PATH_DEFAULT) - public static final String VAULT_API_HEALTH_PATH = "edc.vault.hashicorp.api.health.check.path"; - @Setting(value = "Specifies if being a standby should still return the active status code instead of the standby status code", defaultValue = "false", type = "boolean") - public static final String VAULT_HEALTH_CHECK_STANDBY_OK = "edc.vault.hashicorp.health.check.standby.ok"; - public static final boolean VAULT_HEALTH_CHECK_STANDBY_OK_DEFAULT = false; + public static final String NAME = "Hashicorp Vault Health"; @Inject private HealthCheckService healthCheckService; @Inject - private TypeManager typeManager; - - @Inject - private EdcHttpClient httpClient; + private HashicorpVaultClient client; @Override public String name() { - return "Hashicorp Vault Health Check"; + return NAME; } - @Override public void initialize(ServiceExtensionContext context) { - var config = HashicorpVaultClientConfig.create(context); - var client = new HashicorpVaultClient(config, httpClient, typeManager.getMapper()); - configureHealthCheck(client, context); - - context.getMonitor().info("HashicorpVaultExtension: health check initialization complete."); - } - - private void configureHealthCheck(HashicorpVaultClient client, ServiceExtensionContext context) { - var healthCheckEnabled = - context.getSetting(VAULT_HEALTH_CHECK, VAULT_HEALTH_CHECK_DEFAULT); - if (!healthCheckEnabled) return; - - var healthCheck = - new HashicorpVaultHealthCheck(client, context.getMonitor()); - - healthCheckService.addLivenessProvider(healthCheck); - healthCheckService.addReadinessProvider(healthCheck); - healthCheckService.addStartupStatusProvider(healthCheck); + var monitor = context.getMonitor().withPrefix(NAME); + var healthCheckEnabled = context.getSetting(VAULT_HEALTH_CHECK_ENABLED, VAULT_HEALTH_CHECK_ENABLED_DEFAULT); + if (healthCheckEnabled) { + var healthCheck = new HashicorpVaultHealthCheck(client, monitor); + healthCheckService.addLivenessProvider(healthCheck); + healthCheckService.addReadinessProvider(healthCheck); + healthCheckService.addStartupStatusProvider(healthCheck); + monitor.info("Vault health check initialization complete"); + } else { + monitor.info("Vault health check disabled"); + } } -} +} \ No newline at end of file diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSettings.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSettings.java new file mode 100644 index 00000000000..9043bbea77e --- /dev/null +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSettings.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2024 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 - Implement automatic Hashicorp Vault token renewal + * + */ + +package org.eclipse.edc.vault.hashicorp; + +import okhttp3.HttpUrl; + +import static java.util.Objects.requireNonNull; + +/** + * Value container for {@link HashicorpVaultExtension} settings. + */ +public class HashicorpVaultSettings { + + private HttpUrl url; + private boolean healthCheckEnabled; + private String healthCheckPath; + private boolean healthStandbyOk; + private String token; + private boolean scheduledTokenRenewEnabled; + private long ttl; + private long renewBuffer; + private String secretPath; + + private HashicorpVaultSettings() {} + + public HttpUrl url() { + return url; + } + + public boolean healthCheckEnabled() { + return healthCheckEnabled; + } + + public String healthCheckPath() { + return healthCheckPath; + } + + public boolean healthStandbyOk() { + return healthStandbyOk; + } + + public String token() { + return token; + } + + public boolean scheduledTokenRenewEnabled() { + return scheduledTokenRenewEnabled; + } + + public long ttl() { + return ttl; + } + + public long renewBuffer() { + return renewBuffer; + } + + public String secretPath() { + return secretPath; + } + + public static class Builder { + private final HashicorpVaultSettings values; + + private Builder() { + values = new HashicorpVaultSettings(); + } + + public static Builder newInstance() { + return new Builder(); + } + + public Builder url(String url) { + requireNonNull(url, "Vault url must not be null"); + values.url = HttpUrl.parse(url); + return this; + } + + public Builder healthCheckEnabled(boolean healthCheckEnabled) { + values.healthCheckEnabled = healthCheckEnabled; + return this; + } + + public Builder healthCheckPath(String healthCheckPath) { + values.healthCheckPath = healthCheckPath; + return this; + } + + public Builder healthStandbyOk(boolean healthStandbyOk) { + values.healthStandbyOk = healthStandbyOk; + return this; + } + + public Builder token(String token) { + values.token = token; + return this; + } + + public Builder scheduledTokenRenewEnabled(boolean scheduledTokenRenewEnabled) { + values.scheduledTokenRenewEnabled = scheduledTokenRenewEnabled; + return this; + } + + public Builder ttl(long ttl) { + values.ttl = ttl; + return this; + } + + public Builder renewBuffer(long renewBuffer) { + values.renewBuffer = renewBuffer; + return this; + } + + public Builder secretPath(String secretPath) { + values.secretPath = secretPath; + return this; + } + + public HashicorpVaultSettings build() { + requireNonNull(values.url, "Vault url must be valid"); + requireNonNull(values.healthCheckPath, "Vault health check path must not be null"); + requireNonNull(values.token, "Vault token must not be null"); + + if (values.ttl < 5) { + throw new IllegalArgumentException("Vault token ttl minimum value is 5"); + } + + if (values.renewBuffer >= values.ttl) { + throw new IllegalArgumentException("Vault token renew buffer value must be less than ttl value"); + } + + return values; + } + } +} diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultTokenRenewTask.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultTokenRenewTask.java new file mode 100644 index 00000000000..a7e9472d0c3 --- /dev/null +++ b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultTokenRenewTask.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2024 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 - Implement automatic Hashicorp Vault token renewal + * + */ + +package org.eclipse.edc.vault.hashicorp; + +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.system.ExecutorInstrumentation; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * This task implements the Hashicorp Vault token renewal mechanism. + * To ensure that this task is really cancelled, call the stop method before program shut down. + */ +public class HashicorpVaultTokenRenewTask { + + private static final String INITIAL_TOKEN_LOOK_UP_ERR_MSG_FORMAT = "Initial token look up failed with reason: %s"; + private static final String INITIAL_TOKEN_RENEW_ERR_MSG_FORMAT = "Initial token renewal failed with reason: %s"; + private static final String SCHEDULED_TOKEN_RENEWAL_ERR_MSG_FORMAT = "Scheduled token renewal failed: %s"; + + @NotNull + private final ExecutorInstrumentation executorInstrumentation; + @NotNull + private final HashicorpVaultClient client; + @NotNull + private final Monitor monitor; + private final long renewBuffer; + private ScheduledExecutorService scheduledExecutorService; + private Future tokenRenewTask; + private final AtomicBoolean isRunning = new AtomicBoolean(false); + + /** + * Constructor for the HashicorpVaultTokenRenewTask. + * Pass a reasonable {@code renewBuffer} (like 10s) to ensure that the token renewal operation can be executed + * before the token expires and failed renewals can be retried in time. + * + * @param executorInstrumentation executor instrumentation used to initialize a {@link ScheduledExecutorService} + * @param client the HashicorpVaultClient + * @param renewBuffer the renewal buffer time in seconds + * @param monitor the monitor + */ + public HashicorpVaultTokenRenewTask(@NotNull ExecutorInstrumentation executorInstrumentation, + @NotNull HashicorpVaultClient client, + long renewBuffer, + @NotNull Monitor monitor) { + this.executorInstrumentation = executorInstrumentation; + this.client = client; + this.renewBuffer = renewBuffer; + this.monitor = monitor; + } + + /** + * Starts the scheduled token renewal. + * Runs asynchronously. + */ + public void start() { + if (!isRunning()) { + scheduledExecutorService = executorInstrumentation.instrument(Executors.newSingleThreadScheduledExecutor(), HashicorpVaultExtension.NAME); + scheduledExecutorService.execute(this::initialize); + isRunning.set(true); + } + } + + /** + * Stops the scheduled token renewal. Running tasks will be interrupted. + */ + public void stop() { + if (isRunning()) { + if (tokenRenewTask != null) { + tokenRenewTask.cancel(true); + } + scheduledExecutorService.shutdownNow(); + isRunning.set(false); + } + } + + public boolean isRunning() { + return isRunning.get(); + } + + private void initialize() { + var tokenLookUpResult = client.isTokenRenewable(); + + if (tokenLookUpResult.succeeded()) { + var isRenewable = tokenLookUpResult.getContent(); + + if (isRenewable) { + renewToken(INITIAL_TOKEN_RENEW_ERR_MSG_FORMAT); + } + } else { + monitor.warning(INITIAL_TOKEN_LOOK_UP_ERR_MSG_FORMAT.formatted(tokenLookUpResult.getFailureDetail())); + } + } + + /** + * Renews the token & schedules the next renewal if successful. The renewal is not scheduled in error cases since + * tokens are invalidated forever after their ttl expires. + * + * @param errMsgFormat the error message format + */ + private void renewToken(String errMsgFormat) { + var tokenRenewResult = client.renewToken(); + + if (tokenRenewResult.succeeded()) { + var ttl = tokenRenewResult.getContent(); + scheduleNextTokenRenewal(ttl); + } else { + monitor.warning(errMsgFormat.formatted(tokenRenewResult.getFailureDetail())); + } + } + + /** + * Schedules the token renewal operation which executes after a delay defined as {@code delay = ttl - renewBuffer}. + * + * @param ttl the ttl of the token + */ + private void scheduleNextTokenRenewal(long ttl) { + var delay = ttl - renewBuffer; + + tokenRenewTask = scheduledExecutorService.schedule( + () -> renewToken(SCHEDULED_TOKEN_RENEWAL_ERR_MSG_FORMAT), + delay, + TimeUnit.SECONDS); + } +} 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 index f9a77e26b9d..9aa4caa8f1c 100644 --- 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 @@ -20,7 +20,7 @@ public class CreateEntryResponsePayload { private EntryMetadata data; - public CreateEntryResponsePayload() { + private CreateEntryResponsePayload() { } public EntryMetadata getData() { 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 index 9bd4bdbac3a..911daaaef03 100644 --- 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 @@ -15,7 +15,6 @@ package org.eclipse.edc.vault.hashicorp.model; import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; @@ -24,17 +23,13 @@ @JsonDeserialize(builder = EntryMetadata.Builder.class) public class EntryMetadata { - @JsonProperty() private Map customMetadata; - @JsonProperty() private Boolean destroyed; - @JsonProperty() private Integer version; - EntryMetadata() { - } + private EntryMetadata() {} public Map getCustomMetadata() { return this.customMetadata; 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 index f034ea21331..13ef6a6d8a3 100644 --- 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 @@ -20,8 +20,7 @@ public class GetEntryResponsePayload { private GetEntryResponsePayloadGetVaultEntryData data; - public GetEntryResponsePayload() { - } + private GetEntryResponsePayload() {} public GetEntryResponsePayloadGetVaultEntryData getData() { return this.data; diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/HealthResponse.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/HealthResponse.java deleted file mode 100644 index f305613e692..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/HealthResponse.java +++ /dev/null @@ -1,91 +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; - -public class HealthResponse { - - private HealthResponsePayload payload; - private int code; - - private HealthResponse() { - } - - public int getCode() { - return code; - } - - public HashiCorpVaultHealthResponseCode getCodeAsEnum() { - switch (code) { - case 200: - return HashiCorpVaultHealthResponseCode - .INITIALIZED_UNSEALED_AND_ACTIVE; - case 429: - return HashiCorpVaultHealthResponseCode.UNSEALED_AND_STANDBY; - case 472: - return HashiCorpVaultHealthResponseCode - .DISASTER_RECOVERY_MODE_REPLICATION_SECONDARY_AND_ACTIVE; - case 473: - return HashiCorpVaultHealthResponseCode.PERFORMANCE_STANDBY; - case 501: - return HashiCorpVaultHealthResponseCode.NOT_INITIALIZED; - case 503: - return HashiCorpVaultHealthResponseCode.SEALED; - default: - return HashiCorpVaultHealthResponseCode.UNSPECIFIED; - } - } - - public HealthResponsePayload getPayload() { - return payload; - } - - - public enum HashiCorpVaultHealthResponseCode { - UNSPECIFIED, // undefined status codes - INITIALIZED_UNSEALED_AND_ACTIVE, // status code 200 - UNSEALED_AND_STANDBY, // status code 429 - DISASTER_RECOVERY_MODE_REPLICATION_SECONDARY_AND_ACTIVE, // status code 472 - PERFORMANCE_STANDBY, // status code 473 - NOT_INITIALIZED, // status code 501 - SEALED // status code 503 - } - - public static final class Builder { - - private final HealthResponse response; - - private Builder() { - response = new HealthResponse(); - } - - public static Builder newInstance() { - return new Builder(); - } - - public Builder payload(HealthResponsePayload payload) { - this.response.payload = payload; - return this; - } - - public Builder code(int code) { - this.response.code = code; - return this; - } - - public HealthResponse build() { - return response; - } - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/HealthResponsePayload.java b/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/HealthResponsePayload.java deleted file mode 100644 index a7fde939962..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/main/java/org/eclipse/edc/vault/hashicorp/model/HealthResponsePayload.java +++ /dev/null @@ -1,107 +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.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -@JsonIgnoreProperties(ignoreUnknown = true) -public class HealthResponsePayload { - @JsonProperty("initialized") - private boolean isInitialized; - - @JsonProperty("sealed") - private boolean isSealed; - - @JsonProperty("standby") - private boolean isStandby; - - @JsonProperty("performance_standby") - private boolean isPerformanceStandby; - - @JsonProperty("replication_performance_mode") - private String replicationPerformanceMode; - - @JsonProperty("replication_dr_mode") - private String replicationDrMode; - - @JsonProperty("server_time_utc") - private long serverTimeUtc; - - @JsonProperty("version") - private String version; - - @JsonProperty("cluster_name") - private String clusterName; - - @JsonProperty("cluster_id") - private String clusterId; - - public boolean isInitialized() { - return isInitialized; - } - - public boolean isSealed() { - return isSealed; - } - - public boolean isStandby() { - return isStandby; - } - - public boolean isPerformanceStandby() { - return isPerformanceStandby; - } - - public String getReplicationPerformanceMode() { - return replicationPerformanceMode; - } - - public String getReplicationDrMode() { - return replicationDrMode; - } - - public long getServerTimeUtc() { - return serverTimeUtc; - } - - public String getVersion() { - return version; - } - - public String getClusterName() { - return clusterName; - } - - public String getClusterId() { - return clusterId; - } - - @Override - public String toString() { - return "HealthResponsePayload{" + - "isInitialized=" + isInitialized + - ", isSealed=" + isSealed + - ", isStandby=" + isStandby + - ", isPerformanceStandby=" + isPerformanceStandby + - ", replicationPerformanceMode='" + replicationPerformanceMode + '\'' + - ", replicationDrMode='" + replicationDrMode + '\'' + - ", serverTimeUtc=" + serverTimeUtc + - ", version='" + version + '\'' + - ", clusterName='" + clusterName + '\'' + - ", clusterId='" + clusterId + '\'' + - '}'; - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/extensions/common/vault/vault-hashicorp/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension index d6f1ee27c27..ddbc4f4566c 100644 --- a/extensions/common/vault/vault-hashicorp/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension +++ b/extensions/common/vault/vault-hashicorp/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -11,3 +11,4 @@ # Mercedes-Benz Tech Innovation GmbH - Initial ServiceExtension file # org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension +org.eclipse.edc.vault.hashicorp.HashicorpVaultHealthExtension diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpCertificateResolverTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpCertificateResolverTest.java deleted file mode 100644 index 2d34f2fcfe2..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpCertificateResolverTest.java +++ /dev/null @@ -1,69 +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 API and Implementation - * - */ - -package org.eclipse.edc.vault.hashicorp; - -import org.bouncycastle.operator.OperatorCreationException; -import org.eclipse.edc.spi.monitor.Monitor; -import org.eclipse.edc.vault.hashicorp.util.X509CertificateTestUtil; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -import java.io.IOException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; - -class HashicorpCertificateResolverTest { - private static final String KEY = "key"; - - // mocks - private HashicorpCertificateResolver certificateResolver; - private HashicorpVault vault; - - @BeforeEach - void setup() { - vault = Mockito.mock(HashicorpVault.class); - final Monitor monitor = Mockito.mock(Monitor.class); - certificateResolver = new HashicorpCertificateResolver(vault, monitor); - } - - @Test - void resolveCertificate() throws CertificateException, IOException, NoSuchAlgorithmException, OperatorCreationException { - // prepare - X509Certificate certificateExpected = X509CertificateTestUtil.generateCertificate(5, "Test"); - String pem = X509CertificateTestUtil.convertToPem(certificateExpected); - Mockito.when(vault.resolveSecret(KEY)).thenReturn(pem); - - // invoke - certificateResolver.resolveCertificate(KEY); - - // verify - Mockito.verify(vault, Mockito.times(1)).resolveSecret(KEY); - } - - @Test - void nullIfVaultEmpty() { - // prepare - Mockito.when(vault.resolveSecret(KEY)).thenReturn(null); - - // invoke - final X509Certificate certificate = certificateResolver.resolveCertificate(KEY); - - // verify - Assertions.assertNull(certificate); - } -} diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientFallbackFactoryTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientFallbackFactoryTest.java new file mode 100644 index 00000000000..9d271734c9c --- /dev/null +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientFallbackFactoryTest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 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 - Implement automatic Hashicorp Vault token renewal + * + */ + +package org.eclipse.edc.vault.hashicorp; + +import okhttp3.Request; +import org.eclipse.edc.spi.http.FallbackFactories; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.mockStatic; + +class HashicorpVaultClientFallbackFactoryTest { + + private static final int[] NON_RETRYABLE_STATUS_CODES = {200, 204, 400, 403, 404, 405}; + + @Test + void create_shouldInitializeWithCorrectStatusCodes() { + try (var mockedFallbackFactories = mockStatic(FallbackFactories.class)) { + mockedFallbackFactories.when(() -> FallbackFactories.retryWhenStatusIsNotIn(NON_RETRYABLE_STATUS_CODES)).thenCallRealMethod(); + + new HashicorpVaultClientFallbackFactory().create(new Request.Builder().url("http://test.local").get().build()); + + mockedFallbackFactories.verify(() -> FallbackFactories.retryWhenStatusIsNotIn(NON_RETRYABLE_STATUS_CODES)); + } + } + +} diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientIntegrationTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientIntegrationTest.java new file mode 100644 index 00000000000..ac793adbd50 --- /dev/null +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientIntegrationTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2024 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 - Implement automatic Hashicorp Vault token renewal + * + */ + +package org.eclipse.edc.vault.hashicorp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.json.Json; +import org.eclipse.edc.junit.annotations.ComponentTest; +import org.eclipse.edc.junit.testfixtures.TestUtils; +import org.eclipse.edc.spi.monitor.ConsoleMonitor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.vault.VaultContainer; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + +@ComponentTest +@Testcontainers +class HashicorpVaultClientIntegrationTest { + @Container + static final VaultContainer VAULT_CONTAINER = new VaultContainer<>("vault:1.9.6") + .withVaultToken(UUID.randomUUID().toString()); + + private static final String HTTP_URL_FORMAT = "http://%s:%s"; + private static final String HEALTH_CHECK_PATH = "/health/path"; + private static final String CLIENT_TOKEN_KEY = "client_token"; + private static final String AUTH_KEY = "auth"; + private static final long CREATION_TTL = 6L; + private static final long TTL = 5L; + private static final long RENEW_BUFFER = 4L; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ConsoleMonitor MONITOR = new ConsoleMonitor(); + + private static HashicorpVaultClient client; + + @BeforeEach + void beforeEach() throws IOException, InterruptedException { + assertThat(CREATION_TTL).isGreaterThan(TTL); + client = new HashicorpVaultClient( + TestUtils.testHttpClient(), + OBJECT_MAPPER, + MONITOR, + getSettings() + ); + } + + @Test + void lookUpToken_whenTokenNotExpired_shouldSucceed() { + var tokenLookUpResult = client.isTokenRenewable(); + + assertThat(tokenLookUpResult).isSucceeded().isEqualTo(true); + } + + @Test + void lookUpToken_whenTokenExpired_shouldFail() { + await() + .pollDelay(CREATION_TTL, TimeUnit.SECONDS) + .atMost(CREATION_TTL + 1, TimeUnit.SECONDS) + .untilAsserted(() -> { + var tokenLookUpResult = client.isTokenRenewable(); + assertThat(tokenLookUpResult).isFailed(); + assertThat(tokenLookUpResult.getFailureDetail()).isEqualTo("Token look up failed with status 403"); + }); + } + + @Test + void renewToken_whenTokenNotExpired_shouldSucceed() { + var tokenRenewResult = client.renewToken(); + + assertThat(tokenRenewResult).isSucceeded().satisfies(ttl -> assertThat(ttl).isEqualTo(TTL)); + } + + @Test + void renewToken_whenTokenExpired_shouldFail() { + await() + .pollDelay(CREATION_TTL, TimeUnit.SECONDS) + .atMost(CREATION_TTL + 1, TimeUnit.SECONDS) + .untilAsserted(() -> { + var tokenRenewResult = client.renewToken(); + assertThat(tokenRenewResult).isFailed(); + assertThat(tokenRenewResult.getFailureDetail()).isEqualTo("Token renew failed with status: 403"); + }); + } + + public static HashicorpVaultSettings getSettings() throws IOException, InterruptedException { + var execResult = VAULT_CONTAINER.execInContainer( + "vault", + "token", + "create", + "-policy=root", + "-ttl=%d".formatted(CREATION_TTL), + "-format=json"); + + var jsonParser = Json.createParser(new StringReader(execResult.getStdout())); + jsonParser.next(); + var auth = jsonParser.getObjectStream().filter(e -> e.getKey().equals(AUTH_KEY)) + .map(Map.Entry::getValue) + .findFirst() + .orElseThrow() + .asJsonObject(); + var clientToken = auth.getString(CLIENT_TOKEN_KEY); + + return HashicorpVaultSettings.Builder.newInstance() + .url(HTTP_URL_FORMAT.formatted(VAULT_CONTAINER.getHost(), VAULT_CONTAINER.getFirstMappedPort())) + .healthCheckPath(HEALTH_CHECK_PATH) + .token(clientToken) + .ttl(TTL) + .renewBuffer(RENEW_BUFFER) + .build(); + } +} diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientTest.java index 83a48bf1a4f..f92b2f19f53 100644 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientTest.java +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultClientTest.java @@ -9,31 +9,51 @@ * * Contributors: * Mercedes-Benz Tech Innovation GmbH - Initial Test + * Mercedes-Benz Tech Innovation GmbH - Implement automatic Hashicorp Vault token renewal * */ package org.eclipse.edc.vault.hashicorp; +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; import okhttp3.Response; import okhttp3.ResponseBody; +import okio.Buffer; import org.eclipse.edc.spi.http.EdcHttpClient; +import org.eclipse.edc.spi.http.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.HealthResponse; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; +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; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; import java.io.IOException; -import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.UUID; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; +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; import static org.mockito.Mockito.argThat; import static org.mockito.Mockito.mock; @@ -41,196 +61,384 @@ import static org.mockito.Mockito.when; class HashicorpVaultClientTest { - private static final String KEY = "key"; - private static final String CUSTOM_SECRET_PATH = "v1/test/secret"; + + private static final String VAULT_URL = "https://mock.url"; private static final String HEALTH_PATH = "sys/health"; - private static final Duration TIMEOUT = Duration.ofSeconds(30); + private static final String VAULT_TOKEN = UUID.randomUUID().toString(); + private static final long VAULT_TOKEN_TTL = 5L; + private static final long RENEW_BUFFER = 4L; + private static final String CUSTOM_SECRET_PATH = "v1/test/secret"; + private static final String KEY = "key"; + private static final String DATA_KEY = "data"; + private static final String RENEWABLE_KEY = "renewable"; + private static final String AUTH_KEY = "auth"; + private static final String LEASE_DURATION_KEY = "lease_duration"; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - private final EdcHttpClient edcClientMock = mock(EdcHttpClient.class); - - @BeforeEach - void setup() { - + private static final TypeReference> MAP_TYPE_REFERENCE = new TypeReference<>() { + }; + private static final String INCREMENT_KEY = "increment"; + private static final HashicorpVaultSettings HASHICORP_VAULT_CLIENT_CONFIG_VALUES = HashicorpVaultSettings.Builder.newInstance() + .url(VAULT_URL) + .healthCheckPath(HEALTH_PATH) + .healthStandbyOk(false) + .token(VAULT_TOKEN) + .ttl(VAULT_TOKEN_TTL) + .renewBuffer(RENEW_BUFFER) + .secretPath(CUSTOM_SECRET_PATH) + .build(); + + private final EdcHttpClient httpClient = mock(); + private final Monitor monitor = mock(); + private final HashicorpVaultClient vaultClient = new HashicorpVaultClient( + httpClient, + OBJECT_MAPPER, + monitor, + HASHICORP_VAULT_CLIENT_CONFIG_VALUES); + + @Nested + class HealthCheck { + @Test + void doHealthCheck_whenHealthCheckReturns200_shouldSucceed() throws IOException { + var body = """ + { + "initialized": true, + "sealed": false, + "standby": false, + "performance_standby": false, + "replication_performance_mode": "mode", + "replication_dr_mode": "mode", + "server_time_utc": 100, + "version": "1.0.0", + "cluster_name": "name", + "cluster_id": "id" + } + """; + var response = new Response.Builder() + .code(200) + .message("any") + .body(ResponseBody.create(body, 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 healthCheckResponseResult = vaultClient.doHealthCheck(); + + assertThat(healthCheckResponseResult).isSucceeded(); + verify(httpClient).execute( + argThat(request -> request.method().equalsIgnoreCase("GET") && + request.url().encodedPath().contains(HEALTH_PATH) && + request.url().queryParameter("standbyok").equals("false") && + request.url().queryParameter("perfstandbyok").equals("false"))); + } + + @ParameterizedTest + @MethodSource("healthCheckErrorResponseProvider") + void doHealthCheck_whenHealthCheckDoesNotReturn200_shouldFail(HealthCheckTestParameter testParameter) throws IOException { + var body = """ + { + "initialized": false, + "sealed": true, + "standby": true, + "performance_standby": true, + "replication_performance_mode": "mode", + "replication_dr_mode": "mode", + "server_time_utc": 100, + "version": "1.0.0", + "cluster_name": "name", + "cluster_id": "id" + } + """; + var response = new Response.Builder() + .code(testParameter.code()) + .message("any") + .body(ResponseBody.create(body, 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 healthCheckResult = vaultClient.doHealthCheck(); + + assertThat(healthCheckResult.failed()).isTrue(); + verify(httpClient).execute( + argThat(request -> request.method().equalsIgnoreCase("GET") && + request.url().encodedPath().contains(HEALTH_PATH) && + request.url().queryParameter("standbyok").equals("false") && + request.url().queryParameter("perfstandbyok").equals("false"))); + assertThat(healthCheckResult.getFailureDetail()).isEqualTo("Vault is not available. Reason: %s, additional information: %s".formatted(testParameter.errMsg(), body)); + } + + @Test + void doHealthCheck_whenHealthCheckThrowsIoException_shouldFail() throws IOException { + when(httpClient.execute(any(Request.class))).thenThrow(new IOException("foo-bar")); + + var healthCheckResult = vaultClient.doHealthCheck(); + + assertThat(healthCheckResult.failed()).isTrue(); + assertThat(healthCheckResult.getFailureDetail()).isEqualTo("Failed to perform healthcheck with reason: foo-bar"); + } + + private static List healthCheckErrorResponseProvider() { + return List.of( + new HealthCheckTestParameter(429, "Vault is in standby"), + new HealthCheckTestParameter(472, "Vault is in recovery mode"), + new HealthCheckTestParameter(473, "Vault is in performance standby"), + new HealthCheckTestParameter(501, "Vault is not initialized"), + new HealthCheckTestParameter(503, "Vault is sealed"), + new HealthCheckTestParameter(999, "Vault returned unspecified code 999") + ); + } + + private record HealthCheckTestParameter(int code, String errMsg) { + } } - @Test - void getSecretValue() throws IOException { - // prepare - var vaultUrl = "https://mock.url"; - var vaultToken = UUID.randomUUID().toString(); - var config = - HashicorpVaultClientConfig.Builder.newInstance() - .vaultUrl(vaultUrl) - .vaultApiSecretPath(CUSTOM_SECRET_PATH) - .vaultApiHealthPath(HEALTH_PATH) - .isVaultApiHealthStandbyOk(false) - .vaultToken(vaultToken) - .timeout(TIMEOUT) - .build(); - - - var vaultClient = new HashicorpVaultClient(config, edcClientMock, OBJECT_MAPPER); - var response = mock(Response.class); - var body = mock(ResponseBody.class); - var payload = new GetEntryResponsePayload(); - - when(edcClientMock.execute(any(Request.class))).thenReturn(response); - when(response.code()).thenReturn(200); - when(response.body()).thenReturn(body); - when(body.string()).thenReturn(payload.toString()); - - // invoke - var result = vaultClient.getSecretValue(KEY); - - // verify - assertNotNull(result); - verify(edcClientMock).execute(argThat(request -> request.method().equalsIgnoreCase("GET") && - request.url().encodedPath().contains(CUSTOM_SECRET_PATH + "/data") && - request.url().encodedPathSegments().contains(KEY))); + @Nested + class Token { + @Test + void lookUpToken_whenApiReturns200_shouldSucceed() throws IOException { + var body = """ + { + "data": { + "renewable": true + } + } + """; + var response = new Response.Builder() + .code(200) + .message("any") + .body(ResponseBody.create(body, MediaType.get("application/json"))) + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("http://any").build()) + .build(); + when(httpClient.execute(any(Request.class), anyList())).thenReturn(response); + + var tokenLookUpResult = vaultClient.isTokenRenewable(); + + verify(httpClient).execute(any(Request.class), argThat((List factories) -> factories.get(0) instanceof HashicorpVaultClientFallbackFactory)); + assertThat(tokenLookUpResult).isSucceeded().satisfies(isRenewable -> assertThat(isRenewable).isTrue()); + } + + @Test + void lookUpToken_whenApiReturnsErrorCode_shouldFail() throws IOException { + var response = new Response.Builder() + .code(403) + .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), anyList())).thenReturn(response); + + var tokenLookUpResult = vaultClient.isTokenRenewable(); + + assertThat(tokenLookUpResult.failed()).isTrue(); + assertThat(tokenLookUpResult.getFailureDetail()).isEqualTo("Token look up failed with status %s".formatted(403)); + } + + @Test + void lookUpToken_whenHttpClientThrowsIoException_shouldFail() throws IOException { + when(httpClient.execute(any(Request.class), anyList())).thenThrow(new IOException("foo-bar")); + + var tokenLookUpResult = vaultClient.isTokenRenewable(); + + assertThat(tokenLookUpResult.failed()).isTrue(); + assertThat(tokenLookUpResult.getFailureDetail()).isEqualTo("Failed to look up token with reason: foo-bar"); + } + + @ParameterizedTest + @ArgumentsSource(InvalidTokenLookUpResponseArgumentProvider.class) + void lookUpToken_withInvalidTokenLookUpResponse_shouldFail(Map tokenLookUpResponse) throws IOException { + var response = new Response.Builder() + .code(200) + .message("any") + .body(ResponseBody.create(OBJECT_MAPPER.writeValueAsString(tokenLookUpResponse), MediaType.get("application/json"))) + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("http://any").build()) + .build(); + when(httpClient.execute(any(Request.class), anyList())).thenReturn(response); + + var tokenLookUpResult = vaultClient.isTokenRenewable(); + + assertThat(tokenLookUpResult.failed()).isTrue(); + assertThat(tokenLookUpResult.getFailureDetail()).startsWith("Token look up response could not be parsed: Failed to parse renewable flag"); + } + + @Test + void renewToken_whenApiReturns200_shouldSucceed() throws IOException { + var body = """ + { + "auth": { + "lease_duration": 1800 + } + } + """; + var response = new Response.Builder() + .code(200) + .message("any") + .body(ResponseBody.create(body, MediaType.get("application/json"))) + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("http://any").build()) + .build(); + var requestCaptor = ArgumentCaptor.forClass(Request.class); + when(httpClient.execute(any(Request.class), anyList())).thenReturn(response); + + var tokenRenewResult = vaultClient.renewToken(); + + verify(httpClient).execute(requestCaptor.capture(), argThat((List ids) -> ids.get(0) instanceof HashicorpVaultClientFallbackFactory)); + var request = requestCaptor.getValue(); + var copy = Objects.requireNonNull(request.newBuilder().build()); + var buffer = new Buffer(); + Objects.requireNonNull(copy.body()).writeTo(buffer); + var tokenRenewRequest = OBJECT_MAPPER.readValue(buffer.readUtf8(), MAP_TYPE_REFERENCE); + // given a configured ttl of 5 this should equal "5s" + assertThat(tokenRenewRequest.get(INCREMENT_KEY)).isEqualTo("%ds".formatted(HASHICORP_VAULT_CLIENT_CONFIG_VALUES.ttl())); + assertThat(tokenRenewResult).isSucceeded().isEqualTo(1800L); + } + + @Test + void renewToken_whenApiReturnsErrorCode_shouldFail() throws IOException { + var response = new Response.Builder() + .code(403) + .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), anyList())).thenReturn(response); + + var tokenRenewResult = vaultClient.renewToken(); + + assertThat(tokenRenewResult.failed()).isTrue(); + assertThat(tokenRenewResult.getFailureDetail()).isEqualTo("Token renew failed with status: %s".formatted(403)); + } + + @Test + void renewToken_whenHttpClientThrowsIoException_shouldFail() throws IOException { + when(httpClient.execute(any(Request.class), anyList())).thenThrow(new IOException("foo-bar")); + + var tokenRenewResult = vaultClient.renewToken(); + + assertThat(tokenRenewResult.failed()).isTrue(); + assertThat(tokenRenewResult.getFailureDetail()).isEqualTo("Failed to renew token with reason: foo-bar"); + // should be called only once + verify(httpClient).execute(any(Request.class), anyList()); + } + + @ParameterizedTest + @ArgumentsSource(InvalidTokenRenewResponseArgumentProvider.class) + void renewToken_withInvalidTokenRenewResponse_shouldFail(Map tokenRenewResponse) throws IOException { + var response = new Response.Builder() + .code(200) + .message("any") + .body(ResponseBody.create(OBJECT_MAPPER.writeValueAsString(tokenRenewResponse), MediaType.get("application/json"))) + .protocol(Protocol.HTTP_1_1) + .request(new Request.Builder().url("http://any").build()) + .build(); + when(httpClient.execute(any(Request.class), anyList())).thenReturn(response); + + var tokenRenewResult = vaultClient.renewToken(); + + assertThat(tokenRenewResult.failed()).isTrue(); + assertThat(tokenRenewResult.getFailureDetail()).startsWith("Token renew response could not be parsed: Failed to parse ttl"); + } + + private static class InvalidTokenLookUpResponseArgumentProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(ExtensionContext context) { + return Stream.of( + arguments(Map.of()), + arguments(Map.of(DATA_KEY, Map.of())), + arguments(Map.of(DATA_KEY, Map.of(RENEWABLE_KEY, "not a boolean"))) + ); + } + } + + private static class InvalidTokenRenewResponseArgumentProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(ExtensionContext context) { + return Stream.of( + arguments(Map.of()), + arguments(Map.of(AUTH_KEY, Map.of())), + arguments(Map.of(AUTH_KEY, Map.of(LEASE_DURATION_KEY, "not a long"))) + ); + } + } } - @Test - void setSecretValue() throws IOException { - // prepare - var vaultUrl = "https://mock.url"; - var vaultToken = UUID.randomUUID().toString(); - var secretValue = UUID.randomUUID().toString(); - var hashicorpVaultClientConfig = - HashicorpVaultClientConfig.Builder.newInstance() - .vaultUrl(vaultUrl) - .vaultApiSecretPath(CUSTOM_SECRET_PATH) - .vaultApiHealthPath(HEALTH_PATH) - .isVaultApiHealthStandbyOk(false) - .vaultToken(vaultToken) - .timeout(TIMEOUT) - .build(); - - var vaultClient = new HashicorpVaultClient(hashicorpVaultClientConfig, edcClientMock, OBJECT_MAPPER); - var payload = new CreateEntryResponsePayload(); - - var call = mock(Call.class); - var response = mock(Response.class); - var body = mock(ResponseBody.class); - - when(edcClientMock.execute(any(Request.class))).thenReturn(response); - when(call.execute()).thenReturn(response); - when(response.code()).thenReturn(200); - when(response.body()).thenReturn(body); - when(body.string()).thenReturn(payload.toString()); - - // invoke - var result = vaultClient.setSecret(KEY, secretValue); - - // verify - assertNotNull(result); - verify(edcClientMock).execute(argThat(request -> request.method().equalsIgnoreCase("POST") && - request.url().encodedPath().contains(CUSTOM_SECRET_PATH + "/data") && - request.url().encodedPathSegments().contains(KEY))); + @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 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)*/)); + } } - @Test - void getHealth() throws IOException { - // prepare - var vaultUrl = "https://mock.url"; - var vaultToken = UUID.randomUUID().toString(); - var hashicorpVaultClientConfig = - HashicorpVaultClientConfig.Builder.newInstance() - .vaultUrl(vaultUrl) - .vaultApiSecretPath(CUSTOM_SECRET_PATH) - .vaultApiHealthPath(HEALTH_PATH) - .isVaultApiHealthStandbyOk(false) - .vaultToken(vaultToken) - .timeout(TIMEOUT) - .build(); - - var vaultClient = - new HashicorpVaultClient(hashicorpVaultClientConfig, edcClientMock, OBJECT_MAPPER); - - var response = mock(Response.class); - var body = mock(ResponseBody.class); - - when(edcClientMock.execute(any(Request.class))).thenReturn(response); - when(response.code()).thenReturn(200); - when(response.isSuccessful()).thenReturn(true); - when(response.body()).thenReturn(body); - when(body.string()) - .thenReturn( - "{ " + - "\"initialized\": true, " + - "\"sealed\": false," + - "\"standby\": false," + - "\"performance_standby\": false," + - "\"replication_performance_mode\": \"mode\"," + - "\"replication_dr_mode\": \"mode\"," + - "\"server_time_utc\": 100," + - "\"version\": \"1.0.0\"," + - "\"cluster_name\": \"name\"," + - "\"cluster_id\": \"id\" " + - " }"); - - // invoke - var result = vaultClient.getHealth(); - - // verify - assertNotNull(result); - verify(edcClientMock).execute( - argThat(request -> request.method().equalsIgnoreCase("GET") && - request.url().encodedPath().contains(HEALTH_PATH) && - request.url().queryParameter("standbyok").equals("false") && - request.url().queryParameter("perfstandbyok").equals("false"))); - assertEquals(200, result.getCode()); - assertEquals( - HealthResponse.HashiCorpVaultHealthResponseCode - .INITIALIZED_UNSEALED_AND_ACTIVE, - result.getCodeAsEnum()); - - var resultPayload = result.getPayload(); - - assertNotNull(resultPayload); - Assertions.assertTrue(resultPayload.isInitialized()); - Assertions.assertFalse(resultPayload.isSealed()); - Assertions.assertFalse(resultPayload.isStandby()); - Assertions.assertFalse(resultPayload.isPerformanceStandby()); - assertEquals("mode", resultPayload.getReplicationPerformanceMode()); - assertEquals("mode", resultPayload.getReplicationDrMode()); - assertEquals(100, resultPayload.getServerTimeUtc()); - assertEquals("1.0.0", resultPayload.getVersion()); - assertEquals("id", resultPayload.getClusterId()); - assertEquals("name", resultPayload.getClusterName()); - } - - @Test - void destroySecretValue() throws IOException { - // prepare - var vaultUrl = "https://mock.url"; - var vaultToken = UUID.randomUUID().toString(); - var hashicorpVaultClientConfig = - HashicorpVaultClientConfig.Builder.newInstance() - .vaultUrl(vaultUrl) - .vaultApiSecretPath(CUSTOM_SECRET_PATH) - .vaultApiHealthPath(HEALTH_PATH) - .isVaultApiHealthStandbyOk(false) - .vaultToken(vaultToken) - .timeout(TIMEOUT) - .build(); - - var vaultClient = new HashicorpVaultClient(hashicorpVaultClientConfig, edcClientMock, OBJECT_MAPPER); - - var response = mock(Response.class); - var body = mock(ResponseBody.class); - when(edcClientMock.execute(any(Request.class))).thenReturn(response); - when(response.isSuccessful()).thenReturn(true); - when(response.body()).thenReturn(body); - - // invoke - var result = vaultClient.destroySecret(KEY); - - // verify - assertThat(result).isNotNull(); - assertThat(result.succeeded()).isTrue(); - verify(edcClientMock).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/HashicorpVaultExtensionTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultExtensionTest.java index 739291295b8..bddc4f413fc 100644 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultExtensionTest.java +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultExtensionTest.java @@ -9,59 +9,95 @@ * * Contributors: * Mercedes-Benz Tech Innovation GmbH - Initial Test + * Mercedes-Benz Tech Innovation GmbH - Implement automatic Hashicorp Vault token renewal * */ package org.eclipse.edc.vault.hashicorp; import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; -import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.http.EdcHttpClient; +import org.eclipse.edc.spi.system.ExecutorInstrumentation; import org.eclipse.edc.spi.system.ServiceExtensionContext; -import org.eclipse.edc.spi.system.health.HealthCheckService; import org.eclipse.edc.spi.system.injection.ObjectFactory; +import org.eclipse.edc.spi.types.TypeManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import java.util.concurrent.ScheduledExecutorService; + import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_TOKEN; +import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_TOKEN_SCHEDULED_RENEW_ENABLED; +import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_TOKEN_SCHEDULED_RENEW_ENABLED_DEFAULT; import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_URL; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(DependencyInjectionExtension.class) class HashicorpVaultExtensionTest { + private static final String URL = "https://test.com/vault"; + private static final String TOKEN = "some-token"; + + private HashicorpVaultExtension extension; + private final ExecutorInstrumentation executorInstrumentation = mock(); + private final ScheduledExecutorService scheduledExecutorService = mock(); + private final EdcHttpClient httpClient = mock(); @BeforeEach - void setUp(ObjectFactory factory, ServiceExtensionContext context) { - var healthCheckService = mock(HealthCheckService.class); - context.registerService(HealthCheckService.class, healthCheckService); + void beforeEach(ObjectFactory factory, ServiceExtensionContext context) { + context.registerService(EdcHttpClient.class, httpClient); + context.registerService(TypeManager.class, mock(TypeManager.class)); + context.registerService(ExecutorInstrumentation.class, executorInstrumentation); + when(context.getSetting(VAULT_URL, null)).thenReturn(URL); + when(context.getSetting(VAULT_TOKEN, null)).thenReturn(TOKEN); + when(executorInstrumentation.instrument(any(), anyString())).thenReturn(scheduledExecutorService); + extension = factory.constructInstance(HashicorpVaultExtension.class); } @Test - void createVault_whenNoVaultUrl_expectException(ServiceExtensionContext context, HashicorpVaultExtension extension) { - when(context.getSetting(VAULT_URL, null)).thenReturn(null); - - assertThatThrownBy(() -> extension.hashicorpVault(context)).isInstanceOf(EdcException.class); + void hashicorpVault_ensureType(ServiceExtensionContext context) { + extension.initialize(context); + assertThat(extension.hashicorpVault()).isInstanceOf(HashicorpVault.class); } @Test - void createVault_whenNoVaultToken_expectException(ServiceExtensionContext context, HashicorpVaultExtension extension) { - when(context.getSetting(VAULT_TOKEN, null)).thenReturn(null); - when(context.getSetting(VAULT_URL, null)).thenReturn("https://some.vault"); - - assertThrows(EdcException.class, () -> extension.hashicorpVault(context)); + void start_withTokenRenewEnabled_shouldStartTokenRenewTask(ServiceExtensionContext context) { + extension.initialize(context); + extension.start(); + verify(executorInstrumentation).instrument(any(), anyString()); + verify(scheduledExecutorService).execute(any()); } @Test - void createVault_ensureType(HashicorpVaultExtension extension, ServiceExtensionContext context) { - when(context.getSetting(VAULT_URL, null)).thenReturn("https://some.vault"); - when(context.getSetting(VAULT_TOKEN, null)).thenReturn("some-token"); + void start_withTokenRenewDisabled_shouldNotStartTokenRenewTask(ServiceExtensionContext context) { + when(context.getSetting(VAULT_TOKEN_SCHEDULED_RENEW_ENABLED, VAULT_TOKEN_SCHEDULED_RENEW_ENABLED_DEFAULT)).thenReturn(false); + extension.initialize(context); + extension.start(); + verify(executorInstrumentation, never()).instrument(any(), anyString()); + verify(scheduledExecutorService, never()).execute(any()); + } + @Test + void shutdown_withTokenRenewTaskRunning_shouldStopTokenRenewTask(ServiceExtensionContext context) { + extension.initialize(context); + extension.start(); + verify(executorInstrumentation).instrument(any(), anyString()); + verify(scheduledExecutorService).execute(any()); + extension.shutdown(); + verify(scheduledExecutorService).shutdownNow(); + } - assertThat(extension.hashicorpVault(context)).isInstanceOf(HashicorpVault.class); + @Test + void shutdown_withTokenRenewTaskNotRunning_shouldNotStopTokenRenewTask(ServiceExtensionContext context) { + extension.initialize(context); + extension.shutdown(); + verify(scheduledExecutorService, never()).shutdownNow(); } } diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthCheckTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthCheckTest.java index 3b733004ca2..ec439778b43 100644 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthCheckTest.java +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultHealthCheckTest.java @@ -9,67 +9,104 @@ * * Contributors: * Mercedes-Benz Tech Innovation GmbH - Initial API and Implementation + * Mercedes-Benz Tech Innovation GmbH - Implement automatic Hashicorp Vault token renewal * */ package org.eclipse.edc.vault.hashicorp; -import org.eclipse.edc.spi.EdcException; import org.eclipse.edc.spi.monitor.Monitor; -import org.eclipse.edc.vault.hashicorp.model.HealthResponse; -import org.eclipse.edc.vault.hashicorp.model.HealthResponsePayload; +import org.eclipse.edc.spi.result.Result; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; -import static org.mockito.ArgumentMatchers.anyString; +import static org.eclipse.edc.junit.assertions.FailureAssert.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 HashicorpVaultHealthCheckTest { - private HashicorpVaultHealthCheck healthCheck; + private static final Result TOKEN_LOOK_UP_RESULT_200 = Result.success(Boolean.TRUE); - private final Monitor monitor = mock(); private final HashicorpVaultClient client = mock(); + private final Monitor monitor = mock(); + private final HashicorpVaultHealthCheck healthCheck = new HashicorpVaultHealthCheck(client, monitor); - @BeforeEach - void setup() { - healthCheck = new HashicorpVaultHealthCheck(client, monitor); - } + @Nested + class TokenValid { - @Test - void shouldSucceed_whenClientReturns200() { - var response = HealthResponse.Builder.newInstance().payload(new HealthResponsePayload()).code(200).build(); - when(client.getHealth()).thenReturn(response); + @BeforeEach + void beforeEach() { + when(client.isTokenRenewable()).thenReturn(TOKEN_LOOK_UP_RESULT_200); + } - var result = healthCheck.get(); + @Test + void get_whenHealthCheckSucceeded_shouldSucceed() { + when(client.doHealthCheck()).thenReturn(Result.success()); + + var result = healthCheck.get(); + + assertThat(result).isSucceeded(); + } + + @Test + void get_whenHealthCheckFailed_shouldFail() { + var healthCheckErr = "Vault is not available. Reason: Vault is in standby, additional information: hello"; + when(client.doHealthCheck()).thenReturn(Result.failure(healthCheckErr)); + + var result = healthCheck.get(); - assertThat(result).isSucceeded(); + assertThat(result).isFailed(); + assertThat(result.getFailure()).messages().hasSize(1); + verify(monitor).debug("Vault health check failed with reason(s): %s".formatted(healthCheckErr)); + } } - @ParameterizedTest - @ValueSource(ints = {409, 472, 473, 501, 503, 999}) - void shouldFail_whenClientReturnsErrorCodes(int code) { - var response = HealthResponse.Builder.newInstance().payload(new HealthResponsePayload()).code(code).build(); - when(client.getHealth()).thenReturn(response); + @Nested + class HealthCheck200 { - var result = healthCheck.get(); + @BeforeEach + void beforeEach() { + when(client.doHealthCheck()).thenReturn(Result.success()); + } - assertThat(result).isFailed(); - verify(monitor, times(1)).warning(anyString()); + @Test + void get_whenTokenValid_shouldSucceed() { + when(client.isTokenRenewable()).thenReturn(TOKEN_LOOK_UP_RESULT_200); + + var result = healthCheck.get(); + + assertThat(result).isSucceeded(); + } + + @Test + void get_whenTokenNotValid_shouldFail() { + var tokenLookUpErr = "Token look up failed with status 403"; + when(client.isTokenRenewable()).thenReturn(Result.failure(tokenLookUpErr)); + + var result = healthCheck.get(); + + assertThat(result).isFailed(); + assertThat(result.getFailure()).messages().hasSize(1); + verify(monitor).debug("Vault health check failed with reason(s): %s".formatted(tokenLookUpErr)); + } } @Test - void testResponseFromException() { - when(client.getHealth()).thenThrow(new EdcException("foo-bar")); + void get_whenHealthCheckFailedAndTokenNotValid_shouldFail() { + var healthCheckErr = "Vault is not available. Reason: Vault is in standby, additional information: hello"; + var tokenLookUpErr = "Token look up failed with status 403"; + + when(client.doHealthCheck()).thenReturn(Result.failure(healthCheckErr)); + when(client.isTokenRenewable()).thenReturn(Result.failure(tokenLookUpErr)); var result = healthCheck.get(); assertThat(result).isFailed(); + assertThat(result.getFailure()).messages().hasSize(2); + verify(monitor).debug("Vault health check failed with reason(s): %s, %s".formatted(healthCheckErr, tokenLookUpErr)); } } 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 286416a7183..ea4bfab02ea 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 @@ -17,7 +17,6 @@ import org.eclipse.edc.junit.annotations.ComponentTest; import org.eclipse.edc.junit.extensions.EdcExtension; -import org.eclipse.edc.spi.security.CertificateResolver; import org.eclipse.edc.spi.security.Vault; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -29,20 +28,14 @@ import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.vault.VaultContainer; -import java.io.IOException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; import java.util.HashMap; import java.util.Map; import java.util.UUID; import static java.lang.String.format; import static org.assertj.core.api.Assertions.assertThat; -import static org.eclipse.edc.vault.hashicorp.HashicorpVaultClient.VAULT_DATA_ENTRY_NAME; import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_TOKEN; import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_URL; -import static org.eclipse.edc.vault.hashicorp.util.X509CertificateTestUtil.convertToPem; -import static org.eclipse.edc.vault.hashicorp.util.X509CertificateTestUtil.generateCertificate; @ComponentTest @Testcontainers @@ -51,6 +44,7 @@ class HashicorpVaultIntegrationTest { static final String DOCKER_IMAGE_NAME = "vault:1.9.6"; 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 @@ -142,29 +136,6 @@ void testDeleteSecret_doesNotExist(Vault vault) { assertThat(vault.resolveSecret(key)).isNull(); } - @Test - void resolveCertificate_success(Vault vault, CertificateResolver resolver) throws CertificateException, IOException, NoSuchAlgorithmException, org.bouncycastle.operator.OperatorCreationException { - var key = UUID.randomUUID().toString(); - var certificateExpected = generateCertificate(5, "Test"); - var pem = convertToPem(certificateExpected); - - vault.storeSecret(key, pem); - var certificateResult = resolver.resolveCertificate(key); - - assertThat(certificateExpected).isEqualTo(certificateResult); - } - - @Test - void resolveCertificate_malformed(Vault vault, CertificateResolver resolver) { - var key = UUID.randomUUID().toString(); - var value = UUID.randomUUID().toString(); - vault.storeSecret(key, value); - - var certificateResult = resolver.resolveCertificate(key); - assertThat(certificateResult).isNull(); - } - - private Map getConfig() { return new HashMap<>() { { diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSettingsTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSettingsTest.java new file mode 100644 index 00000000000..668c45d0f88 --- /dev/null +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultSettingsTest.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2024 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 - Implement automatic Hashicorp Vault token renewal + * + */ + +package org.eclipse.edc.vault.hashicorp; + +import okhttp3.HttpUrl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_TOKEN_RENEW_BUFFER_DEFAULT; +import static org.eclipse.edc.vault.hashicorp.HashicorpVaultExtension.VAULT_TOKEN_TTL_DEFAULT; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class HashicorpVaultSettingsTest { + + private static final String TOKEN = "token"; + private static final String URL = "https://test.com/vault"; + private static final HttpUrl HTTP_URL = HttpUrl.parse(URL); + private static final String HEALTH_CHECK_PATH = "/healthcheck/path"; + private static final String SECRET_PATH = "/secret/path"; + + @Test + void createSettings_withDefaultValues_shouldSucceed() { + var settings = assertDoesNotThrow(() -> createSettings( + URL, + TOKEN, + HEALTH_CHECK_PATH, VAULT_TOKEN_TTL_DEFAULT, + VAULT_TOKEN_RENEW_BUFFER_DEFAULT)); + assertThat(settings.url()).isEqualTo(HTTP_URL); + assertThat(settings.healthCheckEnabled()).isEqualTo(true); + assertThat(settings.healthCheckPath()).isEqualTo(HEALTH_CHECK_PATH); + assertThat(settings.healthStandbyOk()).isEqualTo(true); + assertThat(settings.token()).isEqualTo(TOKEN); + assertThat(settings.ttl()).isEqualTo(VAULT_TOKEN_TTL_DEFAULT); + assertThat(settings.renewBuffer()).isEqualTo(VAULT_TOKEN_RENEW_BUFFER_DEFAULT); + assertThat(settings.secretPath()).isEqualTo(SECRET_PATH); + } + + @Test + void createSettings_withVaultUrlNull_shouldThrowException() { + var throwable = assertThrows(Exception.class, () -> createSettings( + null, + TOKEN, + HEALTH_CHECK_PATH, VAULT_TOKEN_TTL_DEFAULT, + VAULT_TOKEN_RENEW_BUFFER_DEFAULT)); + assertThat(throwable.getMessage()).isEqualTo("Vault url must not be null"); + } + + @Test + void createSettings_withVaultUrlInvalid_shouldThrowException() { + var throwable = assertThrows(Exception.class, () -> createSettings( + "this is not valid", + TOKEN, + HEALTH_CHECK_PATH, + VAULT_TOKEN_TTL_DEFAULT, + VAULT_TOKEN_RENEW_BUFFER_DEFAULT)); + assertThat(throwable.getMessage()).isEqualTo("Vault url must be valid"); + } + + @Test + void createSettings_withHealthCheckPathNull_shouldThrowException() { + var throwable = assertThrows(Exception.class, () -> createSettings( + URL, + TOKEN, + null, + VAULT_TOKEN_TTL_DEFAULT, + VAULT_TOKEN_RENEW_BUFFER_DEFAULT)); + assertThat(throwable.getMessage()).isEqualTo("Vault health check path must not be null"); + } + + @Test + void createSettings_withVaultTokenNull_shouldThrowException() { + var throwable = assertThrows(Exception.class, () -> createSettings( + URL, + null, + HEALTH_CHECK_PATH, + VAULT_TOKEN_TTL_DEFAULT, + VAULT_TOKEN_RENEW_BUFFER_DEFAULT)); + assertThat(throwable.getMessage()).isEqualTo("Vault token must not be null"); + } + + @Test + void createSettings_withVaultTokenTtlLessThan5_shouldThrowException() { + var throwable = assertThrows(Exception.class, () -> createSettings( + URL, + TOKEN, + HEALTH_CHECK_PATH, + 4, + VAULT_TOKEN_RENEW_BUFFER_DEFAULT)); + assertThat(throwable.getMessage()).isEqualTo("Vault token ttl minimum value is 5"); + } + + @ParameterizedTest + @ValueSource(longs = {VAULT_TOKEN_TTL_DEFAULT, VAULT_TOKEN_TTL_DEFAULT + 1}) + void createSettings_withVaultTokenRenewBufferEqualOrGreaterThanTtl_shouldThrowException(long value) { + var throwable = assertThrows(Exception.class, () -> createSettings( + URL, + TOKEN, + HEALTH_CHECK_PATH, + VAULT_TOKEN_TTL_DEFAULT, + value)); + assertThat(throwable.getMessage()).isEqualTo("Vault token renew buffer value must be less than ttl value"); + } + + private HashicorpVaultSettings createSettings(String url, + String token, + String healthCheckPath, + long ttl, + long renewBuffer) { + return HashicorpVaultSettings.Builder.newInstance() + .url(url) + .healthCheckEnabled(true) + .healthCheckPath(healthCheckPath) + .healthStandbyOk(true) + .token(token) + .ttl(ttl) + .renewBuffer(renewBuffer) + .secretPath(SECRET_PATH) + .build(); + } +} 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 index f64a0781d3a..cf6eda7ad1e 100644 --- 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 @@ -43,80 +43,62 @@ void setup() { @Test void getSecretSuccess() { - // prepare when(vaultClient.getSecretValue(KEY)).thenReturn(Result.success("test-secret")); - // invoke var returnValue = vault.resolveSecret(KEY); - // verify verify(vaultClient, times(1)).getSecretValue(KEY); assertThat(returnValue).isEqualTo("test-secret"); } @Test void getSecretFailure() { - // prepare when(vaultClient.getSecretValue(KEY)).thenReturn(Result.failure("test-failure")); - // invoke var returnValue = vault.resolveSecret(KEY); - // verify verify(vaultClient, times(1)).getSecretValue(KEY); assertThat(returnValue).isNull(); } @Test void setSecretSuccess() { - // prepare var value = UUID.randomUUID().toString(); when(vaultClient.setSecret(KEY, value)).thenReturn(Result.success(null)); - // invoke var returnValue = vault.storeSecret(KEY, value); - // verify verify(vaultClient, times(1)).setSecret(KEY, value); assertThat(returnValue.succeeded()).isTrue(); } @Test void setSecretFailure() { - // prepare var value = UUID.randomUUID().toString(); when(vaultClient.setSecret(KEY, value)).thenReturn(Result.failure("test-failure")); - // invoke var returnValue = vault.storeSecret(KEY, value); - // verify verify(vaultClient, times(1)).setSecret(KEY, value); assertThat(returnValue.failed()).isTrue(); } @Test void destroySecretSuccess() { - // prepare when(vaultClient.destroySecret(KEY)).thenReturn(Result.success()); - // invoke var returnValue = vault.deleteSecret(KEY); - // verify verify(vaultClient, times(1)).destroySecret(KEY); assertThat(returnValue.succeeded()).isTrue(); } @Test void destroySecretFailure() { - // prepare when(vaultClient.destroySecret(KEY)).thenReturn(Result.failure("test-failure")); - // invoke var returnValue = vault.deleteSecret(KEY); - // verify 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/HashicorpVaultTokenRenewTaskTest.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultTokenRenewTaskTest.java new file mode 100644 index 00000000000..1b9959f791f --- /dev/null +++ b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/HashicorpVaultTokenRenewTaskTest.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2024 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 - Implement automatic Hashicorp Vault token renewal + * + */ + +package org.eclipse.edc.vault.hashicorp; + +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.system.ExecutorInstrumentation; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.Mockito.atMostOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + +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 HashicorpVaultTokenRenewTask tokenRenewTask = new HashicorpVaultTokenRenewTask( + ExecutorInstrumentation.noop(), + client, + RENEW_BUFFER, + monitor + ); + + @Test + void start_withValidAndRenewableToken_shouldScheduleNextTokenRenewal() { + doReturn(Result.success(Boolean.TRUE)).when(client).isTokenRenewable(); + // return a successful renewal result twice + // first result should be consumed by the initial token renewal + // second renewal should be consumed by the first renewal iteration + doReturn(Result.success(VAULT_TOKEN_TTL)) + .doReturn(Result.success(VAULT_TOKEN_TTL)) + // break the renewal loop by returning a failed renewal result on the 3rd attempt + .doReturn(Result.failure("break the loop")) + .when(client) + .renewToken(); + assertThat(tokenRenewTask.isRunning()).isFalse(); + + tokenRenewTask.start(); + + await() + .atMost(VAULT_TOKEN_TTL, TimeUnit.SECONDS) + .untilAsserted(() -> { + verify(monitor, never()).warning(matches("Initial token look up failed with reason: *")); + verify(monitor, never()).warning(matches("Initial token renewal failed with reason: *")); + // initial token look up + verify(client).isTokenRenewable(); + // initial renewal + first scheduled renewal + second scheduled renewal + verify(client, times(3)).renewToken(); + verify(monitor).warning("Scheduled token renewal failed: break the loop"); + }); + assertThat(tokenRenewTask.isRunning()).isTrue(); + } + + @Test + void start_withFailedTokenLookUp_shouldNotScheduleNextTokenRenewal() { + doReturn(Result.failure("Token look up failed with status 403")).when(client).isTokenRenewable(); + + tokenRenewTask.start(); + + await() + .atMost(1L, TimeUnit.SECONDS) + .untilAsserted(() -> { + verify(monitor).warning("Initial token look up failed with reason: Token look up failed with status 403"); + verify(client, never()).renewToken(); + }); + } + + @Test + void start_withTokenNotRenewable_shouldNotScheduleNextTokenRenewal() { + doReturn(Result.success(Boolean.FALSE)).when(client).isTokenRenewable(); + + tokenRenewTask.start(); + + await() + .atMost(1L, TimeUnit.SECONDS) + .untilAsserted(() -> { + verify(monitor, never()).warning(anyString()); + verify(client, never()).renewToken(); + }); + } + + @Test + void start_withFailedTokenRenew_shouldNotScheduleNextTokenRenewal() { + doReturn(Result.success(Boolean.TRUE)).when(client).isTokenRenewable(); + doReturn(Result.failure("Token renew failed with status: 403")).when(client).renewToken(); + + tokenRenewTask.start(); + + await() + .atMost(1L, TimeUnit.SECONDS) + .untilAsserted(() -> { + verify(monitor).warning("Initial token renewal failed with reason: Token renew failed with status: 403"); + verify(client, atMostOnce()).renewToken(); + }); + } + + @Test + void stop_withTaskRunning_shouldSetRunningFalse() { + tokenRenewTask.start(); + assertThat(tokenRenewTask.isRunning()).isTrue(); + tokenRenewTask.stop(); + assertThat(tokenRenewTask.isRunning()).isFalse(); + } +} diff --git a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/util/X509CertificateTestUtil.java b/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/util/X509CertificateTestUtil.java deleted file mode 100644 index 4641e293507..00000000000 --- a/extensions/common/vault/vault-hashicorp/src/test/java/org/eclipse/edc/vault/hashicorp/util/X509CertificateTestUtil.java +++ /dev/null @@ -1,111 +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.util; - -import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers; -import org.bouncycastle.asn1.x500.X500Name; -import org.bouncycastle.asn1.x509.AlgorithmIdentifier; -import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier; -import org.bouncycastle.asn1.x509.BasicConstraints; -import org.bouncycastle.asn1.x509.Extension; -import org.bouncycastle.asn1.x509.SubjectKeyIdentifier; -import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; -import org.bouncycastle.cert.X509ExtensionUtils; -import org.bouncycastle.cert.X509v3CertificateBuilder; -import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; -import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.operator.OperatorCreationException; -import org.bouncycastle.operator.bc.BcDigestCalculatorProvider; -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; -import org.testcontainers.shaded.org.bouncycastle.openssl.jcajce.JcaPEMWriter; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStreamWriter; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.Provider; -import java.security.PublicKey; -import java.security.SecureRandom; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import java.time.Duration; -import java.time.Instant; -import java.util.Date; -import java.util.Optional; - -public class X509CertificateTestUtil { - private static final String SIGNATURE_ALGORITHM = "SHA256WithRSAEncryption"; - private static final Provider PROVIDER = new BouncyCastleProvider(); - private static final JcaX509CertificateConverter JCA_X509_CERTIFICATE_CONVERTER = - new JcaX509CertificateConverter().setProvider(PROVIDER); - - public static X509Certificate generateCertificate(int validity, String cn) throws CertificateException, OperatorCreationException, IOException, NoSuchAlgorithmException { - - var keyPair = generateKeyPair(); - - var now = Instant.now(); - var contentSigner = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM).build(keyPair.getPrivate()); - var issuer = - new X500Name(String.format("CN=%s", Optional.ofNullable(cn) - .map(String::trim).filter(s -> !s.isEmpty()) - .orElse("rootCA"))); - var serial = BigInteger.valueOf(now.toEpochMilli()); - var notBefore = Date.from(now); - var notAfter = Date.from(now.plus(Duration.ofDays(validity))); - var publicKey = keyPair.getPublic(); - X509v3CertificateBuilder certificateBuilder = new JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, issuer, publicKey); - certificateBuilder = certificateBuilder.addExtension(Extension.subjectKeyIdentifier, false, createSubjectKeyId(publicKey)); - certificateBuilder = certificateBuilder.addExtension(Extension.authorityKeyIdentifier, false, createAuthorityKeyId(publicKey)); - certificateBuilder = certificateBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); - return JCA_X509_CERTIFICATE_CONVERTER.getCertificate(certificateBuilder.build(contentSigner)); - } - - private static KeyPair generateKeyPair() throws NoSuchAlgorithmException { - var keyPairGenerator = KeyPairGenerator.getInstance("RSA", PROVIDER); - keyPairGenerator.initialize(1024, new SecureRandom()); - - return keyPairGenerator.generateKeyPair(); - } - - private static SubjectKeyIdentifier createSubjectKeyId(PublicKey publicKey) throws OperatorCreationException { - var publicKeyInfo = SubjectPublicKeyInfo.getInstance(publicKey.getEncoded()); - var digCalc = new BcDigestCalculatorProvider().get(new AlgorithmIdentifier(OIWObjectIdentifiers.idSHA1)); - return new X509ExtensionUtils(digCalc).createSubjectKeyIdentifier(publicKeyInfo); - } - - private static AuthorityKeyIdentifier createAuthorityKeyId(PublicKey publicKey) throws OperatorCreationException { - var publicKeyInfo = SubjectPublicKeyInfo.getInstance(publicKey.getEncoded()); - var digCalc = new BcDigestCalculatorProvider().get(new AlgorithmIdentifier(OIWObjectIdentifiers.idSHA1)); - return new X509ExtensionUtils(digCalc).createAuthorityKeyIdentifier(publicKeyInfo); - } - - public static String convertToPem(X509Certificate certificate) { - try (var stream = new ByteArrayOutputStream()) { - try (var writer = new OutputStreamWriter(stream)) { - var pemWriter = new JcaPEMWriter(writer); - pemWriter.writeObject(certificate); - pemWriter.flush(); - } - return stream.toString(StandardCharsets.UTF_8); - } catch (IOException e) { - throw new RuntimeException(e); - } - } -} diff --git a/spi/common/core-spi/src/main/java/org/eclipse/edc/spi/system/health/HealthCheckResult.java b/spi/common/core-spi/src/main/java/org/eclipse/edc/spi/system/health/HealthCheckResult.java index ce25f0b198c..91eca1ce682 100644 --- a/spi/common/core-spi/src/main/java/org/eclipse/edc/spi/system/health/HealthCheckResult.java +++ b/spi/common/core-spi/src/main/java/org/eclipse/edc/spi/system/health/HealthCheckResult.java @@ -20,6 +20,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -43,6 +44,10 @@ public static HealthCheckResult failed(String... errors) { return new HealthCheckResult(false, new Failure(errorList)); } + public static HealthCheckResult failed(List errors) { + return new HealthCheckResult(false, new Failure(errors)); + } + @JsonProperty("isHealthy") @Override