aws / aws-sdk-ruby

The official AWS SDK for Ruby.
https://aws.amazon.com/sdk-for-ruby/
Apache License 2.0
3.56k stars 1.22k forks source link

Allow reuse of the current profile's sso_session while providing a different sso_role_name or sso_account_id #3120

Open ravron opened 1 week ago

ravron commented 1 week ago

Describe the feature

When creating an instance of SSOCredentials to provide to an API client, I would like a way to indicate that the instance should look up and use the sso_session associated with the current profile, rather than passing it myself.

Use Case

When creating an instance of SSOCredentials to provide to an API client, I would like to select the permission set used by providing the sso_role_name. However, doing so requires that I know the name of the user's configured sso_session, which I generally do not, so I must needlessly require that users of the tool I'm building name their sso_session a particular string.

For example, imagine a user has this ~/.aws/config:

[sso-session unusual-session-name]
sso_start_url = https://d-example.awsapps.com/start
sso_region = us-west-2
sso_registration_scopes = sso:account:access

[default]
region = us-west-2
sso_session = unusual-session-name
sso_account_id = 111111111111
sso_role_name = PermissionSetA

If PermissionSetA has the necessary permissions to perform my task, this is easy. The user first runs aws sso login and the SSO token is cached to disk. My code can simply create a client without any options and rely on the CredentialProviderChain to find the SSO token and create a valid SSOCredentials instance:

sts = Aws::STS::Client.new
puts(sts.get_caller_identity)  # reports PermissionSetA in account 111111111111

However, consider the case where I would like to use PermissionSetB in account 222222222222. I expect that the user's existing SSO token should work to assume that PermissionSetB, so I simply need to specify a different permission set:

credentials = Aws::SSOCredentials.new(
  sso_account_id: '222222222222',
  sso_role_name: 'PermissionSetB',
  sso_region: 'us-west-2',
  sso_session: ''  # required - but what do I put here?
)
sts = Aws::STS::Client.new(credentials:)
puts(sts.get_caller_identity) 

The user's SSO session is named unusual-session-name, and the SharedConfig knows it. But, as far as I can tell, that information isn't exposed. SharedConfig has both sso_credentials_from_config and sso_token_from_config, but both are private. Even if they were public, they wouldn't quite work: sso_credentials_from_config returns an SSOCredentials that has the permission set baked in, while sso_token_from_config returns an SSOTokenProvider that cannot be used to construct an SSOCredentials.

Proposed Solution

The first solution that comes to mind requires two changes:

  1. Make SharedConfig#sso_token_from_config public. This would make it possible to get an SSOTokenProvider corresponding to the user's current AWS profile and SSO session. This is challenging, though, because all of SharedConfig is currently a private API.
  2. Allow SSOCredentials.initialize to accept a :token_provider option. If passed, :sso_session is no longer needed, and the provided SSOTokenProvider is used instead of creating a new one.

I'm not sure what's a clean option here, though, and I'm looking for feedback.

Other Information

No response

Acknowledgements

SDK version used

3.188.0

Environment details (OS name and version, etc.)

macOS 14.6.1

alextwoods commented 4 days ago

Thanks for submitting - this is an interesting, but I think valid use case.

In general resolution of defaults (including the profile, credential/token providers, ect) is complex and we generally want to keep those details as api private so I don't necessarily think we should make SharedConfig#sso_token_from_config public. In general though, you could determine the "default" token provider by creating a client and accessing client.config.token_provider. I do think its reasonable to be able to construct SSOCredentials with a SSOTokenProvider though and you could then get the users default token provider off a client and use that.

Can you provide more details on how you are creating and using clients? In general, a user may have more than 1 sso-sessions configured and which would get used is based on configured profile (which in client creation, is resolved from various possible locations).

ravron commented 4 days ago

Thanks for the the response!

In general resolution of defaults (including the profile, credential/token providers, ect) is complex and we generally want to keep those details as api private

I totally understand. I would much rather rely on the SDK to do the right thing with respect to finding the right credentials. I didn't know that I can access a client's token_provider to get the resolved token provider — that's a great step in the right direction.

Can you provide more details on how you are creating and using clients?

Sure. This is a utility script used by many different developers at my organization to make changes to SSM parameters in one of several AWS accounts (for example, the sandbox or production accounts). In our previous IAM configuration, developers had IAM users in the management account, and those users could assume IAM roles in the member accounts to make changes. In this configuration, creating SDK clients was easy. The developer would call the script:

./config.rb --account sandbox

and I could hard-code the appropriate roles:

account_name = ... # get account name from command line
roles = {
  sandbox:    'arn:aws:iam::111111111111:role/ConfigureSSM',
  production: 'arn:aws:iam::222222222222:role/ConfigureSSM'
}
ssm = Aws::SSM::Client.new(role_arn: roles[account_name])

This meant that the script didn't care about the developer's AWS CLI configuration. As long as the developer provided credentials that could assume the necessary role, the script worked.

Now, we'd like to switch from ordinary IAM roles in the member accounts to SSO permission sets, while keeping the same convenient developer experience. The developer will still call the script as before, but now script will hard-code permission sets and account IDs:

account_name = ... # get account name from command line

permission_sets = {
  sandbox: {
    sso_account_id: '111111111111',
    sso_role_name: 'ConfigureSSM',
    sso_region: 'us-west-2',
    sso_session: ''  # required - but what do I put here?
  },
  production: {
    sso_account_id: '111111111111',
    sso_role_name: 'ConfigureSSM',
    sso_region: 'us-west-2',
    sso_session: ''  # required - but what do I put here?
  },
}

credentials = Aws::SSOCredentials.new(**permission_sets[account_name])
ssm = Aws::SSM::Client.new(credentials:)

I opened this issue when I could not figure out how to keep the same convenient developer experience we had when using roles directly.

In general, a user may have more than 1 sso-sessions configured and which would get used is based on configured profile.

Absolutely, and I would rather my script not know anything about which sso-session is used. Ideally, the script just knows it needs to use permission set ConfigureSSM in account 111111111111 to do its work, and the SDK figures out whether it can get sigv4 credentials, and does so.

alextwoods commented 4 days ago

That makes sense and I think I may have a relatively simple solution for you: AssumeRoleCredentials. You can provide a role_arn (combination of the account + role) and role_session_name and it will use an STS client with default credentials (meaning that whatever credentials the user has configured, including if they have sso credentials) will be used and as long as those credentials have permission to assume the role, it should work. Something like:

credentials = Aws::AssumeRoleCredentials.new(role_arn: roles[account_name], role_session_name: 'whatever-session-name-you-want') # will use the users default credentials to assume the role
ssm = Aws::SSM::Client.new(credentials:)
ravron commented 4 days ago

Thanks for the suggestion! I don't think I was clear, though: we currently use role assumption with no issue.

The problem is that we want to switch from using roles we provision in each account, to using IAM Identity Center permission sets, and eliminate the existing roles. As you demonstrated, it's easy and seamless to assume a role whose ARN you know, but it's much more complex to assume a permission set whose account ID and name you know, without relying on details of the developer's ~/.aws/config file — hence this issue.

alextwoods commented 6 hours ago

That makes sense. I think we don't want to expose any of the shared config or default resolution behavior required for this.

However, I believe you can get this functionality, using only public functionality by extending the SSOCredentials. Something like:

class SSOCredsDefaultToken < Aws::SSOCredentials

  def initialize(options = nil)
    @legacy = false
    @sso_role_name = options.delete(:sso_role_name)
    @sso_account_id = options.delete(:sso_account_id)
    @sso_region = options.delete(:sso_region)

    @client = options.delete(:client)

    unless @client
      client_opts = {}
      options.each_pair { |k,v| client_opts[k] = v unless CLIENT_EXCLUDE_OPTIONS.include?(k) }
      client_opts[:region] = @sso_region
      client_opts[:credentials] = nil
      @client = Aws::SSO::Client.new(client_opts)
    end

    # get default token_provider resolved by normal chain
    @token_provider = @client.config.token_provider
    raise 'No token provider configured' unless @token_provider

    # do not call super, instead just call refresh
    @async_refresh = true
    refresh
  end
end

Its then fairly easy to use as:

credentials = SSOCredsDefaultToken.new(
  sso_account_id: '222222222222',
  sso_role_name: 'PermissionSetB',
  sso_region: 'us-west-2'
# No need to put sso_session
)
sts = Aws::STS::Client.new(credentials:)

I think the other alternative would be to just rely on private functionality and do something like:

default_sso_session =  Aws.shared_config.send(:get_config_value, 'sso_session', {})