Code walkthrough
Introduction
In this article, we’ll review some simple code for injecting AWS Parameter Store values directly into Spring Boot applications.
TL;DR
For the complete example, you can find the source code covered in this blog at GitHub.
Why consider AWS Parameter Store
Key Benefits
- Serverless Secrets Management: No need for server maintenance, reducing operational overhead.
- Granular Access Control: Leverage IAM roles for specific access, improving auditability and reducing the risk of unauthorized access.
- High Availability: Data is stored across multiple Availability Zones, providing fault tolerance.
Additional Organizational Benefits
- Scalability: As a managed service, there is no work required to scale your usage (although its obviously a pretty simple service)
- Cost-Effectiveness: Reduce costs related to infrastructure management.
- Auditability: This service is already integrated with AWS CloudTrail for auditing parameter access.
Using a path convention for granular IAM Permissions
I’ve adopted the following convention for storing parameters:
/env/app_name/property
For example: /dev/aws-parameter-inject/db.password
dev
: The environmentaws-parameter-inject
: The application namedb.password
: The property name
Example IAM Profiles for Granular Permissions
Development Environment:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ssm",
"Effect": "Allow",
"Action": [
"ssm:GetParametersByPath",
"ssm:GetParameters",
"ssm:GetParameter"
],
"Resource": [
"arn:aws:ssm:us-east-1:${aws_account_id}:parameter/dev/aws-parameter-inject/*"
]
}
]
}
QA Environment:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ssm",
"Effect": "Allow",
"Action": [
"ssm:GetParametersByPath",
"ssm:GetParameters",
"ssm:GetParameter"
],
"Resource": [
"arn:aws:ssm:us-east-1:${aws_account_id}:parameter/qa/aws-parameter-inject/*"
]
}
]
}
Using this path convention in tandem with IAM profiles provides granular, environment-specific control over parameter access.
Implementation
AwsParameter Annotation
We will start by defining a custom annotation @AwsParameter
. This is used to mark the fields that should be injected with values from the AWS Parameter Store.
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AwsParameter {
String value();
}
Implementing BeanPostProcessor
Next we will create a class that implements the interface BeanPostProcessor
and EnvironmentAware
.
package com.dcnorris.aws;
import jakarta.annotation.PostConstruct;
import java.lang.reflect.Field;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
import org.springframework.util.ReflectionUtils;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.services.ssm.SsmClient;
import software.amazon.awssdk.services.ssm.model.GetParametersByPathRequest;
import software.amazon.awssdk.services.ssm.model.GetParametersByPathResponse;
import software.amazon.awssdk.services.ssm.model.Parameter;
/**
*
* @author dcnorris
*/
public class AwsParamBeanPostProcessor implements BeanPostProcessor, EnvironmentAware {
private static final Logger LOG = LoggerFactory.getLogger(AwsParamBeanPostProcessor.class);
private final String appName;
private final String appEnv;
private Environment environment;
private SsmClient ssmClient;
private GetParametersByPathResponse applicationParams;
public AwsParamBeanPostProcessor(SsmClient ssmClient, String appName, String appEnv) {
this.ssmClient = ssmClient;
this.appName = appName;
this.appEnv = appEnv;
}
@PostConstruct
public void init() {
if (ssmClient == null) {
LOG.warn("application cannot be injected with aws ssm parameters without ssmClient.");
}
if (appName == null || appName.isBlank()) {
LOG.warn("application cannot be injected with aws ssm parameters without setting the 'aws.app.name' property.");
}
if (ssmClient != null) {
try {
GetParametersByPathRequest byPathRequest = GetParametersByPathRequest.builder().path("/" + appEnv + "/" + appName + "/").build();
applicationParams = ssmClient.getParametersByPath(byPathRequest);
} catch (SdkException e) {
LOG.warn("Unable to retrieve parameters from SSM due to exception: {}", e.getMessage());
LOG.warn("Parameter injection from SSM will not be possible due to an error communicating with the service. Please check permissions.");
LOG.warn("Parameter injection will only occur through properties and environment variables");
ssmClient = null;
}
}
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
// instrospec the bean for fields with AwsParameter annotation
Class<?> clazz = bean.getClass();
// only scan beans in this package
if (clazz.getName().startsWith("com.dcnorris")) {
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
if (field.isAnnotationPresent(AwsParameter.class)) {
ReflectionUtils.makeAccessible(field);
AwsParameter awsParameter = field.getAnnotation(AwsParameter.class);
String annotationValue = awsParameter.value();
String[] split = annotationValue.split(":");
String paramName = split[0];
String defaultValue = split.length > 1 ? split[1] : null;
if (applicationParams != null) {
Optional<Parameter> valueForField = applicationParams.parameters().stream().filter(param -> param.name().contains(paramName)).findFirst();
if (valueForField.isPresent()) {
LOG.info("Injecting value for field {} into bean {}", paramName, bean.getClass().getCanonicalName());
final String value = valueForField.get().value();
setField(field, bean, value);
continue;
}
}
Object property = environment.getProperty(paramName, field.getType());
if (property != null) {
setField(field, bean, property);
} else if (defaultValue != null) {
setField(field, bean, defaultValue);
}
}
}
}
return bean;
}
private void setField(Field field, Object bean, Object value) {
try {
field.set(bean, value);
} catch (IllegalArgumentException | IllegalAccessException ex) {
LOG.error(ex.getMessage(), ex);
}
}
}
Injection
The core logic of parameter injection is handled by postProcessBeforeInitialization()
.
Spring Configuration
We define a Spring configuration class that sets up the necessary beans. It necessary to ensure this configuration class has high priority as it needs to bootstrap field injection of our managed spring beans, we accomplish this through @Order(Ordered.HIGHEST_PRECEDENCE)
.
package com.dcnorris.aws;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProviderChain;
import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider;
import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider;
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.ssm.SsmClient;
/**
*
* @author dcnorris
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@Configuration
public class AwsConfiguration {
@Value("${aws.app.name}")
private String appName;
@Value("${aws.app.env}")
private String appEnv;
@Lazy(false)
@Bean
public AwsParamBeanPostProcessor setupAwsParamBeanPostProcessor(SsmClient ssmClient) {
AwsParamBeanPostProcessor beanPostProcessor = new AwsParamBeanPostProcessor(ssmClient, appName, appEnv);
return beanPostProcessor;
}
@Bean
public AwsCredentialsProvider awsCredentialsProvider(@Value("${aws.app.name:}") String awsCredentialProfileName) {
if (awsCredentialProfileName != null && !awsCredentialProfileName.isBlank()) {
final ProfileCredentialsProvider create = ProfileCredentialsProvider.create(awsCredentialProfileName);
return AwsCredentialsProviderChain.builder()
.addCredentialsProvider(create)
.addCredentialsProvider(InstanceProfileCredentialsProvider.create())
.build();
}
return AwsCredentialsProviderChain.builder()
.addCredentialsProvider(InstanceProfileCredentialsProvider.create())
.build();
}
@Lazy(false)
@Bean
@ConditionalOnBean(value = AwsCredentialsProvider.class)
public SsmClient ssmClient(AwsCredentialsProvider awsCredentialsProvider) {
return SsmClient.builder()
.credentialsProvider(awsCredentialsProvider)
.region(Region.US_EAST_1)
.httpClient(UrlConnectionHttpClient.builder().build())
.build();
}
}
Application properties
Specify app name and env properties in your application.properties:
aws.app.name=aws-parameter-inject
aws.app.env=dev
Conclusion
This setup allows for simple integration of AWS Parameter Store values into a Spring Boot application, acting as a infrastruce-less secret management solution.