Skip to main content

tsafe_aws/
config.rs

1//! AWS runtime config and credential loading.
2
3use super::error::AwsError;
4
5/// Runtime config for the AWS Secrets Manager client.
6/// Loaded from environment variables.
7#[derive(Clone)]
8pub struct AwsConfig {
9    pub region: String,
10    /// Endpoint URL. Defaults to `https://secretsmanager.{region}.amazonaws.com`.
11    /// Can be overridden for testing or custom endpoints (e.g. LocalStack).
12    pub endpoint: String,
13}
14
15impl AwsConfig {
16    /// Load from `AWS_DEFAULT_REGION` or `AWS_REGION`. Fails fast if absent.
17    pub fn from_env() -> Result<Self, AwsError> {
18        let region = std::env::var("AWS_DEFAULT_REGION")
19            .or_else(|_| std::env::var("AWS_REGION"))
20            .map_err(|_| AwsError::Config("AWS_DEFAULT_REGION or AWS_REGION is not set".into()))?;
21        let endpoint = format!("https://secretsmanager.{region}.amazonaws.com");
22        Ok(Self { region, endpoint })
23    }
24
25    /// Construct with an explicit endpoint URL (for testing / LocalStack).
26    pub fn with_endpoint(region: impl Into<String>, endpoint: impl Into<String>) -> Self {
27        let region = region.into();
28        let endpoint = endpoint.into();
29        Self { region, endpoint }
30    }
31}
32
33/// AWS credentials loaded from environment variables, ECS task role, or IMDSv2.
34#[derive(Clone)]
35pub struct AwsCredentials {
36    pub access_key_id: String,
37    pub secret_access_key: String,
38    pub session_token: Option<String>,
39}
40
41impl AwsCredentials {
42    /// Load credentials. Strategy (in order):
43    /// 1. Static env vars: `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`
44    /// 2. ECS task role: `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`
45    /// 3. IMDSv2: EC2 instance role
46    pub fn from_env_or_imds() -> Result<Self, AwsError> {
47        // 1. Static env vars
48        if let (Ok(key), Ok(secret)) = (
49            std::env::var("AWS_ACCESS_KEY_ID"),
50            std::env::var("AWS_SECRET_ACCESS_KEY"),
51        ) {
52            return Ok(Self {
53                access_key_id: key,
54                secret_access_key: secret,
55                session_token: std::env::var("AWS_SESSION_TOKEN").ok(),
56            });
57        }
58
59        // 2. ECS task role credentials
60        if let Ok(relative) = std::env::var("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") {
61            return fetch_ecs_credentials(&relative);
62        }
63
64        // 3. IMDSv2 (EC2 instance role)
65        fetch_imdsv2_credentials()
66    }
67}
68
69fn http_agent() -> ureq::Agent {
70    ureq::AgentBuilder::new()
71        .timeout_connect(std::time::Duration::from_secs(5))
72        .timeout(std::time::Duration::from_secs(10))
73        .build()
74}
75
76fn fetch_ecs_credentials(relative_uri: &str) -> Result<AwsCredentials, AwsError> {
77    let url = format!("http://169.254.170.2{relative_uri}");
78    let agent = http_agent();
79    let resp: serde_json::Value = agent
80        .get(&url)
81        .call()
82        .map_err(|e| AwsError::Auth(format!("ECS credentials request failed: {e}")))?
83        .into_json()
84        .map_err(|e| AwsError::Transport(e.to_string()))?;
85    parse_credentials_response(&resp)
86}
87
88fn fetch_imdsv2_credentials() -> Result<AwsCredentials, AwsError> {
89    let agent = http_agent();
90
91    // Step 1: get IMDSv2 session token
92    let imds_token: String = agent
93        .put("http://169.254.169.254/latest/api/token")
94        .set("X-aws-ec2-metadata-token-ttl-seconds", "21600")
95        .call()
96        .map_err(|e| {
97            AwsError::Auth(format!(
98                "IMDSv2 unreachable and no AWS credentials set \
99                 (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY): {e}"
100            ))
101        })?
102        .into_string()
103        .map_err(|e| AwsError::Transport(e.to_string()))?;
104    let imds_token = imds_token.trim();
105
106    // Step 2: discover the IAM role name
107    let role_name: String = agent
108        .get("http://169.254.169.254/latest/meta-data/iam/security-credentials/")
109        .set("X-aws-ec2-metadata-token", imds_token)
110        .call()
111        .map_err(|e| AwsError::Auth(format!("IMDS role name request failed: {e}")))?
112        .into_string()
113        .map_err(|e| AwsError::Transport(e.to_string()))?;
114    let role_name = role_name.trim();
115
116    // Step 3: fetch credentials for the role
117    let resp: serde_json::Value = agent
118        .get(&format!(
119            "http://169.254.169.254/latest/meta-data/iam/security-credentials/{role_name}"
120        ))
121        .set("X-aws-ec2-metadata-token", imds_token)
122        .call()
123        .map_err(|e| AwsError::Auth(format!("IMDS credentials request failed: {e}")))?
124        .into_json()
125        .map_err(|e| AwsError::Transport(e.to_string()))?;
126
127    parse_credentials_response(&resp)
128}
129
130fn parse_credentials_response(resp: &serde_json::Value) -> Result<AwsCredentials, AwsError> {
131    let access_key_id = resp["AccessKeyId"]
132        .as_str()
133        .ok_or_else(|| AwsError::Auth("credentials response missing 'AccessKeyId'".into()))?
134        .to_string();
135    let secret_access_key = resp["SecretAccessKey"]
136        .as_str()
137        .ok_or_else(|| AwsError::Auth("credentials response missing 'SecretAccessKey'".into()))?
138        .to_string();
139    let session_token = resp["Token"].as_str().map(|s| s.to_string());
140    Ok(AwsCredentials {
141        access_key_id,
142        secret_access_key,
143        session_token,
144    })
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn from_env_missing_region_returns_config_error() {
153        let result = temp_env::with_vars(
154            [
155                ("AWS_DEFAULT_REGION", None::<&str>),
156                ("AWS_REGION", None::<&str>),
157            ],
158            AwsConfig::from_env,
159        );
160        assert!(matches!(result, Err(AwsError::Config(_))));
161    }
162
163    #[test]
164    fn from_env_uses_aws_default_region() {
165        let result =
166            temp_env::with_var("AWS_DEFAULT_REGION", Some("us-east-1"), AwsConfig::from_env);
167        let cfg = result.unwrap();
168        assert_eq!(cfg.region, "us-east-1");
169        assert_eq!(
170            cfg.endpoint,
171            "https://secretsmanager.us-east-1.amazonaws.com"
172        );
173    }
174
175    #[test]
176    fn from_env_falls_back_to_aws_region() {
177        let result = temp_env::with_vars(
178            [
179                ("AWS_DEFAULT_REGION", None::<&str>),
180                ("AWS_REGION", Some("eu-west-1")),
181            ],
182            AwsConfig::from_env,
183        );
184        let cfg = result.unwrap();
185        assert_eq!(cfg.region, "eu-west-1");
186    }
187
188    #[test]
189    fn static_credentials_from_env() {
190        let creds = temp_env::with_vars(
191            [
192                ("AWS_ACCESS_KEY_ID", Some("AKIAIOSFODNN7EXAMPLE")),
193                ("AWS_SECRET_ACCESS_KEY", Some("secret")),
194                ("AWS_SESSION_TOKEN", Some("tok")),
195            ],
196            AwsCredentials::from_env_or_imds,
197        )
198        .unwrap();
199        assert_eq!(creds.access_key_id, "AKIAIOSFODNN7EXAMPLE");
200        assert_eq!(creds.secret_access_key, "secret");
201        assert_eq!(creds.session_token.as_deref(), Some("tok"));
202    }
203
204    #[test]
205    fn static_credentials_no_session_token() {
206        let creds = temp_env::with_vars(
207            [
208                ("AWS_ACCESS_KEY_ID", Some("AKIAIOSFODNN7EXAMPLE")),
209                ("AWS_SECRET_ACCESS_KEY", Some("secret")),
210                ("AWS_SESSION_TOKEN", None::<&str>),
211            ],
212            AwsCredentials::from_env_or_imds,
213        )
214        .unwrap();
215        assert!(creds.session_token.is_none());
216    }
217
218    #[test]
219    fn parse_credentials_response_success() {
220        let resp = serde_json::json!({
221            "AccessKeyId": "ASIA...",
222            "SecretAccessKey": "wJalrXUtn",
223            "Token": "session-tok"
224        });
225        let creds = parse_credentials_response(&resp).unwrap();
226        assert_eq!(creds.access_key_id, "ASIA...");
227        assert_eq!(creds.session_token.as_deref(), Some("session-tok"));
228    }
229
230    #[test]
231    fn parse_credentials_response_missing_key_returns_auth_error() {
232        let resp = serde_json::json!({"SecretAccessKey": "secret"});
233        assert!(matches!(
234            parse_credentials_response(&resp),
235            Err(AwsError::Auth(_))
236        ));
237    }
238}