1use crate::profile::credentials::ProfileFileError;
16use crate::profile::{Profile, ProfileSet};
17use crate::sensitive_command::CommandWithSensitiveArgs;
18use aws_credential_types::Credentials;
19
20#[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#[derive(Clone, Debug)]
48#[non_exhaustive]
49pub(crate) enum BaseProvider<'a> {
50 NamedSource(&'a str),
62
63 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 Sso {
81 sso_session_name: Option<&'a str>,
82 sso_region: &'a str,
83 sso_start_url: &'a str,
84
85 sso_account_id: Option<&'a str>,
87 sso_role_name: Option<&'a str>,
88 },
89
90 CredentialProcess(CommandWithSensitiveArgs<&'a str>),
96}
97
98#[derive(Debug)]
103pub(crate) struct RoleArn<'a> {
104 pub(crate) role_arn: &'a str,
106 pub(crate) external_id: Option<&'a str>,
108
109 pub(crate) session_name: Option<&'a str>,
111}
112
113pub(crate) fn resolve_chain(
115 profile_set: &ProfileSet,
116) -> Result<ProfileChain<'_>, ProfileFileError> {
117 if profile_set.is_empty() {
119 return Err(ProfileFileError::NoProfilesDefined);
120 }
121
122 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 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 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 visited_profiles.push(source_profile_name);
161 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 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 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 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 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 (None, Some(_credential_source)) => Ok(NextProfile::SelfReference),
284 }
285}
286
287fn role_arn_from_profile(profile: &Profile) -> Option<RoleArn<'_>> {
288 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 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) => { }
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
413fn 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 let (None, None, None) = (access_key, secret_key, session_token) {
428 return Err(ProfileFileError::ProfileDidNotContainCredentials {
429 profile: profile.name().to_string(),
430 });
431 }
432 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 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
451fn 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}