diff --git a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/encryption/vault/VaultEnvironmentEncryptor.java b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/encryption/vault/VaultEnvironmentEncryptor.java index ab2412b6f2..893805c0c6 100644 --- a/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/encryption/vault/VaultEnvironmentEncryptor.java +++ b/spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/encryption/vault/VaultEnvironmentEncryptor.java @@ -20,6 +20,8 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -36,7 +38,25 @@ * VaultEnvironmentEncryptor that can decrypt property values prefixed with {vault} * marker. * + *

+ * This class is responsible for decrypting properties in the environment that are + * prefixed with "{vault}". The vault key-value pairs are retrieved from a Vault server + * using the provided {@link VaultKeyValueOperations} template. Properties that start with + * "{vault}" are expected to follow a specific format: "{vault}:key#path" where: + *

+ * + *

+ * Before retrieving values from Vault, any environment variable placeholders in the + * format ${VAR_NAME} found in the vault reference are replaced with actual environment + * variable values. If an environment variable is not found, the placeholder remains + * unchanged. + *

+ * * @author Alexey Zhokhov + * @author Pavel Andrusov */ public class VaultEnvironmentEncryptor implements EnvironmentEncryptor { @@ -50,6 +70,20 @@ public VaultEnvironmentEncryptor(VaultKeyValueOperations keyValueTemplate) { this.keyValueTemplate = keyValueTemplate; } + /** + * Decrypts property values in the provided environment that are prefixed with + * "{vault}". For each such property, this method: + *
    + *
  1. Extracts the Vault key and path from the property value
  2. + *
  3. Replaces any environment variable placeholders (${VAR_NAME}) in the vault + * reference with actual environment values
  4. + *
  5. Retrieves the secret value from Vault using the provided key-value + * template
  6. + *
  7. Replaces the property value with the decrypted value from Vault
  8. + *
+ * @param environment the environment containing properties to decrypt + * @return a new Environment object with decrypted values where applicable + */ @Override public Environment decrypt(Environment environment) { Map loadedVaultKeys = new HashMap<>(); @@ -86,8 +120,8 @@ public Environment decrypt(Environment environment) { throw new RuntimeException("Wrong format"); } - String vaultKey = parts[0]; - String vaultParamName = parts[1]; + String vaultKey = replaceEnvironmentPlaceholders(parts[0]); + String vaultParamName = replaceEnvironmentPlaceholders(parts[1]); if (!loadedVaultKeys.containsKey(vaultKey)) { loadedVaultKeys.put(vaultKey, keyValueTemplate.get(vaultKey)); @@ -125,7 +159,80 @@ else if (logger.isWarnEnabled()) { return result; } - public void setPrefixInvalidProperties(boolean prefixInvalidProperties) { + /** + * Replace environment variable placeholders like ${VAR_NAME} with actual values from + * system environment variables. + * + *

+ * If an environment variable is not found, the placeholder remains unchanged. + *

+ * @param value the string value that may contain environment variable placeholders + * @return the string with environment variable placeholders replaced by their values, + * or the original string if no placeholders are found + */ + private String replaceEnvironmentPlaceholders(final String value) { + if (value == null) { + logger.debug("Input value is null, returning null"); + return null; + } + + logger.debug("Processing placeholder replacement for input: %s".formatted(value)); + + // Pattern to match ${VAR_NAME} format + Pattern pattern = Pattern.compile("\\$\\{([^}]+)}"); + Matcher matcher = pattern.matcher(value); + + if (!matcher.find()) { + logger.debug("No placeholders found in input string"); + return value; + } + + // Reset matcher for replacement process + matcher.reset(); + + StringBuilder result = new StringBuilder(); + int lastEnd = 0; + int processedCount = 0; + + while (matcher.find()) { + processedCount++; + String variableName = matcher.group(1); + logger.debug("Found placeholder with variable name: %s".formatted(variableName)); + + // Append the text before the placeholder + result.append(value, lastEnd, matcher.start()); + + String replacement = System.getenv(variableName); + if (replacement != null) { + // Replace with environment variable value + result.append(replacement); + logger.info( + "Successfully resolved '%s' placeholder from system environment variables. Placeholder replaced." + .formatted(variableName)); + } + else { + // If environment variable not found, keep original placeholder + result.append(matcher.group(0)); + logger + .warn("Environment variable '%s' not found. Keeping original placeholder.".formatted(variableName)); + } + + lastEnd = matcher.end(); + } + + // Append the remaining text after the last placeholder + result.append(value, lastEnd, value.length()); + + logger.debug("Placeholder replacement completed. Processed %d placeholders.".formatted(processedCount)); + + return result.toString(); + } + + /** + * Set whether to prefix invalid properties with "invalid.". + * @param prefixInvalidProperties whether to prefix invalid properties + */ + public void setPrefixInvalidProperties(final boolean prefixInvalidProperties) { this.prefixInvalidProperties = prefixInvalidProperties; } diff --git a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/encryption/vault/VaultEnvironmentEncryptorTests.java b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/encryption/vault/VaultEnvironmentEncryptorTests.java index 9ee18c8b68..91ff1b6fcf 100644 --- a/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/encryption/vault/VaultEnvironmentEncryptorTests.java +++ b/spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/encryption/vault/VaultEnvironmentEncryptorTests.java @@ -33,6 +33,7 @@ /** * @author Alexey Zhokhov + * @author Pavel Andrusov */ public class VaultEnvironmentEncryptorTests { @@ -208,6 +209,199 @@ public void shouldMarkAsInvalidPropertyWithWrongFormat2() { .isEqualTo(""); } + @Test + public void shouldResolvePropertyWithEnvironmentVariableInVaultKey() { + // given + String secret = "mysecret"; + String vaultKeyWithEnvVar = "accounts/${PATH}/mypay"; + + VaultKeyValueOperations keyValueTemplate = mock(VaultKeyValueOperations.class); + + // Use PATH environment variable which should exist on most systems + String pathValue = System.getenv("PATH"); + when(keyValueTemplate.get("accounts/" + pathValue + "/mypay")) + .thenReturn(withVaultResponse("access_key", secret)); + + VaultEnvironmentEncryptor encryptor = new VaultEnvironmentEncryptor(keyValueTemplate); + + // when + Environment environment = new Environment("name", "profile", "label"); + environment.add(new PropertySource("a", Collections.singletonMap(environment.getName(), + "{vault}:" + vaultKeyWithEnvVar + "#access_key"))); + + // then + assertThat(encryptor.decrypt(environment).getPropertySources().get(0).getSource().get(environment.getName())) + .isEqualTo(secret); + } + + @Test + public void shouldResolvePropertyWithEnvironmentVariableInVaultParamName() { + // given + String secret = "mysecret"; + String vaultParamWithEnvVar = "${USER}_key"; + + VaultKeyValueOperations keyValueTemplate = mock(VaultKeyValueOperations.class); + + // Use USER environment variable which should exist on most systems + String userValue = System.getenv("USER"); + when(keyValueTemplate.get("accounts/mypay")).thenReturn(withVaultResponse(userValue + "_key", secret)); + + VaultEnvironmentEncryptor encryptor = new VaultEnvironmentEncryptor(keyValueTemplate); + + // when + Environment environment = new Environment("name", "profile", "label"); + environment.add(new PropertySource("a", Collections.singletonMap(environment.getName(), + "{vault}:accounts/mypay#" + vaultParamWithEnvVar))); + + // then + assertThat(encryptor.decrypt(environment).getPropertySources().get(0).getSource().get(environment.getName())) + .isEqualTo(secret); + } + + @Test + public void shouldResolvePropertyWithMultipleEnvironmentVariables() { + // given + String secret = "mysecret"; + String vaultKeyWithMultipleEnvVars = "${USER}/accounts/${PATH}/mypay"; + + VaultKeyValueOperations keyValueTemplate = mock(VaultKeyValueOperations.class); + + // Use USER and PATH environment variables which should exist on most systems + String userValue = System.getenv("USER"); + String pathValue = System.getenv("PATH"); + when(keyValueTemplate.get(userValue + "/accounts/" + pathValue + "/mypay")) + .thenReturn(withVaultResponse("access_key", secret)); + + VaultEnvironmentEncryptor encryptor = new VaultEnvironmentEncryptor(keyValueTemplate); + + // when + Environment environment = new Environment("name", "profile", "label"); + environment.add(new PropertySource("a", Collections.singletonMap(environment.getName(), + "{vault}:" + vaultKeyWithMultipleEnvVars + "#access_key"))); + + // then + assertThat(encryptor.decrypt(environment).getPropertySources().get(0).getSource().get(environment.getName())) + .isEqualTo(secret); + } + + @Test + public void shouldKeepOriginalPlaceholderWhenEnvironmentVariableNotFound() { + // given + String secret = "mysecret"; + String vaultKeyWithNonExistentEnvVar = "accounts/${NON_EXISTENT_VAR}/mypay"; + + VaultKeyValueOperations keyValueTemplate = mock(VaultKeyValueOperations.class); + when(keyValueTemplate.get("accounts/${NON_EXISTENT_VAR}/mypay")) + .thenReturn(withVaultResponse("access_key", secret)); + + VaultEnvironmentEncryptor encryptor = new VaultEnvironmentEncryptor(keyValueTemplate); + + // when + Environment environment = new Environment("name", "profile", "label"); + environment.add(new PropertySource("a", Collections.singletonMap(environment.getName(), + "{vault}:" + vaultKeyWithNonExistentEnvVar + "#access_key"))); + + // then + assertThat(encryptor.decrypt(environment).getPropertySources().get(0).getSource().get(environment.getName())) + .isEqualTo(secret); + } + + @Test + public void shouldHandleMixedExistingAndNonExistingEnvironmentVariables() { + // given + String secret = "mysecret"; + String vaultKeyWithMixedEnvVars = "${USER}/accounts/${NON_EXISTENT_VAR}/mypay"; + + VaultKeyValueOperations keyValueTemplate = mock(VaultKeyValueOperations.class); + + // Use USER environment variable which should exist on most systems + String userValue = System.getenv("USER"); + when(keyValueTemplate.get(userValue + "/accounts/${NON_EXISTENT_VAR}/mypay")) + .thenReturn(withVaultResponse("access_key", secret)); + + VaultEnvironmentEncryptor encryptor = new VaultEnvironmentEncryptor(keyValueTemplate); + + // when + Environment environment = new Environment("name", "profile", "label"); + environment.add(new PropertySource("a", Collections.singletonMap(environment.getName(), + "{vault}:" + vaultKeyWithMixedEnvVars + "#access_key"))); + + // then + assertThat(encryptor.decrypt(environment).getPropertySources().get(0).getSource().get(environment.getName())) + .isEqualTo(secret); + } + + @Test + public void shouldHandleNullInputGracefully() { + // given + String secret = "mysecret"; + + VaultKeyValueOperations keyValueTemplate = mock(VaultKeyValueOperations.class); + when(keyValueTemplate.get(null)).thenReturn(withVaultResponse("access_key", secret)); + + VaultEnvironmentEncryptor encryptor = new VaultEnvironmentEncryptor(keyValueTemplate); + + // when + Environment environment = new Environment("name", "profile", "label"); + environment.add(new PropertySource("a", + Collections.singletonMap(environment.getName(), "{vault}:#access_key"))); + + // then + Environment processedEnvironment = encryptor.decrypt(environment); + assertThat(processedEnvironment.getPropertySources().get(0).getSource().get("invalid." + environment.getName())) + .isEqualTo(""); + } + + @Test + public void shouldHandleEmptyStringInput() { + // given + String secret = "mysecret"; + + VaultKeyValueOperations keyValueTemplate = mock(VaultKeyValueOperations.class); + when(keyValueTemplate.get("")).thenReturn(withVaultResponse("access_key", secret)); + + VaultEnvironmentEncryptor encryptor = new VaultEnvironmentEncryptor(keyValueTemplate); + + // when + Environment environment = new Environment("name", "profile", "label"); + environment.add(new PropertySource("a", + Collections.singletonMap(environment.getName(), "{vault}:#access_key"))); + + // then + Environment processedEnvironment = encryptor.decrypt(environment); + assertThat(processedEnvironment.getPropertySources().get(0).getSource().get("invalid." + environment.getName())) + .isEqualTo(""); + } + + @Test + public void shouldHandleMultiplePropertiesWithEnvironmentVariables() { + // given + String secret1 = "secret1"; + String secret2 = "secret2"; + + VaultKeyValueOperations keyValueTemplate = mock(VaultKeyValueOperations.class); + String userValue = System.getenv("USER"); + String pathValue = System.getenv("PATH"); + when(keyValueTemplate.get("accounts/" + userValue + "/mypay")) + .thenReturn(withVaultResponse("access_key", secret1)); + when(keyValueTemplate.get("accounts/" + pathValue + "/mypay")) + .thenReturn(withVaultResponse("access_key", secret2)); + + VaultEnvironmentEncryptor encryptor = new VaultEnvironmentEncryptor(keyValueTemplate); + + // when + Environment environment = new Environment("name", "profile", "label"); + Map properties = new HashMap<>(); + properties.put("property1", "{vault}:accounts/${USER}/mypay#access_key"); + properties.put("property2", "{vault}:accounts/${PATH}/mypay#access_key"); + environment.add(new PropertySource("a", properties)); + + // then + Environment processedEnvironment = encryptor.decrypt(environment); + assertThat(processedEnvironment.getPropertySources().get(0).getSource().get("property1")).isEqualTo(secret1); + assertThat(processedEnvironment.getPropertySources().get(0).getSource().get("property2")).isEqualTo(secret2); + } + private VaultResponse withVaultResponse(String key, Object value) { Map responseData = new HashMap<>(); responseData.put(key, value);