1use 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#[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#[derive(Clone, Debug)]
49#[non_exhaustive]
50pub(crate) enum BaseProvider<'a> {
51 NamedSource(&'a str),
63
64 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 Sso {
82 sso_session_name: Option<&'a str>,
83 sso_region: &'a str,
84 sso_start_url: &'a str,
85
86 sso_account_id: Option<&'a str>,
88 sso_role_name: Option<&'a str>,
89 },
90
91 CredentialProcess {
97 command_with_sensitive_args: CommandWithSensitiveArgs<&'a str>,
98 account_id: Option<&'a str>,
101 },
102}
103
104#[derive(Debug)]
109pub(crate) struct RoleArn<'a> {
110 pub(crate) role_arn: &'a str,
112 pub(crate) external_id: Option<&'a str>,
114
115 pub(crate) session_name: Option<&'a str>,
117}
118
119pub(crate) fn resolve_chain(
121 profile_set: &ProfileSet,
122) -> Result<ProfileChain<'_>, ProfileFileError> {
123 if profile_set.is_empty() {
125 return Err(ProfileFileError::NoProfilesDefined);
126 }
127
128 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 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 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 visited_profiles.push(source_profile_name);
167 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 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 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 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 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 (None, Some(_credential_source)) => Ok(NextProfile::SelfReference),
291 }
292}
293
294fn role_arn_from_profile(profile: &Profile) -> Option<RoleArn<'_>> {
295 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 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) => { }
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
420fn 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 let (None, None, None) = (access_key, secret_key, session_token) {
435 return Err(ProfileFileError::ProfileDidNotContainCredentials {
436 profile: profile.name().to_string(),
437 });
438 }
439 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 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
458fn 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}