awslabs / aws-sdk-rust

AWS SDK for the Rust Programming Language
https://awslabs.github.io/aws-sdk-rust/
Apache License 2.0
2.96k stars 242 forks source link

Add MFA support when profile assumes role #527

Open jelford opened 2 years ago

jelford commented 2 years ago

Describe the feature

Currently, the SDK regognizes the role_arn directive in profiles under the normal config file at ~/.aws/config. When sending a request, it will correctly attempt to assume the specified role. However, if that role requires an MFA token, the SDK will not request an MFA token from the user, and will fail to assume the role.

The feature request is to add support for MFA tokens, analogous to support in boto3 for the same.

Use Case

I use an IAM with limited permissions, which assumes a more privileged role, protected by MFA. The idea is to avoid having long-lived credentials present on my development laptop for a privileged account. When using the AWS CLI, or boto3 in a python script (run from a tty), I am prompted for my MFA key when first authenticating. I'd like to use the same workflow for programmes written against the Rust SDK.

To give a complete picture, my ~/.aws/config looks something like this (with long term credentials for the home profile in ~/.aws/credentials):

[profile home]
region=eu-west-2
role_arn=arn:aws:iam::<my-account>:role/MorePrivilegedRole
source_profile=home
mfa_serial=arn:aws:iam::<my-account>:mfa/jelford-laptop

Proposed Solution

To fit with the "Batteries included, but replaceable" design tenet, I think it would make sense to include:

The general idea is to:

  1. Load mfa_serial from profile config when present, onto aws_config::profile::credentials::repr::RoleArn
  2. Add a trait ProvideMfaToken that can provide mfa tokens when required in aws_config::profile::credentials::exec::AssumeRoleProvider::credentials
  3. Add an implementation of ProvideMfaToken used by default when stdin is a tty, that sources MFA tokens from stdin
  4. Provide an easy hook on aws_config::default_provider::credentials::Builder to pass in a custom ProvideMfaToken

I'd propose usage looks a bit like this (using my "home" profile name from above), first for the default case:

// using default MFA token provider

let creds = credentials::Builder::default().profile_name("home").build();
let region = region::Builder::default().profile_name("home").build();

let config = aws_config::from_env()
    .credentials_provider(creds.await)
    .region(region)
    .load()
    .await;

let s3_client = aws_sdk_s3::Client::new(&config);
let buckets = s3_client.list_buckets().send().await.unwrap();

And for customizing the ProvideMfaTokenImplementation (assuming a future::ProvideMfaToken analogous to future::ProvideCredentials):

struct MyProvideMfaToken{}

impl ProvideMfaToken for MyProvideMfaToken {
    fn provide_mfa_token<'a>(&'a self) -> future::ProvideMfaToken<'a> ... {
        ...
   }
}
...

let creds = credentials::Builder::default().profile_name("home").mfa_token(MyProvideMfaToken{}).build();
let region = ...

let config = aws_config::from_env()
    .credentials_provider(creds.await)
    .region(region)
    .load()
    .await;

... etc

Other Information

For completeness, here's the detail from tracing when trying to use the profile above with an MFA token, following the proposed "default" code:

2022-05-01T12:11:12.105241Z  INFO send_operation{operation="ListBuckets" service="s3"}:provide_credentials{provider=default_chain}:lazy_load_credentials:load_credentials{provider=Profile}: aws_config::profile::credentials: constructed abstract provider from config file chain=ProfileChain { base: AccessKey(Credentials { provider_name: "ProfileFile", access_key_id: "<my-access-key>", secret_access_key: "** redacted **" }), chain: [RoleArn { role_arn: "arn:aws:iam::<my-account>:role/MorePrivilegedRole", external_id: None, session_name: None }] }
2022-05-01T12:11:12.105271Z  INFO send_operation{operation="ListBuckets" service="s3"}:provide_credentials{provider=default_chain}:lazy_load_credentials:load_credentials{provider=Profile}: aws_config::profile::credentials::exec: first credentials will be loaded from AccessKey(Credentials { provider_name: "ProfileFile", access_key_id: "<my-access-key>", secret_access_key: "** redacted **" }) base=AccessKey(Credentials { provider_name: "ProfileFile", access_key_id: "<my-access-key>", secret_access_key: "** redacted **" })
2022-05-01T12:11:12.105296Z  INFO send_operation{operation="ListBuckets" service="s3"}:provide_credentials{provider=default_chain}:lazy_load_credentials:load_credentials{provider=Profile}: aws_config::profile::credentials::exec: which will be used to assume a role role_arn=RoleArn { role_arn: "arn:aws:iam::<my-account>:role/MorePrivilegedRole", external_id: None, session_name: None }
2022-05-01T12:11:12.105337Z  INFO send_operation{operation="ListBuckets" service="s3"}:provide_credentials{provider=default_chain}:lazy_load_credentials:load_credentials{provider=Profile}: aws_config::profile::credentials: loaded base credentials creds=Credentials { provider_name: "ProfileFile", access_key_id: "<my-access-key>", secret_access_key: "** redacted **" }
2022-05-01T12:11:12.107050Z DEBUG send_operation{operation="ListBuckets" service="s3"}:provide_credentials{provider=default_chain}:lazy_load_credentials:load_credentials{provider=Profile}:load_assume_role{provider=AssumeRoleProvider { role_arn: "arn:aws:iam::<my-account>:role/MorePrivilegedRole", external_id: None, session_name: None }}: aws_endpoint: resolved endpoint endpoint=AwsEndpoint { endpoint: Endpoint { uri: https://sts.eu-west-2.amazonaws.com/, immutable: false }, credential_scope: CredentialScope { region: Some(SigningRegion("eu-west-2")), service: None } } base_region=Region("eu-west-2")
<-- snip: a bunch of http request preparation / sending stuff -->
2022-05-01T12:11:12.199119Z  WARN send_operation{operation="ListBuckets" service="s3"}:provide_credentials{provider=default_chain}:lazy_load_credentials:load_credentials{provider=Profile}: aws_config::profile::credentials: failed to load assume role credentials provider=AssumeRoleProvider { role_arn: "arn:aws:iam::<my-account>:role/MorePrivilegedRole", external_id: None, session_name: None }
2022-05-01T12:11:12.199167Z  WARN send_operation{operation="ListBuckets" service="s3"}:provide_credentials{provider=default_chain}:lazy_load_credentials: aws_config::meta::credentials::chain: provider failed to provide credentials provider=Profile error=An error occurred while loading credentials: An error occurred while loading credentials: Error { code: "AccessDenied", message: "User: arn:aws:iam::<my-account>:user/jelford-laptop is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::<my-account>:role/MorePrivilegedRole", request_id: "..." }

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: ConstructionFailure(CredentialsLoadingError(ProviderError { cause: ProviderError { cause: ServiceError { err: AssumeRoleError { kind: Unhandled(Error { code: Some("AccessDenied"), message: Some("User: arn:aws:iam::<my-account>:user/jelford-laptop is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::<my-account>:role/MorePrivilegedRole"), request_id: Some("..."), extras: {} }), ...
<-- snip -->

Acknowledgements

A note for the community

Community Note

Velfi commented 2 years ago

Thanks for submitting this well thought-out request/proposal! If you're interested in writing up an RFC or submitting a PR, we'd welcome it.

I think that adding a trait for MfaTokenProvider and the relevant config setters could be a small and quick update. With regards to implementing an MfaTokenProvider that receives input thru StdIn: I'd like to see an RFC on this that collects a bit of info on how other SDKs implemented this feature and what their user experience is like.

jelford commented 2 years ago

Sure, sounds sensible, and thanks for the quick response - I've actually started on a little PR so I'll post something on that probably later this week, covering the new trait / config setters.

I'm happy to put together an RFC - is there some documentation around the typical shape of these / what to expect to include? Sorry if I've missed it - I did a quick scan through CONTRIBUTING and the discussions here on GH and didn't see much prior art. No worries if not.

On the piont about other SDKs, so far I have been looking only at boto3, which has the behaviour I describe in OP - is there a sensible group, e.g. "boto + java", that taken together make for a reasonably canonical sample?

Velfi commented 2 years ago

Sure, sounds sensible, and thanks for the quick response - I've actually started on a little PR so I'll post something on that probably later this week, covering the new trait / config setters.

I can't wait to see it

I'm happy to put together an RFC - is there some documentation around the typical shape of these / what to expect to include? Sorry if I've missed it - I did a quick scan through CONTRIBUTING and the discussions here on GH and didn't see much prior art. No worries if not.

We don't have a guide for writing RFCs but you can take a look at past ones here. In my opinion a good RFC will state the customer need, define terms, show what the user experience will look like for the new feature, and then dive into the implementation of the feature. We usually include a checklist at the end that summarizes what will be implemented.

On the point about other SDKs, so far I have been looking only at boto3, which has the behavior I describe in OP - is there a sensible group, e.g. "boto3 + java", that taken together make for a reasonably canonical sample?

The CLI/boto3 is generally our gold standard. We also often look to the Java, Javascript, Go, and Kotlin SDKs. The goal is that features should be relatively consistent across SDKs. Unsurprising code is easier to use and reason about.

jelford commented 2 years ago

Sounds good, thanks!

jelford commented 2 years ago

I've put up a preliminary PR for this: https://github.com/awslabs/smithy-rs/pull/1359 A few outstanding questions around the implementation, but as you said, not too big.