Skip to content

Commit

Permalink
feat: implement Hashicorp Vault signing service (#4749)
Browse files Browse the repository at this point in the history
* add functionality to sign, verify, rotate

* refactor hashicorp vault

* upgrade hashicorp vault image in tests

* renamed hashicorp vault client

* satisfy github scanning - ssrf

* license headers

* javadoc, cosmetics

* pr remarks
  • Loading branch information
paullatzelsperger authored Jan 22, 2025
1 parent f5bead2 commit 82df774
Show file tree
Hide file tree
Showing 24 changed files with 689 additions and 773 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,49 +15,154 @@

package org.eclipse.edc.vault.hashicorp;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.HttpUrl;
import okhttp3.Request;
import okhttp3.RequestBody;
import org.eclipse.edc.http.spi.EdcHttpClient;
import org.eclipse.edc.spi.EdcException;
import org.eclipse.edc.spi.monitor.Monitor;
import org.eclipse.edc.spi.result.Result;
import org.eclipse.edc.spi.security.Vault;
import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultClient;
import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings;
import org.eclipse.edc.vault.hashicorp.util.PathUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;

import static org.eclipse.edc.vault.hashicorp.VaultConstants.VAULT_SECRET_METADATA_PATH;
import static org.eclipse.edc.vault.hashicorp.VaultConstants.VAULT_TOKEN_HEADER;

/**
* Implements a vault backed by Hashicorp Vault.
*/
public class HashicorpVault implements Vault {
private static final String VAULT_SECRET_DATA_PATH = "data";
private static final String VAULT_DATA_ENTRY_NAME = "content";

@NotNull
private final HashicorpVaultClient hashicorpVaultClient;
@NotNull
private final Monitor monitor;

public HashicorpVault(@NotNull HashicorpVaultClient hashicorpVaultClient, @NotNull Monitor monitor) {
this.hashicorpVaultClient = hashicorpVaultClient;
private final HashicorpVaultSettings settings;
private final EdcHttpClient httpClient;
private final ObjectMapper objectMapper;

public HashicorpVault(@NotNull Monitor monitor, HashicorpVaultSettings settings, EdcHttpClient httpClient, ObjectMapper objectMapper) {
this.monitor = monitor;
this.settings = settings;
this.httpClient = httpClient;
this.objectMapper = objectMapper;
}

@Override
public @Nullable String resolveSecret(String key) {
var result = hashicorpVaultClient.getSecretValue(key);

if (result.failed()) {
monitor.debug("Failed to resolve secret '%s': %s".formatted(key, result.getFailureMessages()));
return null;
}
var requestUri = getSecretUrl(key, VAULT_SECRET_DATA_PATH);
var request = new Request.Builder()
.url(requestUri)
.header(VAULT_TOKEN_HEADER, settings.token())
.get()
.build();

try (var response = httpClient.execute(request)) {

if (response.isSuccessful()) {

return result.getContent();
var responseBody = response.body();
if (responseBody != null) {
// using JsonNode here because it makes traversing down the tree null-safe
var payload = objectMapper.readValue(responseBody.string(), JsonNode.class);
return payload.path("data").path("data").get(VAULT_DATA_ENTRY_NAME).asText();
}
monitor.debug("Secret response body is empty");

} else {
if (response.code() == 404) {
monitor.debug("Secret not found");
} else {
monitor.debug("Failed to get secret with status %d".formatted(response.code()));
}
}
} catch (IOException e) {
monitor.warning("Failed to get secret with reason: %s".formatted(e.getMessage()));
}
return null;
}

@Override
public Result<Void> storeSecret(String key, String value) {
var result = hashicorpVaultClient.setSecret(key, value);

return result.succeeded() ? Result.success() : Result.failure(result.getFailureMessages());
var requestUri = getSecretUrl(key, VAULT_SECRET_DATA_PATH);

var requestPayload = Map.of("data", Map.of(VAULT_DATA_ENTRY_NAME, value));
var request = new Request.Builder()
.url(requestUri)
.header(VAULT_TOKEN_HEADER, settings.token())
.post(jsonBody(requestPayload))
.build();

try (var response = httpClient.execute(request)) {
if (response.isSuccessful()) {
return response.body() == null ? Result.failure("Setting secret returned empty body") : Result.success();
} else {
return Result.failure("Failed to set secret with status %d".formatted(response.code()));
}
} catch (IOException e) {
return Result.failure("Failed to set secret with reason: %s".formatted(e.getMessage()));
}
}

@Override
public Result<Void> deleteSecret(String key) {
return hashicorpVaultClient.destroySecret(key);
var requestUri = getSecretUrl(key, VAULT_SECRET_METADATA_PATH);
var request = new Request.Builder()
.url(requestUri)
.header(VAULT_TOKEN_HEADER, settings.token())
.delete()
.build();

try (var response = httpClient.execute(request)) {
return response.isSuccessful() || response.code() == 404 ? Result.success() : Result.failure("Failed to destroy secret with status %d".formatted(response.code()));
} catch (IOException e) {
return Result.failure("Failed to destroy secret with reason: %s".formatted(e.getMessage()));
}
}

private HttpUrl getSecretUrl(String key, String entryType) {
key = URLEncoder.encode(key, StandardCharsets.UTF_8);

// restore '/' characters to allow subdirectories
var sanitizedKey = key.replace("%2F", "/");

var vaultApiPath = settings.secretPath();
var folderPath = settings.getFolderPath();

var builder = HttpUrl.parse(settings.url())
.newBuilder()
.addPathSegments(PathUtil.trimLeadingOrEndingSlash(vaultApiPath))
.addPathSegment(entryType);

if (folderPath != null) {
builder.addPathSegments(PathUtil.trimLeadingOrEndingSlash(folderPath));
}

return builder
.addPathSegments(sanitizedKey)
.build();
}

private RequestBody jsonBody(Object body) {
String jsonRepresentation;
try {
jsonRepresentation = objectMapper.writeValueAsString(body);
} catch (JsonProcessingException e) {
throw new EdcException(e);
}
return RequestBody.create(jsonRepresentation, VaultConstants.MEDIA_TYPE_APPLICATION_JSON);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,22 @@
import org.eclipse.edc.runtime.metamodel.annotation.Inject;
import org.eclipse.edc.runtime.metamodel.annotation.Provider;
import org.eclipse.edc.spi.monitor.Monitor;
import org.eclipse.edc.spi.security.SignatureService;
import org.eclipse.edc.spi.security.Vault;
import org.eclipse.edc.spi.system.ExecutorInstrumentation;
import org.eclipse.edc.spi.system.ServiceExtension;
import org.eclipse.edc.spi.system.ServiceExtensionContext;
import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultClient;
import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultHealthService;
import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultSettings;
import org.eclipse.edc.vault.hashicorp.client.HashicorpVaultTokenRenewTask;
import org.jetbrains.annotations.NotNull;

import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;

@Extension(value = HashicorpVaultExtension.NAME)
public class HashicorpVaultExtension implements ServiceExtension {
public static final String NAME = "Hashicorp Vault";
public static final ObjectMapper MAPPER = new ObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

@Inject
private EdcHttpClient httpClient;
Expand All @@ -45,30 +48,23 @@ public class HashicorpVaultExtension implements ServiceExtension {
@Configuration
private HashicorpVaultSettings config;

private HashicorpVaultClient client;
private HashicorpVaultTokenRenewTask tokenRenewalTask;
private Monitor monitor;
private HashicorpVaultHealthService healthService;

@Override
public String name() {
return NAME;
}

@Provider
public HashicorpVaultClient hashicorpVaultClient() {
if (client == null) {
// the default type manager cannot be used as the Vault is a primordial service loaded at boot
var mapper = new ObjectMapper();
mapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

client = new HashicorpVaultClient(httpClient, mapper, monitor, config);
}
return client;
public Vault hashicorpVault() {
return new HashicorpVault(monitor, config, httpClient, MAPPER);
}

@Provider
public Vault hashicorpVault() {
return new HashicorpVault(hashicorpVaultClient(), monitor);
public SignatureService signatureService() {
return new HashicorpVaultSignatureService(monitor, config, httpClient, MAPPER);
}

@Override
Expand All @@ -77,11 +73,19 @@ public void initialize(ServiceExtensionContext context) {
tokenRenewalTask = new HashicorpVaultTokenRenewTask(
NAME,
executorInstrumentation,
hashicorpVaultClient(),
createHealthService(),
config.renewBuffer(),
monitor);
}

@Provider
public @NotNull HashicorpVaultHealthService createHealthService() {
if (healthService == null) {
healthService = new HashicorpVaultHealthService(httpClient, MAPPER, monitor, config);
}
return healthService;
}

@Override
public void start() {
if (config.scheduledTokenRenewEnabled()) {
Expand Down
Loading

0 comments on commit 82df774

Please sign in to comment.