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