Initial commit
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
# AWS Secrets Manager Spring Boot Integration
|
||||
|
||||
## Overview
|
||||
Integrate AWS Secrets Manager with Spring Boot applications using the caching library for optimal performance and security.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Required Dependencies
|
||||
```xml
|
||||
<!-- AWS Secrets Manager -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>secretsmanager</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- AWS Secrets Manager Caching -->
|
||||
<dependency>
|
||||
<groupId>com.amazonaws.secretsmanager</groupId>
|
||||
<artifactId>aws-secretsmanager-caching-java</artifactId>
|
||||
<version>2.0.0</version> // Use the latest version compatible with sdk v2
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot Starter -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Jackson for JSON processing -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Connection Pooling -->
|
||||
<dependency>
|
||||
<groupId>com.zaxxer</groupId>
|
||||
<artifactId>HikariCP</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Configuration Properties
|
||||
|
||||
### application.yml
|
||||
```yaml
|
||||
spring:
|
||||
application:
|
||||
name: aws-secrets-manager-app
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/mydb
|
||||
username: ${db.username}
|
||||
password: ${db.password}
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
minimum-idle: 5
|
||||
|
||||
aws:
|
||||
secrets:
|
||||
region: us-east-1
|
||||
# Secret names for different environments
|
||||
database-credentials: prod/database/credentials
|
||||
api-keys: prod/external-api/keys
|
||||
redis-config: prod/redis/config
|
||||
|
||||
app:
|
||||
external-api:
|
||||
secret-name: prod/external/credentials
|
||||
base-url: https://api.example.com
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### SecretsManager Configuration
|
||||
```java
|
||||
import com.amazonaws.secretsmanager.caching.SecretCache;
|
||||
import com.amazonaws.secretsmanager.caching.SecretCacheConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient;
|
||||
|
||||
@Configuration
|
||||
public class SecretsManagerConfiguration {
|
||||
|
||||
@Value("${aws.secrets.region}")
|
||||
private String region;
|
||||
|
||||
@Bean
|
||||
public SecretsManagerClient secretsManagerClient() {
|
||||
return SecretsManagerClient.builder()
|
||||
.region(Region.of(region))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecretCache secretCache(SecretsManagerClient secretsClient) {
|
||||
SecretCacheConfiguration config = SecretCacheConfiguration.builder()
|
||||
.maxCacheSize(100)
|
||||
.cacheItemTTL(3600000) // 1 hour
|
||||
.build();
|
||||
|
||||
return new SecretCache(secretsClient, config);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Secrets Service
|
||||
```java
|
||||
import com.amazonaws.secretsmanager.caching.SecretCache;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class SecretsService {
|
||||
|
||||
private final SecretCache secretCache;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SecretsService(SecretCache secretCache, ObjectMapper objectMapper) {
|
||||
this.secretCache = secretCache;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secret as string
|
||||
*/
|
||||
public String getSecret(String secretName) {
|
||||
try {
|
||||
return secretCache.getSecretString(secretName);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to retrieve secret: " + secretName, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secret as object of specified type
|
||||
*/
|
||||
public <T> T getSecretAsObject(String secretName, Class<T> type) {
|
||||
try {
|
||||
String secretJson = secretCache.getSecretString(secretName);
|
||||
return objectMapper.readValue(secretJson, type);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse secret: " + secretName, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secret as Map
|
||||
*/
|
||||
public Map<String, String> getSecretAsMap(String secretName) {
|
||||
try {
|
||||
String secretJson = secretCache.getSecretString(secretName);
|
||||
return objectMapper.readValue(secretJson,
|
||||
new TypeReference<Map<String, String>>() {});
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse secret map: " + secretName, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secret with fallback
|
||||
*/
|
||||
public String getSecretWithFallback(String secretName, String defaultValue) {
|
||||
try {
|
||||
String secret = secretCache.getSecretString(secretName);
|
||||
return secret != null ? secret : defaultValue;
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Configuration Integration
|
||||
|
||||
### Dynamic DataSource Configuration
|
||||
```java
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import javax.sql.DataSource;
|
||||
|
||||
@Configuration
|
||||
public class DatabaseConfiguration {
|
||||
|
||||
private final SecretsService secretsService;
|
||||
|
||||
@Value("${aws.secrets.database-credentials}")
|
||||
private String dbSecretName;
|
||||
|
||||
public DatabaseConfiguration(SecretsService secretsService) {
|
||||
this.secretsService = secretsService;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DataSource dataSource() {
|
||||
Map<String, String> credentials = secretsService.getSecretAsMap(dbSecretName);
|
||||
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(credentials.get("url"));
|
||||
config.setUsername(credentials.get("username"));
|
||||
config.setPassword(credentials.get("password"));
|
||||
config.setMaximumPoolSize(10);
|
||||
config.setMinimumIdle(5);
|
||||
config.setConnectionTimeout(30000);
|
||||
config.setIdleTimeout(600000);
|
||||
config.setMaxLifetime(1800000);
|
||||
config.setLeakDetectionThreshold(15000);
|
||||
|
||||
return new HikariDataSource(config);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Properties with Secrets
|
||||
```java
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app")
|
||||
public class AppProperties {
|
||||
|
||||
private final SecretsService secretsService;
|
||||
|
||||
@Value("${app.external-api.secret-name}")
|
||||
private String apiSecretName;
|
||||
|
||||
public AppProperties(SecretsService secretsService) {
|
||||
this.secretsService = secretsService;
|
||||
}
|
||||
|
||||
private String apiKey;
|
||||
|
||||
public String getApiKey() {
|
||||
if (apiKey == null) {
|
||||
apiKey = secretsService.getSecret(apiSecretName);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
// Additional application properties
|
||||
private String externalApiBaseUrl;
|
||||
|
||||
public String getExternalApiBaseUrl() {
|
||||
return externalApiBaseUrl;
|
||||
}
|
||||
|
||||
public void setExternalApiBaseUrl(String externalApiBaseUrl) {
|
||||
this.externalApiBaseUrl = externalApiBaseUrl;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Property Source Integration
|
||||
|
||||
### Custom Property Source
|
||||
```java
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class SecretsManagerPropertySource extends PropertySource<SecretsService> {
|
||||
|
||||
public static final String SECRETS_MANAGER_PROPERTY_SOURCE_NAME = "secretsManagerPropertySource";
|
||||
|
||||
private final SecretsService secretsService;
|
||||
private final Environment environment;
|
||||
|
||||
public SecretsManagerPropertySource(SecretsService secretsService, Environment environment) {
|
||||
super(SECRETS_MANAGER_PROPERTY_SOURCE_NAME, secretsService);
|
||||
this.secretsService = secretsService;
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void loadSecrets() {
|
||||
// Load secrets specified in application.yml
|
||||
String secretPrefix = "aws.secrets.";
|
||||
environment.getPropertyNames().forEach(propertyName -> {
|
||||
if (propertyName.startsWith(secretPrefix)) {
|
||||
String secretName = environment.getProperty(propertyName);
|
||||
String secretValue = secretsService.getSecret(secretName);
|
||||
if (secretValue != null) {
|
||||
// Add to property source (note: this is simplified)
|
||||
// In practice, you'd need to work with PropertySources
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getProperty(String name) {
|
||||
if (name.startsWith("aws.secret.")) {
|
||||
String secretName = name.substring("aws.secret.".length());
|
||||
return secretsService.getSecret(secretName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
### REST Client with Secrets
|
||||
```java
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Service
|
||||
public class ExternalApiClient {
|
||||
|
||||
private final SecretsService secretsService;
|
||||
private final RestTemplate restTemplate;
|
||||
private final AppProperties appProperties;
|
||||
|
||||
public ExternalApiClient(SecretsService secretsService,
|
||||
RestTemplate restTemplate,
|
||||
AppProperties appProperties) {
|
||||
this.secretsService = secretsService;
|
||||
this.restTemplate = restTemplate;
|
||||
this.appProperties = appProperties;
|
||||
}
|
||||
|
||||
public String callExternalApi(String endpoint) {
|
||||
Map<String, String> apiCredentials = secretsService.getSecretAsMap(
|
||||
appProperties.getExternalApiSecretName());
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + apiCredentials.get("api_token"));
|
||||
headers.set("X-API-Key", apiCredentials.get("api_key"));
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
endpoint,
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
String.class);
|
||||
|
||||
return response.getBody();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration for REST Template
|
||||
```java
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Configuration
|
||||
public class RestTemplateConfiguration {
|
||||
|
||||
@Bean
|
||||
public RestTemplate restTemplate() {
|
||||
return new RestTemplate();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Configuration
|
||||
|
||||
### Security Setup
|
||||
```java
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfiguration {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/secrets/**").hasRole("ADMIN")
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.httpBasic()
|
||||
.and()
|
||||
.csrf().disable();
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Configuration
|
||||
|
||||
### Test Configuration
|
||||
```java
|
||||
import com.amazonaws.secretsmanager.caching.SecretCache;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.mock.env.MockEnvironment;
|
||||
import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient;
|
||||
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@TestConfiguration
|
||||
public class TestSecretsConfiguration {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public SecretsManagerClient secretsManagerClient() {
|
||||
SecretsManagerClient mockClient = mock(SecretsManagerClient.class);
|
||||
|
||||
// Mock successful secret retrieval
|
||||
when(mockClient.getSecretValue(any()))
|
||||
.thenReturn(GetSecretValueResponse.builder()
|
||||
.secretString("{\"username\":\"test\",\"password\":\"testpass\"}")
|
||||
.build());
|
||||
|
||||
return mockClient;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public SecretCache secretCache(SecretsManagerClient mockClient) {
|
||||
SecretCache mockCache = mock(SecretCache.class);
|
||||
when(mockCache.getSecretString(anyString()))
|
||||
.thenReturn("{\"username\":\"test\",\"password\":\"testpass\"}");
|
||||
return mockCache;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MockEnvironment mockEnvironment() {
|
||||
MockEnvironment env = new MockEnvironment();
|
||||
env.setProperty("aws.secrets.region", "us-east-1");
|
||||
env.setProperty("aws.secrets.database-credentials", "test-db-credentials");
|
||||
return env;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Unit Tests
|
||||
```java
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SecretsServiceTest {
|
||||
|
||||
@Mock
|
||||
private SecretCache secretCache;
|
||||
|
||||
@InjectMocks
|
||||
private SecretsService secretsService;
|
||||
|
||||
@Test
|
||||
void shouldGetSecret() {
|
||||
String secretName = "test-secret";
|
||||
String expectedValue = "secret-value";
|
||||
|
||||
when(secretCache.getSecretString(secretName))
|
||||
.thenReturn(expectedValue);
|
||||
|
||||
String result = secretsService.getSecret(secretName);
|
||||
|
||||
assertEquals(expectedValue, result);
|
||||
verify(secretCache).getSecretString(secretName);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGetSecretAsMap() throws Exception {
|
||||
String secretName = "test-secret";
|
||||
String secretJson = "{\"key\":\"value\"}";
|
||||
Map<String, String> expectedMap = Map.of("key", "value");
|
||||
|
||||
when(secretCache.getSecretString(secretName))
|
||||
.thenReturn(secretJson);
|
||||
|
||||
Map<String, String> result = secretsService.getSecretAsMap(secretName);
|
||||
|
||||
assertEquals(expectedMap, result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Environment-Specific Configuration**:
|
||||
- Use different secret names for development, staging, and production
|
||||
- Implement proper environment variable management
|
||||
- Use Spring profiles for environment-specific configurations
|
||||
|
||||
2. **Security Considerations**:
|
||||
- Never log secret values
|
||||
- Use appropriate IAM roles and policies
|
||||
- Enable encryption in transit and at rest
|
||||
- Implement proper access controls
|
||||
|
||||
3. **Performance Optimization**:
|
||||
- Use caching for frequently accessed secrets
|
||||
- Configure appropriate TTL values
|
||||
- Monitor cache hit rates and adjust accordingly
|
||||
- Use connection pooling for database connections
|
||||
|
||||
4. **Error Handling**:
|
||||
- Implement fallback mechanisms for critical secrets
|
||||
- Handle partial secret retrieval gracefully
|
||||
- Provide meaningful error messages without exposing sensitive information
|
||||
- Implement circuit breakers for external API calls
|
||||
|
||||
5. **Monitoring and Logging**:
|
||||
- Monitor secret retrieval performance
|
||||
- Track cache hit/miss ratios
|
||||
- Log secret access patterns (without values)
|
||||
- Set up alerts for abnormal secret access patterns
|
||||
Reference in New Issue
Block a user