diff --git a/docker-postgres-vault-example.sh b/docker-postgres-vault-example.sh deleted file mode 100644 index 75e856c..0000000 --- a/docker-postgres-vault-example.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# https://learn.hashicorp.com/tutorials/vault/database-secrets - -docker run \ - --detach \ - --name learn-postgres \ - -e POSTGRES_USER=root \ - -e POSTGRES_PASSWORD=rootpassword \ - -p 5434:5432 \ - --rm \ - postgres - -docker exec -i \ - learn-postgres \ - psql -U root -c "CREATE ROLE \"ro\" NOINHERIT;" - -docker exec -i \ - learn-postgres \ - psql -U root -c "GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"ro\";" - -export VAULT_ADDR='http://127.0.0.1:8201' -export VAULT_TOKEN=root - -vault server -dev -dev-root-token-id root -dev-listen-address=127.0.0.1:8201 - -vault secrets enable database - -vault write database/config/postgresql \ - plugin_name=postgresql-database-plugin \ - connection_url="postgresql://{{username}}:{{password}}@localhost:5434/postgres?sslmode=disable" \ - allowed_roles=readonly \ - username="root" \ - password="rootpassword" - -tee readonly.sql < - - - diff --git a/docker-postgres-vault-kv1-example.sh b/docker-postgres-vault-kv1-example.sh new file mode 100755 index 0000000..13f18fe --- /dev/null +++ b/docker-postgres-vault-kv1-example.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# https://learn.hashicorp.com/tutorials/vault/database-secrets +set -euo pipefail + +# Usage: +# ./docker-postgres-vault-kv1-example.sh # start +# ./docker-postgres-vault-kv1-example.sh --stop # stop vault + container + +export VAULT_ADDR='http://127.0.0.1:8201' +export VAULT_TOKEN='root' + +POSTGRES_CONTAINER_NAME='datagrip-vault-postgres' +POSTGRES_PORT='5432' +POSTGRES_USER='root' +POSTGRES_PASSWORD='rootpassword' +POSTGRES_DB='postgres' + +MODE=${1:-""} + +cleanup_container() { + local name="$1" + if docker ps -a --format '{{.Names}}' | grep -qx "${name}"; then + echo "Removing existing container: ${name}" + docker rm -f "${name}" > /dev/null + fi +} + +kill_vault_on_ports() { + if ! command -v lsof > /dev/null 2>&1; then + echo "lsof not found; skipping port checks." >&2 + return 0 + fi + local ports=("$@") + local all_pids="" + for port in "${ports[@]}"; do + local pids + pids=$(lsof -tiTCP:"${port}" -sTCP:LISTEN || true) + if [[ -n "${pids}" ]]; then + all_pids="${all_pids} ${pids}" + fi + done + local uniq_pids + uniq_pids=$(echo "${all_pids}" | tr ' ' '\n' | sed '/^$/d' | sort -u | tr '\n' ' ') + if [[ -n "${uniq_pids}" ]]; then + for pid in ${uniq_pids}; do + local cmd + cmd=$(ps -p "${pid}" -o comm= | tr -d '\n') + if [[ "${cmd}" == "vault" ]]; then + echo "Killing vault process on ports ${ports[*]}: PID ${pid}" + kill "${pid}" + killed_any=1 + else + echo "Skipping PID ${pid} (${cmd}); not a vault process." >&2 + fi + done + fi +} + +ensure_ports_available() { + if ! command -v lsof > /dev/null 2>&1; then + echo "lsof not found; skipping port checks." >&2 + return 0 + fi + local ports=("$@") + local all_pids="" + for port in "${ports[@]}"; do + local pids + pids=$(lsof -tiTCP:"${port}" -sTCP:LISTEN || true) + if [[ -n "${pids}" ]]; then + echo "Port ${port} is already in use by PID(s): ${pids}" + all_pids="${all_pids} ${pids}" + fi + done + local uniq_pids + uniq_pids=$(echo "${all_pids}" | tr ' ' '\n' | sed '/^$/d' | sort -u | tr '\n' ' ') + if [[ -n "${uniq_pids}" ]]; then + for pid in ${uniq_pids}; do + local cmd + cmd=$(ps -p "${pid}" -o comm= | tr -d '\n') + if [[ -n "${cmd}" ]]; then + echo "- ${pid}: ${cmd}" + fi + done + read -r -p "Kill these process(es)? [y/N] " answer + case "${answer}" in + [yY][eE][sS]|[yY]) + echo "Killing PID(s): ${uniq_pids}" + kill ${uniq_pids} + ;; + *) + echo "Aborting." >&2 + exit 1 + ;; + esac + fi +} + +if [[ "${MODE}" == "--stop" ]]; then + container_removed=0 + killed_any=0 + if docker ps -a --format '{{.Names}}' | grep -qx "${POSTGRES_CONTAINER_NAME}"; then + cleanup_container "${POSTGRES_CONTAINER_NAME}" + container_removed=1 + fi + kill_vault_on_ports 8201 8202 + if [[ "${container_removed}" -eq 1 ]]; then + echo "Docker container removed: ${POSTGRES_CONTAINER_NAME}" + fi + if [[ "${container_removed}" -eq 0 && "${killed_any}" -eq 0 ]]; then + echo "Everything already stopped." + fi + exit 0 +fi + +cleanup_container "${POSTGRES_CONTAINER_NAME}" +ensure_ports_available 8201 8202 + +docker run \ + --detach \ + --name "${POSTGRES_CONTAINER_NAME}" \ + -e POSTGRES_USER="${POSTGRES_USER}" \ + -e POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \ + -e POSTGRES_DB="${POSTGRES_DB}" \ + -p "${POSTGRES_PORT}:5432" \ + --rm \ + postgres + +echo "Waiting for Postgres to accept connections..." +for i in {1..30}; do + if docker exec "${POSTGRES_CONTAINER_NAME}" pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! docker exec "${POSTGRES_CONTAINER_NAME}" pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then + echo "Postgres did not become ready in time." >&2 + exit 1 +fi + +docker exec -i \ + "${POSTGRES_CONTAINER_NAME}" \ + psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "CREATE ROLE \"ro\" NOINHERIT;" + +docker exec -i \ + "${POSTGRES_CONTAINER_NAME}" \ + psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"ro\";" + +vault server -dev -dev-root-token-id root -dev-listen-address=127.0.0.1:8201 & +VAULT_PID=$! + +sleep 1 + +vault secrets enable database + +vault write database/config/postgresql \ + plugin_name=postgresql-database-plugin \ + connection_url="postgresql://{{username}}:{{password}}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable" \ + allowed_roles=readonly \ + username="${POSTGRES_USER}" \ + password="${POSTGRES_PASSWORD}" + +vault write database/roles/readonly \ + db_name=postgresql \ + creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}' INHERIT; GRANT ro TO \"{{name}}\";" \ + default_ttl=1h \ + max_ttl=24h + +vault read database/creds/readonly + +cat < /dev/null + fi +} + +kill_vault_on_ports() { + if ! command -v lsof > /dev/null 2>&1; then + echo "lsof not found; skipping port checks." >&2 + return 0 + fi + local ports=("$@") + local all_pids="" + for port in "${ports[@]}"; do + local pids + pids=$(lsof -tiTCP:"${port}" -sTCP:LISTEN || true) + if [[ -n "${pids}" ]]; then + all_pids="${all_pids} ${pids}" + fi + done + local uniq_pids + uniq_pids=$(echo "${all_pids}" | tr ' ' '\n' | sed '/^$/d' | sort -u | tr '\n' ' ') + if [[ -n "${uniq_pids}" ]]; then + for pid in ${uniq_pids}; do + local cmd + cmd=$(ps -p "${pid}" -o comm= | tr -d '\n') + if [[ "${cmd}" == "vault" ]]; then + echo "Killing vault process on ports ${ports[*]}: PID ${pid}" + kill "${pid}" + killed_any=1 + else + echo "Skipping PID ${pid} (${cmd}); not a vault process." >&2 + fi + done + fi +} + +ensure_ports_available() { + if ! command -v lsof > /dev/null 2>&1; then + echo "lsof not found; skipping port checks." >&2 + return 0 + fi + local ports=("$@") + local all_pids="" + for port in "${ports[@]}"; do + local pids + pids=$(lsof -tiTCP:"${port}" -sTCP:LISTEN || true) + if [[ -n "${pids}" ]]; then + echo "Port ${port} is already in use by PID(s): ${pids}" + all_pids="${all_pids} ${pids}" + fi + done + local uniq_pids + uniq_pids=$(echo "${all_pids}" | tr ' ' '\n' | sed '/^$/d' | sort -u | tr '\n' ' ') + if [[ -n "${uniq_pids}" ]]; then + for pid in ${uniq_pids}; do + local cmd + cmd=$(ps -p "${pid}" -o comm= | tr -d '\n') + if [[ -n "${cmd}" ]]; then + echo "- ${pid}: ${cmd}" + fi + done + read -r -p "Kill these process(es)? [y/N] " answer + case "${answer}" in + [yY][eE][sS]|[yY]) + echo "Killing PID(s): ${uniq_pids}" + kill ${uniq_pids} + ;; + *) + echo "Aborting." >&2 + exit 1 + ;; + esac + fi +} + +if [[ "${MODE}" == "--stop" ]]; then + container_removed=0 + killed_any=0 + if docker ps -a --format '{{.Names}}' | grep -qx "${POSTGRES_CONTAINER_NAME}"; then + cleanup_container "${POSTGRES_CONTAINER_NAME}" + container_removed=1 + fi + kill_vault_on_ports 8203 8204 + if [[ "${container_removed}" -eq 1 ]]; then + echo "Docker container removed: ${POSTGRES_CONTAINER_NAME}" + fi + if [[ "${container_removed}" -eq 0 && "${killed_any}" -eq 0 ]]; then + echo "Everything already stopped." + fi + exit 0 +fi + +cleanup_container "${POSTGRES_CONTAINER_NAME}" +ensure_ports_available 8203 8204 + +docker run \ + --detach \ + --name "${POSTGRES_CONTAINER_NAME}" \ + -e POSTGRES_USER="${POSTGRES_USER}" \ + -e POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \ + -e POSTGRES_DB="${POSTGRES_DB}" \ + -p "${POSTGRES_PORT}:5432" \ + --rm \ + postgres + +echo "Waiting for Postgres to accept connections..." +for i in {1..30}; do + if docker exec "${POSTGRES_CONTAINER_NAME}" pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! docker exec "${POSTGRES_CONTAINER_NAME}" pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then + echo "Postgres did not become ready in time." >&2 + exit 1 +fi + +vault server -dev -dev-root-token-id root -dev-listen-address=127.0.0.1:8203 & +VAULT_PID=$! + +sleep 1 + +vault secrets enable -path=kv kv-v2 +vault kv put kv/my_db_credentials db_user="${POSTGRES_USER}" db_pass="${POSTGRES_PASSWORD}" + +vault kv get kv/my_db_credentials + +cat < secretsCache = new ConcurrentHashMap<>(); @@ -75,7 +77,7 @@ public ApplicabilityLevel.Result getApplicability(@NotNull DatabaseConnectionPoi logger.info("Secret used: " + secret); DefaultVaultTokenLoader vaultTokenLoader = new DefaultVaultTokenLoader( - Optional.ofNullable(protoConnection.getConnectionPoint().getAdditionalProperty(PROP_TOKEN_FILE)).map(Path::of), + getTokenFile(protoConnection), address ); @@ -85,7 +87,7 @@ public ApplicabilityLevel.Result getApplicability(@NotNull DatabaseConnectionPoi case KV1 -> Request.kv1Request(usernameKey, passwordKey); case KV2 -> Request.kv2Request(usernameKey, passwordKey); }; - final var key = new CacheKey(address, secret, secretType); + final var key = new CacheKey(address, secret, secretType, usernameKey, passwordKey); logger.info("Cache key used: " + key); final var value = secretsCache.compute(key, (k, v) -> { @@ -185,11 +187,27 @@ public ApplicabilityLevel.Result getApplicability(@NotNull DatabaseConnectionPoi } private @Nullable String getUsernameKey(ProtoConnection protoConnection) { - return protoConnection.getConnectionPoint().getAdditionalProperty(PROP_USERNAME_KEY); + final var configuredKey = protoConnection.getConnectionPoint().getAdditionalProperty(PROP_USERNAME_KEY); + if (configuredKey == null || configuredKey.isBlank()) { + return DEFAULT_USERNAME_KEY; + } + return configuredKey; } private @Nullable String getPasswordKey(ProtoConnection protoConnection) { - return protoConnection.getConnectionPoint().getAdditionalProperty(PROP_PASSWORD_KEY); + final var configuredKey = protoConnection.getConnectionPoint().getAdditionalProperty(PROP_PASSWORD_KEY); + if (configuredKey == null || configuredKey.isBlank()) { + return DEFAULT_PASSWORD_KEY; + } + return configuredKey; + } + + private @NotNull Optional getTokenFile(ProtoConnection protoConnection) { + final var configuredTokenFile = protoConnection.getConnectionPoint().getAdditionalProperty(PROP_TOKEN_FILE); + if (configuredTokenFile == null || configuredTokenFile.isBlank()) { + return Optional.empty(); + } + return Optional.of(Path.of(configuredTokenFile)); } } diff --git a/src/main/java/com/premiumminds/vault/client/VaultClient.java b/src/main/java/com/premiumminds/vault/client/VaultClient.java index 32955fb..47dbe93 100644 --- a/src/main/java/com/premiumminds/vault/client/VaultClient.java +++ b/src/main/java/com/premiumminds/vault/client/VaultClient.java @@ -20,6 +20,7 @@ import java.security.cert.Certificate; import java.security.cert.CertificateFactory; import java.time.Duration; +import java.util.Collection; import java.util.Map; import java.util.Optional; @@ -151,27 +152,65 @@ public Credentials getCredentials( // TODO: replace with JEP 441: Pattern Matching for switch in Java 21 final var vaultResponse = gson.fromJson(response.body(), VaultResponse.class); if (credentialsRequest instanceof Request.StaticRequest) { - final var username = (String) vaultResponse.getData().get("username"); - final var password = (String) vaultResponse.getData().get("password"); + final var username = requireStringValue(vaultResponse.getData(), "username", secret, "STATIC_ROLE"); + final var password = requireStringValue(vaultResponse.getData(), "password", secret, "STATIC_ROLE"); return new Response(username, password); } else if (credentialsRequest instanceof Request.DynamicRequest) { - final var username = (String) vaultResponse.getData().get("username"); - final var password = (String) vaultResponse.getData().get("password"); + final var username = requireStringValue(vaultResponse.getData(), "username", secret, "DYNAMIC_ROLE"); + final var password = requireStringValue(vaultResponse.getData(), "password", secret, "DYNAMIC_ROLE"); return new ResponseWithLease(username, password, vaultResponse.getLeaseId()); } else if (credentialsRequest instanceof Request.KV1Request kv1Request) { - final var username = (String) vaultResponse.getData().get(kv1Request.userKey()); - final var password = (String) vaultResponse.getData().get(kv1Request.passKey()); + validateConfiguredKey(kv1Request.userKey(), "username", secret, "KV1"); + validateConfiguredKey(kv1Request.passKey(), "password", secret, "KV1"); + final var username = requireStringValue(vaultResponse.getData(), kv1Request.userKey(), secret, "KV1"); + final var password = requireStringValue(vaultResponse.getData(), kv1Request.passKey(), secret, "KV1"); return new Response(username, password); } else if (credentialsRequest instanceof Request.KV2Request kv2Request) { - final var data = (Map) vaultResponse.getData().get("data"); - final var username = data.get(kv2Request.userKey()); - final var password = data.get(kv2Request.passKey()); + validateConfiguredKey(kv2Request.userKey(), "username", secret, "KV2"); + validateConfiguredKey(kv2Request.passKey(), "password", secret, "KV2"); + final var data = requireNestedDataMap(vaultResponse.getData(), secret, "KV2"); + final var username = requireStringValue(data, kv2Request.userKey(), secret, "KV2"); + final var password = requireStringValue(data, kv2Request.passKey(), secret, "KV2"); return new Response(username, password); } else { throw new IllegalStateException("Unknown request type: " + credentialsRequest.getClass().getName()); } } + private static void validateConfiguredKey(String configuredKey, String fieldName, String secret, String secretType) { + if (configuredKey == null || configuredKey.isBlank()) { + throw new IllegalArgumentException( + "Vault secret '" + secret + "' requires a non-empty " + fieldName + " key configuration" + ); + } + } + + @SuppressWarnings("unchecked") + private static Map requireNestedDataMap(Map data, String secret, String secretType) { + final var nestedData = data.get("data"); + if (!(nestedData instanceof Map nestedMap)) { + throw new IllegalArgumentException( + "Vault secret '" + secret + "' does not contain the expected nested 'data' object" + ); + } + return (Map) nestedMap; + } + + private static String requireStringValue(Map data, String key, String secret, String secretType) { + final var value = data.get(key); + if (!(value instanceof String stringValue) || stringValue.isBlank()) { + throw new IllegalArgumentException( + "Vault secret '" + secret + "' does not contain a value for key '" + key + + "'. Available keys: " + formatKeys(data.keySet()) + ); + } + return stringValue; + } + + private static String formatKeys(Collection keys) { + return keys.toString(); + } + private record Response(String username, String password) implements Credentials { } diff --git a/src/test/java/com/premiumminds/vault/client/VaultClientTest.java b/src/test/java/com/premiumminds/vault/client/VaultClientTest.java index 2489743..506aaa7 100644 --- a/src/test/java/com/premiumminds/vault/client/VaultClientTest.java +++ b/src/test/java/com/premiumminds/vault/client/VaultClientTest.java @@ -1,6 +1,7 @@ package com.premiumminds.vault.client; import com.github.dockerjava.api.model.Capability; +import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.testcontainers.containers.ExecConfig; @@ -8,11 +9,14 @@ import org.testcontainers.containers.Network; import org.testcontainers.utility.DockerImageName; +import java.io.OutputStream; +import java.net.InetSocketAddress; import java.nio.file.Files; import java.nio.file.Path; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; +import java.nio.charset.StandardCharsets; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -73,6 +77,110 @@ void certificateDirectoryReturnsExplicitError() throws Exception { assertEquals("Vault certificate path is not a file: " + certificateDir, exception.getMessage()); } + /** + * When the user explicitly configures KV2 field names, missing keys should fail + * fast during Vault response parsing instead of returning null credentials and + * surfacing later as a database authentication failure. + */ + @Test + void kv2MissingConfiguredKeyFailsFast() throws Exception { + final var responseBody = """ + { + "request_id": "req-missing-key", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "data": { + "username": "app_user", + "password": "app_password" + } + } + } + """; + + final var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1/kv/data/app", exchange -> { + exchange.getResponseHeaders().add("Content-Type", "application/json"); + final var bytes = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(bytes); + } + }); + server.start(); + + try { + final var vaultClient = VaultClient.builder() + .withAddress("http://127.0.0.1:" + server.getAddress().getPort()) + .withTokenLoader(() -> "root") + .build(); + + final var exception = assertThrows( + IllegalArgumentException.class, + () -> vaultClient.getCredentials("kv/data/app", Request.kv2Request("missing_user", "password")) + ); + + assertEquals( + "Vault secret 'kv/data/app' does not contain a value for key 'missing_user'. Available keys: [username, password]", + exception.getMessage() + ); + } finally { + server.stop(0); + } + } + + /** + * Mirrors the KV2 validation behavior for KV1 secrets. Both configurable secret + * formats should fail immediately when the requested key is not present in the + * returned secret payload. + */ + @Test + void kv1MissingConfiguredKeyFailsFast() throws Exception { + final var responseBody = """ + { + "request_id": "req-missing-key", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "username": "app_user", + "password": "app_password" + } + } + """; + + final var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1/kv/data/app", exchange -> { + exchange.getResponseHeaders().add("Content-Type", "application/json"); + final var bytes = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(bytes); + } + }); + server.start(); + + try { + final var vaultClient = VaultClient.builder() + .withAddress("http://127.0.0.1:" + server.getAddress().getPort()) + .withTokenLoader(() -> "root") + .build(); + + final var exception = assertThrows( + IllegalArgumentException.class, + () -> vaultClient.getCredentials("kv/data/app", Request.kv1Request("missing_user", "password")) + ); + + assertEquals( + "Vault secret 'kv/data/app' does not contain a value for key 'missing_user'. Available keys: [username, password]", + exception.getMessage() + ); + } finally { + server.stop(0); + } + } + @Test void dynamicCredentials() throws Exception {