Skip to content

Commit 055a5f2

Browse files
feat(secrets): support JSON-structured Secrets Manager secrets via DD_API_KEY_SECRET_JSON_KEY
Add DD_API_KEY_SECRET_JSON_KEY env var that, when set alongside DD_API_KEY_SECRET_ARN, parses the secret value as JSON and extracts the specified field as the API key. This allows users with key/value JSON secrets to avoid duplicating or exposing their API key. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d7d6815 commit 055a5f2

File tree

4 files changed

+60
-1
lines changed

4 files changed

+60
-1
lines changed

bottlecap/src/config/env.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,12 @@ pub struct EnvConfig {
381381
/// The AWS ARN of the secret containing the Datadog API key.
382382
#[serde(deserialize_with = "deserialize_optional_string")]
383383
pub api_key_secret_arn: Option<String>,
384+
/// @env `DD_API_KEY_SECRET_JSON_KEY`
385+
///
386+
/// When set, the secret fetched via `DD_API_KEY_SECRET_ARN` is parsed as JSON
387+
/// and the value of this key is used as the API key.
388+
#[serde(deserialize_with = "deserialize_optional_string")]
389+
pub api_key_secret_json_key: Option<String>,
384390
/// @env `DD_KMS_API_KEY`
385391
///
386392
/// The AWS KMS API key to use for the Datadog Agent.
@@ -661,6 +667,7 @@ fn merge_config(config: &mut Config, env_config: &EnvConfig) {
661667

662668
// AWS Lambda
663669
merge_string!(config, env_config, api_key_secret_arn);
670+
merge_string!(config, env_config, api_key_secret_json_key);
664671
merge_string!(config, env_config, kms_api_key);
665672
merge_string!(config, env_config, api_key_ssm_arn);
666673
merge_option_to_value!(config, env_config, serverless_logs_enabled);
@@ -871,6 +878,7 @@ mod tests {
871878
"DD_API_KEY_SECRET_ARN",
872879
"arn:aws:secretsmanager:region:account:secret:datadog-api-key",
873880
);
881+
jail.set_env("DD_API_KEY_SECRET_JSON_KEY", "apiKey");
874882
jail.set_env("DD_KMS_API_KEY", "test-kms-key");
875883
jail.set_env("DD_SERVERLESS_LOGS_ENABLED", "false");
876884
jail.set_env("DD_SERVERLESS_FLUSH_STRATEGY", "periodically,60000");
@@ -1026,6 +1034,7 @@ mod tests {
10261034
dogstatsd_queue_size: Some(2048),
10271035
api_key_secret_arn: "arn:aws:secretsmanager:region:account:secret:datadog-api-key"
10281036
.to_string(),
1037+
api_key_secret_json_key: "apiKey".to_string(),
10291038
kms_api_key: "test-kms-key".to_string(),
10301039
api_key_ssm_arn: String::default(),
10311040
serverless_logs_enabled: false,

bottlecap/src/config/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ pub struct Config {
354354
pub api_key_secret_arn: String,
355355
pub kms_api_key: String,
356356
pub api_key_ssm_arn: String,
357+
pub api_key_secret_json_key: String,
357358
pub serverless_logs_enabled: bool,
358359
pub serverless_flush_strategy: FlushStrategy,
359360
pub enhanced_metrics: bool,
@@ -469,6 +470,7 @@ impl Default for Config {
469470
api_key_secret_arn: String::default(),
470471
kms_api_key: String::default(),
471472
api_key_ssm_arn: String::default(),
473+
api_key_secret_json_key: String::default(),
472474
serverless_logs_enabled: true,
473475
serverless_flush_strategy: FlushStrategy::Default,
474476
enhanced_metrics: true,

bottlecap/src/config/yaml.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ pub struct YamlConfig {
112112
#[serde(deserialize_with = "deserialize_optional_string")]
113113
pub api_key_secret_arn: Option<String>,
114114
#[serde(deserialize_with = "deserialize_optional_string")]
115+
pub api_key_secret_json_key: Option<String>,
116+
#[serde(deserialize_with = "deserialize_optional_string")]
115117
pub kms_api_key: Option<String>,
116118
#[serde(deserialize_with = "deserialize_optional_bool_from_anything")]
117119
pub serverless_logs_enabled: Option<bool>,
@@ -693,6 +695,7 @@ fn merge_config(config: &mut Config, yaml_config: &YamlConfig) {
693695

694696
// AWS Lambda
695697
merge_string!(config, yaml_config, api_key_secret_arn);
698+
merge_string!(config, yaml_config, api_key_secret_json_key);
696699
merge_string!(config, yaml_config, kms_api_key);
697700

698701
// Handle serverless_logs_enabled with OR logic: if either logs_enabled or serverless_logs_enabled is true, enable logs
@@ -875,6 +878,7 @@ otlp_config:
875878
876879
# AWS Lambda
877880
api_key_secret_arn: "arn:aws:secretsmanager:region:account:secret:datadog-api-key"
881+
api_key_secret_json_key: "apiKey"
878882
kms_api_key: "test-kms-key"
879883
serverless_logs_enabled: false
880884
serverless_flush_strategy: "periodically,60000"
@@ -1008,6 +1012,7 @@ api_security_sample_delay: 60 # Seconds
10081012
otlp_config_logs_enabled: true,
10091013
api_key_secret_arn: "arn:aws:secretsmanager:region:account:secret:datadog-api-key"
10101014
.to_string(),
1015+
api_key_secret_json_key: "apiKey".to_string(),
10111016
kms_api_key: "test-kms-key".to_string(),
10121017
api_key_ssm_arn: String::default(),
10131018
serverless_logs_enabled: false,

bottlecap/src/secrets/decrypt.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ pub async fn resolve_secrets(config: Arc<Config>, aws_config: Arc<AwsConfig>) ->
8383
decrypt_aws_sm(
8484
&client,
8585
config.api_key_secret_arn.clone(),
86+
&config.api_key_secret_json_key,
8687
aws_config,
8788
&aws_credentials,
8889
)
@@ -194,6 +195,7 @@ async fn decrypt_aws_kms(
194195
async fn decrypt_aws_sm(
195196
client: &Client,
196197
secret_arn: String,
198+
json_key: &str,
197199
aws_config: Arc<AwsConfig>,
198200
aws_credentials: &AwsCredentials,
199201
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
@@ -217,9 +219,25 @@ async fn decrypt_aws_sm(
217219
);
218220

219221
let v = request(json_body, headers?, client).await?;
222+
extract_secret_string(&v, json_key)
223+
}
220224

225+
fn extract_secret_string(
226+
v: &Value,
227+
json_key: &str,
228+
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
221229
if let Some(secret_string) = v["SecretString"].as_str() {
222-
Ok(secret_string.to_string())
230+
if json_key.is_empty() {
231+
return Ok(secret_string.to_string());
232+
}
233+
let parsed: Value = serde_json::from_str(secret_string)?;
234+
let extracted = parsed[json_key].as_str().ok_or_else(|| {
235+
Error::new(
236+
std::io::ErrorKind::InvalidData,
237+
format!("JSON key '{json_key}' not found in secret or is not a string"),
238+
)
239+
})?;
240+
Ok(extracted.to_string())
223241
} else {
224242
Err(Error::new(std::io::ErrorKind::InvalidData, v.to_string()).into())
225243
}
@@ -401,6 +419,31 @@ mod tests {
401419
use super::*;
402420
use chrono::{NaiveDateTime, TimeZone};
403421

422+
fn make_sm_response(secret_string: &str) -> Value {
423+
serde_json::json!({ "SecretString": secret_string })
424+
}
425+
426+
#[test]
427+
fn test_json_secret_extraction() {
428+
let v = make_sm_response(r#"{"apiKey":"abc123"}"#);
429+
let result = extract_secret_string(&v, "apiKey").expect("should extract apiKey");
430+
assert_eq!(result, "abc123");
431+
}
432+
433+
#[test]
434+
fn test_json_secret_missing_key() {
435+
let v = make_sm_response(r#"{"apiKey":"abc123"}"#);
436+
let err = extract_secret_string(&v, "wrongKey").expect_err("should fail on missing key");
437+
assert!(err.to_string().contains("wrongKey"));
438+
}
439+
440+
#[test]
441+
fn test_plain_secret_unaffected() {
442+
let v = make_sm_response("abc123");
443+
let result = extract_secret_string(&v, "").expect("should return raw value");
444+
assert_eq!(result, "abc123");
445+
}
446+
404447
#[test]
405448
fn key_cleanup() {
406449
let key = clean_api_key(Some(" 32alxcxf\n".to_string()));

0 commit comments

Comments
 (0)