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