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