Skip to main content

vantage_aws/
account.rs

1//! `AwsAccount` — account-wide credentials handle.
2//!
3//! Holds the access key, secret key, and region. Cheap to clone (everything
4//! lives behind an `Arc`). Used directly as the `TableSource` for JSON-1.1
5//! tables — see `crate::json1` for the protocol impl. The per-operation
6//! configuration (service, operation target, response array key) lives in
7//! the table name, formatted as `array_key:service/target`.
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use vantage_core::Result;
14use vantage_core::error;
15
16#[derive(Clone)]
17pub struct AwsAccount {
18    inner: Arc<Inner>,
19}
20
21struct Inner {
22    access_key: String,
23    secret_key: String,
24    session_token: Option<String>,
25    region: String,
26    /// Override for the AWS service endpoint URL. Defaults to
27    /// `https://{service}.{region}.amazonaws.com/`. Set when pointing at
28    /// DynamoDB Local, LocalStack, or a custom endpoint. Picked up from
29    /// `AWS_ENDPOINT_URL` automatically by every constructor below.
30    endpoint: Option<String>,
31    http: reqwest::Client,
32}
33
34impl AwsAccount {
35    /// Construct from explicit static credentials.
36    pub fn new(
37        access_key: impl Into<String>,
38        secret_key: impl Into<String>,
39        region: impl Into<String>,
40    ) -> Self {
41        Self {
42            inner: Arc::new(Inner {
43                access_key: access_key.into(),
44                secret_key: secret_key.into(),
45                session_token: None,
46                region: region.into(),
47                endpoint: env_endpoint(),
48                http: reqwest::Client::new(),
49            }),
50        }
51    }
52
53    /// Read `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and
54    /// `AWS_REGION` from the environment. Picks up `AWS_SESSION_TOKEN`
55    /// for temporary credentials if present.
56    pub fn from_env() -> Result<Self> {
57        let access_key =
58            std::env::var("AWS_ACCESS_KEY_ID").map_err(|_| error!("AWS_ACCESS_KEY_ID not set"))?;
59        let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
60            .map_err(|_| error!("AWS_SECRET_ACCESS_KEY not set"))?;
61        let region = std::env::var("AWS_REGION").map_err(|_| error!("AWS_REGION not set"))?;
62        let session_token = std::env::var("AWS_SESSION_TOKEN").ok();
63
64        Ok(Self {
65            inner: Arc::new(Inner {
66                access_key,
67                secret_key,
68                session_token,
69                region,
70                endpoint: env_endpoint(),
71                http: reqwest::Client::new(),
72            }),
73        })
74    }
75
76    /// Read the profile named by `AWS_PROFILE` (or `default`) from
77    /// `~/.aws/credentials`. For SSO / assume-role profiles whose
78    /// credentials don't live in that file, falls back to shelling out
79    /// to `aws configure export-credentials --profile <name> --format env`,
80    /// which the AWS CLI uses as a public, stable handover format and
81    /// which knows how to materialise SSO tokens, assumed-role chains, etc.
82    ///
83    /// Region resolution falls through `AWS_REGION` →
84    /// `AWS_DEFAULT_REGION` → `~/.aws/config` profile `region`.
85    pub fn from_credentials_file() -> Result<Self> {
86        let profile = std::env::var("AWS_PROFILE").unwrap_or_else(|_| "default".to_string());
87        Self::from_profile(&profile)
88    }
89
90    /// Build an `AwsAccount` from a specific profile name. See
91    /// [`from_credentials_file`](Self::from_credentials_file) for the
92    /// resolution algorithm.
93    pub fn from_profile(profile: &str) -> Result<Self> {
94        let home_dir = home_dir().ok_or_else(|| error!("HOME not set"))?;
95        let region = resolve_region_for(&home_dir, profile)?;
96
97        // 1. Static credentials in `~/.aws/credentials [profile]`.
98        let creds_path = home_dir.join(".aws/credentials");
99        if let Ok(creds_text) = std::fs::read_to_string(&creds_path)
100            && let Some(creds) =
101                parse_profile(&creds_text, profile, /* config_style = */ false)
102            && let (Some(ak), Some(sk)) = (
103                creds.get("aws_access_key_id"),
104                creds.get("aws_secret_access_key"),
105            )
106        {
107            return Ok(Self {
108                inner: Arc::new(Inner {
109                    access_key: ak.clone(),
110                    secret_key: sk.clone(),
111                    session_token: creds.get("aws_session_token").cloned(),
112                    region,
113                    endpoint: env_endpoint(),
114                    http: reqwest::Client::new(),
115                }),
116            });
117        }
118
119        // 2. SSO, assume-role, or `credential_process` profile: shell
120        //    out to the AWS CLI's canonical export. Requires
121        //    `aws sso login` to have run recently for SSO profiles.
122        let (ak, sk, token) = export_credentials_via_aws_cli(profile)?;
123        Ok(Self {
124            inner: Arc::new(Inner {
125                access_key: ak,
126                secret_key: sk,
127                session_token: token,
128                region,
129                endpoint: env_endpoint(),
130                http: reqwest::Client::new(),
131            }),
132        })
133    }
134
135    /// Try [`from_env`](Self::from_env), fall back to
136    /// [`from_credentials_file`](Self::from_credentials_file). Use
137    /// this when you don't care which one — typical CLI / dev setup.
138    pub fn from_default() -> Result<Self> {
139        match Self::from_env() {
140            Ok(acc) => Ok(acc),
141            Err(_) => Self::from_credentials_file(),
142        }
143    }
144
145    /// Return a copy with the region overridden. Useful when credentials
146    /// come from `~/.aws/credentials` but the target region differs from
147    /// the profile default (e.g. a test fixture provisioned in a fixed
148    /// region regardless of the developer's local config).
149    pub fn with_region(self, region: impl Into<String>) -> Self {
150        let inner = &self.inner;
151        Self {
152            inner: std::sync::Arc::new(Inner {
153                access_key: inner.access_key.clone(),
154                secret_key: inner.secret_key.clone(),
155                session_token: inner.session_token.clone(),
156                region: region.into(),
157                endpoint: inner.endpoint.clone(),
158                http: inner.http.clone(),
159            }),
160        }
161    }
162
163    /// Return a copy pointing at a custom service endpoint URL (e.g.
164    /// `http://localhost:8000` for DynamoDB Local, or a LocalStack URL).
165    /// SigV4 still applies — the host derived from the URL is folded
166    /// into the canonical request, so the local server must accept the
167    /// signature (DynamoDB Local does, with any access/secret values).
168    pub fn with_endpoint(self, endpoint: impl Into<String>) -> Self {
169        let inner = &self.inner;
170        Self {
171            inner: std::sync::Arc::new(Inner {
172                access_key: inner.access_key.clone(),
173                secret_key: inner.secret_key.clone(),
174                session_token: inner.session_token.clone(),
175                region: inner.region.clone(),
176                endpoint: Some(endpoint.into()),
177                http: inner.http.clone(),
178            }),
179        }
180    }
181
182    pub(crate) fn region(&self) -> &str {
183        &self.inner.region
184    }
185
186    pub(crate) fn access_key(&self) -> &str {
187        &self.inner.access_key
188    }
189
190    pub(crate) fn secret_key(&self) -> &str {
191        &self.inner.secret_key
192    }
193
194    pub(crate) fn session_token(&self) -> Option<&str> {
195        self.inner.session_token.as_deref()
196    }
197
198    pub(crate) fn http(&self) -> &reqwest::Client {
199        &self.inner.http
200    }
201
202    /// Resolve the endpoint URL and `Host` header for a request to
203    /// `service`. Returns `(url, host)` — the URL ends in `/`, the host
204    /// is what goes into the SigV4 canonical request and the wire
205    /// `Host` header.
206    pub(crate) fn endpoint_for(&self, service: &str) -> (String, String) {
207        match self.inner.endpoint.as_deref() {
208            Some(ep) => {
209                let trimmed = ep.trim_end_matches('/');
210                let host = trimmed
211                    .split_once("://")
212                    .map(|(_, rest)| rest)
213                    .unwrap_or(trimmed)
214                    .to_string();
215                (format!("{trimmed}/"), host)
216            }
217            None => {
218                let host = format!("{service}.{}.amazonaws.com", self.inner.region);
219                (format!("https://{host}/"), host)
220            }
221        }
222    }
223}
224
225impl std::fmt::Debug for AwsAccount {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        f.debug_struct("AwsAccount")
228            .field("region", &self.inner.region)
229            .field("endpoint", &self.inner.endpoint)
230            .field("access_key", &"<redacted>")
231            .field("secret_key", &"<redacted>")
232            .field(
233                "session_token",
234                &self.inner.session_token.as_ref().map(|_| "<redacted>"),
235            )
236            .finish()
237    }
238}
239
240fn home_dir() -> Option<PathBuf> {
241    std::env::var_os("HOME").map(PathBuf::from)
242}
243
244/// Picks `AWS_ENDPOINT_URL` out of the environment so every constructor
245/// honours it without forcing callers to chain `.with_endpoint(...)`. The
246/// AWS CLI uses the same env var for the same purpose.
247fn env_endpoint() -> Option<String> {
248    std::env::var("AWS_ENDPOINT_URL")
249        .ok()
250        .filter(|s| !s.is_empty())
251}
252
253/// Pull a named profile's key=value pairs out of an AWS-style INI file.
254///
255/// `config_style: true` looks for `[profile <name>]` (the form used by
256/// `~/.aws/config` for non-default profiles); `false` looks for `[<name>]`
257/// (the form used by `~/.aws/credentials` and the bare `[default]`
258/// section in `~/.aws/config`). The default profile uses `[default]` in
259/// both files, so we always also accept it.
260fn parse_profile(
261    content: &str,
262    profile: &str,
263    config_style: bool,
264) -> Option<HashMap<String, String>> {
265    let target_section = if config_style && profile != "default" {
266        format!("profile {}", profile)
267    } else {
268        profile.to_string()
269    };
270
271    let mut in_target = false;
272    let mut found = false;
273    let mut map = HashMap::new();
274
275    for raw in content.lines() {
276        let line = raw.trim();
277        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
278            continue;
279        }
280        if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
281            in_target = section.trim() == target_section;
282            if in_target {
283                found = true;
284            }
285            continue;
286        }
287        if in_target && let Some((k, v)) = line.split_once('=') {
288            map.insert(k.trim().to_string(), v.trim().to_string());
289        }
290    }
291
292    found.then_some(map)
293}
294
295/// Region resolution for a named profile.
296/// Order: `AWS_REGION` env → `AWS_DEFAULT_REGION` env → profile entry in
297/// `~/.aws/config`.
298fn resolve_region_for(home_dir: &std::path::Path, profile: &str) -> Result<String> {
299    if let Ok(r) = std::env::var("AWS_REGION") {
300        return Ok(r);
301    }
302    if let Ok(r) = std::env::var("AWS_DEFAULT_REGION") {
303        return Ok(r);
304    }
305    let config_path = home_dir.join(".aws/config");
306    if let Ok(text) = std::fs::read_to_string(&config_path)
307        && let Some(profile_map) = parse_profile(&text, profile, true)
308        && let Some(r) = profile_map.get("region")
309    {
310        return Ok(r.clone());
311    }
312    Err(error!(
313        "AWS region not found (set AWS_REGION, or add `region = ...` under the profile in ~/.aws/config)",
314        profile = profile
315    ))
316}
317
318/// Shell out to `aws configure export-credentials --profile X --format env`
319/// to materialise creds for SSO, assume-role, and `credential_process`
320/// profiles. The CLI prints `export AWS_ACCESS_KEY_ID=...` /
321/// `export AWS_SECRET_ACCESS_KEY=...` / (optionally)
322/// `export AWS_SESSION_TOKEN=...` lines to stdout. The session token is
323/// absent for `credential_process` returning permanent IAM creds, so it's
324/// optional in the return shape. Every failure path — CLI not installed,
325/// CLI exit non-zero, output missing access/secret — surfaces as `Err`
326/// with a specific message rather than collapsing into a generic
327/// "profile not resolvable".
328fn export_credentials_via_aws_cli(profile: &str) -> Result<(String, String, Option<String>)> {
329    let output = match std::process::Command::new("aws")
330        .args([
331            "configure",
332            "export-credentials",
333            "--profile",
334            profile,
335            "--format",
336            "env",
337        ])
338        .output()
339    {
340        Ok(o) => o,
341        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
342            return Err(error!(
343                "AWS CLI not installed — needed to materialise SSO, assume-role, or credential_process credentials. Install via mise or your package manager.",
344                profile = profile
345            ));
346        }
347        Err(e) => return Err(error!(format!("failed to spawn `aws`: {e}"))),
348    };
349    if !output.status.success() {
350        let stderr = String::from_utf8_lossy(&output.stderr);
351        return Err(error!(
352            "`aws configure export-credentials` failed — for SSO profiles try `aws sso login --profile <name>` first",
353            profile = profile,
354            stderr = stderr.trim().to_string()
355        ));
356    }
357    let stdout = String::from_utf8_lossy(&output.stdout);
358    let mut access_key = None;
359    let mut secret_key = None;
360    let mut session_token = None;
361    for line in stdout.lines() {
362        let line = line.trim();
363        // The CLI uses `export KEY=VALUE`; tolerate `KEY=VALUE` too.
364        let body = line.strip_prefix("export ").unwrap_or(line);
365        if let Some((k, v)) = body.split_once('=') {
366            match k.trim() {
367                "AWS_ACCESS_KEY_ID" => access_key = Some(v.trim().to_string()),
368                "AWS_SECRET_ACCESS_KEY" => secret_key = Some(v.trim().to_string()),
369                "AWS_SESSION_TOKEN" => session_token = Some(v.trim().to_string()),
370                _ => {}
371            }
372        }
373    }
374    match (access_key, secret_key) {
375        (Some(ak), Some(sk)) => Ok((ak, sk, session_token)),
376        _ => Err(error!(
377            "`aws configure export-credentials` returned no usable credentials (missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY)",
378            profile = profile
379        )),
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::parse_profile;
386
387    #[test]
388    fn picks_default_section_only() {
389        let ini = "\
390[other]
391aws_access_key_id = NOPE
392aws_secret_access_key = NOPE
393
394[default]
395aws_access_key_id = AKIA_DEFAULT
396aws_secret_access_key = secret_default
397aws_session_token = token_default
398
399[another]
400aws_access_key_id = ALSO_NOPE
401";
402        let p = parse_profile(ini, "default", false).expect("default section");
403        assert_eq!(p.get("aws_access_key_id").unwrap(), "AKIA_DEFAULT");
404        assert_eq!(p.get("aws_secret_access_key").unwrap(), "secret_default");
405        assert_eq!(p.get("aws_session_token").unwrap(), "token_default");
406    }
407
408    #[test]
409    fn picks_named_credentials_profile() {
410        let ini = "\
411[default]
412aws_access_key_id = NOPE
413
414[work]
415aws_access_key_id = AKIA_WORK
416aws_secret_access_key = secret_work
417";
418        let p = parse_profile(ini, "work", false).expect("work section");
419        assert_eq!(p.get("aws_access_key_id").unwrap(), "AKIA_WORK");
420    }
421
422    #[test]
423    fn picks_named_config_profile_uses_profile_prefix() {
424        // ~/.aws/config writes named profiles as `[profile NAME]`,
425        // not bare `[NAME]`.
426        let ini = "\
427[default]
428region = eu-west-2
429
430[profile work]
431region = us-east-1
432";
433        let p = parse_profile(ini, "work", true).expect("work section");
434        assert_eq!(p.get("region").unwrap(), "us-east-1");
435        // And `default` in config still uses the bare form.
436        let d = parse_profile(ini, "default", true).expect("default section");
437        assert_eq!(d.get("region").unwrap(), "eu-west-2");
438    }
439
440    #[test]
441    fn missing_profile_returns_none() {
442        let ini = "[work]\naws_access_key_id = X\n";
443        assert!(parse_profile(ini, "default", false).is_none());
444    }
445
446    #[test]
447    fn ignores_comments_and_blank_lines() {
448        let ini = "\
449# top comment
450; also a comment
451
452[default]
453# inline comment line
454aws_access_key_id = AK
455  aws_secret_access_key  =  SK
456";
457        let p = parse_profile(ini, "default", false).unwrap();
458        assert_eq!(p.get("aws_access_key_id").unwrap(), "AK");
459        assert_eq!(p.get("aws_secret_access_key").unwrap(), "SK");
460    }
461}