Skip to content

Commit e2bb830

Browse files
Add configurable AWS credential validation at bootstrap (#6629)
Add per-secret skip_validation_on_start flag for credential validation - Add skip_validation_on_start boolean field to AwsSecretManagerConfiguration - Default value is false (safe-by-default - validates credentials at bootstrap) - When set to false, secret retrieval is deferred until first access - Implement lazy-loading logic in AwsSecretsSupplier for deferred secrets - Add comprehensive unit and integration tests - Maintain backward compatibility (default behavior unchanged) This allows users to disable credential validation per-secret when credentials are not available at bootstrap time, while maintaining fail-fast behavior by default for production safety. Resolves issue where DataPrepper fails to start with invalid credentials by providing per-secret control over bootstrap validation. Make lazy-loading of secrets atomic and handle updateValue with unloaded secrets Use ConcurrentMap.compute() in loadSecretIfNeeded() to ensure the sentinel check and secret retrieval are performed atomically, avoiding race conditions with concurrent access. Add loadSecretIfNeeded() call at the beginning of updateValue() to ensure secrets are loaded before update logic runs, preventing the NOT_LOADED_SENTINEL from being treated as a plain value store. Signed-off-by: Sumit Bhattacharya <sumit4739@gmail.com>
1 parent d4b8363 commit e2bb830

6 files changed

Lines changed: 285 additions & 2 deletions

File tree

data-prepper-plugins/aws-plugin/src/main/java/org/opensearch/dataprepper/plugins/aws/AwsSecretManagerConfiguration.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ public class AwsSecretManagerConfiguration {
5252
@JsonProperty("disable_refresh")
5353
private boolean disableRefresh = false;
5454

55+
@JsonProperty("skip_validation_on_start")
56+
private boolean skipValidationOnStart = false; // Default: false (validate by default)
57+
5558
public String getAwsSecretId() {
5659
return awsSecretId;
5760
}
@@ -68,6 +71,10 @@ public boolean isDisableRefresh() {
6871
return disableRefresh;
6972
}
7073

74+
public boolean isSkipValidationOnStart() {
75+
return skipValidationOnStart;
76+
}
77+
7178
public SecretsManagerClient createSecretManagerClient(final AwsCredentialsSupplier awsCredentialsSupplier) {
7279
final AwsCredentialsProvider awsCredentialsProvider = awsCredentialsSupplier.getProvider(AwsCredentialsOptions.builder()
7380
.withRegion(this.awsRegion)

data-prepper-plugins/aws-plugin/src/main/java/org/opensearch/dataprepper/plugins/aws/AwsSecretsSupplier.java

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ public class AwsSecretsSupplier implements SecretsSupplier {
3131
static final TypeReference<Map<String, String>> MAP_TYPE_REFERENCE = new TypeReference<>() {
3232
};
3333
private static final Logger LOG = LoggerFactory.getLogger(AwsSecretsSupplier.class);
34+
private static final Object NOT_LOADED_SENTINEL = new Object(); // Sentinel to indicate secret not loaded yet
35+
3436
private final SecretValueDecoder secretValueDecoder;
3537
private final ObjectMapper objectMapper;
3638
private final Map<String, AwsSecretManagerConfiguration> awsSecretManagerConfigurationMap;
@@ -58,6 +60,14 @@ private ConcurrentMap<String, Object> toSecretMap(
5860
final AwsSecretManagerConfiguration awsSecretManagerConfiguration =
5961
awsSecretManagerConfigurationMap.get(secretConfigurationId);
6062
final SecretsManagerClient secretsManagerClient = entry.getValue();
63+
64+
// Check if validation on start is skipped for this secret
65+
if (awsSecretManagerConfiguration.isSkipValidationOnStart()) {
66+
LOG.info("Skipping secret retrieval on start for secret: {} (skip_validation_on_start=true)",
67+
awsSecretManagerConfiguration.getAwsSecretId());
68+
return NOT_LOADED_SENTINEL; // Mark as not loaded, will be loaded on first access
69+
}
70+
6171
return retrieveSecretsFromSecretManager(awsSecretManagerConfiguration, secretsManagerClient);
6272
}));
6373
}
@@ -77,7 +87,10 @@ public Object retrieveValue(String secretId, String key) {
7787
if (!secretIdToValue.containsKey(secretId)) {
7888
throw new IllegalArgumentException(String.format("Unable to find secretId: %s", secretId));
7989
}
80-
final Object keyValuePairs = secretIdToValue.get(secretId);
90+
91+
// Load secret if it was skipped on start
92+
final Object keyValuePairs = loadSecretIfNeeded(secretId);
93+
8194
if (!(keyValuePairs instanceof Map)) {
8295
throw new IllegalArgumentException(String.format("The value under secretId: %s is not a valid json.",
8396
secretId));
@@ -95,8 +108,11 @@ public Object retrieveValue(String secretId) {
95108
if (!secretIdToValue.containsKey(secretId)) {
96109
throw new IllegalArgumentException(String.format("Unable to find secretId: %s", secretId));
97110
}
111+
112+
// Load secret if it was skipped on start
113+
final Object secretValue = loadSecretIfNeeded(secretId);
114+
98115
try {
99-
final Object secretValue = secretIdToValue.get(secretId);
100116
return secretValue instanceof Map ? objectMapper.writeValueAsString(secretValue) :
101117
secretValue;
102118
} catch (JsonProcessingException e) {
@@ -105,6 +121,25 @@ public Object retrieveValue(String secretId) {
105121
}
106122
}
107123

124+
/**
125+
* Loads a secret if it was skipped on start (lazy-loading).
126+
* Uses {@link ConcurrentMap#compute} to ensure atomicity of the sentinel check and refresh.
127+
*
128+
* @param secretId The secret configuration ID
129+
* @return The loaded secret value
130+
*/
131+
private Object loadSecretIfNeeded(String secretId) {
132+
return secretIdToValue.compute(secretId, (key, currentValue) -> {
133+
if (currentValue == NOT_LOADED_SENTINEL) {
134+
LOG.info("Secret {} was not loaded on start, loading now on first access.", key);
135+
final AwsSecretManagerConfiguration config = awsSecretManagerConfigurationMap.get(key);
136+
final SecretsManagerClient client = secretsManagerClientMap.get(key);
137+
return retrieveSecretsFromSecretManager(config, client);
138+
}
139+
return currentValue;
140+
});
141+
}
142+
108143

109144
@Override
110145
public void refresh(String secretConfigId) {
@@ -152,6 +187,8 @@ public String updateValue(String secretId, Object newValue) {
152187

153188
@Override
154189
public String updateValue(String secretId, String keyToUpdate, Object newValue) {
190+
// Ensure the secret is loaded before attempting to update
191+
loadSecretIfNeeded(secretId);
155192
Object currentSecretStore = secretIdToValue.get(secretId);
156193
if (currentSecretStore instanceof Map) {
157194
if (keyToUpdate == null) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* The OpenSearch Contributors require contributions made to
6+
* this file be licensed under the Apache-2.0 license or a
7+
* compatible open source license.
8+
*/
9+
10+
package org.opensearch.dataprepper.plugins.aws;
11+
12+
import com.fasterxml.jackson.databind.ObjectMapper;
13+
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
14+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
15+
import org.junit.jupiter.api.Test;
16+
17+
import static org.hamcrest.CoreMatchers.equalTo;
18+
import static org.hamcrest.MatcherAssert.assertThat;
19+
20+
class AwsSecretManagerConfigurationValidateAtBootstrapTest {
21+
22+
private final ObjectMapper objectMapper = new ObjectMapper(new YAMLFactory())
23+
.registerModule(new JavaTimeModule());
24+
25+
@Test
26+
void testDefaultSkipValidationOnStart() {
27+
final AwsSecretManagerConfiguration config = new AwsSecretManagerConfiguration();
28+
29+
// Default should be false (validate by default)
30+
assertThat(config.isSkipValidationOnStart(), equalTo(false));
31+
}
32+
33+
@Test
34+
void testSkipValidationOnStartFromYaml_Enabled() throws Exception {
35+
final String yaml =
36+
"secret_id: my-secret\n" +
37+
"region: us-east-1\n" +
38+
"refresh_interval: PT1H\n" +
39+
"skip_validation_on_start: true\n";
40+
41+
final AwsSecretManagerConfiguration config =
42+
objectMapper.readValue(yaml, AwsSecretManagerConfiguration.class);
43+
44+
assertThat(config.isSkipValidationOnStart(), equalTo(true));
45+
}
46+
47+
@Test
48+
void testSkipValidationOnStartFromYaml_Disabled() throws Exception {
49+
final String yaml =
50+
"secret_id: my-secret\n" +
51+
"region: us-east-1\n" +
52+
"refresh_interval: PT1H\n" +
53+
"skip_validation_on_start: false\n";
54+
55+
final AwsSecretManagerConfiguration config =
56+
objectMapper.readValue(yaml, AwsSecretManagerConfiguration.class);
57+
58+
assertThat(config.isSkipValidationOnStart(), equalTo(false));
59+
}
60+
}

data-prepper-plugins/aws-plugin/src/test/java/org/opensearch/dataprepper/plugins/aws/AwsSecretPluginIT.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ void testInitializationWithNonNullConfig() {
9999
when(awsSecretPluginConfig.getAwsSecretManagerConfigurationMap()).thenReturn(
100100
Map.of(TEST_SECRET_CONFIG_ID, awsSecretManagerConfiguration));
101101
when(awsSecretManagerConfiguration.getRefreshInterval()).thenReturn(testInterval);
102+
when(awsSecretManagerConfiguration.isSkipValidationOnStart()).thenReturn(false); // Default behavior
102103
when(awsSecretManagerConfiguration.createSecretManagerClient(awsCredentialsSupplier)).thenReturn(secretsManagerClient);
103104
when(awsSecretManagerConfiguration.createGetSecretValueRequest()).thenReturn(getSecretValueRequest);
104105
when(secretsManagerClient.getSecretValue(eq(getSecretValueRequest))).thenReturn(getSecretValueResponse);
@@ -133,6 +134,7 @@ void testInitializationWithDisableRefresh() {
133134
when(awsSecretPluginConfig.getAwsSecretManagerConfigurationMap()).thenReturn(
134135
Map.of(TEST_SECRET_CONFIG_ID, awsSecretManagerConfiguration));
135136
when(awsSecretManagerConfiguration.isDisableRefresh()).thenReturn(true);
137+
when(awsSecretManagerConfiguration.isSkipValidationOnStart()).thenReturn(false); // Default behavior
136138
when(awsSecretManagerConfiguration.createSecretManagerClient(awsCredentialsSupplier)).thenReturn(secretsManagerClient);
137139
when(awsSecretManagerConfiguration.createGetSecretValueRequest()).thenReturn(getSecretValueRequest);
138140
when(secretsManagerClient.getSecretValue(eq(getSecretValueRequest))).thenReturn(getSecretValueResponse);
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* The OpenSearch Contributors require contributions made to
6+
* this file be licensed under the Apache-2.0 license or a
7+
* compatible open source license.
8+
*/
9+
10+
package org.opensearch.dataprepper.plugins.aws;
11+
12+
import com.fasterxml.jackson.core.JsonProcessingException;
13+
import com.fasterxml.jackson.databind.ObjectMapper;
14+
import org.junit.jupiter.api.BeforeEach;
15+
import org.junit.jupiter.api.Test;
16+
import org.junit.jupiter.api.extension.ExtendWith;
17+
import org.mockito.Mock;
18+
import org.mockito.junit.jupiter.MockitoExtension;
19+
import org.opensearch.dataprepper.aws.api.AwsCredentialsSupplier;
20+
import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient;
21+
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest;
22+
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse;
23+
import software.amazon.awssdk.services.secretsmanager.model.PutSecretValueRequest;
24+
import software.amazon.awssdk.services.secretsmanager.model.PutSecretValueResponse;
25+
26+
import java.util.Map;
27+
import java.util.UUID;
28+
29+
import static org.hamcrest.CoreMatchers.equalTo;
30+
import static org.hamcrest.MatcherAssert.assertThat;
31+
import static org.mockito.ArgumentMatchers.any;
32+
import static org.mockito.ArgumentMatchers.eq;
33+
import static org.mockito.Mockito.never;
34+
import static org.mockito.Mockito.times;
35+
import static org.mockito.Mockito.verify;
36+
import static org.mockito.Mockito.when;
37+
38+
/**
39+
* Tests for lazy-loading behavior when skip_validation_on_start is true.
40+
*/
41+
@ExtendWith(MockitoExtension.class)
42+
class AwsSecretsSupplierLazyLoadTest {
43+
44+
private ObjectMapper objectMapper;
45+
private String testSecretId;
46+
private String testKey;
47+
private String testValue;
48+
49+
@Mock
50+
private SecretValueDecoder secretValueDecoder;
51+
52+
@Mock
53+
private AwsSecretPluginConfig awsSecretPluginConfig;
54+
55+
@Mock
56+
private AwsSecretManagerConfiguration awsSecretManagerConfiguration;
57+
58+
@Mock
59+
private SecretsManagerClient secretsManagerClient;
60+
61+
@Mock
62+
private GetSecretValueRequest getSecretValueRequest;
63+
64+
@Mock
65+
private GetSecretValueResponse getSecretValueResponse;
66+
67+
@Mock
68+
private PutSecretValueRequest putSecretValueRequest;
69+
70+
@Mock
71+
private PutSecretValueResponse putSecretValueResponse;
72+
73+
@Mock
74+
private AwsCredentialsSupplier awsCredentialsSupplier;
75+
76+
@BeforeEach
77+
void setUp() {
78+
objectMapper = new ObjectMapper();
79+
testSecretId = UUID.randomUUID().toString();
80+
testKey = UUID.randomUUID().toString();
81+
testValue = UUID.randomUUID().toString();
82+
}
83+
84+
@Test
85+
void testSecretWithSkipValidationOnStartTrue_LoadsOnFirstAccess() throws JsonProcessingException {
86+
// Given: Secret configured with skip_validation_on_start=true
87+
when(awsSecretPluginConfig.getAwsSecretManagerConfigurationMap()).thenReturn(
88+
Map.of(testSecretId, awsSecretManagerConfiguration)
89+
);
90+
when(awsSecretManagerConfiguration.isSkipValidationOnStart()).thenReturn(true); // Skip on start
91+
when(awsSecretManagerConfiguration.createSecretManagerClient(awsCredentialsSupplier)).thenReturn(secretsManagerClient);
92+
when(awsSecretManagerConfiguration.createGetSecretValueRequest()).thenReturn(getSecretValueRequest);
93+
when(secretValueDecoder.decode(eq(getSecretValueResponse))).thenReturn(objectMapper.writeValueAsString(
94+
Map.of(testKey, testValue)
95+
));
96+
when(secretsManagerClient.getSecretValue(eq(getSecretValueRequest))).thenReturn(getSecretValueResponse);
97+
98+
// When: AwsSecretsSupplier is constructed
99+
final AwsSecretsSupplier supplier = new AwsSecretsSupplier(
100+
secretValueDecoder, awsSecretPluginConfig, objectMapper, awsCredentialsSupplier
101+
);
102+
103+
// Then: Secret is NOT retrieved at construction time
104+
verify(secretsManagerClient, never()).getSecretValue(eq(getSecretValueRequest));
105+
106+
// When: Secret is accessed for the first time
107+
final Object value = supplier.retrieveValue(testSecretId, testKey);
108+
109+
// Then: Secret is loaded on-demand
110+
verify(secretsManagerClient, times(1)).getSecretValue(eq(getSecretValueRequest));
111+
assertThat(value, equalTo(testValue));
112+
}
113+
114+
@Test
115+
void testSecretWithSkipValidationOnStartFalse_LoadsAtConstruction() throws JsonProcessingException {
116+
// Given: Secret configured with skip_validation_on_start=false (default)
117+
when(awsSecretPluginConfig.getAwsSecretManagerConfigurationMap()).thenReturn(
118+
Map.of(testSecretId, awsSecretManagerConfiguration)
119+
);
120+
when(awsSecretManagerConfiguration.isSkipValidationOnStart()).thenReturn(false); // Load on start
121+
when(awsSecretManagerConfiguration.createSecretManagerClient(awsCredentialsSupplier)).thenReturn(secretsManagerClient);
122+
when(awsSecretManagerConfiguration.createGetSecretValueRequest()).thenReturn(getSecretValueRequest);
123+
when(secretValueDecoder.decode(eq(getSecretValueResponse))).thenReturn(objectMapper.writeValueAsString(
124+
Map.of(testKey, testValue)
125+
));
126+
when(secretsManagerClient.getSecretValue(eq(getSecretValueRequest))).thenReturn(getSecretValueResponse);
127+
128+
// When: AwsSecretsSupplier is constructed
129+
final AwsSecretsSupplier supplier = new AwsSecretsSupplier(
130+
secretValueDecoder, awsSecretPluginConfig, objectMapper, awsCredentialsSupplier
131+
);
132+
133+
// Then: Secret IS retrieved at construction time
134+
verify(secretsManagerClient, times(1)).getSecretValue(eq(getSecretValueRequest));
135+
136+
// When: Secret is accessed
137+
final Object value = supplier.retrieveValue(testSecretId, testKey);
138+
139+
// Then: No additional retrieval (already loaded)
140+
verify(secretsManagerClient, times(1)).getSecretValue(eq(getSecretValueRequest));
141+
assertThat(value, equalTo(testValue));
142+
}
143+
144+
@Test
145+
void testUpdateValue_withSkipValidationOnStart_loadsSecretBeforeUpdate() throws JsonProcessingException {
146+
// Given: Secret configured with skip_validation_on_start=true
147+
when(awsSecretPluginConfig.getAwsSecretManagerConfigurationMap()).thenReturn(
148+
Map.of(testSecretId, awsSecretManagerConfiguration)
149+
);
150+
when(awsSecretManagerConfiguration.isSkipValidationOnStart()).thenReturn(true);
151+
when(awsSecretManagerConfiguration.createSecretManagerClient(awsCredentialsSupplier)).thenReturn(secretsManagerClient);
152+
when(awsSecretManagerConfiguration.createGetSecretValueRequest()).thenReturn(getSecretValueRequest);
153+
when(secretValueDecoder.decode(eq(getSecretValueResponse))).thenReturn(objectMapper.writeValueAsString(
154+
Map.of(testKey, testValue)
155+
));
156+
when(secretsManagerClient.getSecretValue(eq(getSecretValueRequest))).thenReturn(getSecretValueResponse);
157+
when(awsSecretManagerConfiguration.putSecretValueRequest(any())).thenReturn(putSecretValueRequest);
158+
when(secretsManagerClient.putSecretValue(eq(putSecretValueRequest))).thenReturn(putSecretValueResponse);
159+
final String newVersionId = UUID.randomUUID().toString();
160+
when(putSecretValueResponse.versionId()).thenReturn(newVersionId);
161+
162+
final AwsSecretsSupplier supplier = new AwsSecretsSupplier(
163+
secretValueDecoder, awsSecretPluginConfig, objectMapper, awsCredentialsSupplier
164+
);
165+
166+
// Then: Secret is NOT retrieved at construction time
167+
verify(secretsManagerClient, never()).getSecretValue(eq(getSecretValueRequest));
168+
169+
// When: updateValue is called before any retrieveValue
170+
final String versionId = supplier.updateValue(testSecretId, testKey, "newValue");
171+
172+
// Then: Secret was loaded on-demand and update succeeded
173+
verify(secretsManagerClient, times(1)).getSecretValue(eq(getSecretValueRequest));
174+
assertThat(versionId, equalTo(newVersionId));
175+
}
176+
}

data-prepper-plugins/aws-plugin/src/test/java/org/opensearch/dataprepper/plugins/aws/AwsSecretsSupplierTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class AwsSecretsSupplierTest {
8686
@BeforeEach
8787
void setUp() throws JsonProcessingException {
8888
when(awsSecretManagerConfiguration.createGetSecretValueRequest()).thenReturn(getSecretValueRequest);
89+
when(awsSecretManagerConfiguration.isSkipValidationOnStart()).thenReturn(false); // Default: validate on start
8990
when(awsSecretPluginConfig.getAwsSecretManagerConfigurationMap()).thenReturn(
9091
Map.of(TEST_AWS_SECRET_CONFIGURATION_NAME, awsSecretManagerConfiguration)
9192
);

0 commit comments

Comments
 (0)