Skip to content

Commit 9f655d6

Browse files
authored
[SVLS-7704] add support for SSM Parameter API key (#924)
## Overview * Add support for customers storing Datadog API Key in SSM Parameter Store. ## Testing * Deployed changes and confirmed this work with Parameter Store String and SecureString.
1 parent 9805ba1 commit 9f655d6

File tree

4 files changed

+242
-71
lines changed

4 files changed

+242
-71
lines changed

bottlecap/src/config/env.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,11 @@ pub struct EnvConfig {
357357
/// The AWS KMS API key to use for the Datadog Agent.
358358
#[serde(deserialize_with = "deserialize_optional_string")]
359359
pub kms_api_key: Option<String>,
360+
/// @env `DD_API_KEY_SSM_ARN`
361+
///
362+
/// The AWS Systems Manager Parameter Store parameter ARN containing the Datadog API key.
363+
#[serde(deserialize_with = "deserialize_optional_string")]
364+
pub api_key_ssm_arn: Option<String>,
360365
/// @env `DD_SERVERLESS_LOGS_ENABLED`
361366
///
362367
/// Enable logs for AWS Lambda. Default is `true`.
@@ -609,6 +614,7 @@ fn merge_config(config: &mut Config, env_config: &EnvConfig) {
609614
// AWS Lambda
610615
merge_string!(config, env_config, api_key_secret_arn);
611616
merge_string!(config, env_config, kms_api_key);
617+
merge_string!(config, env_config, api_key_ssm_arn);
612618
merge_option_to_value!(config, env_config, serverless_logs_enabled);
613619
merge_option_to_value!(config, env_config, serverless_flush_strategy);
614620
merge_option_to_value!(config, env_config, enhanced_metrics);
@@ -952,6 +958,7 @@ mod tests {
952958
api_key_secret_arn: "arn:aws:secretsmanager:region:account:secret:datadog-api-key"
953959
.to_string(),
954960
kms_api_key: "test-kms-key".to_string(),
961+
api_key_ssm_arn: String::default(),
955962
serverless_logs_enabled: false,
956963
serverless_flush_strategy: FlushStrategy::Periodically(PeriodicStrategy {
957964
interval: 60000,

bottlecap/src/config/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ pub struct Config {
337337
// AWS Lambda
338338
pub api_key_secret_arn: String,
339339
pub kms_api_key: String,
340+
pub api_key_ssm_arn: String,
340341
pub serverless_logs_enabled: bool,
341342
pub serverless_flush_strategy: FlushStrategy,
342343
pub enhanced_metrics: bool,
@@ -440,6 +441,7 @@ impl Default for Config {
440441
// AWS Lambda
441442
api_key_secret_arn: String::default(),
442443
kms_api_key: String::default(),
444+
api_key_ssm_arn: String::default(),
443445
serverless_logs_enabled: true,
444446
serverless_flush_strategy: FlushStrategy::Default,
445447
enhanced_metrics: true,

bottlecap/src/config/yaml.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,7 @@ api_security_sample_delay: 60 # Seconds
972972
api_key_secret_arn: "arn:aws:secretsmanager:region:account:secret:datadog-api-key"
973973
.to_string(),
974974
kms_api_key: "test-kms-key".to_string(),
975+
api_key_ssm_arn: String::default(),
975976
serverless_logs_enabled: false,
976977
serverless_flush_strategy: FlushStrategy::Periodically(PeriodicStrategy {
977978
interval: 60000,

bottlecap/src/secrets/decrypt.rs

Lines changed: 232 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -18,87 +18,97 @@ use tracing::debug;
1818
use tracing::error;
1919

2020
pub async fn resolve_secrets(config: Arc<Config>, aws_config: Arc<AwsConfig>) -> Option<String> {
21-
let api_key_candidate =
22-
if !config.api_key_secret_arn.is_empty() || !config.kms_api_key.is_empty() {
23-
let before_decrypt = Instant::now();
21+
let api_key_candidate = if !config.api_key_secret_arn.is_empty()
22+
|| !config.kms_api_key.is_empty()
23+
|| !config.api_key_ssm_arn.is_empty()
24+
{
25+
let before_decrypt = Instant::now();
26+
27+
let builder = match create_reqwest_client_builder() {
28+
Ok(builder) => builder,
29+
Err(err) => {
30+
error!("Error creating reqwest client builder: {}", err);
31+
return None;
32+
}
33+
};
2434

25-
let builder = match create_reqwest_client_builder() {
26-
Ok(builder) => builder,
27-
Err(err) => {
28-
error!("Error creating reqwest client builder: {}", err);
29-
return None;
30-
}
31-
};
35+
let client = match builder.build() {
36+
Ok(client) => client,
37+
Err(err) => {
38+
error!("Error creating reqwest client: {}", err);
39+
return None;
40+
}
41+
};
3242

33-
let client = match builder.build() {
34-
Ok(client) => client,
43+
let mut aws_credentials = AwsCredentials::from_env();
44+
45+
if aws_credentials.aws_secret_access_key.is_empty()
46+
&& aws_credentials.aws_access_key_id.is_empty()
47+
&& !aws_credentials
48+
.aws_container_credentials_full_uri
49+
.is_empty()
50+
&& !aws_credentials.aws_container_authorization_token.is_empty()
51+
{
52+
// We're in Snap Start
53+
let credentials = match get_snapstart_credentials(&aws_credentials, &client).await {
54+
Ok(credentials) => credentials,
3555
Err(err) => {
36-
error!("Error creating reqwest client: {}", err);
56+
error!("Error getting Snap Start credentials: {}", err);
3757
return None;
3858
}
3959
};
60+
aws_credentials.aws_access_key_id = credentials["AccessKeyId"]
61+
.as_str()
62+
.unwrap_or_default()
63+
.to_string();
64+
aws_credentials.aws_secret_access_key = credentials["SecretAccessKey"]
65+
.as_str()
66+
.unwrap_or_default()
67+
.to_string();
68+
aws_credentials.aws_session_token = credentials["Token"]
69+
.as_str()
70+
.unwrap_or_default()
71+
.to_string();
72+
}
4073

41-
let mut aws_credentials = AwsCredentials::from_env();
42-
43-
if aws_credentials.aws_secret_access_key.is_empty()
44-
&& aws_credentials.aws_access_key_id.is_empty()
45-
&& !aws_credentials
46-
.aws_container_credentials_full_uri
47-
.is_empty()
48-
&& !aws_credentials.aws_container_authorization_token.is_empty()
49-
{
50-
// We're in Snap Start
51-
let credentials = match get_snapstart_credentials(&aws_credentials, &client).await {
52-
Ok(credentials) => credentials,
53-
Err(err) => {
54-
error!("Error getting Snap Start credentials: {}", err);
55-
return None;
56-
}
57-
};
58-
aws_credentials.aws_access_key_id = credentials["AccessKeyId"]
59-
.as_str()
60-
.unwrap_or_default()
61-
.to_string();
62-
aws_credentials.aws_secret_access_key = credentials["SecretAccessKey"]
63-
.as_str()
64-
.unwrap_or_default()
65-
.to_string();
66-
aws_credentials.aws_session_token = credentials["Token"]
67-
.as_str()
68-
.unwrap_or_default()
69-
.to_string();
70-
}
71-
72-
let decrypted_key = if config.kms_api_key.is_empty() {
73-
decrypt_aws_sm(
74-
&client,
75-
config.api_key_secret_arn.clone(),
76-
aws_config,
77-
&aws_credentials,
78-
)
79-
.await
80-
} else {
81-
decrypt_aws_kms(
82-
&client,
83-
config.kms_api_key.clone(),
84-
aws_config,
85-
&aws_credentials,
86-
)
87-
.await
88-
};
74+
let decrypted_key = if !config.kms_api_key.is_empty() {
75+
decrypt_aws_kms(
76+
&client,
77+
config.kms_api_key.clone(),
78+
aws_config,
79+
&aws_credentials,
80+
)
81+
.await
82+
} else if !config.api_key_secret_arn.is_empty() {
83+
decrypt_aws_sm(
84+
&client,
85+
config.api_key_secret_arn.clone(),
86+
aws_config,
87+
&aws_credentials,
88+
)
89+
.await
90+
} else {
91+
decrypt_aws_ssm(
92+
&client,
93+
config.api_key_ssm_arn.clone(),
94+
aws_config,
95+
&aws_credentials,
96+
)
97+
.await
98+
};
8999

90-
debug!("Decrypt took {} ms", before_decrypt.elapsed().as_millis());
100+
debug!("Decrypt took {} ms", before_decrypt.elapsed().as_millis());
91101

92-
match decrypted_key {
93-
Ok(key) => Some(key),
94-
Err(err) => {
95-
error!("Error decrypting key: {}", err);
96-
None
97-
}
102+
match decrypted_key {
103+
Ok(key) => Some(key),
104+
Err(err) => {
105+
error!("Error decrypting key: {}", err);
106+
None
98107
}
99-
} else {
100-
Some(config.api_key.clone())
101-
};
108+
}
109+
} else {
110+
Some(config.api_key.clone())
111+
};
102112

103113
clean_api_key(api_key_candidate)
104114
}
@@ -215,6 +225,39 @@ async fn decrypt_aws_sm(
215225
}
216226
}
217227

228+
async fn decrypt_aws_ssm(
229+
client: &Client,
230+
parameter_arn: String,
231+
aws_config: Arc<AwsConfig>,
232+
aws_credentials: &AwsCredentials,
233+
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
234+
let json_body = &serde_json::json!({ "Name": parameter_arn, "WithDecryption": true });
235+
let parameter_region = parameter_arn
236+
.split(':')
237+
.nth(3)
238+
.unwrap_or(&aws_config.region)
239+
.to_string();
240+
let headers = build_get_secret_signed_headers(
241+
aws_config,
242+
aws_credentials,
243+
parameter_region,
244+
RequestArgs {
245+
service: "ssm".to_string(),
246+
body: json_body,
247+
time: Utc::now(),
248+
x_amz_target: "AmazonSSM.GetParameter".to_string(),
249+
},
250+
);
251+
252+
let v = request(json_body, headers?, client).await?;
253+
if let Some(parameter) = v["Parameter"].as_object() {
254+
if let Some(value) = parameter["Value"].as_str() {
255+
return Ok(value.to_string());
256+
}
257+
}
258+
Err(Error::new(std::io::ErrorKind::InvalidData, v.to_string()).into())
259+
}
260+
218261
async fn get_snapstart_credentials(
219262
aws_credentials: &AwsCredentials,
220263
client: &Client,
@@ -483,4 +526,122 @@ mod tests {
483526
assert_eq!(headers.get(k).expect("cannot get header"), v);
484527
}
485528
}
529+
530+
#[test]
531+
#[allow(clippy::unwrap_used)]
532+
fn test_ssm_parameter_headers() {
533+
let time = Utc.from_utc_datetime(
534+
&NaiveDateTime::parse_from_str("2024-05-30 09:10:11", "%Y-%m-%d %H:%M:%S").unwrap(),
535+
);
536+
let headers = build_get_secret_signed_headers(
537+
Arc::new(AwsConfig {
538+
region: "us-east-1".to_string(),
539+
aws_lwa_proxy_lambda_runtime_api: Some("***".into()),
540+
function_name: "arn:some-function".to_string(),
541+
sandbox_init_time: Instant::now(),
542+
runtime_api: String::new(),
543+
exec_wrapper: None,
544+
}),
545+
&AwsCredentials{
546+
aws_access_key_id: "AKIDEXAMPLE".to_string(),
547+
aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
548+
aws_session_token: "AQoDYXdzEJr...<remainder of session token>".to_string(),
549+
aws_container_authorization_token: String::new(),
550+
aws_container_credentials_full_uri: String::new(),
551+
},
552+
"us-east-1".to_string(),
553+
RequestArgs {
554+
service: "ssm".to_string(),
555+
body: &serde_json::json!({ "Name": "arn:aws:ssm:us-east-1:account-id:parameter/my-parameter", "WithDecryption": true }),
556+
time,
557+
x_amz_target: "AmazonSSM.GetParameter".to_string(),
558+
},
559+
).unwrap();
560+
561+
let mut expected_headers = HeaderMap::new();
562+
expected_headers.insert("authorization", HeaderValue::from_str("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20240530/us-east-1/ssm/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-security-token;x-amz-target, Signature=f45eebacb9f2f575cf1a235c65c97adc1c0d6ca21174044baa0c6457d4518d64").unwrap());
563+
expected_headers.insert(
564+
"host",
565+
HeaderValue::from_str("ssm.us-east-1.amazonaws.com").unwrap(),
566+
);
567+
expected_headers.insert(
568+
"content-type",
569+
HeaderValue::from_str("application/x-amz-json-1.1").unwrap(),
570+
);
571+
expected_headers.insert(
572+
"x-amz-date",
573+
HeaderValue::from_str("20240530T091011Z").unwrap(),
574+
);
575+
expected_headers.insert(
576+
"x-amz-target",
577+
HeaderValue::from_str("AmazonSSM.GetParameter").unwrap(),
578+
);
579+
expected_headers.insert(
580+
"x-amz-security-token",
581+
HeaderValue::from_str("AQoDYXdzEJr...<remainder of session token>").unwrap(),
582+
);
583+
584+
for (k, v) in &expected_headers {
585+
assert_eq!(headers.get(k).expect("cannot get header"), v);
586+
}
587+
}
588+
589+
#[test]
590+
#[allow(clippy::unwrap_used)]
591+
fn test_cross_region_ssm_parameter() {
592+
let time = Utc.from_utc_datetime(
593+
&NaiveDateTime::parse_from_str("2024-05-30 09:10:11", "%Y-%m-%d %H:%M:%S").unwrap(),
594+
);
595+
let headers = build_get_secret_signed_headers(
596+
Arc::new(AwsConfig {
597+
region: "us-east-1".to_string(),
598+
aws_lwa_proxy_lambda_runtime_api: Some("***".into()),
599+
function_name: "arn:some-function".to_string(),
600+
sandbox_init_time: Instant::now(),
601+
runtime_api: String::new(),
602+
exec_wrapper: None,
603+
}),
604+
&AwsCredentials{
605+
aws_access_key_id: "AKIDEXAMPLE".to_string(),
606+
aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
607+
aws_session_token: "AQoDYXdzEJr...<remainder of session token>".to_string(),
608+
aws_container_authorization_token: String::new(),
609+
aws_container_credentials_full_uri: String::new(),
610+
},
611+
"eu-west-1".to_string(),
612+
RequestArgs {
613+
service: "ssm".to_string(),
614+
body: &serde_json::json!({ "Name": "arn:aws:ssm:eu-west-1:account-id:parameter/my-parameter", "WithDecryption": true }),
615+
time,
616+
x_amz_target: "AmazonSSM.GetParameter".to_string(),
617+
},
618+
).unwrap();
619+
620+
let mut expected_headers = HeaderMap::new();
621+
expected_headers.insert("authorization", HeaderValue::from_str("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20240530/eu-west-1/ssm/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-security-token;x-amz-target, Signature=4bdd2a27b632981c73a70b52ea84e92d3e26fe2c768e9cf18b96630188748b7a").unwrap());
622+
expected_headers.insert(
623+
"host",
624+
HeaderValue::from_str("ssm.eu-west-1.amazonaws.com").unwrap(),
625+
);
626+
expected_headers.insert(
627+
"content-type",
628+
HeaderValue::from_str("application/x-amz-json-1.1").unwrap(),
629+
);
630+
expected_headers.insert(
631+
"x-amz-date",
632+
HeaderValue::from_str("20240530T091011Z").unwrap(),
633+
);
634+
expected_headers.insert(
635+
"x-amz-target",
636+
HeaderValue::from_str("AmazonSSM.GetParameter").unwrap(),
637+
);
638+
expected_headers.insert(
639+
"x-amz-security-token",
640+
HeaderValue::from_str("AQoDYXdzEJr...<remainder of session token>").unwrap(),
641+
);
642+
643+
for (k, v) in &expected_headers {
644+
assert_eq!(headers.get(k).expect("cannot get header"), v);
645+
}
646+
}
486647
}

0 commit comments

Comments
 (0)