Skip to main content

tsafe_aws/
secretsmanager.rs

1//! AWS Secrets Manager HTTP client.
2// `ureq::Error` is large; propagating it through `Result` triggers `clippy::result_large_err`.
3#![allow(clippy::result_large_err)]
4
5use super::config::{AwsConfig, AwsCredentials};
6use super::error::AwsError;
7use super::sigv4::sign;
8
9const MAX_RETRIES: u32 = 3;
10const DEFAULT_RETRY_SECS: u64 = 2;
11
12fn http_agent() -> ureq::Agent {
13    ureq::AgentBuilder::new()
14        .timeout_connect(std::time::Duration::from_secs(10))
15        .timeout(std::time::Duration::from_secs(30))
16        .build()
17}
18
19/// Execute an HTTP call with retry on 429 (throttled) responses.
20///
21/// On the final attempt (`attempt == MAX_RETRIES`), the throttled-retry guard
22/// is false, so a 429 falls into the catch-all `Err(e) => return Err(e)` arm
23/// and the caller sees `AwsError::Http { status: 429, .. }` via
24/// `map_ureq_error`. The loop therefore always returns from inside; the
25/// trailing `Err(...)` below is unreachable in practice but exists so a future
26/// edit to the retry guard can never panic the process.
27fn call_with_retry(
28    make_request: impl Fn() -> Result<ureq::Response, ureq::Error>,
29) -> Result<ureq::Response, ureq::Error> {
30    for attempt in 0..=MAX_RETRIES {
31        match make_request() {
32            Ok(resp) => return Ok(resp),
33            Err(ureq::Error::Status(429, resp)) if attempt < MAX_RETRIES => {
34                let retry_after = resp
35                    .header("Retry-After")
36                    .and_then(|v| v.parse::<u64>().ok())
37                    .unwrap_or(DEFAULT_RETRY_SECS * 2u64.pow(attempt));
38                let wait = std::cmp::min(retry_after, 30); // cap at 30 s
39                std::thread::sleep(std::time::Duration::from_secs(wait));
40            }
41            Err(e) => return Err(e),
42        }
43    }
44    let body = format!(
45        "secrets manager retry loop exhausted after {} attempts",
46        MAX_RETRIES + 1
47    );
48    let resp = ureq::Response::new(504, "Retries Exhausted", &body)
49        .expect("static 504 response must build");
50    Err(ureq::Error::Status(504, resp))
51}
52
53fn map_ureq_error(e: ureq::Error, secret_name: Option<&str>) -> AwsError {
54    match e {
55        ureq::Error::Status(400, resp) => {
56            let body = resp.into_string().unwrap_or_default();
57            // ResourceNotFoundException is a 400 in some SDK versions but
58            // more commonly Secrets Manager returns it as a serialized error.
59            if body.contains("ResourceNotFoundException") {
60                AwsError::NotFound(secret_name.unwrap_or("").to_string())
61            } else {
62                AwsError::Http {
63                    status: 400,
64                    message: body,
65                }
66            }
67        }
68        ureq::Error::Status(s, resp) => AwsError::Http {
69            status: s,
70            message: resp
71                .into_string()
72                .unwrap_or_else(|_| "<unreadable response>".into()),
73        },
74        other => AwsError::Transport(other.to_string()),
75    }
76}
77
78/// Normalise an AWS Secrets Manager secret name to an environment variable key.
79/// `/` and `-` are replaced with `_`; the result is uppercased.
80///
81/// Examples:
82///   `my-secret`           → `MY_SECRET`
83///   `myapp/db-password`   → `MYAPP_DB_PASSWORD`
84pub fn normalize_name(name: &str) -> String {
85    name.replace(['/', '-'], "_").to_uppercase()
86}
87
88/// Pull secrets from AWS Secrets Manager, optionally filtered by `prefix`.
89///
90/// `get_creds` is called once per page during listing and once more before
91/// the per-secret fetch phase, so credentials are always fresh even on
92/// large vaults that span multiple ListSecrets pages.
93///
94/// Returns `(normalized_key, value)` pairs ready to set in the local vault.
95pub fn pull_secrets(
96    cfg: &AwsConfig,
97    get_creds: &impl Fn() -> Result<AwsCredentials, AwsError>,
98    prefix: Option<&str>,
99) -> Result<Vec<(String, String)>, AwsError> {
100    let names = list_secret_names(cfg, get_creds, prefix)?;
101    // Refresh credentials before the per-secret fetch phase.
102    let creds = get_creds()?;
103    let mut secrets = Vec::new();
104
105    for name in &names {
106        let value = get_secret_value(cfg, &creds, name)?;
107        let key = normalize_name(name);
108        secrets.push((key, value));
109    }
110
111    Ok(secrets)
112}
113
114/// List names of all secrets (optionally matching `prefix`).
115/// Handles pagination via `NextToken` in the request body.
116fn list_secret_names(
117    cfg: &AwsConfig,
118    get_creds: &impl Fn() -> Result<AwsCredentials, AwsError>,
119    prefix: Option<&str>,
120) -> Result<Vec<String>, AwsError> {
121    const TARGET: &str = "secretsmanager.ListSecrets";
122    let agent = http_agent();
123    let mut names = Vec::new();
124    let mut next_token: Option<String> = None;
125
126    loop {
127        let creds = get_creds()?;
128
129        // Build request body
130        let body = match (&next_token, prefix) {
131            (Some(tok), Some(p)) => serde_json::json!({
132                "MaxResults": 100,
133                "Filters": [{"Key": "name", "Values": [p]}],
134                "NextToken": tok,
135            }),
136            (Some(tok), None) => serde_json::json!({
137                "MaxResults": 100,
138                "NextToken": tok,
139            }),
140            (None, Some(p)) => serde_json::json!({
141                "MaxResults": 100,
142                "Filters": [{"Key": "name", "Values": [p]}],
143            }),
144            (None, None) => serde_json::json!({ "MaxResults": 100 }),
145        };
146        let body_str = body.to_string();
147
148        let sig = sign(
149            &cfg.region,
150            TARGET,
151            &body_str,
152            &creds.access_key_id,
153            &creds.secret_access_key,
154            creds.session_token.as_deref(),
155        );
156
157        let body_clone = body_str.clone();
158        let endpoint = cfg.endpoint.clone();
159        let resp: serde_json::Value = call_with_retry(|| {
160            let mut req = agent
161                .post(&endpoint)
162                .set("Content-Type", "application/x-amz-json-1.1")
163                .set("X-Amz-Target", TARGET)
164                .set("X-Amz-Date", &sig.x_amz_date)
165                .set("Authorization", &sig.authorization);
166            if let Some(ref tok) = sig.x_amz_security_token {
167                req = req.set("X-Amz-Security-Token", tok);
168            }
169            req.send_string(&body_clone)
170        })
171        .map_err(|e| map_ureq_error(e, None))?
172        .into_json()
173        .map_err(|e| AwsError::Transport(e.to_string()))?;
174
175        let list = resp["SecretList"].as_array().ok_or_else(|| {
176            AwsError::Transport("Secrets Manager response missing 'SecretList' array".into())
177        })?;
178        for item in list {
179            if let Some(name) = item["Name"].as_str() {
180                if !name.is_empty() {
181                    names.push(name.to_string());
182                }
183            }
184        }
185
186        // Pagination — NextToken is opaque and goes in the request body (no SSRF risk)
187        next_token = resp["NextToken"].as_str().map(|s| s.to_string());
188        if next_token.is_none() {
189            break;
190        }
191    }
192
193    Ok(names)
194}
195
196/// Fetch the current string value of a single secret.
197/// Returns `AwsError::NotFound` if the secret does not exist or has no `SecretString`.
198fn get_secret_value(
199    cfg: &AwsConfig,
200    creds: &AwsCredentials,
201    name: &str,
202) -> Result<String, AwsError> {
203    const TARGET: &str = "secretsmanager.GetSecretValue";
204    let agent = http_agent();
205    let body = serde_json::json!({ "SecretId": name }).to_string();
206
207    let sig = sign(
208        &cfg.region,
209        TARGET,
210        &body,
211        &creds.access_key_id,
212        &creds.secret_access_key,
213        creds.session_token.as_deref(),
214    );
215
216    let body_clone = body.clone();
217    let endpoint = cfg.endpoint.clone();
218    let sig_date = sig.x_amz_date.clone();
219    let sig_auth = sig.authorization.clone();
220    let sig_tok = sig.x_amz_security_token.clone();
221
222    let resp: serde_json::Value = call_with_retry(|| {
223        let mut req = agent
224            .post(&endpoint)
225            .set("Content-Type", "application/x-amz-json-1.1")
226            .set("X-Amz-Target", TARGET)
227            .set("X-Amz-Date", &sig_date)
228            .set("Authorization", &sig_auth);
229        if let Some(ref tok) = sig_tok {
230            req = req.set("X-Amz-Security-Token", tok);
231        }
232        req.send_string(&body_clone)
233    })
234    .map_err(|e| map_ureq_error(e, Some(name)))?
235    .into_json()
236    .map_err(|e| AwsError::Transport(e.to_string()))?;
237
238    // SecretString for plain-text secrets; SecretBinary is not supported.
239    resp["SecretString"]
240        .as_str()
241        .map(|s| s.to_string())
242        .ok_or_else(|| AwsError::NotFound(name.to_string()))
243}
244
245// ── tests ─────────────────────────────────────────────────────────────────────
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn test_creds() -> AwsCredentials {
252        AwsCredentials {
253            access_key_id: "AKID-TEST".into(),
254            secret_access_key: "secret-test".into(),
255            session_token: None,
256        }
257    }
258
259    fn cfg(url: &str) -> AwsConfig {
260        AwsConfig::with_endpoint("us-east-1", url)
261    }
262
263    fn list_response(names: &[&str], next_token: Option<&str>) -> String {
264        let items: Vec<String> = names
265            .iter()
266            .map(|n| {
267                format!(r#"{{"Name":"{n}","ARN":"arn:aws:secretsmanager:us-east-1:123:{n}"}}"#)
268            })
269            .collect();
270        match next_token {
271            Some(tok) => format!(
272                r#"{{"SecretList":[{}],"NextToken":"{tok}"}}"#,
273                items.join(",")
274            ),
275            None => format!(r#"{{"SecretList":[{}]}}"#, items.join(",")),
276        }
277    }
278
279    fn secret_response(value: &str) -> String {
280        format!(r#"{{"Name":"test","SecretString":"{value}","ARN":"arn:..."}}"#)
281    }
282
283    // ── pure-logic tests ──────────────────────────────────────────────────────
284
285    #[test]
286    fn normalize_name_hyphens() {
287        assert_eq!(normalize_name("my-secret"), "MY_SECRET");
288    }
289
290    #[test]
291    fn normalize_name_slashes() {
292        assert_eq!(normalize_name("myapp/db-password"), "MYAPP_DB_PASSWORD");
293    }
294
295    #[test]
296    fn normalize_name_mixed() {
297        assert_eq!(normalize_name("prod/my-app/api-key"), "PROD_MY_APP_API_KEY");
298    }
299
300    // ── mock-server tests ─────────────────────────────────────────────────────
301
302    #[test]
303    fn pull_secrets_empty_vault() {
304        let mut server = mockito::Server::new();
305        let _m = server
306            .mock("POST", "/")
307            .with_status(200)
308            .with_header("Content-Type", "application/x-amz-json-1.1")
309            .with_body(r#"{"SecretList":[]}"#)
310            .create();
311
312        let result = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
313        assert!(result.is_empty());
314    }
315
316    #[test]
317    fn pull_secrets_fetches_and_normalises_key() {
318        let mut server = mockito::Server::new();
319        let _list = server
320            .mock("POST", "/")
321            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
322            .with_status(200)
323            .with_header("Content-Type", "application/x-amz-json-1.1")
324            .with_body(list_response(&["my-db-password"], None))
325            .create();
326        let _get = server
327            .mock("POST", "/")
328            .match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
329            .with_status(200)
330            .with_header("Content-Type", "application/x-amz-json-1.1")
331            .with_body(secret_response("s3cr3t"))
332            .create();
333
334        let secrets = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
335        assert_eq!(secrets.len(), 1);
336        assert_eq!(secrets[0].0, "MY_DB_PASSWORD");
337        assert_eq!(secrets[0].1, "s3cr3t");
338    }
339
340    #[test]
341    fn pull_secrets_pagination() {
342        let mut server = mockito::Server::new();
343        let _page1 = server
344            .mock("POST", "/")
345            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
346            .with_status(200)
347            .with_header("Content-Type", "application/x-amz-json-1.1")
348            .with_body(list_response(&["secret-a"], Some("page2-token")))
349            .expect(1)
350            .create();
351        let _page2 = server
352            .mock("POST", "/")
353            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
354            .with_status(200)
355            .with_header("Content-Type", "application/x-amz-json-1.1")
356            .with_body(list_response(&["secret-b"], None))
357            .expect(1)
358            .create();
359        // GetSecretValue for both secrets
360        let _get = server
361            .mock("POST", "/")
362            .match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
363            .with_status(200)
364            .with_header("Content-Type", "application/x-amz-json-1.1")
365            .with_body(secret_response("val"))
366            .expect(2)
367            .create();
368
369        let secrets = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
370        assert_eq!(secrets.len(), 2);
371        let keys: Vec<&str> = secrets.iter().map(|(k, _)| k.as_str()).collect();
372        assert!(keys.contains(&"SECRET_A"));
373        assert!(keys.contains(&"SECRET_B"));
374    }
375
376    #[test]
377    fn pull_secrets_resource_not_found_returns_not_found_error() {
378        let mut server = mockito::Server::new();
379        let _list = server
380            .mock("POST", "/")
381            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
382            .with_status(200)
383            .with_header("Content-Type", "application/x-amz-json-1.1")
384            .with_body(list_response(&["ghost"], None))
385            .create();
386        let _get = server
387            .mock("POST", "/")
388            .match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
389            .with_status(400)
390            .with_header("Content-Type", "application/x-amz-json-1.1")
391            .with_body(r#"{"__type":"ResourceNotFoundException","Message":"Secrets Manager can't find the specified secret."}"#)
392            .create();
393
394        let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
395        assert!(
396            matches!(err, AwsError::NotFound(_)),
397            "expected NotFound, got {err:?}"
398        );
399    }
400
401    #[test]
402    fn pull_secrets_401_returns_http_error() {
403        let mut server = mockito::Server::new();
404        let _m = server
405            .mock("POST", "/")
406            .with_status(403)
407            .with_header("Content-Type", "application/x-amz-json-1.1")
408            .with_body(r#"{"__type":"AccessDeniedException","Message":"Access denied"}"#)
409            .create();
410
411        let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
412        assert!(
413            matches!(err, AwsError::Http { status: 403, .. }),
414            "expected Http 403, got {err:?}"
415        );
416    }
417
418    #[test]
419    fn pull_secrets_503_returns_http_error() {
420        let mut server = mockito::Server::new();
421        let _m = server
422            .mock("POST", "/")
423            .with_status(503)
424            .with_body("Service Unavailable")
425            .create();
426
427        let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
428        assert!(matches!(err, AwsError::Http { status: 503, .. }));
429    }
430
431    #[test]
432    fn pull_secrets_malformed_list_response_returns_transport_error() {
433        let mut server = mockito::Server::new();
434        let _m = server
435            .mock("POST", "/")
436            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
437            .with_status(200)
438            .with_header("Content-Type", "application/x-amz-json-1.1")
439            .with_body("not valid json {{{{")
440            .create();
441
442        let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
443        assert!(matches!(err, AwsError::Transport(_)));
444    }
445
446    #[test]
447    fn pull_secrets_missing_secret_list_returns_transport_error() {
448        let mut server = mockito::Server::new();
449        let _m = server
450            .mock("POST", "/")
451            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
452            .with_status(200)
453            .with_header("Content-Type", "application/x-amz-json-1.1")
454            .with_body(r#"{"Unexpected":[]}"#)
455            .create();
456
457        let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
458        assert!(
459            matches!(err, AwsError::Transport(_)),
460            "expected Transport for malformed Secrets Manager schema, got {err:?}"
461        );
462    }
463
464    #[test]
465    fn pull_secrets_malformed_get_response_returns_transport_error() {
466        let mut server = mockito::Server::new();
467        let _list = server
468            .mock("POST", "/")
469            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
470            .with_status(200)
471            .with_header("Content-Type", "application/x-amz-json-1.1")
472            .with_body(list_response(&["my-secret"], None))
473            .create();
474        let _get = server
475            .mock("POST", "/")
476            .match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
477            .with_status(200)
478            .with_header("Content-Type", "application/x-amz-json-1.1")
479            .with_body("not json")
480            .create();
481
482        let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
483        assert!(matches!(err, AwsError::Transport(_)));
484    }
485
486    #[test]
487    fn pull_secrets_429_exhausts_retries_returns_http_error() {
488        let mut server = mockito::Server::new();
489        let _m = server
490            .mock("POST", "/")
491            .with_status(429)
492            .with_header("Retry-After", "0")
493            .with_body("Too Many Requests")
494            .expect(MAX_RETRIES as usize + 1)
495            .create();
496
497        let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
498        assert!(matches!(err, AwsError::Http { status: 429, .. }));
499    }
500
501    #[test]
502    fn creds_refresh_failure_before_fetch_phase_propagates_error() {
503        use std::sync::atomic::{AtomicUsize, Ordering};
504        let call_count = AtomicUsize::new(0);
505
506        let mut server = mockito::Server::new();
507        let _list = server
508            .mock("POST", "/")
509            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
510            .with_status(200)
511            .with_header("Content-Type", "application/x-amz-json-1.1")
512            .with_body(list_response(&["my-secret"], None))
513            .create();
514
515        let err = pull_secrets(
516            &cfg(&server.url()),
517            &|| {
518                let n = call_count.fetch_add(1, Ordering::SeqCst);
519                if n == 0 {
520                    Ok(test_creds())
521                } else {
522                    Err(AwsError::Auth("token refresh failed".into()))
523                }
524            },
525            None,
526        )
527        .unwrap_err();
528
529        assert!(
530            matches!(err, AwsError::Auth(_)),
531            "expected Auth error on creds refresh, got {err:?}"
532        );
533    }
534
535    #[test]
536    fn creds_failure_on_first_list_call_propagates_error() {
537        let server = mockito::Server::new();
538        // No mock needed — creds fail before first HTTP call.
539        let err = pull_secrets(
540            &cfg(&server.url()),
541            &|| Err(AwsError::Auth("no credentials".into())),
542            None,
543        )
544        .unwrap_err();
545        assert!(matches!(err, AwsError::Auth(_)));
546    }
547
548    #[test]
549    fn x_amz_target_header_sent_for_list() {
550        let mut server = mockito::Server::new();
551        let _m = server
552            .mock("POST", "/")
553            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
554            .with_status(200)
555            .with_header("Content-Type", "application/x-amz-json-1.1")
556            .with_body(r#"{"SecretList":[]}"#)
557            .create();
558
559        let result = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap();
560        assert!(result.is_empty());
561        // If the header didn't match, mockito returns 501 and the call would fail.
562    }
563
564    #[test]
565    fn get_secret_no_secret_string_returns_not_found() {
566        let mut server = mockito::Server::new();
567        let _list = server
568            .mock("POST", "/")
569            .match_header("X-Amz-Target", "secretsmanager.ListSecrets")
570            .with_status(200)
571            .with_header("Content-Type", "application/x-amz-json-1.1")
572            .with_body(list_response(&["binary-secret"], None))
573            .create();
574        // Return a response without SecretString (e.g. binary secret)
575        let _get = server
576            .mock("POST", "/")
577            .match_header("X-Amz-Target", "secretsmanager.GetSecretValue")
578            .with_status(200)
579            .with_header("Content-Type", "application/x-amz-json-1.1")
580            .with_body(r#"{"Name":"binary-secret","SecretBinary":"base64data=="}"#)
581            .create();
582
583        let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_creds()), None).unwrap_err();
584        assert!(
585            matches!(err, AwsError::NotFound(_)),
586            "binary secrets without SecretString should return NotFound"
587        );
588    }
589}