diff --git a/.gitlab/datasources/test-suites.yaml b/.gitlab/datasources/test-suites.yaml index 810135910..257b1ba04 100644 --- a/.gitlab/datasources/test-suites.yaml +++ b/.gitlab/datasources/test-suites.yaml @@ -3,3 +3,4 @@ test_suites: - name: otlp - name: snapstart - name: lmi + - name: auth diff --git a/bottlecap/src/bin/bottlecap/main.rs b/bottlecap/src/bin/bottlecap/main.rs index 0b29f95d2..8ee88bb26 100644 --- a/bottlecap/src/bin/bottlecap/main.rs +++ b/bottlecap/src/bin/bottlecap/main.rs @@ -150,7 +150,11 @@ async fn main() -> anyhow::Result<()> { let config = Arc::new(config::get_config(Path::new(&lambda_directory))); let aws_config = Arc::new(aws_config); - let api_key_factory = create_api_key_factory(&config, &aws_config); + // Build one shared reqwest::Client for metrics, logs, trace proxy flushing, and calls to + // Datadog APIs (e.g. delegated auth). reqwest::Client is Arc-based internally, so cloning + // just increments a refcount and shares the connection pool. + let shared_client = bottlecap::http::get_client(&config); + let api_key_factory = create_api_key_factory(&config, &aws_config, &shared_client); let r = response .await @@ -161,6 +165,7 @@ async fn main() -> anyhow::Result<()> { Arc::clone(&aws_config), &config, &client, + shared_client, &r, Arc::clone(&api_key_factory), start_time, @@ -246,17 +251,23 @@ fn get_flush_strategy_for_mode( } } -fn create_api_key_factory(config: &Arc, aws_config: &Arc) -> Arc { +fn create_api_key_factory( + config: &Arc, + aws_config: &Arc, + client: &reqwest::Client, +) -> Arc { let config = Arc::clone(config); let aws_config = Arc::clone(aws_config); + let client = client.clone(); let api_key_secret_reload_interval = config.api_key_secret_reload_interval; Arc::new(ApiKeyFactory::new_from_resolver( Arc::new(move || { let config = Arc::clone(&config); let aws_config = Arc::clone(&aws_config); + let client = client.clone(); - Box::pin(async move { resolve_secrets(config, aws_config).await }) + Box::pin(async move { resolve_secrets(config, aws_config, client).await }) }), api_key_secret_reload_interval, )) @@ -285,6 +296,7 @@ async fn extension_loop_active( aws_config: Arc, config: &Arc, client: &Client, + shared_client: reqwest::Client, r: &RegisterResponse, api_key_factory: Arc, start_time: Instant, @@ -294,11 +306,6 @@ async fn extension_loop_active( let account_id = r.account_id.as_ref().unwrap_or(&"none".to_string()).clone(); let tags_provider = setup_tag_provider(&Arc::clone(&aws_config), config, &account_id); - // Build one shared reqwest::Client for metrics, logs, and trace proxy flushing. - // reqwest::Client is Arc-based internally, so cloning just increments a refcount - // and shares the connection pool. - let shared_client = bottlecap::http::get_client(config); - let ( logs_agent_channel, logs_flusher, diff --git a/bottlecap/src/config/env.rs b/bottlecap/src/config/env.rs index 4c056d32b..84f9a820c 100644 --- a/bottlecap/src/config/env.rs +++ b/bottlecap/src/config/env.rs @@ -482,6 +482,12 @@ pub struct EnvConfig { /// The delay between two samples of the API Security schema collection, in seconds. #[serde(deserialize_with = "deserialize_optional_duration_from_seconds")] pub api_security_sample_delay: Option, + + /// @env `DD_ORG_UUID` + /// + /// The Datadog organization UUID. When set, delegated auth is auto-enabled. + #[serde(deserialize_with = "deserialize_string_or_int")] + pub org_uuid: Option, } #[allow(clippy::too_many_lines)] @@ -684,6 +690,8 @@ fn merge_config(config: &mut Config, env_config: &EnvConfig) { merge_option_to_value!(config, env_config, appsec_waf_timeout); merge_option_to_value!(config, env_config, api_security_enabled); merge_option_to_value!(config, env_config, api_security_sample_delay); + + merge_string!(config, dd_org_uuid, env_config, org_uuid); } #[derive(Debug, PartialEq, Clone, Copy)] @@ -1044,6 +1052,8 @@ mod tests { appsec_waf_timeout: Duration::from_secs(1), api_security_enabled: false, api_security_sample_delay: Duration::from_secs(60), + + dd_org_uuid: String::default(), }; assert_eq!(config, expected_config); diff --git a/bottlecap/src/config/mod.rs b/bottlecap/src/config/mod.rs index f220028d5..87903961c 100644 --- a/bottlecap/src/config/mod.rs +++ b/bottlecap/src/config/mod.rs @@ -364,6 +364,8 @@ pub struct Config { pub span_dedup_timeout: Option, pub api_key_secret_reload_interval: Option, + pub dd_org_uuid: String, + pub serverless_appsec_enabled: bool, pub appsec_rules: Option, pub appsec_waf_timeout: Duration, @@ -479,6 +481,8 @@ impl Default for Config { span_dedup_timeout: None, api_key_secret_reload_interval: None, + dd_org_uuid: String::default(), + serverless_appsec_enabled: false, appsec_rules: None, appsec_waf_timeout: Duration::from_millis(5), diff --git a/bottlecap/src/config/yaml.rs b/bottlecap/src/config/yaml.rs index 37e90ec85..d1300f67e 100644 --- a/bottlecap/src/config/yaml.rs +++ b/bottlecap/src/config/yaml.rs @@ -1036,6 +1036,8 @@ api_security_sample_delay: 60 # Seconds dogstatsd_so_rcvbuf: Some(1_048_576), dogstatsd_buffer_size: Some(65507), dogstatsd_queue_size: Some(2048), + + dd_org_uuid: String::default(), }; // Assert that diff --git a/bottlecap/src/secrets/decrypt.rs b/bottlecap/src/secrets/decrypt.rs index 673075919..47467c7ee 100644 --- a/bottlecap/src/secrets/decrypt.rs +++ b/bottlecap/src/secrets/decrypt.rs @@ -14,13 +14,19 @@ use sha2::{Digest, Sha256}; use std::io::Error; use std::sync::Arc; use tokio::time::Instant; -use tracing::debug; -use tracing::error; +use tracing::{debug, error}; -pub async fn resolve_secrets(config: Arc, aws_config: Arc) -> Option { +use crate::secrets::delegated_auth; + +pub async fn resolve_secrets( + config: Arc, + aws_config: Arc, + shared_client: Client, +) -> Option { let api_key_candidate = if !config.api_key_secret_arn.is_empty() || !config.kms_api_key.is_empty() || !config.api_key_ssm_arn.is_empty() + || !config.dd_org_uuid.is_empty() { let before_decrypt = Instant::now(); @@ -40,38 +46,17 @@ pub async fn resolve_secrets(config: Arc, aws_config: Arc) -> } }; - let mut aws_credentials = AwsCredentials::from_env(); - - if aws_credentials.aws_secret_access_key.is_empty() - && aws_credentials.aws_access_key_id.is_empty() - && !aws_credentials - .aws_container_credentials_full_uri - .is_empty() - && !aws_credentials.aws_container_authorization_token.is_empty() - { - // We're in Snap Start - let credentials = match get_snapstart_credentials(&aws_credentials, &client).await { - Ok(credentials) => credentials, - Err(err) => { - error!("Error getting Snap Start credentials: {}", err); - return None; - } - }; - aws_credentials.aws_access_key_id = credentials["AccessKeyId"] - .as_str() - .unwrap_or_default() - .to_string(); - aws_credentials.aws_secret_access_key = credentials["SecretAccessKey"] - .as_str() - .unwrap_or_default() - .to_string(); - aws_credentials.aws_session_token = credentials["Token"] - .as_str() - .unwrap_or_default() - .to_string(); - } + let aws_credentials = get_aws_credentials(&client).await?; - let decrypted_key = if !config.kms_api_key.is_empty() { + let decrypted_key = if !config.dd_org_uuid.is_empty() { + delegated_auth::get_delegated_api_key( + &config, + &aws_config, + &shared_client, + &aws_credentials, + ) + .await + } else if !config.kms_api_key.is_empty() { decrypt_aws_kms( &client, config.kms_api_key.clone(), @@ -258,6 +243,39 @@ async fn decrypt_aws_ssm( Err(Error::new(std::io::ErrorKind::InvalidData, v.to_string()).into()) } +async fn get_aws_credentials(client: &Client) -> Option { + let mut aws_credentials = AwsCredentials::from_env(); + // We're in SnapStart — fetch short-lived credentials from the container endpoint + if aws_credentials.aws_secret_access_key.is_empty() + && aws_credentials.aws_access_key_id.is_empty() + && !aws_credentials + .aws_container_credentials_full_uri + .is_empty() + && !aws_credentials.aws_container_authorization_token.is_empty() + { + let credentials = match get_snapstart_credentials(&aws_credentials, client).await { + Ok(credentials) => credentials, + Err(err) => { + error!("Error getting SnapStart credentials: {}", err); + return None; + } + }; + aws_credentials.aws_access_key_id = credentials["AccessKeyId"] + .as_str() + .unwrap_or_default() + .to_string(); + aws_credentials.aws_secret_access_key = credentials["SecretAccessKey"] + .as_str() + .unwrap_or_default() + .to_string(); + aws_credentials.aws_session_token = credentials["Token"] + .as_str() + .unwrap_or_default() + .to_string(); + } + Some(aws_credentials) +} + async fn get_snapstart_credentials( aws_credentials: &AwsCredentials, client: &Client, diff --git a/bottlecap/src/secrets/delegated_auth/auth_proof.rs b/bottlecap/src/secrets/delegated_auth/auth_proof.rs new file mode 100644 index 000000000..11a8270b6 --- /dev/null +++ b/bottlecap/src/secrets/delegated_auth/auth_proof.rs @@ -0,0 +1,291 @@ +use base64::prelude::*; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::io::Error; +use tracing::debug; + +use crate::config::aws::AwsCredentials; + +/// The STS `GetCallerIdentity` request body (form-encoded) +const GET_CALLER_IDENTITY_BODY: &str = "Action=GetCallerIdentity&Version=2011-06-15"; +/// Content-Type for the STS request (required by `SigV4`) +const CONTENT_TYPE: &str = "application/x-www-form-urlencoded; charset=utf-8"; +/// Datadog organization ID header included in the signed headers for backend verification +const ORG_ID_HEADER: &str = "x-ddog-org-id"; +/// AWS service name used in `SigV4` credential scope +const STS_SERVICE: &str = "sts"; + +/// Generates an authentication proof from AWS credentials. +/// +/// The proof consists of a signed STS `GetCallerIdentity` request that can be +/// verified by Datadog's backend. The format is: +/// `base64(body)|base64(headers_json)|POST|base64(url)` +/// +/// # Arguments +/// * `aws_credentials` - The AWS credentials to use for signing +/// * `region` - The AWS region for the STS endpoint +/// * `org_uuid` - The Datadog organization UUID to include in the signed headers +/// +/// # Returns +/// The base64-encoded proof string, or an error if signing fails +#[allow(clippy::similar_names)] +pub fn generate_auth_proof( + aws_credentials: &AwsCredentials, + region: &str, + org_uuid: &str, +) -> Result> { + debug!("Generating delegated auth proof for region: {}", region); + + // By the time this function is called, the caller has already resolved SnapStart + // credentials from the container credentials endpoint if needed. + if aws_credentials.aws_access_key_id.is_empty() + || aws_credentials.aws_secret_access_key.is_empty() + { + return Err("Missing AWS credentials for delegated auth".into()); + } + + if org_uuid.is_empty() { + return Err("Missing org UUID for delegated auth".into()); + } + + let sts_host = format!("sts.{region}.amazonaws.com"); + let sts_url = format!("https://{sts_host}"); + + let now = Utc::now(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + + let payload_hash = hex::encode(Sha256::digest(GET_CALLER_IDENTITY_BODY.as_bytes())); + + // Canonical headers must be sorted alphabetically per SigV4 spec + let canonical_headers = format!( + "content-type:{CONTENT_TYPE}\nhost:{sts_host}\nx-amz-date:{amz_date}\n{ORG_ID_HEADER}:{org_uuid}" + ); + + let (signed_headers, canonical_headers) = if aws_credentials.aws_session_token.is_empty() { + ( + "content-type;host;x-amz-date;x-ddog-org-id", + canonical_headers, + ) + } else { + let headers = format!( + "content-type:{CONTENT_TYPE}\nhost:{sts_host}\nx-amz-date:{amz_date}\nx-amz-security-token:{}\n{ORG_ID_HEADER}:{org_uuid}", + aws_credentials.aws_session_token + ); + ( + "content-type;host;x-amz-date;x-amz-security-token;x-ddog-org-id", + headers, + ) + }; + + let canonical_request = + format!("POST\n/\n\n{canonical_headers}\n\n{signed_headers}\n{payload_hash}"); + + debug!( + "Canonical request hash: {}", + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + let algorithm = "AWS4-HMAC-SHA256"; + let credential_scope = format!("{date_stamp}/{region}/{STS_SERVICE}/aws4_request"); + let string_to_sign = format!( + "{algorithm}\n{amz_date}\n{credential_scope}\n{}", + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + let signing_key = get_aws4_signature_key( + &aws_credentials.aws_secret_access_key, + &date_stamp, + region, + STS_SERVICE, + )?; + + let signature = hex::encode(sign(&signing_key, &string_to_sign)?); + + let authorization = format!( + "{algorithm} Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}", + aws_credentials.aws_access_key_id + ); + + // BTreeMap ensures consistent ordering for signature verification + let mut headers_map: BTreeMap> = BTreeMap::new(); + headers_map.insert("Authorization".to_string(), vec![authorization]); + headers_map.insert("Content-Type".to_string(), vec![CONTENT_TYPE.to_string()]); + headers_map.insert("Host".to_string(), vec![sts_host]); + headers_map.insert("X-Amz-Date".to_string(), vec![amz_date]); + headers_map.insert("X-Ddog-Org-Id".to_string(), vec![org_uuid.to_string()]); + + if !aws_credentials.aws_session_token.is_empty() { + headers_map.insert( + "X-Amz-Security-Token".to_string(), + vec![aws_credentials.aws_session_token.clone()], + ); + } + + let headers_json = serde_json::to_string(&headers_map)?; + + // Proof format: base64(body)|base64(headers)|POST|base64(url) + let proof = format!( + "{}|{}|POST|{}", + BASE64_STANDARD.encode(GET_CALLER_IDENTITY_BODY), + BASE64_STANDARD.encode(&headers_json), + BASE64_STANDARD.encode(&sts_url) + ); + + debug!("Generated delegated auth proof successfully"); + Ok(proof) +} + +/// Signs a message using HMAC-SHA256 +fn sign(key: &[u8], msg: &str) -> Result, Box> { + let mut mac = Hmac::::new_from_slice(key).map_err(|err| { + Error::new( + std::io::ErrorKind::InvalidInput, + format!("Error creating HMAC: {err}"), + ) + })?; + mac.update(msg.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) +} + +/// Derives the AWS `SigV4` signing key +fn get_aws4_signature_key( + key: &str, + date_stamp: &str, + region_name: &str, + service_name: &str, +) -> Result, Box> { + let k_date = sign(format!("AWS4{key}").as_bytes(), date_stamp)?; + let k_region = sign(&k_date, region_name)?; + let k_service = sign(&k_region, service_name)?; + sign(&k_service, "aws4_request") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_auth_proof_missing_credentials() { + let creds = AwsCredentials { + aws_access_key_id: String::new(), + aws_secret_access_key: String::new(), + aws_session_token: String::new(), + aws_container_credentials_full_uri: String::new(), + aws_container_authorization_token: String::new(), + }; + + let result = generate_auth_proof(&creds, "us-east-1", "test-org-uuid"); + assert!(result.is_err()); + assert!( + result + .expect_err("expected error for missing credentials") + .to_string() + .contains("Missing AWS credentials") + ); + } + + #[test] + fn test_generate_auth_proof_missing_org_uuid() { + let creds = AwsCredentials { + aws_access_key_id: "AKIDEXAMPLE".to_string(), + aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), + aws_session_token: String::new(), + aws_container_credentials_full_uri: String::new(), + aws_container_authorization_token: String::new(), + }; + + let result = generate_auth_proof(&creds, "us-east-1", ""); + assert!(result.is_err()); + assert!( + result + .expect_err("expected error for missing org UUID") + .to_string() + .contains("Missing org UUID") + ); + } + + #[test] + fn test_generate_auth_proof_format() { + let creds = AwsCredentials { + aws_access_key_id: "AKIDEXAMPLE".to_string(), + aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), + aws_session_token: "AQoDYXdzEJrtoken".to_string(), + aws_container_credentials_full_uri: String::new(), + aws_container_authorization_token: String::new(), + }; + + let result = generate_auth_proof(&creds, "us-east-1", "test-org-uuid"); + assert!(result.is_ok()); + + let proof = result.expect("failed to generate auth proof"); + let parts: Vec<&str> = proof.split('|').collect(); + assert_eq!(parts.len(), 4); + + // Verify body is base64-encoded GET_CALLER_IDENTITY_BODY + let body = String::from_utf8( + BASE64_STANDARD + .decode(parts[0]) + .expect("Failed to decode base64 body"), + ) + .expect("Failed to convert body to UTF-8"); + assert_eq!(body, GET_CALLER_IDENTITY_BODY); + + // Verify method + assert_eq!(parts[2], "POST"); + + // Verify URL is base64-encoded STS URL + let url = String::from_utf8( + BASE64_STANDARD + .decode(parts[3]) + .expect("Failed to decode base64 URL"), + ) + .expect("Failed to convert URL to UTF-8"); + assert!(url.contains("sts.us-east-1.amazonaws.com")); + } + + #[test] + fn test_generate_auth_proof_headers_contain_required_fields() { + let creds = AwsCredentials { + aws_access_key_id: "AKIDEXAMPLE".to_string(), + aws_secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), + aws_session_token: "AQoDYXdzEJrtoken".to_string(), + aws_container_credentials_full_uri: String::new(), + aws_container_authorization_token: String::new(), + }; + + let result = generate_auth_proof(&creds, "us-east-1", "my-org-uuid"); + assert!(result.is_ok()); + + let proof = result.expect("Failed to generate auth proof"); + let parts: Vec<&str> = proof.split('|').collect(); + + // Decode and parse headers + let headers_json = String::from_utf8( + BASE64_STANDARD + .decode(parts[1]) + .expect("Failed to decode base64 headers"), + ) + .expect("Failed to convert headers to UTF-8"); + let headers: BTreeMap> = + serde_json::from_str(&headers_json).expect("Failed to parse headers JSON"); + + // Verify required headers (canonical casing) + assert!(headers.contains_key("Authorization")); + assert!(headers.contains_key("Content-Type")); + assert!(headers.contains_key("Host")); + assert!(headers.contains_key("X-Amz-Date")); + assert!(headers.contains_key("X-Amz-Security-Token")); + assert!(headers.contains_key("X-Ddog-Org-Id")); + + // Verify org-id header value (array format) + assert_eq!( + headers + .get("X-Ddog-Org-Id") + .expect("Missing X-Ddog-Org-Id header"), + &vec!["my-org-uuid".to_string()] + ); + } +} diff --git a/bottlecap/src/secrets/delegated_auth/client.rs b/bottlecap/src/secrets/delegated_auth/client.rs new file mode 100644 index 000000000..ae995d2e2 --- /dev/null +++ b/bottlecap/src/secrets/delegated_auth/client.rs @@ -0,0 +1,188 @@ +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; +use std::sync::Arc; +use tracing::{debug, error}; + +use super::auth_proof::generate_auth_proof; +use crate::config::Config; +use crate::config::aws::{AwsConfig, AwsCredentials}; + +const INTAKE_KEY_ENDPOINT: &str = "/api/v2/intake-key"; + +/// Gets a delegated API key from Datadog using AWS credentials. +/// +/// This function: +/// 1. Generates a signed STS `GetCallerIdentity` proof +/// 2. Sends the proof to Datadog's intake-key API +/// 3. Returns the managed API key +/// +/// # Arguments +/// * `config` - The extension configuration containing site and `org_uuid` +/// * `aws_config` - The AWS configuration containing region +/// * `client` - A pre-built `reqwest::Client` to use for the request. The client is built +/// with `create_reqwest_client_builder()` which respects proxy configuration via +/// `HTTPS_PROXY` environment variables, so hardcoded `https://` in the URL does not +/// bypass proxy settings. +/// * `aws_credentials` - Pre-resolved AWS credentials (`SnapStart` credentials must be +/// fetched by the caller before invoking this function) +/// +/// # Returns +/// The API key string, or an error if the request fails +pub async fn get_delegated_api_key( + config: &Arc, + aws_config: &Arc, + client: &reqwest::Client, + aws_credentials: &AwsCredentials, +) -> Result> { + debug!("Attempting to get API key via delegated auth"); + + let proof = generate_auth_proof(aws_credentials, &aws_config.region, &config.dd_org_uuid)?; + + let url = get_api_endpoint(&config.site); + debug!("Requesting delegated API key from: {}", url); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Delegated {proof}"))?, + ); + + let response = client + .post(&url) + .headers(headers) + .body("") + .send() + .await + .map_err(|err| { + error!("Error sending delegated auth request: {}", err); + err + })?; + + let status = response.status(); + let response_body = response.text().await.unwrap_or_default(); + if !status.is_success() { + let err_msg = format!( + "Delegated auth request failed with status {status} (response body length: {} bytes)", + response_body.len() + ); + error!("{err_msg}"); + return Err(err_msg.into()); + } + + let parsed: serde_json::Value = serde_json::from_str(&response_body).map_err(|err| { + error!( + "Failed to parse delegated auth response: {} (body length: {} bytes)", + err, + response_body.len() + ); + err + })?; + + let api_key = parsed["data"]["attributes"]["api_key"] + .as_str() + .unwrap_or_default() + .to_string(); + + if api_key.is_empty() { + return Err("Received empty API key from delegated auth".into()); + } + + Ok(api_key) +} + +/// Gets the API endpoint URL based on the site configuration. +/// +/// Maps the `DD_SITE` value to the appropriate API endpoint: +/// - datadoghq.com -> api.datadoghq.com +/// - us3.datadoghq.com -> api.us3.datadoghq.com +/// - datadoghq.eu -> api.datadoghq.eu +/// - ddog-gov.com -> api.ddog-gov.com +fn get_api_endpoint(site: &str) -> String { + let site = site.trim(); + + if site.is_empty() { + return format!("https://api.datadoghq.com{INTAKE_KEY_ENDPOINT}"); + } + + let domain = if site.starts_with("https://") || site.starts_with("http://") { + site.split("://") + .nth(1) + .unwrap_or(site) + .split('/') + .next() + .unwrap_or(site) + } else { + site + }; + + format!("https://api.{domain}{INTAKE_KEY_ENDPOINT}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_api_endpoint_default() { + assert_eq!( + get_api_endpoint("datadoghq.com"), + "https://api.datadoghq.com/api/v2/intake-key" + ); + } + + #[test] + fn test_get_api_endpoint_us3() { + assert_eq!( + get_api_endpoint("us3.datadoghq.com"), + "https://api.us3.datadoghq.com/api/v2/intake-key" + ); + } + + #[test] + fn test_get_api_endpoint_us5() { + assert_eq!( + get_api_endpoint("us5.datadoghq.com"), + "https://api.us5.datadoghq.com/api/v2/intake-key" + ); + } + + #[test] + fn test_get_api_endpoint_eu() { + assert_eq!( + get_api_endpoint("datadoghq.eu"), + "https://api.datadoghq.eu/api/v2/intake-key" + ); + } + + #[test] + fn test_get_api_endpoint_gov() { + assert_eq!( + get_api_endpoint("ddog-gov.com"), + "https://api.ddog-gov.com/api/v2/intake-key" + ); + } + + #[test] + fn test_get_api_endpoint_ap1() { + assert_eq!( + get_api_endpoint("ap1.datadoghq.com"), + "https://api.ap1.datadoghq.com/api/v2/intake-key" + ); + } + + #[test] + fn test_get_api_endpoint_empty() { + assert_eq!( + get_api_endpoint(""), + "https://api.datadoghq.com/api/v2/intake-key" + ); + } + + #[test] + fn test_get_api_endpoint_with_protocol() { + assert_eq!( + get_api_endpoint("https://datadoghq.com"), + "https://api.datadoghq.com/api/v2/intake-key" + ); + } +} diff --git a/bottlecap/src/secrets/delegated_auth/mod.rs b/bottlecap/src/secrets/delegated_auth/mod.rs new file mode 100644 index 000000000..3146aa34d --- /dev/null +++ b/bottlecap/src/secrets/delegated_auth/mod.rs @@ -0,0 +1,5 @@ +pub mod auth_proof; +pub mod client; + +pub use auth_proof::generate_auth_proof; +pub use client::get_delegated_api_key; diff --git a/bottlecap/src/secrets/mod.rs b/bottlecap/src/secrets/mod.rs index fb74fb885..39de867c9 100644 --- a/bottlecap/src/secrets/mod.rs +++ b/bottlecap/src/secrets/mod.rs @@ -1 +1,2 @@ pub mod decrypt; +pub mod delegated_auth; diff --git a/integration-tests/bin/app.ts b/integration-tests/bin/app.ts index 08806d31d..d822e6cac 100644 --- a/integration-tests/bin/app.ts +++ b/integration-tests/bin/app.ts @@ -5,6 +5,8 @@ import {OnDemand} from '../lib/stacks/on-demand'; import {Otlp} from '../lib/stacks/otlp'; import {Snapstart} from '../lib/stacks/snapstart'; import {LambdaManagedInstancesStack} from '../lib/stacks/lmi'; +import {AuthStack} from '../lib/stacks/auth'; +import {AuthRoleStack} from '../lib/auth-role'; import {ACCOUNT, getIdentifier, REGION} from '../config'; import {CapacityProviderStack} from "../lib/capacity-provider"; @@ -20,6 +22,7 @@ const identifier = getIdentifier(); // Use the same Lambda Managed Instance Capacity Provider for all LMI functions. // It is slow to create/destroy the related resources. new CapacityProviderStack(app, `integ-default-capacity-provider`, {env}); +new AuthRoleStack(app, `integ-auth-role`, {env}); const stacks = [ new OnDemand(app, `integ-${identifier}-on-demand`, { @@ -34,6 +37,9 @@ const stacks = [ new LambdaManagedInstancesStack(app, `integ-${identifier}-lmi`, { env, }), + new AuthStack(app, `integ-${identifier}-auth`, { + env, + }), ] // Tag all stacks so we can easily clean them up diff --git a/integration-tests/lib/auth-role.ts b/integration-tests/lib/auth-role.ts new file mode 100644 index 000000000..d81cabd12 --- /dev/null +++ b/integration-tests/lib/auth-role.ts @@ -0,0 +1,28 @@ +import * as cdk from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; + +/** + * Stable IAM role name used by all auth integration test stacks. + * The intake mapping is configured once for this role ARN. + */ +export const AUTH_ROLE_NAME = 'integ-auth-delegated-role'; + +export class AuthRoleStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const role = new iam.Role(this, 'AuthRole', { + roleName: AUTH_ROLE_NAME, + assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'), + ], + }); + + new cdk.CfnOutput(this, 'RoleArn', { + value: role.roleArn, + description: 'Stable IAM Role ARN for auth integration tests - configure in intake mapping', + }); + } +} diff --git a/integration-tests/lib/stacks/auth.ts b/integration-tests/lib/stacks/auth.ts new file mode 100644 index 000000000..0214fa96c --- /dev/null +++ b/integration-tests/lib/stacks/auth.ts @@ -0,0 +1,89 @@ +import * as cdk from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { Construct } from 'constructs'; +import { + createLogGroup, + getExtensionLayer, + getDefaultJavaLayer, + defaultNodeRuntime, + defaultJavaRuntime, +} from '../util'; +import { AUTH_ROLE_NAME } from '../auth-role'; + +/** + * CDK Stack for Authentication Integration Tests + * + * Tests delegated authentication - Lambda uses IAM role to obtain API key. + * Includes on-demand (Node) and SnapStart (Java) functions. + * + * Uses a shared IAM role (from AuthRoleStack) so the intake mapping is stable. + */ +export class AuthStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: cdk.StackProps) { + super(scope, id, props); + + const extensionLayer = getExtensionLayer(this); + const javaLayer = getDefaultJavaLayer(this); + + const role = iam.Role.fromRoleName(this, 'AuthRole', AUTH_ROLE_NAME); + + const orgUuid = '80372b20-c861-11ea-9d80-67f811a2b630'; + + const delegatedAuthEnv = { + DD_SITE: 'datadoghq.com', + DD_ENV: 'integration', + DD_VERSION: '1.0.0', + DD_SERVERLESS_FLUSH_STRATEGY: 'end', + DD_SERVERLESS_LOGS_ENABLED: 'true', + DD_LOG_LEVEL: 'debug', + DD_ORG_UUID: orgUuid, + TS: Date.now().toString(), + }; + + const nodeFunctionName = `${id}-node`; + const nodeFn = new lambda.Function(this, nodeFunctionName, { + role, + runtime: defaultNodeRuntime, + architecture: lambda.Architecture.ARM_64, + handler: 'index.handler', + code: lambda.Code.fromAsset('./lambda/default-node'), + functionName: nodeFunctionName, + timeout: cdk.Duration.seconds(30), + memorySize: 256, + environment: { + ...delegatedAuthEnv, + DD_SERVICE: nodeFunctionName, + }, + logGroup: createLogGroup(this, nodeFunctionName), + }); + nodeFn.addLayers(extensionLayer); + + const javaFunctionName = `${id}-java`; + const javaFn = new lambda.Function(this, javaFunctionName, { + role, + runtime: defaultJavaRuntime, + architecture: lambda.Architecture.ARM_64, + handler: 'example.Handler::handleRequest', + code: lambda.Code.fromAsset('./lambda/default-java/target/function.jar'), + functionName: javaFunctionName, + timeout: cdk.Duration.seconds(30), + memorySize: 512, + snapStart: lambda.SnapStartConf.ON_PUBLISHED_VERSIONS, + environment: { + ...delegatedAuthEnv, + DD_SERVICE: javaFunctionName, + AWS_LAMBDA_EXEC_WRAPPER: '/opt/datadog_wrapper', + DD_TRACE_ENABLED: 'true', + }, + logGroup: createLogGroup(this, javaFunctionName), + }); + javaFn.addLayers(extensionLayer); + javaFn.addLayers(javaLayer); + const javaVersion = javaFn.currentVersion; + new lambda.Alias(this, `${javaFunctionName}-snapstart-alias`, { + aliasName: 'snapstart', + version: javaVersion, + }); + } +} diff --git a/integration-tests/tests/auth.test.ts b/integration-tests/tests/auth.test.ts new file mode 100644 index 000000000..6a62e0477 --- /dev/null +++ b/integration-tests/tests/auth.test.ts @@ -0,0 +1,60 @@ +import { invokeAndCollectTelemetry, FunctionConfig } from './utils/default'; +import { DatadogTelemetry } from './utils/datadog'; +import { forceColdStart, publishVersion, waitForSnapStartReady } from './utils/lambda'; +import { getIdentifier } from '../config'; + +const identifier = getIdentifier(); +const stackName = `integ-${identifier}-auth`; + +describe('Auth Integration Tests', () => { + let telemetry: Record; + + const getFirstInvocation = (runtime: string) => telemetry[runtime]?.threads[0]?.[0]; + + beforeAll(async () => { + const nodeFunctionName = `${stackName}-node`; + const javaFunctionName = `${stackName}-java`; + + await forceColdStart(nodeFunctionName); + + const javaVersion = await publishVersion(javaFunctionName); + await waitForSnapStartReady(javaFunctionName, javaVersion); + + const functions: FunctionConfig[] = [ + { functionName: nodeFunctionName, runtime: 'node' }, + { functionName: `${javaFunctionName}:${javaVersion}`, runtime: 'java' }, + ]; + + telemetry = await invokeAndCollectTelemetry(functions, 1); + + console.log('All invocations and data fetching completed'); + }, 600000); + + describe('on-demand (node)', () => { + it('should invoke Lambda successfully', () => { + const result = getFirstInvocation('node'); + expect(result).toBeDefined(); + expect(result.statusCode).toBe(200); + }); + + it('should send logs to Datadog via delegated auth', () => { + const result = getFirstInvocation('node'); + expect(result).toBeDefined(); + expect(result.logs!.length).toBeGreaterThan(0); + }); + }); + + describe('snapstart (java)', () => { + it('should invoke Lambda successfully', () => { + const result = getFirstInvocation('java'); + expect(result).toBeDefined(); + expect(result.statusCode).toBe(200); + }); + + it('should send logs to Datadog via delegated auth', () => { + const result = getFirstInvocation('java'); + expect(result).toBeDefined(); + expect(result.logs!.length).toBeGreaterThan(0); + }); + }); +});