AWS Parameter Store as secret manager with Springboot.

By David Norris on
Post Banner

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

  1. Serverless Secrets Management: No need for server maintenance, reducing operational overhead.
  2. Granular Access Control: Leverage IAM roles for specific access, improving auditability and reducing the risk of unauthorized access.
  3. High Availability: Data is stored across multiple Availability Zones, providing fault tolerance.

Additional Organizational Benefits

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

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.

© Copyright 2023 by David Norris.