aws_config/profile/credentials/
repr.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! Flattened Representation of an AssumeRole chain
7//!
8//! Assume Role credentials in profile files can chain together credentials from multiple
9//! different providers with subsequent credentials being used to configure subsequent providers.
10//!
11//! This module can parse and resolve the profile chain into a flattened representation with
12//! 1-credential-per row (as opposed to a direct profile file representation which can combine
13//! multiple actions into the same profile).
14
15use crate::profile::credentials::ProfileFileError;
16use crate::profile::{Profile, ProfileSet};
17use crate::sensitive_command::CommandWithSensitiveArgs;
18use aws_credential_types::attributes::AccountId;
19use aws_credential_types::Credentials;
20
21/// Chain of Profile Providers
22///
23/// Within a profile file, a chain of providers is produced. Starting with a base provider,
24/// subsequent providers use the credentials from previous providers to perform their task.
25///
26/// ProfileChain is a direct representation of the Profile. It can contain named providers
27/// that don't actually have implementations.
28#[derive(Debug)]
29pub(crate) struct ProfileChain<'a> {
30    pub(crate) base: BaseProvider<'a>,
31    pub(crate) chain: Vec<RoleArn<'a>>,
32}
33
34impl<'a> ProfileChain<'a> {
35    pub(crate) fn base(&self) -> &BaseProvider<'a> {
36        &self.base
37    }
38
39    pub(crate) fn chain(&self) -> &[RoleArn<'a>] {
40        self.chain.as_slice()
41    }
42}
43
44/// A base member of the profile chain
45///
46/// Base providers do not require input credentials to provide their own credentials,
47/// e.g. IMDS, ECS, Environment variables
48#[derive(Clone, Debug)]
49#[non_exhaustive]
50pub(crate) enum BaseProvider<'a> {
51    /// A profile that specifies a named credential source
52    /// Eg: `credential_source = Ec2InstanceMetadata`
53    ///
54    /// The following profile produces two separate `ProfileProvider` rows:
55    /// 1. `BaseProvider::NamedSource("Ec2InstanceMetadata")`
56    /// 2. `RoleArn { role_arn: "...", ... }
57    /// ```ini
58    /// [profile assume-role]
59    /// role_arn = arn:aws:iam::123456789:role/MyRole
60    /// credential_source = Ec2InstanceMetadata
61    /// ```
62    NamedSource(&'a str),
63
64    /// A profile with explicitly configured access keys
65    ///
66    /// Example
67    /// ```ini
68    /// [profile C]
69    /// aws_access_key_id = abc123
70    /// aws_secret_access_key = def456
71    /// ```
72    AccessKey(Credentials),
73
74    WebIdentityTokenRole {
75        role_arn: &'a str,
76        web_identity_token_file: &'a str,
77        session_name: Option<&'a str>,
78    },
79
80    /// An SSO Provider
81    Sso {
82        sso_session_name: Option<&'a str>,
83        sso_region: &'a str,
84        sso_start_url: &'a str,
85
86        // Credentials from SSO fields
87        sso_account_id: Option<&'a str>,
88        sso_role_name: Option<&'a str>,
89    },
90
91    /// A profile that specifies a `credential_process`
92    /// ```ini
93    /// [profile assume-role]
94    /// credential_process = /opt/bin/awscreds-custom --username helen
95    /// ```
96    CredentialProcess {
97        command_with_sensitive_args: CommandWithSensitiveArgs<&'a str>,
98        // The account ID that the credential process falls back to
99        // if the process execution result does not provide one.
100        account_id: Option<&'a str>,
101    },
102}
103
104/// A profile that specifies a role to assume
105///
106/// A RoleArn can only be created from either a profile with `source_profile`
107/// or one with `credential_source`.
108#[derive(Debug)]
109pub(crate) struct RoleArn<'a> {
110    /// Role to assume
111    pub(crate) role_arn: &'a str,
112    /// external_id parameter to pass to the assume role provider
113    pub(crate) external_id: Option<&'a str>,
114
115    /// session name parameter to pass to the assume role provider
116    pub(crate) session_name: Option<&'a str>,
117}
118
119/// Resolve a ProfileChain from a ProfileSet or return an error
120pub(crate) fn resolve_chain(
121    profile_set: &ProfileSet,
122) -> Result<ProfileChain<'_>, ProfileFileError> {
123    // If there are no profiles, allow flowing into the next provider
124    if profile_set.is_empty() {
125        return Err(ProfileFileError::NoProfilesDefined);
126    }
127
128    // If:
129    // - There is no explicit profile override
130    // - We're looking for the default profile (no configuration)
131    // - There is no default profile
132    // Then:
133    // - Treat this situation as if no profiles were defined
134    if profile_set.selected_profile() == "default" && profile_set.get_profile("default").is_none() {
135        tracing::debug!("No default profile defined");
136        return Err(ProfileFileError::NoProfilesDefined);
137    }
138    let mut source_profile_name = profile_set.selected_profile();
139    let mut visited_profiles = vec![];
140    let mut chain = vec![];
141    let base = loop {
142        // Get the next profile in the chain
143        let profile = profile_set.get_profile(source_profile_name).ok_or(
144            ProfileFileError::MissingProfile {
145                profile: source_profile_name.into(),
146                message: format!(
147                    "could not find source profile {} referenced from {}",
148                    source_profile_name,
149                    visited_profiles.last().unwrap_or(&"the root profile")
150                )
151                .into(),
152            },
153        )?;
154        // If the profile we just got is one we've already seen, we're in a loop and
155        // need to break out with a CredentialLoop error
156        if visited_profiles.contains(&source_profile_name) {
157            return Err(ProfileFileError::CredentialLoop {
158                profiles: visited_profiles
159                    .into_iter()
160                    .map(|s| s.to_string())
161                    .collect(),
162                next: source_profile_name.to_string(),
163            });
164        }
165        // otherwise, store the name of the profile in case we see it again later
166        visited_profiles.push(source_profile_name);
167        // After the first item in the chain, we will prioritize static credentials if they exist
168        if visited_profiles.len() > 1 {
169            let try_static = static_creds_from_profile(profile);
170            if let Ok(static_credentials) = try_static {
171                break BaseProvider::AccessKey(static_credentials);
172            }
173        }
174
175        let next_profile = {
176            // The existence of a `role_arn` is the only signal that multiple profiles will be chained.
177            // We check for one here and then process the profile accordingly as either a "chain provider"
178            // or a "base provider"
179            if let Some(role_provider) = role_arn_from_profile(profile) {
180                let next = chain_provider(profile)?;
181                chain.push(role_provider);
182                next
183            } else {
184                break base_provider(profile_set, profile).map_err(|err| {
185                    // It's possible for base_provider to return a `ProfileFileError::ProfileDidNotContainCredentials`
186                    // if we're still looking at the first provider we want to surface it. However,
187                    // if we're looking at any provider after the first we want to instead return a `ProfileFileError::InvalidCredentialSource`
188                    if visited_profiles.len() == 1 {
189                        err
190                    } else {
191                        ProfileFileError::InvalidCredentialSource {
192                            profile: profile.name().into(),
193                            message: format!("could not load source profile: {err}").into(),
194                        }
195                    }
196                })?;
197            }
198        };
199
200        match next_profile {
201            NextProfile::SelfReference => {
202                // self referential profile, don't go through the loop because it will error
203                // on the infinite loop check. Instead, reload this profile as a base profile
204                // and exit.
205                break base_provider(profile_set, profile)?;
206            }
207            NextProfile::Named(name) => source_profile_name = name,
208        }
209    };
210    chain.reverse();
211    Ok(ProfileChain { base, chain })
212}
213
214mod role {
215    pub(super) const ROLE_ARN: &str = "role_arn";
216    pub(super) const EXTERNAL_ID: &str = "external_id";
217    pub(super) const SESSION_NAME: &str = "role_session_name";
218
219    pub(super) const CREDENTIAL_SOURCE: &str = "credential_source";
220    pub(super) const SOURCE_PROFILE: &str = "source_profile";
221}
222
223mod sso {
224    pub(super) const ACCOUNT_ID: &str = "sso_account_id";
225    pub(super) const REGION: &str = "sso_region";
226    pub(super) const ROLE_NAME: &str = "sso_role_name";
227    pub(super) const START_URL: &str = "sso_start_url";
228    pub(super) const SESSION_NAME: &str = "sso_session";
229}
230
231mod web_identity_token {
232    pub(super) const TOKEN_FILE: &str = "web_identity_token_file";
233}
234
235mod static_credentials {
236    pub(super) const AWS_ACCESS_KEY_ID: &str = "aws_access_key_id";
237    pub(super) const AWS_SECRET_ACCESS_KEY: &str = "aws_secret_access_key";
238    pub(super) const AWS_SESSION_TOKEN: &str = "aws_session_token";
239    pub(super) const AWS_ACCOUNT_ID: &str = "aws_account_id";
240}
241
242mod credential_process {
243    pub(super) const CREDENTIAL_PROCESS: &str = "credential_process";
244}
245
246const PROVIDER_NAME: &str = "ProfileFile";
247
248fn base_provider<'a>(
249    profile_set: &'a ProfileSet,
250    profile: &'a Profile,
251) -> Result<BaseProvider<'a>, ProfileFileError> {
252    // the profile must define either a `CredentialsSource` or a concrete set of access keys
253    match profile.get(role::CREDENTIAL_SOURCE) {
254        Some(source) => Ok(BaseProvider::NamedSource(source)),
255        None => web_identity_token_from_profile(profile)
256            .or_else(|| sso_from_profile(profile_set, profile).transpose())
257            .or_else(|| credential_process_from_profile(profile))
258            .unwrap_or_else(|| Ok(BaseProvider::AccessKey(static_creds_from_profile(profile)?))),
259    }
260}
261
262enum NextProfile<'a> {
263    SelfReference,
264    Named(&'a str),
265}
266
267fn chain_provider(profile: &Profile) -> Result<NextProfile<'_>, ProfileFileError> {
268    let (source_profile, credential_source) = (
269        profile.get(role::SOURCE_PROFILE),
270        profile.get(role::CREDENTIAL_SOURCE),
271    );
272    match (source_profile, credential_source) {
273        (Some(_), Some(_)) => Err(ProfileFileError::InvalidCredentialSource {
274            profile: profile.name().to_string(),
275            message: "profile contained both source_profile and credential_source. \
276                Only one or the other can be defined"
277                .into(),
278        }),
279        (None, None) => Err(ProfileFileError::InvalidCredentialSource {
280            profile: profile.name().to_string(),
281            message:
282            "profile must contain `source_profile` or `credential_source` but neither were defined"
283                .into(),
284        }),
285        (Some(source_profile), None) if source_profile == profile.name() => {
286            Ok(NextProfile::SelfReference)
287        }
288        (Some(source_profile), None) => Ok(NextProfile::Named(source_profile)),
289        // we want to loop back into this profile and pick up the credential source
290        (None, Some(_credential_source)) => Ok(NextProfile::SelfReference),
291    }
292}
293
294fn role_arn_from_profile(profile: &Profile) -> Option<RoleArn<'_>> {
295    // Web Identity Tokens are root providers, not chained roles
296    if profile.get(web_identity_token::TOKEN_FILE).is_some() {
297        return None;
298    }
299    let role_arn = profile.get(role::ROLE_ARN)?;
300    let session_name = profile.get(role::SESSION_NAME);
301    let external_id = profile.get(role::EXTERNAL_ID);
302    Some(RoleArn {
303        role_arn,
304        external_id,
305        session_name,
306    })
307}
308
309fn sso_from_profile<'a>(
310    profile_set: &'a ProfileSet,
311    profile: &'a Profile,
312) -> Result<Option<BaseProvider<'a>>, ProfileFileError> {
313    /*
314    -- Sample without sso-session: --
315
316    [profile sample-profile]
317    sso_account_id = 012345678901
318    sso_region = us-east-1
319    sso_role_name = SampleRole
320    sso_start_url = https://d-abc123.awsapps.com/start-beta
321
322    -- Sample with sso-session: --
323
324    [profile sample-profile]
325    sso_session = dev
326    sso_account_id = 012345678901
327    sso_role_name = SampleRole
328
329    [sso-session dev]
330    sso_region = us-east-1
331    sso_start_url = https://d-abc123.awsapps.com/start-beta
332    */
333    let sso_account_id = profile.get(sso::ACCOUNT_ID);
334    let mut sso_region = profile.get(sso::REGION);
335    let sso_role_name = profile.get(sso::ROLE_NAME);
336    let mut sso_start_url = profile.get(sso::START_URL);
337    let sso_session_name = profile.get(sso::SESSION_NAME);
338    if [
339        sso_account_id,
340        sso_region,
341        sso_role_name,
342        sso_start_url,
343        sso_session_name,
344    ]
345    .iter()
346    .all(Option::is_none)
347    {
348        return Ok(None);
349    }
350
351    let invalid_sso_config = |s: &str| ProfileFileError::InvalidSsoConfig {
352        profile: profile.name().into(),
353        message: format!(
354            "`{s}` can only be specified in the [sso-session] config when a session name is given"
355        )
356        .into(),
357    };
358    if let Some(sso_session_name) = sso_session_name {
359        if sso_start_url.is_some() {
360            return Err(invalid_sso_config(sso::START_URL));
361        }
362        if sso_region.is_some() {
363            return Err(invalid_sso_config(sso::REGION));
364        }
365        if let Some(session) = profile_set.sso_session(sso_session_name) {
366            sso_start_url = session.get(sso::START_URL);
367            sso_region = session.get(sso::REGION);
368        } else {
369            return Err(ProfileFileError::MissingSsoSession {
370                profile: profile.name().into(),
371                sso_session: sso_session_name.into(),
372            });
373        }
374    }
375
376    let invalid_sso_creds = |left: &str, right: &str| ProfileFileError::InvalidSsoConfig {
377        profile: profile.name().into(),
378        message: format!("if `{left}` is set, then `{right}` must also be set").into(),
379    };
380    match (sso_account_id, sso_role_name) {
381        (Some(_), Some(_)) | (None, None) => { /* good */ }
382        (Some(_), None) => return Err(invalid_sso_creds(sso::ACCOUNT_ID, sso::ROLE_NAME)),
383        (None, Some(_)) => return Err(invalid_sso_creds(sso::ROLE_NAME, sso::ACCOUNT_ID)),
384    }
385
386    let missing_field = |s| move || ProfileFileError::missing_field(profile, s);
387    let sso_region = sso_region.ok_or_else(missing_field(sso::REGION))?;
388    let sso_start_url = sso_start_url.ok_or_else(missing_field(sso::START_URL))?;
389    Ok(Some(BaseProvider::Sso {
390        sso_account_id,
391        sso_region,
392        sso_role_name,
393        sso_start_url,
394        sso_session_name,
395    }))
396}
397
398fn web_identity_token_from_profile(
399    profile: &Profile,
400) -> Option<Result<BaseProvider<'_>, ProfileFileError>> {
401    let session_name = profile.get(role::SESSION_NAME);
402    match (
403        profile.get(role::ROLE_ARN),
404        profile.get(web_identity_token::TOKEN_FILE),
405    ) {
406        (Some(role_arn), Some(token_file)) => Some(Ok(BaseProvider::WebIdentityTokenRole {
407            role_arn,
408            web_identity_token_file: token_file,
409            session_name,
410        })),
411        (None, None) => None,
412        (Some(_role_arn), None) => None,
413        (None, Some(_token_file)) => Some(Err(ProfileFileError::InvalidCredentialSource {
414            profile: profile.name().to_string(),
415            message: "`web_identity_token_file` was specified but `role_arn` was missing".into(),
416        })),
417    }
418}
419
420/// Load static credentials from a profile
421///
422/// Example:
423/// ```ini
424/// [profile B]
425/// aws_access_key_id = abc123
426/// aws_secret_access_key = def456
427/// ```
428fn static_creds_from_profile(profile: &Profile) -> Result<Credentials, ProfileFileError> {
429    use static_credentials::*;
430    let access_key = profile.get(AWS_ACCESS_KEY_ID);
431    let secret_key = profile.get(AWS_SECRET_ACCESS_KEY);
432    let session_token = profile.get(AWS_SESSION_TOKEN);
433    // If all three fields are missing return a `ProfileFileError::ProfileDidNotContainCredentials`
434    if let (None, None, None) = (access_key, secret_key, session_token) {
435        return Err(ProfileFileError::ProfileDidNotContainCredentials {
436            profile: profile.name().to_string(),
437        });
438    }
439    // Otherwise, check to make sure the access and secret keys are defined
440    let access_key = access_key.ok_or_else(|| ProfileFileError::InvalidCredentialSource {
441        profile: profile.name().to_string(),
442        message: "profile missing aws_access_key_id".into(),
443    })?;
444    let secret_key = secret_key.ok_or_else(|| ProfileFileError::InvalidCredentialSource {
445        profile: profile.name().to_string(),
446        message: "profile missing aws_secret_access_key".into(),
447    })?;
448    // There might not be an active session token so we don't error out if it's missing
449    let mut builder = Credentials::builder()
450        .access_key_id(access_key)
451        .secret_access_key(secret_key)
452        .provider_name(PROVIDER_NAME);
453    builder.set_session_token(session_token.map(String::from));
454    builder.set_account_id(profile.get(AWS_ACCOUNT_ID).map(AccountId::from));
455    Ok(builder.build())
456}
457
458/// Load credentials from `credential_process`
459///
460/// Example:
461/// ```ini
462/// [profile B]
463/// credential_process = /opt/bin/awscreds-custom --username helen
464/// ```
465fn credential_process_from_profile(
466    profile: &Profile,
467) -> Option<Result<BaseProvider<'_>, ProfileFileError>> {
468    let account_id = profile.get(static_credentials::AWS_ACCOUNT_ID);
469    profile
470        .get(credential_process::CREDENTIAL_PROCESS)
471        .map(|credential_process| {
472            Ok(BaseProvider::CredentialProcess {
473                command_with_sensitive_args: CommandWithSensitiveArgs::new(credential_process),
474                account_id,
475            })
476        })
477}
478
479#[cfg(test)]
480mod tests {
481
482    #[cfg(feature = "test-util")]
483    use super::ProfileChain;
484    use crate::profile::credentials::repr::BaseProvider;
485    use crate::sensitive_command::CommandWithSensitiveArgs;
486    use serde::Deserialize;
487    #[cfg(feature = "test-util")]
488    use std::collections::HashMap;
489
490    #[cfg(feature = "test-util")]
491    #[test]
492    fn run_test_cases() -> Result<(), Box<dyn std::error::Error>> {
493        let test_cases: Vec<TestCase> = serde_json::from_str(&std::fs::read_to_string(
494            "./test-data/assume-role-tests.json",
495        )?)?;
496        for test_case in test_cases {
497            print!("checking: {}...", test_case.docs);
498            check(test_case);
499            println!("ok")
500        }
501        Ok(())
502    }
503
504    #[cfg(feature = "test-util")]
505    fn check(test_case: TestCase) {
506        use super::resolve_chain;
507        use aws_runtime::env_config::property::Properties;
508        use aws_runtime::env_config::section::EnvConfigSections;
509        let source = EnvConfigSections::new(
510            test_case.input.profiles,
511            test_case.input.selected_profile,
512            test_case.input.sso_sessions,
513            Properties::new(),
514        );
515        let actual = resolve_chain(&source);
516        let expected = test_case.output;
517        match (expected, actual) {
518            (TestOutput::Error(s), Err(e)) => assert!(
519                format!("{}", e).contains(&s),
520                "expected\n{}\nto contain\n{}\n",
521                e,
522                s
523            ),
524            (TestOutput::ProfileChain(expected), Ok(actual)) => {
525                assert_eq!(to_test_output(actual), expected)
526            }
527            (expected, actual) => panic!(
528                "error/success mismatch. Expected:\n {:?}\nActual:\n {:?}",
529                &expected, actual
530            ),
531        }
532    }
533
534    #[derive(Deserialize)]
535    #[cfg(feature = "test-util")]
536    struct TestCase {
537        docs: String,
538        input: TestInput,
539        output: TestOutput,
540    }
541
542    #[derive(Deserialize)]
543    #[cfg(feature = "test-util")]
544    struct TestInput {
545        profiles: HashMap<String, HashMap<String, String>>,
546        selected_profile: String,
547        #[serde(default)]
548        sso_sessions: HashMap<String, HashMap<String, String>>,
549    }
550
551    #[cfg(feature = "test-util")]
552    fn to_test_output(profile_chain: ProfileChain<'_>) -> Vec<Provider> {
553        let mut output = vec![];
554        match profile_chain.base {
555            BaseProvider::NamedSource(name) => output.push(Provider::NamedSource(name.into())),
556            BaseProvider::AccessKey(creds) => output.push(Provider::AccessKey {
557                access_key_id: creds.access_key_id().into(),
558                secret_access_key: creds.secret_access_key().into(),
559                session_token: creds.session_token().map(|tok| tok.to_string()),
560                account_id: creds.account_id().map(|id| id.as_str().to_string()),
561            }),
562            BaseProvider::CredentialProcess {
563                command_with_sensitive_args,
564                account_id,
565            } => output.push(Provider::CredentialProcess {
566                command: command_with_sensitive_args.unredacted().into(),
567                account_id: account_id.map(|id| id.to_string()),
568            }),
569            BaseProvider::WebIdentityTokenRole {
570                role_arn,
571                web_identity_token_file,
572                session_name,
573            } => output.push(Provider::WebIdentityToken {
574                role_arn: role_arn.into(),
575                web_identity_token_file: web_identity_token_file.into(),
576                role_session_name: session_name.map(|sess| sess.to_string()),
577            }),
578            BaseProvider::Sso {
579                sso_region,
580                sso_start_url,
581                sso_session_name,
582                sso_account_id,
583                sso_role_name,
584            } => output.push(Provider::Sso {
585                sso_region: sso_region.into(),
586                sso_start_url: sso_start_url.into(),
587                sso_session: sso_session_name.map(|s| s.to_string()),
588                sso_account_id: sso_account_id.map(|s| s.to_string()),
589                sso_role_name: sso_role_name.map(|s| s.to_string()),
590            }),
591        };
592        for role in profile_chain.chain {
593            output.push(Provider::AssumeRole {
594                role_arn: role.role_arn.into(),
595                external_id: role.external_id.map(ToString::to_string),
596                role_session_name: role.session_name.map(ToString::to_string),
597            })
598        }
599        output
600    }
601
602    #[derive(Deserialize, Debug, PartialEq, Eq)]
603    enum TestOutput {
604        ProfileChain(Vec<Provider>),
605        Error(String),
606    }
607
608    #[derive(Deserialize, Debug, Eq, PartialEq)]
609    enum Provider {
610        AssumeRole {
611            role_arn: String,
612            external_id: Option<String>,
613            role_session_name: Option<String>,
614        },
615        AccessKey {
616            access_key_id: String,
617            secret_access_key: String,
618            session_token: Option<String>,
619            account_id: Option<String>,
620        },
621        NamedSource(String),
622        CredentialProcess {
623            command: String,
624            account_id: Option<String>,
625        },
626        WebIdentityToken {
627            role_arn: String,
628            web_identity_token_file: String,
629            role_session_name: Option<String>,
630        },
631        Sso {
632            sso_region: String,
633            sso_start_url: String,
634            sso_session: Option<String>,
635
636            sso_account_id: Option<String>,
637            sso_role_name: Option<String>,
638        },
639    }
640
641    #[test]
642    fn base_provider_process_credentials_args_redaction() {
643        assert_eq!(
644            r#"CredentialProcess { command_with_sensitive_args: "program", account_id: None }"#,
645            format!(
646                "{:?}",
647                BaseProvider::CredentialProcess {
648                    command_with_sensitive_args: CommandWithSensitiveArgs::new("program"),
649                    account_id: None,
650                }
651            )
652        );
653        assert_eq!(
654            r#"CredentialProcess { command_with_sensitive_args: "program ** arguments redacted **", account_id: None }"#,
655            format!(
656                "{:?}",
657                BaseProvider::CredentialProcess {
658                    command_with_sensitive_args: CommandWithSensitiveArgs::new("program arg1 arg2"),
659                    account_id: None
660                }
661            )
662        );
663        assert_eq!(
664            r#"CredentialProcess { command_with_sensitive_args: "program ** arguments redacted **", account_id: None }"#,
665            format!(
666                "{:?}",
667                BaseProvider::CredentialProcess {
668                    command_with_sensitive_args: CommandWithSensitiveArgs::new(
669                        "program\targ1 arg2"
670                    ),
671                    account_id: None
672                }
673            )
674        );
675    }
676}